From ae00fdb0e958504000a4ddbc820886ebe3e4c9e5 Mon Sep 17 00:00:00 2001 From: Constantin Graf Date: Sun, 13 Apr 2025 16:26:31 +0200 Subject: [PATCH] Add localization settings --- app/Actions/Fortify/CreateNewUser.php | 14 +- app/Actions/Jetstream/CreateOrganization.php | 23 +- .../Commands/Admin/UserCreateCommand.php | 4 +- app/Enums/CurrencyFormat.php | 36 ++ app/Enums/DateFormat.php | 48 +++ app/Enums/IntervalFormat.php | 32 ++ app/Enums/NumberFormat.php | 37 ++ app/Enums/TimeFormat.php | 28 ++ .../Resources/OrganizationResource.php | 20 + .../UserResource/Pages/CreateUser.php | 2 +- .../Api/V1/OrganizationController.php | 32 +- .../Api/V1/TimeEntryController.php | 7 +- .../Controllers/Api/V1/UserController.php | 3 +- .../OrganizationUpdateRequest.php | 60 ++- .../V1/Organization/OrganizationResource.php | 10 + .../V1/Report/DetailedReportResource.php | 10 +- .../Report/DetailedWithDataReportResource.php | 6 +- .../Resources/V1/Report/ReportResource.php | 6 +- app/Models/Organization.php | 17 +- app/Service/CurrencyService.php | 377 ++++++++++++++++++ app/Service/LocalizationService.php | 144 +++++++ app/Service/OrganizationService.php | 68 ++++ .../TimeEntriesDetailedExport.php | 12 +- app/Service/UserService.php | 60 ++- config/app.php | 14 + database/factories/OrganizationFactory.php | 10 + ...ization_columns_to_organizations_table.php | 46 +++ lang/en/enum.php | 43 ++ .../time-entry-aggregate/pdf.blade.php | 21 +- .../reports/time-entry-index/pdf.blade.php | 14 +- .../Api/V1/OrganizationEndpointTest.php | 58 ++- tests/Unit/Service/CurrencyServiceTest.php | 97 +++++ .../Unit/Service/LocalizationServiceTest.php | 256 ++++++++++++ 33 files changed, 1526 insertions(+), 89 deletions(-) create mode 100644 app/Enums/CurrencyFormat.php create mode 100644 app/Enums/DateFormat.php create mode 100644 app/Enums/IntervalFormat.php create mode 100644 app/Enums/NumberFormat.php create mode 100644 app/Enums/TimeFormat.php create mode 100644 app/Service/CurrencyService.php create mode 100644 app/Service/LocalizationService.php create mode 100644 app/Service/OrganizationService.php create mode 100644 database/migrations/2025_04_03_101827_add_localization_columns_to_organizations_table.php create mode 100644 tests/Unit/Service/CurrencyServiceTest.php create mode 100644 tests/Unit/Service/LocalizationServiceTest.php diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index df8fc21d..253a63d9 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -76,6 +76,11 @@ class CreateNewUser implements CreatesNewUsers $ipLookupResponse = app(IpLookupServiceContract::class)->lookup(request()->ip()); $startOfWeek = Weekday::Monday; + $numberFormat = null; + $currencyFormat = null; + $dateFormat = null; + $intervalFormat = null; + $timeFormat = null; $currency = null; if ($ipLookupResponse !== null) { $startOfWeek = $ipLookupResponse->startOfWeek ?? Weekday::Monday; @@ -85,7 +90,7 @@ class CreateNewUser implements CreatesNewUsers $currency = $ipLookupResponse->currency; } $user = null; - DB::transaction(function () use (&$user, $input, $timezone, $startOfWeek, $currency): void { + DB::transaction(function () use (&$user, $input, $timezone, $startOfWeek, $currency, $numberFormat, $currencyFormat, $dateFormat, $intervalFormat, $timeFormat): void { $userService = app(UserService::class); $user = $userService->createUser( $input['name'], @@ -93,7 +98,12 @@ class CreateNewUser implements CreatesNewUsers $input['password'], $timezone ?? 'UTC', $startOfWeek, - $currency ?? 'EUR', + $currency, + $numberFormat, + $currencyFormat, + $dateFormat, + $intervalFormat, + $timeFormat ); }); diff --git a/app/Actions/Jetstream/CreateOrganization.php b/app/Actions/Jetstream/CreateOrganization.php index 018a1944..13981abc 100644 --- a/app/Actions/Jetstream/CreateOrganization.php +++ b/app/Actions/Jetstream/CreateOrganization.php @@ -4,10 +4,11 @@ declare(strict_types=1); namespace App\Actions\Jetstream; -use App\Enums\Role; use App\Events\AfterCreateOrganization; use App\Models\Organization; use App\Models\User; +use App\Service\IpLookup\IpLookupServiceContract; +use App\Service\OrganizationService; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Validator; @@ -33,16 +34,18 @@ class CreateOrganization implements CreatesTeams 'name' => ['required', 'string', 'max:255'], ])->validateWithBag('createTeam'); - $organization = new Organization; - $organization->name = $input['name']; - $organization->personal_team = false; - $organization->owner()->associate($user); - $organization->save(); + $ipLookupResponse = app(IpLookupServiceContract::class)->lookup(request()->ip()); - $organization->users()->attach( - $user, [ - 'role' => Role::Owner->value, - ] + $currency = null; + if ($ipLookupResponse !== null) { + $currency = $ipLookupResponse->currency; + } + + $organization = app(OrganizationService::class)->createOrganization( + $input['name'], + $user, + false, + $currency ); $user->switchTeam($organization); diff --git a/app/Console/Commands/Admin/UserCreateCommand.php b/app/Console/Commands/Admin/UserCreateCommand.php index 5ac83127..65b471fe 100644 --- a/app/Console/Commands/Admin/UserCreateCommand.php +++ b/app/Console/Commands/Admin/UserCreateCommand.php @@ -64,8 +64,8 @@ class UserCreateCommand extends Command $password, 'UTC', Weekday::Monday, - 'EUR', - $verifyEmail + null, + verifyEmail: $verifyEmail ); }); /** @var Organization|null $organization */ diff --git a/app/Enums/CurrencyFormat.php b/app/Enums/CurrencyFormat.php new file mode 100644 index 00000000..920ced99 --- /dev/null +++ b/app/Enums/CurrencyFormat.php @@ -0,0 +1,36 @@ + + */ + public static function toSelectArray(): array + { + $selectArray = []; + foreach (self::values() as $value) { + $selectArray[(string) $value] = (string) __('enum.currency_format.'.$value); + } + + return $selectArray; + } +} diff --git a/app/Enums/DateFormat.php b/app/Enums/DateFormat.php new file mode 100644 index 00000000..d628bbb0 --- /dev/null +++ b/app/Enums/DateFormat.php @@ -0,0 +1,48 @@ +value) { + self::PointSeperatedDMYYYY->value => 'j.n.Y', + self::SlashSeperatedMMDDYYYY->value => 'm/d/Y', + self::SlashSeperatedDDMMYYYY->value => 'd/m/Y', + self::HyphenSeperatedDDMMYYY->value => 'd-m-Y', + self::HyphenSeperatedMMDDDYYYY->value => 'm-d-Y', + self::HyphenSeperatedYYYYMMDD->value => 'Y-m-d', + }; + } + + /** + * @return array + */ + public static function toSelectArray(): array + { + $selectArray = []; + foreach (self::values() as $value) { + $selectArray[(string) $value] = (string) __('enum.date_format.'.$value); + } + + return $selectArray; + } +} diff --git a/app/Enums/IntervalFormat.php b/app/Enums/IntervalFormat.php new file mode 100644 index 00000000..869aab76 --- /dev/null +++ b/app/Enums/IntervalFormat.php @@ -0,0 +1,32 @@ + + */ + public static function toSelectArray(): array + { + $selectArray = []; + foreach (self::values() as $value) { + $selectArray[(string) $value] = (string) __('enum.interval_format.'.$value); + } + + return $selectArray; + } +} diff --git a/app/Enums/NumberFormat.php b/app/Enums/NumberFormat.php new file mode 100644 index 00000000..b8c79a60 --- /dev/null +++ b/app/Enums/NumberFormat.php @@ -0,0 +1,37 @@ + + */ + public static function toSelectArray(): array + { + $selectArray = []; + foreach (self::values() as $value) { + $selectArray[(string) $value] = (string) __('enum.number_format.'.$value); + } + + return $selectArray; + } +} diff --git a/app/Enums/TimeFormat.php b/app/Enums/TimeFormat.php new file mode 100644 index 00000000..9f5fdaeb --- /dev/null +++ b/app/Enums/TimeFormat.php @@ -0,0 +1,28 @@ + + */ + public static function toSelectArray(): array + { + $selectArray = []; + foreach (self::values() as $value) { + $selectArray[(string) $value] = (string) __('enum.time_format.'.$value); + } + + return $selectArray; + } +} diff --git a/app/Filament/Resources/OrganizationResource.php b/app/Filament/Resources/OrganizationResource.php index b0abcc0f..040fa302 100644 --- a/app/Filament/Resources/OrganizationResource.php +++ b/app/Filament/Resources/OrganizationResource.php @@ -4,6 +4,11 @@ declare(strict_types=1); namespace App\Filament\Resources; +use App\Enums\CurrencyFormat; +use App\Enums\DateFormat; +use App\Enums\IntervalFormat; +use App\Enums\NumberFormat; +use App\Enums\TimeFormat; use App\Filament\Resources\OrganizationResource\Pages; use App\Filament\Resources\OrganizationResource\RelationManagers\InvitationsRelationManager; use App\Filament\Resources\OrganizationResource\RelationManagers\UsersRelationManager; @@ -56,6 +61,21 @@ class OrganizationResource extends Resource ->searchable(['name', 'email']) ->disabledOn(['edit']) ->required(), + Select::make('date_format') + ->options(DateFormat::toSelectArray()) + ->required(), + Select::make('currency_format') + ->options(CurrencyFormat::toSelectArray()) + ->required(), + Select::make('interval_format') + ->options(IntervalFormat::toSelectArray()) + ->required(), + Select::make('number_format') + ->options(NumberFormat::toSelectArray()) + ->required(), + Select::make('time_format') + ->options(TimeFormat::toSelectArray()) + ->required(), Forms\Components\Select::make('currency') ->label('Currency') ->options(function (): array { diff --git a/app/Filament/Resources/UserResource/Pages/CreateUser.php b/app/Filament/Resources/UserResource/Pages/CreateUser.php index 7badbd47..51666d49 100644 --- a/app/Filament/Resources/UserResource/Pages/CreateUser.php +++ b/app/Filament/Resources/UserResource/Pages/CreateUser.php @@ -24,7 +24,7 @@ class CreateUser extends CreateRecord $data['timezone'], Weekday::from($data['week_start']), $data['currency'], - (bool) $data['is_email_verified'] + verifyEmail: (bool) $data['is_email_verified'] ); return $user; diff --git a/app/Http/Controllers/Api/V1/OrganizationController.php b/app/Http/Controllers/Api/V1/OrganizationController.php index 2fe8eff7..e3a0b1eb 100644 --- a/app/Http/Controllers/Api/V1/OrganizationController.php +++ b/app/Http/Controllers/Api/V1/OrganizationController.php @@ -40,15 +40,35 @@ class OrganizationController extends Controller { $this->checkPermission($organization, 'organizations:update'); - $organization->name = $request->input('name'); - $oldBillableRate = $organization->billable_rate; - if ($request->has('employees_can_see_billable_rates')) { - $organization->employees_can_see_billable_rates = $request->validated('employees_can_see_billable_rates'); + if ($request->getName() !== null) { + $organization->name = $request->getName(); + } + if ($request->getEmployeesCanSeeBillableRates() !== null) { + $organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates(); + } + if ($request->getNumberFormat() !== null) { + $organization->number_format = $request->getNumberFormat(); + } + if ($request->getCurrencyFormat() !== null) { + $organization->currency_format = $request->getCurrencyFormat(); + } + if ($request->getDateFormat() !== null) { + $organization->date_format = $request->getDateFormat(); + } + if ($request->getIntervalFormat() !== null) { + $organization->interval_format = $request->getIntervalFormat(); + } + if ($request->getTimeFormat() !== null) { + $organization->time_format = $request->getTimeFormat(); + } + $hasBillableRate = $request->has('billable_rate'); + if ($hasBillableRate) { + $oldBillableRate = $organization->billable_rate; + $organization->billable_rate = $request->getBillableRate(); } - $organization->billable_rate = $request->getBillableRate(); $organization->save(); - if ($oldBillableRate !== $request->getBillableRate()) { + if ($hasBillableRate && $oldBillableRate !== $request->getBillableRate()) { $billableRateService->updateTimeEntriesBillableRateForOrganization($organization); } diff --git a/app/Http/Controllers/Api/V1/TimeEntryController.php b/app/Http/Controllers/Api/V1/TimeEntryController.php index 9bfd6025..48a0e23e 100644 --- a/app/Http/Controllers/Api/V1/TimeEntryController.php +++ b/app/Http/Controllers/Api/V1/TimeEntryController.php @@ -27,6 +27,7 @@ use App\Models\Organization; use App\Models\Project; use App\Models\Task; use App\Models\TimeEntry; +use App\Service\LocalizationService; use App\Service\ReportExport\TimeEntriesDetailedCsvExport; use App\Service\ReportExport\TimeEntriesDetailedExport; use App\Service\ReportExport\TimeEntriesReportExport; @@ -194,6 +195,7 @@ class TimeEntryController extends Controller $filename = 'time-entries-export-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension(); $folderPath = 'exports'; $path = $folderPath.'/'.$filename; + $localizationService = LocalizationService::forOrganization($organization); if ($format === ExportFormat::CSV) { $export = new TimeEntriesDetailedCsvExport(config('filesystems.private'), $folderPath, $filename, $timeEntriesQuery, 1000, $timezone); $export->export(); @@ -223,6 +225,7 @@ class TimeEntryController extends Controller 'currency' => $organization->currency, 'start' => $request->getStart()->timezone($timezone), 'end' => $request->getEnd()->timezone($timezone), + 'localization' => $localizationService, ]); $footerViewFile = file_get_contents(resource_path('views/reports/time-entry-index/pdf-footer.blade.php')); if ($footerViewFile === false) { @@ -257,7 +260,7 @@ class TimeEntryController extends Controller ->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename); } else { Excel::store( - new TimeEntriesDetailedExport($timeEntriesQuery, $format, $timezone), + new TimeEntriesDetailedExport($timeEntriesQuery, $format, $timezone, $localizationService), $path, config('filesystems.private'), $format->getExportPackageType(), @@ -394,6 +397,7 @@ class TimeEntryController extends Controller ); $currency = $organization->currency; $timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user()); + $localizationService = LocalizationService::forOrganization($organization); $filename = 'time-entries-report-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension(); $folderPath = 'exports'; @@ -423,6 +427,7 @@ class TimeEntryController extends Controller 'start' => $request->getStart()->timezone($timezone), 'end' => $request->getEnd()->timezone($timezone), 'debug' => $debug, + 'localization' => $localizationService, ]); $footerViewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate/pdf-footer.blade.php')); if ($footerViewFile === false) { diff --git a/app/Http/Controllers/Api/V1/UserController.php b/app/Http/Controllers/Api/V1/UserController.php index 547b6c1a..d4338916 100644 --- a/app/Http/Controllers/Api/V1/UserController.php +++ b/app/Http/Controllers/Api/V1/UserController.php @@ -6,7 +6,6 @@ namespace App\Http\Controllers\Api\V1; use App\Http\Resources\V1\User\UserResource; use Illuminate\Auth\Access\AuthorizationException; -use Illuminate\Http\Resources\Json\JsonResource; class UserController extends Controller { @@ -19,7 +18,7 @@ class UserController extends Controller * * @throws AuthorizationException */ - public function me(): JsonResource + public function me(): UserResource { $user = $this->user(); diff --git a/app/Http/Requests/V1/Organization/OrganizationUpdateRequest.php b/app/Http/Requests/V1/Organization/OrganizationUpdateRequest.php index babe43fb..8031b15c 100644 --- a/app/Http/Requests/V1/Organization/OrganizationUpdateRequest.php +++ b/app/Http/Requests/V1/Organization/OrganizationUpdateRequest.php @@ -4,9 +4,14 @@ declare(strict_types=1); namespace App\Http\Requests\V1\Organization; +use App\Enums\CurrencyFormat; +use App\Enums\DateFormat; +use App\Enums\IntervalFormat; +use App\Enums\NumberFormat; +use App\Enums\TimeFormat; use App\Models\Organization; -use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; /** * @property Organization $organization Organization from model binding @@ -16,13 +21,12 @@ class OrganizationUpdateRequest extends FormRequest /** * Get the validation rules that apply to the request. * - * @return array> + * @return array> */ public function rules(): array { return [ 'name' => [ - 'required', 'string', 'max:255', ], @@ -35,13 +39,63 @@ class OrganizationUpdateRequest extends FormRequest 'employees_can_see_billable_rates' => [ 'boolean', ], + 'number_format' => [ + Rule::enum(NumberFormat::class), + ], + 'currency_format' => [ + Rule::enum(CurrencyFormat::class), + ], + 'date_format' => [ + Rule::enum(DateFormat::class), + ], + 'interval_format' => [ + Rule::enum(IntervalFormat::class), + ], + 'time_format' => [ + Rule::enum(TimeFormat::class), + ], ]; } + public function getName(): ?string + { + return $this->has('name') ? (string) $this->input('name') : null; + } + + public function getNumberFormat(): ?NumberFormat + { + return $this->has('number_format') ? NumberFormat::from($this->input('number_format')) : null; + } + + public function getCurrencyFormat(): ?CurrencyFormat + { + return $this->has('currency_format') ? CurrencyFormat::from($this->input('currency_format')) : null; + } + + public function getDateFormat(): ?DateFormat + { + return $this->has('date_format') ? DateFormat::from($this->input('date_format')) : null; + } + + public function getIntervalFormat(): ?IntervalFormat + { + return $this->has('interval_format') ? IntervalFormat::from($this->input('interval_format')) : null; + } + + public function getTimeFormat(): ?TimeFormat + { + return $this->has('time_format') ? TimeFormat::from($this->input('time_format')) : null; + } + public function getBillableRate(): ?int { $input = $this->input('billable_rate'); return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null; } + + public function getEmployeesCanSeeBillableRates(): ?bool + { + return $this->has('employees_can_see_billable_rates') ? $this->boolean('employees_can_see_billable_rates') : null; + } } diff --git a/app/Http/Resources/V1/Organization/OrganizationResource.php b/app/Http/Resources/V1/Organization/OrganizationResource.php index 2d33fdc7..5233a2fd 100644 --- a/app/Http/Resources/V1/Organization/OrganizationResource.php +++ b/app/Http/Resources/V1/Organization/OrganizationResource.php @@ -47,6 +47,16 @@ class OrganizationResource extends BaseResource 'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates, /** @var string $currency Currency code (ISO 4217) */ 'currency' => $this->resource->currency, + /** @var string $number_format Number format */ + 'number_format' => $this->resource->number_format->value, + /** @var string $currency_format Currency format */ + 'currency_format' => $this->resource->currency_format->value, + /** @var string $date_format Date format */ + 'date_format' => $this->resource->date_format->value, + /** @var string $interval_format Interval format */ + 'interval_format' => $this->resource->interval_format->value, + /** @var string $time_format Time format */ + 'time_format' => $this->resource->time_format->value, ]; } } diff --git a/app/Http/Resources/V1/Report/DetailedReportResource.php b/app/Http/Resources/V1/Report/DetailedReportResource.php index 3882f7c3..1391f150 100644 --- a/app/Http/Resources/V1/Report/DetailedReportResource.php +++ b/app/Http/Resources/V1/Report/DetailedReportResource.php @@ -30,7 +30,7 @@ class DetailedReportResource extends BaseResource /** @var bool $is_public Whether the report can be accessed via an external link */ 'is_public' => $this->resource->is_public, /** @var string|null $public_until Date until the report is public */ - 'public_until' => $this->resource->public_until?->toIso8601ZuluString(), + 'public_until' => $this->formatDateTime($this->resource->public_until), /** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */ 'shareable_link' => $this->resource->getShareableLink(), 'properties' => [ @@ -41,9 +41,9 @@ class DetailedReportResource extends BaseResource /** @var string $history_group Type of grouping of the historic aggregation (time chart) */ 'history_group' => $this->resource->properties->historyGroup->value, /** @var string $start Start date of the report */ - 'start' => $this->resource->properties->start->toIso8601ZuluString(), + 'start' => $this->formatDateTime($this->resource->properties->start), /** @var string $end End date of the report */ - 'end' => $this->resource->properties->end->toIso8601ZuluString(), + 'end' => $this->formatDateTime($this->resource->properties->end), /** @var bool|null $active Whether the report is active */ 'active' => $this->resource->properties->active, /** @var array|null $member_ids Filter by multiple member IDs, member IDs are OR combined */ @@ -60,9 +60,9 @@ class DetailedReportResource extends BaseResource 'task_ids' => $this->resource->properties->taskIds?->toArray(), ], /** @var string $created_at Date when the report was created */ - 'created_at' => $this->resource->created_at?->toIso8601ZuluString(), + 'created_at' => $this->formatDateTime($this->resource->created_at), /** @var string $updated_at Date when the report was last updated */ - 'updated_at' => $this->resource->updated_at?->toIso8601ZuluString(), + 'updated_at' => $this->formatDateTime($this->resource->updated_at), ]; } } diff --git a/app/Http/Resources/V1/Report/DetailedWithDataReportResource.php b/app/Http/Resources/V1/Report/DetailedWithDataReportResource.php index 1ccec056..a33434b4 100644 --- a/app/Http/Resources/V1/Report/DetailedWithDataReportResource.php +++ b/app/Http/Resources/V1/Report/DetailedWithDataReportResource.php @@ -70,7 +70,7 @@ class DetailedWithDataReportResource extends BaseResource /** @var string|null $email Description */ 'description' => $this->resource->description, /** @var string|null $public_until Date until the report is public */ - 'public_until' => $this->resource->public_until?->toIso8601ZuluString(), + 'public_until' => $this->formatDateTime($this->resource->public_until), /** @var string $currency Currency code (ISO 4217) */ 'currency' => $this->resource->organization->currency, 'properties' => [ @@ -81,9 +81,9 @@ class DetailedWithDataReportResource extends BaseResource /** @var string $history_group Type of grouping of the historic aggregation (time chart) */ 'history_group' => $this->resource->properties->historyGroup->value, /** @var string $start Start date of the report */ - 'start' => $this->resource->properties->start->toIso8601ZuluString(), + 'start' => $this->formatDateTime($this->resource->properties->start), /** @var string $end End date of the report */ - 'end' => $this->resource->properties->end->toIso8601ZuluString(), + 'end' => $this->formatDateTime($this->resource->properties->end), ], /** @var array{ * grouped_type: string|null, diff --git a/app/Http/Resources/V1/Report/ReportResource.php b/app/Http/Resources/V1/Report/ReportResource.php index 2eec3973..23872ce8 100644 --- a/app/Http/Resources/V1/Report/ReportResource.php +++ b/app/Http/Resources/V1/Report/ReportResource.php @@ -30,13 +30,13 @@ class ReportResource extends BaseResource /** @var bool $is_public Whether the report can be accessed via an external link */ 'is_public' => $this->resource->is_public, /** @var string|null $public_until Date until the report is public */ - 'public_until' => $this->resource->public_until?->toIso8601ZuluString(), + 'public_until' => $this->formatDateTime($this->resource->public_until), /** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */ 'shareable_link' => $this->resource->getShareableLink(), /** @var string $created_at Date when the report was created */ - 'created_at' => $this->resource->created_at?->toIso8601ZuluString(), + 'created_at' => $this->formatDateTime($this->resource->created_at), /** @var string $updated_at Date when the report was last updated */ - 'updated_at' => $this->resource->updated_at?->toIso8601ZuluString(), + 'updated_at' => $this->formatDateTime($this->resource->updated_at), ]; } } diff --git a/app/Models/Organization.php b/app/Models/Organization.php index 7dd8a3b0..8d3af098 100644 --- a/app/Models/Organization.php +++ b/app/Models/Organization.php @@ -4,6 +4,11 @@ declare(strict_types=1); namespace App\Models; +use App\Enums\CurrencyFormat; +use App\Enums\DateFormat; +use App\Enums\IntervalFormat; +use App\Enums\NumberFormat; +use App\Enums\TimeFormat; use App\Models\Concerns\CustomAuditable; use App\Models\Concerns\HasUuids; use Database\Factories\OrganizationFactory; @@ -18,7 +23,6 @@ use Illuminate\Support\Str; use Laravel\Jetstream\Events\TeamCreated; use Laravel\Jetstream\Events\TeamDeleted; use Laravel\Jetstream\Events\TeamUpdated; -use Laravel\Jetstream\Jetstream; use Laravel\Jetstream\Team as JetstreamTeam; use OwenIt\Auditing\Contracts\Auditable as AuditableContract; @@ -37,6 +41,11 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract; * @property Collection $realUsers * @property-read Collection $teamInvitations * @property Member $membership + * @property NumberFormat $number_format + * @property CurrencyFormat $currency_format + * @property DateFormat $date_format + * @property IntervalFormat $interval_format + * @property TimeFormat $time_format * * @method HasMany teamInvitations() * @method static OrganizationFactory factory() @@ -60,6 +69,11 @@ class Organization extends JetstreamTeam implements AuditableContract 'personal_team' => 'boolean', 'currency' => 'string', 'employees_can_see_billable_rates' => 'boolean', + 'number_format' => NumberFormat::class, + 'currency_format' => CurrencyFormat::class, + 'date_format' => DateFormat::class, + 'interval_format' => IntervalFormat::class, + 'time_format' => TimeFormat::class, ]; /** @@ -89,7 +103,6 @@ class Organization extends JetstreamTeam implements AuditableContract * @var array */ protected $attributes = [ - 'currency' => 'EUR', ]; /** diff --git a/app/Service/CurrencyService.php b/app/Service/CurrencyService.php new file mode 100644 index 00000000..92391ebe --- /dev/null +++ b/app/Service/CurrencyService.php @@ -0,0 +1,377 @@ +> + */ + private const array CURRENCIES = [ + 'ALL' => [ + 'symbol' => 'L', + ], + 'AFN' => [ + 'symbol' => '؋', + ], + 'ARS' => [ + 'symbol' => '$', + ], + 'AWG' => [ + 'symbol' => 'ƒ', + ], + 'AUD' => [ + 'symbol' => '$', + ], + 'AZN' => [ + 'symbol' => '₼', + ], + 'BSD' => [ + 'symbol' => '$', + ], + 'BBD' => [ + 'symbol' => '$', + ], + 'BDT' => [ + 'symbol' => '৳', + ], + 'BYR' => [ + 'symbol' => 'Br', + ], + 'BZD' => [ + 'symbol' => 'BZ$', + ], + 'BMD' => [ + 'symbol' => '$', + ], + 'BOB' => [ + 'symbol' => '$b', + ], + 'BAM' => [ + 'symbol' => 'KM', + ], + 'BWP' => [ + 'symbol' => 'P', + ], + 'BGN' => [ + 'symbol' => 'лв', + ], + 'BRL' => [ + 'symbol' => 'R$', + ], + 'BND' => [ + 'symbol' => '$', + ], + 'KHR' => [ + 'symbol' => '៛', + ], + 'CAD' => [ + 'symbol' => '$', + ], + 'KYD' => [ + 'symbol' => '$', + ], + 'CLP' => [ + 'symbol' => '$', + ], + 'CNY' => [ + 'symbol' => '¥', + ], + 'COP' => [ + 'symbol' => '$', + ], + 'CRC' => [ + 'symbol' => '₡', + ], + 'HRK' => [ + 'symbol' => 'kn', + ], + 'CUP' => [ + 'symbol' => '₱', + ], + 'CZK' => [ + 'symbol' => 'Kč', + ], + 'DKK' => [ + 'symbol' => 'kr', + ], + 'DOP' => [ + 'symbol' => 'RD$', + ], + 'XCD' => [ + 'symbol' => '$', + ], + 'EGP' => [ + 'symbol' => '£', + ], + 'SVC' => [ + 'symbol' => '$', + ], + 'EEK' => [ + 'symbol' => 'kr', + ], + 'EUR' => [ + 'symbol' => '€', + ], + 'FKP' => [ + 'symbol' => '£', + ], + 'FJD' => [ + 'symbol' => '$', + ], + 'GHC' => [ + 'symbol' => '₵', + ], + 'GIP' => [ + 'symbol' => '£', + ], + 'GTQ' => [ + 'symbol' => 'Q', + ], + 'GGP' => [ + 'symbol' => '£', + ], + 'GYD' => [ + 'symbol' => '$', + ], + 'HNL' => [ + 'symbol' => 'L', + ], + 'HKD' => [ + 'symbol' => '$', + ], + 'HUF' => [ + 'symbol' => 'Ft', + ], + 'ISK' => [ + 'symbol' => 'kr', + ], + 'INR' => [ + 'symbol' => '₹', + ], + 'IDR' => [ + 'symbol' => 'Rp', + ], + 'IRR' => [ + 'symbol' => '﷼', + ], + 'IMP' => [ + 'symbol' => '£', + ], + 'ILS' => [ + 'symbol' => '₪', + ], + 'JMD' => [ + 'symbol' => 'J$', + ], + 'JPY' => [ + 'symbol' => '¥', + ], + 'JEP' => [ + 'symbol' => '£', + ], + 'KZT' => [ + 'symbol' => 'лв', + ], + 'KPW' => [ + 'symbol' => '₩', + ], + 'KRW' => [ + 'symbol' => '₩', + ], + 'KGS' => [ + 'symbol' => 'лв', + ], + 'LAK' => [ + 'symbol' => '₭', + ], + 'LVL' => [ + 'symbol' => 'Ls', + ], + 'LBP' => [ + 'symbol' => '£', + ], + 'LRD' => [ + 'symbol' => '$', + ], + 'LTL' => [ + 'symbol' => 'Lt', + ], + 'MKD' => [ + 'symbol' => 'ден', + ], + 'MYR' => [ + 'symbol' => 'RM', + ], + 'MUR' => [ + 'symbol' => '₨', + ], + 'MXN' => [ + 'symbol' => '$', + ], + 'MNT' => [ + 'symbol' => '₮', + ], + 'MZN' => [ + 'symbol' => 'MT', + ], + 'NAD' => [ + 'symbol' => '$', + ], + 'NPR' => [ + 'symbol' => '₨', + ], + 'ANG' => [ + 'symbol' => 'ƒ', + ], + 'NZD' => [ + 'symbol' => '$', + ], + 'NIO' => [ + 'symbol' => 'C$', + ], + 'NGN' => [ + 'symbol' => '₦', + ], + 'NOK' => [ + 'symbol' => 'kr', + ], + 'OMR' => [ + 'symbol' => '﷼', + ], + 'PKR' => [ + 'symbol' => '₨', + ], + 'PAB' => [ + 'symbol' => 'B/.', + ], + 'PYG' => [ + 'symbol' => 'Gs', + ], + 'PEN' => [ + 'symbol' => 'S/.', + ], + 'PHP' => [ + 'symbol' => '₱', + ], + 'PLN' => [ + 'symbol' => 'zł', + ], + 'QAR' => [ + 'symbol' => '﷼', + ], + 'RON' => [ + 'symbol' => 'lei', + ], + 'RUB' => [ + 'symbol' => '₽', + ], + 'SHP' => [ + 'symbol' => '£', + ], + 'SAR' => [ + 'symbol' => '﷼', + ], + 'RSD' => [ + 'symbol' => 'Дин.', + ], + 'SCR' => [ + 'symbol' => '₨', + ], + 'SGD' => [ + 'symbol' => '$', + ], + 'SBD' => [ + 'symbol' => '$', + ], + 'SOS' => [ + 'symbol' => 'S', + ], + 'ZAR' => [ + 'symbol' => 'R', + ], + 'LKR' => [ + 'symbol' => '₨', + ], + 'SEK' => [ + 'symbol' => 'kr', + ], + 'CHF' => [ + 'symbol' => 'CHF', + ], + 'SRD' => [ + 'symbol' => '$', + ], + 'SYP' => [ + 'symbol' => '£', + ], + 'TWD' => [ + 'symbol' => 'NT$', + ], + 'THB' => [ + 'symbol' => '฿', + ], + 'TTD' => [ + 'symbol' => 'TT$', + ], + 'TRY' => [ + 'symbol' => '₺', + ], + 'TRL' => [ + 'symbol' => '₤', + ], + 'TVD' => [ + 'symbol' => '$', + ], + 'UAH' => [ + 'symbol' => '₴', + ], + 'GBP' => [ + 'symbol' => '£', + ], + 'UGX' => [ + 'symbol' => 'USh', + ], + 'USD' => [ + 'symbol' => '$', + ], + 'UYU' => [ + 'symbol' => '$U', + ], + 'UZS' => [ + 'symbol' => 'лв', + ], + 'VEF' => [ + 'symbol' => 'Bs', + ], + 'VND' => [ + 'symbol' => '₫', + ], + 'YER' => [ + 'symbol' => '﷼', + ], + 'ZWD' => [ + 'symbol' => 'Z$', + ], + ]; + + public function getCurrencySymbolForMoney(Money $money): string + { + return $this->getCurrencySymbol($money->getCurrency()->getCurrencyCode()); + } + + public function getCurrencySymbol(string $currencyCode): string + { + if (isset(self::CURRENCIES[$currencyCode]['symbol'])) { + return self::CURRENCIES[$currencyCode]['symbol']; + } + + return $currencyCode; + } +} diff --git a/app/Service/LocalizationService.php b/app/Service/LocalizationService.php new file mode 100644 index 00000000..e8964513 --- /dev/null +++ b/app/Service/LocalizationService.php @@ -0,0 +1,144 @@ +currencyFormat = $currencyFormat; + $this->dateFormat = $dateFormat; + $this->timeFormat = $timeFormat; + $this->numberFormat = $numberFormat; + $this->intervalFormat = $intervalFormat; + } + + public static function forOrganization(Organization $organization): self + { + return new LocalizationService( + $organization->currency_format, + $organization->date_format, + $organization->time_format, + $organization->number_format, + $organization->interval_format + ); + } + + public function formatNumber(BigDecimal|float $number): string + { + $numberFloat = $number instanceof BigDecimal ? $number->toFloat() : $number; + + if ($this->numberFormat === NumberFormat::ThousandsPointDecimalComma) { + return number_format($numberFloat, 2, ',', '.'); + } elseif ($this->numberFormat === NumberFormat::ThousandsSpaceDecimalPoint) { + return number_format($numberFloat, 2, '.', ' '); + } elseif ($this->numberFormat === NumberFormat::ThousandsCommaDecimalPoint) { + return number_format($numberFloat, 2, '.', ','); + } elseif ($this->numberFormat === NumberFormat::ThousandsSpaceDecimalComma) { + return number_format($numberFloat, 2, ',', ' '); + } elseif ($this->numberFormat === NumberFormat::ThousandsApostropheDecimalPoint) { + return number_format($numberFloat, 2, '.', '\''); + } + } + + public function formatInterval(CarbonInterval $interval): string + { + if ($this->intervalFormat === IntervalFormat::Decimal) { + $interval->cascade(); + + return $this->formatNumber($interval->totalHours); + } elseif ($this->intervalFormat === IntervalFormat::HoursMinutes) { + $interval->cascade(); + + return ((int) floor($interval->totalHours)).'h '.$interval->format('%I').'m'; + } elseif ($this->intervalFormat === IntervalFormat::HoursMinutesColonSeperated) { + $interval->cascade(); + + return ((int) floor($interval->totalHours)).':'.$interval->format('%I'); + } elseif ($this->intervalFormat === IntervalFormat::HoursMinutesSecondsColonSeperated) { + $interval->cascade(); + + return ((int) floor($interval->totalHours)).':'.$interval->format('%I:%S'); + } + } + + public function formatCurrency(Money $money): string + { + $currencyService = app(CurrencyService::class); + if ($this->currencyFormat === CurrencyFormat::ISOCodeAfterWithSpace) { + return $this->formatNumber($money->getAmount()).' '.$money->getCurrency()->getCurrencyCode(); + } elseif ($this->currencyFormat === CurrencyFormat::ISOCodeBeforeWithSpace) { + return $money->getCurrency()->getCurrencyCode().' '.$this->formatNumber($money->getAmount()); + } elseif ($this->currencyFormat === CurrencyFormat::SymbolAfter) { + return $this->formatNumber($money->getAmount()).$currencyService->getCurrencySymbolForMoney($money); + } elseif ($this->currencyFormat === CurrencyFormat::SymbolBefore) { + return $currencyService->getCurrencySymbolForMoney($money).$this->formatNumber($money->getAmount()); + } elseif ($this->currencyFormat === CurrencyFormat::SymbolBeforeWithSpace) { + return $currencyService->getCurrencySymbolForMoney($money).' '.$this->formatNumber($money->getAmount()); + } elseif ($this->currencyFormat === CurrencyFormat::SymbolAfterWithSpace) { + return $this->formatNumber($money->getAmount()).' '.$currencyService->getCurrencySymbolForMoney($money); + } + } + + public function formatTime(CarbonInterface $time): string + { + if ($this->timeFormat === TimeFormat::TwelveHours) { + return $time->format('h:i a'); // Examples: "11:01 am", "1:02 am" + } elseif ($this->timeFormat === TimeFormat::TwentyFourHours) { + return $time->format('H:i'); // Examples: "23:01", "01:02" + } + } + + public function formatDate(CarbonInterface $date): string + { + return $date->format($this->dateFormat->toCarbonFormat()); + } + + public function setDateFormat(DateFormat $dateFormat): void + { + $this->dateFormat = $dateFormat; + } + + public function setCurrencyFormat(CurrencyFormat $currencyFormat): void + { + $this->currencyFormat = $currencyFormat; + } + + public function setIntervalFormat(IntervalFormat $intervalFormat): void + { + $this->intervalFormat = $intervalFormat; + } + + public function setTimeFormat(TimeFormat $timeFormat): void + { + $this->timeFormat = $timeFormat; + } + + public function setNumberFormat(NumberFormat $numberFormat): void + { + $this->numberFormat = $numberFormat; + } +} diff --git a/app/Service/OrganizationService.php b/app/Service/OrganizationService.php new file mode 100644 index 00000000..1697a782 --- /dev/null +++ b/app/Service/OrganizationService.php @@ -0,0 +1,68 @@ +name = $name; + $organization->personal_team = $personalOrganization; + if ($currency === null) { + $currency = config('app.localization.default_currency'); + } + $organization->currency = $currency; + if ($numberFormat === null) { + $numberFormat = NumberFormat::from(config('app.localization.default_number_format')); + } + $organization->number_format = $numberFormat; + if ($currencyFormat === null) { + $currencyFormat = CurrencyFormat::from(config('app.localization.default_currency_format')); + } + $organization->currency_format = $currencyFormat; + if ($dateFormat === null) { + $dateFormat = DateFormat::from(config('app.localization.default_date_format')); + } + $organization->date_format = $dateFormat; + if ($intervalFormat === null) { + $intervalFormat = IntervalFormat::from(config('app.localization.default_interval_format')); + } + $organization->interval_format = $intervalFormat; + if ($timeFormat === null) { + $timeFormat = TimeFormat::from(config('app.localization.default_time_format')); + } + $organization->time_format = $timeFormat; + $organization->owner()->associate($owner); + $organization->save(); + + $organization->users()->attach( + $owner, [ + 'role' => Role::Owner->value, + ] + ); + + return $organization; + } +} diff --git a/app/Service/ReportExport/TimeEntriesDetailedExport.php b/app/Service/ReportExport/TimeEntriesDetailedExport.php index 10bf8826..e871a922 100644 --- a/app/Service/ReportExport/TimeEntriesDetailedExport.php +++ b/app/Service/ReportExport/TimeEntriesDetailedExport.php @@ -6,7 +6,7 @@ namespace App\Service\ReportExport; use App\Enums\ExportFormat; use App\Models\TimeEntry; -use App\Service\IntervalService; +use App\Service\LocalizationService; use Illuminate\Database\Eloquent\Builder; use LogicException; use Maatwebsite\Excel\Concerns\Exportable; @@ -37,14 +37,17 @@ class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumn private string $timezone; + private LocalizationService $localizationService; + /** * @param Builder $builder */ - public function __construct(Builder $builder, ExportFormat $exportFormat, string $timezone) + public function __construct(Builder $builder, ExportFormat $exportFormat, string $timezone, LocalizationService $localizationService) { $this->builder = $builder; $this->exportFormat = $exportFormat; $this->timezone = $timezone; + $this->localizationService = $localizationService; } /** @@ -113,7 +116,6 @@ class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumn */ public function map($model): array { - $interval = app(IntervalService::class); $duration = $model->getDuration(); if ($this->exportFormat === ExportFormat::XLSX) { @@ -125,7 +127,7 @@ class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumn $model->user->name, Date::dateTimeToExcel($model->start->timezone($this->timezone)), $model->end !== null ? Date::dateTimeToExcel($model->end->timezone($this->timezone)) : null, - $duration !== null ? $interval->format($duration) : null, + $duration !== null ? $this->localizationService->formatInterval($duration) : null, $duration?->totalHours, $model->billable ? 'Yes' : 'No', $model->tagsRelation->pluck('name')->implode(', '), @@ -139,7 +141,7 @@ class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumn $model->user->name, $model->start->timezone($this->timezone)->format('Y-m-d H:i:s'), $model->end?->timezone($this->timezone)?->format('Y-m-d H:i:s'), - $duration !== null ? (int) floor($duration->totalHours).':'.$duration->format('%I:%S') : null, + $duration !== null ? $this->localizationService->formatInterval($duration) : null, $duration?->totalHours, $model->billable ? 'Yes' : 'No', $model->tagsRelation->pluck('name')->implode(', '), diff --git a/app/Service/UserService.php b/app/Service/UserService.php index e0871e77..6c6f42a0 100644 --- a/app/Service/UserService.php +++ b/app/Service/UserService.php @@ -4,7 +4,12 @@ declare(strict_types=1); namespace App\Service; +use App\Enums\CurrencyFormat; +use App\Enums\DateFormat; +use App\Enums\IntervalFormat; +use App\Enums\NumberFormat; use App\Enums\Role; +use App\Enums\TimeFormat; use App\Enums\Weekday; use App\Events\AfterCreateOrganization; use App\Models\Member; @@ -17,8 +22,20 @@ use Illuminate\Support\Facades\Hash; class UserService { - public function createUser(string $name, string $email, string $password, string $timezone, Weekday $weekStart, string $currency, bool $verifyEmail = false): User - { + public function createUser( + string $name, + string $email, + string $password, + string $timezone, + Weekday $weekStart, + ?string $currency, + ?NumberFormat $numberFormat = null, + ?CurrencyFormat $currencyFormat = null, + ?DateFormat $dateFormat = null, + ?IntervalFormat $intervalFormat = null, + ?TimeFormat $timeFormat = null, + bool $verifyEmail = false + ): User { $user = new User; $user->name = $name; $user->email = $email; @@ -30,17 +47,16 @@ class UserService } $user->save(); - $organization = new Organization; - $organization->name = explode(' ', $user->name, 2)[0]."'s Organization"; - $organization->personal_team = true; - $organization->currency = $currency; - $organization->owner()->associate($user); - $organization->save(); - - $organization->users()->attach( - $user, [ - 'role' => Role::Owner->value, - ] + $organization = app(OrganizationService::class)->createOrganization( + $this->getOrganizationNameForUserName($user->name), + $user, + true, + $currency, + $numberFormat, + $currencyFormat, + $dateFormat, + $intervalFormat, + $timeFormat, ); $user->ownedTeams()->save($organization); @@ -78,14 +94,11 @@ class UserService } // Create a new organization - $organization = new Organization; - $organization->name = $user->name."'s Organization"; - $organization->personal_team = true; - $organization->user_id = $user->id; - $organization->save(); - - // Attach the user to the organization - $organization->users()->attach($user, ['role' => Role::Owner->value]); + $organization = app(OrganizationService::class)->createOrganization( + $this->getOrganizationNameForUserName($user->name), + $user, + true + ); // Set the organization as the user's current organization $user->currentOrganization()->associate($organization); @@ -94,6 +107,11 @@ class UserService AfterCreateOrganization::dispatch($organization); } + public function getOrganizationNameForUserName(string $username): string + { + return explode(' ', $username, 2)[0]."'s Organization"; + } + public function makeSureUserHasCurrentOrganization(User $user): void { if ($user->currentOrganization !== null) { diff --git a/config/app.php b/config/app.php index 35a13f39..4cc14159 100644 --- a/config/app.php +++ b/config/app.php @@ -2,6 +2,11 @@ declare(strict_types=1); +use App\Enums\CurrencyFormat; +use App\Enums\DateFormat; +use App\Enums\IntervalFormat; +use App\Enums\NumberFormat; +use App\Enums\TimeFormat; use Illuminate\Support\Facades\Facade; use Illuminate\Support\ServiceProvider; @@ -138,6 +143,15 @@ return [ 'cipher' => 'AES-256-CBC', + 'localization' => [ + 'default_currency' => env('LOCALIZATION_DEFAULT_CURRENCY', 'EUR'), + 'default_number_format' => env('LOCALIZATION_DEFAULT_NUMBER_FORMAT', NumberFormat::ThousandsPointDecimalComma->value), + 'default_currency_format' => env('LOCALIZATION_DEFAULT_CURRENCY_FORMAT', CurrencyFormat::ISOCodeAfterWithSpace->value), + 'default_date_format' => env('LOCALIZATION_DEFAULT_DATE_FORMAT', DateFormat::HyphenSeperatedYYYYMMDD->value), + 'default_time_format' => env('LOCALIZATION_DEFAULT_TIME_FORMAT', TimeFormat::TwentyFourHours->value), + 'default_interval_format' => env('LOCALIZATION_DEFAULT_INTERVAL_FORMAT', IntervalFormat::HoursMinutes->value), + ], + /* |-------------------------------------------------------------------------- | Maintenance Mode Driver diff --git a/database/factories/OrganizationFactory.php b/database/factories/OrganizationFactory.php index 5f19e124..f24cc25d 100644 --- a/database/factories/OrganizationFactory.php +++ b/database/factories/OrganizationFactory.php @@ -4,6 +4,11 @@ declare(strict_types=1); namespace Database\Factories; +use App\Enums\CurrencyFormat; +use App\Enums\DateFormat; +use App\Enums\IntervalFormat; +use App\Enums\NumberFormat; +use App\Enums\TimeFormat; use App\Models\Organization; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; @@ -27,6 +32,11 @@ class OrganizationFactory extends Factory 'user_id' => User::factory(), 'personal_team' => true, 'employees_can_see_billable_rates' => false, + 'number_format' => $this->faker->randomElement(NumberFormat::values()), + 'currency_format' => $this->faker->randomElement(CurrencyFormat::values()), + 'date_format' => $this->faker->randomElement(DateFormat::values()), + 'interval_format' => $this->faker->randomElement(IntervalFormat::values()), + 'time_format' => $this->faker->randomElement(TimeFormat::values()), ]; } diff --git a/database/migrations/2025_04_03_101827_add_localization_columns_to_organizations_table.php b/database/migrations/2025_04_03_101827_add_localization_columns_to_organizations_table.php new file mode 100644 index 00000000..36c553ca --- /dev/null +++ b/database/migrations/2025_04_03_101827_add_localization_columns_to_organizations_table.php @@ -0,0 +1,46 @@ +string('number_format')->default(config('app.localization.default_number_format'))->nullable(false); + $table->string('currency_format')->default(config('app.localization.default_currency_format'))->nullable(false); + $table->string('date_format')->default(config('app.localization.default_date_format'))->nullable(false); + $table->string('interval_format')->default(config('app.localization.default_interval_format'))->nullable(false); + $table->string('time_format')->default(config('app.localization.default_time_format'))->nullable(false); + }); + + Schema::table('organizations', function (Blueprint $table): void { + $table->string('number_format')->default(null)->nullable(false)->change(); + $table->string('currency_format')->default(null)->nullable(false)->change(); + $table->string('date_format')->default(null)->nullable(false)->change(); + $table->string('interval_format')->default(null)->nullable(false)->change(); + $table->string('time_format')->default(null)->nullable(false)->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('organizations', function (Blueprint $table): void { + $table->dropColumn('number_format'); + $table->dropColumn('currency_format'); + $table->dropColumn('date_format'); + $table->dropColumn('interval_format'); + $table->dropColumn('time_format'); + }); + } +}; diff --git a/lang/en/enum.php b/lang/en/enum.php index 6fb1cf7b..9bc60571 100644 --- a/lang/en/enum.php +++ b/lang/en/enum.php @@ -2,6 +2,11 @@ declare(strict_types=1); +use App\Enums\CurrencyFormat; +use App\Enums\DateFormat; +use App\Enums\IntervalFormat; +use App\Enums\NumberFormat; +use App\Enums\TimeFormat; use App\Enums\Weekday; return [ @@ -16,4 +21,42 @@ return [ Weekday::Sunday->value => 'Sunday', ], + 'number_format' => [ + NumberFormat::ThousandsPointDecimalComma->value => '1.111,11', + NumberFormat::ThousandsCommaDecimalPoint->value => '1,111.11', + NumberFormat::ThousandsSpaceDecimalComma->value => '1 111,11', + NumberFormat::ThousandsSpaceDecimalPoint->value => '1 111.11', + NumberFormat::ThousandsApostropheDecimalPoint->value => '1\'111.11', + ], + + 'date_format' => [ + DateFormat::PointSeperatedDMYYYY->value => 'D.M.YYYY', + DateFormat::SlashSeperatedMMDDYYYY->value => 'MM/DD/YYYY', + DateFormat::SlashSeperatedDDMMYYYY->value => 'DD/MM/YYYY', + DateFormat::HyphenSeperatedDDMMYYY->value => 'DD-MM-YYYY', + DateFormat::HyphenSeperatedMMDDDYYYY->value => 'MM-DD-YYYY', + DateFormat::HyphenSeperatedYYYYMMDD->value => 'YYYY-MM-DD', + ], + + 'time_format' => [ + TimeFormat::TwelveHours->value => '12-hour clock', + TimeFormat::TwentyFourHours->value => '24-hour clock', + ], + + 'interval_format' => [ + IntervalFormat::Decimal->value => 'Decimal', + IntervalFormat::HoursMinutes->value => '12h 3m', + IntervalFormat::HoursMinutesColonSeperated->value => '12:03', + IntervalFormat::HoursMinutesSecondsColonSeperated->value => '12:03:45', + ], + + 'currency_format' => [ + CurrencyFormat::ISOCodeBeforeWithSpace->value => 'EUR 111', + CurrencyFormat::ISOCodeAfterWithSpace->value => '111 EUR', + CurrencyFormat::SymbolBefore->value => '€111', + CurrencyFormat::SymbolAfter->value => '111€', + CurrencyFormat::SymbolBeforeWithSpace->value => '€ 111', + CurrencyFormat::SymbolAfterWithSpace->value => '111 €', + ], + ]; diff --git a/resources/views/reports/time-entry-aggregate/pdf.blade.php b/resources/views/reports/time-entry-aggregate/pdf.blade.php index 01d4fcdb..68757b23 100644 --- a/resources/views/reports/time-entry-aggregate/pdf.blade.php +++ b/resources/views/reports/time-entry-aggregate/pdf.blade.php @@ -2,7 +2,6 @@ @use('Brick\Money\Money') @use('PhpOffice\PhpSpreadsheet\Cell\DataType') @use('Carbon\CarbonInterval') -@inject('interval', 'App\Service\IntervalService') @inject('colorService', 'App\Service\ColorService') @@ -139,7 +138,7 @@

Report

- {{ $start->timezone($timezone)->format('d.m.Y') }} - {{ $end->timezone($timezone)->format('d.m.Y') }}

+ {{ $localization->formatDate($start->timezone($timezone)) }} - {{ $localization->formatDate($end->timezone($timezone)) }}

@@ -151,12 +150,12 @@
Duration
{{ $interval->format(CarbonInterval::seconds($aggregatedData['seconds'])) }}
+ style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $localization->formatInterval(CarbonInterval::seconds($aggregatedData['seconds'])) }}
Total cost
{{ Money::of(BigDecimal::ofUnscaledValue($aggregatedData['cost'], 2)->__toString(), $currency)->formatTo('en_US') }}
+ style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $localization->formatCurrency(Money::of(BigDecimal::ofUnscaledValue($aggregatedData['cost'], 2)->__toString(), $currency)) }}
@@ -200,10 +199,10 @@ - {{ $interval->format(CarbonInterval::seconds($group1Entry['seconds'])) }} + {{ $localization->formatInterval(CarbonInterval::seconds($group1Entry['seconds'])) }} - {{ Money::of(BigDecimal::ofUnscaledValue($group1Entry['cost'], 2)->__toString(), $currency)->formatTo('en_US') }} + {{ $localization->formatCurrency(Money::of(BigDecimal::ofUnscaledValue($group1Entry['cost'], 2)->__toString(), $currency)) }} @@ -214,10 +213,10 @@ Total - {{ $interval->format(CarbonInterval::seconds($aggregatedData['seconds'])) }} + {{ $localization->formatInterval(CarbonInterval::seconds($aggregatedData['seconds'])) }} - {{ Money::of(BigDecimal::ofUnscaledValue($aggregatedData['cost'], 2)->__toString(), $currency)->formatTo('en_US') }} + {{ $localization->formatCurrency(Money::of(BigDecimal::ofUnscaledValue($aggregatedData['cost'], 2)->__toString(), $currency)) }} @@ -278,13 +277,13 @@ @endif - {{ $interval->format($duration) }} + {{ $localization->formatInterval($duration) }} - {{ round($duration->totalHours, 2) }} + {{ $localization->formatNumber($duration->totalHours) }} - {{ Money::of(BigDecimal::ofUnscaledValue($group2Entry['cost'], 2)->__toString(), $currency)->formatTo('en_US') }} + {{ $localization->formatCurrency(Money::of(BigDecimal::ofUnscaledValue($group2Entry['cost'], 2)->__toString(), $currency)) }} @php diff --git a/resources/views/reports/time-entry-index/pdf.blade.php b/resources/views/reports/time-entry-index/pdf.blade.php index 94e3f1f0..bd1189ee 100644 --- a/resources/views/reports/time-entry-index/pdf.blade.php +++ b/resources/views/reports/time-entry-index/pdf.blade.php @@ -130,7 +130,7 @@

Detailed Report

- {{ $start->timezone($timezone)->format('d.m.Y') }} - {{ $end->timezone($timezone)->format('d.m.Y') }}

+ {{ $localization->formatDate($start->timezone($timezone)) }} - {{ $localization->formatDate($end->timezone($timezone)) }}

@@ -139,12 +139,12 @@
Duration
{{ $interval->format(CarbonInterval::seconds($aggregatedData['seconds'])) }}
+ style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $localization->formatInterval(CarbonInterval::seconds($aggregatedData['seconds'])) }}
Total cost
{{ Money::of(BigDecimal::ofUnscaledValue($aggregatedData['cost'], 2)->__toString(), $currency)->formatTo('en_US') }}
+ style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $localization->formatCurrency(Money::of(BigDecimal::ofUnscaledValue($aggregatedData['cost'], 2)->__toString(), $currency)) }}
@@ -180,15 +180,15 @@ {{ $timeEntry->user->name }} @if($timeEntry->start->timezone($timezone)->format('Y-m-d') === $timeEntry->end->timezone($timezone)->format('Y-m-d')) - {{ $timeEntry->start->timezone($timezone)->format('Y-m-d') }} + {{ $localization->formatDate($timeEntry->start->timezone($timezone)) }} @else - {{ $timeEntry->start->timezone($timezone)->format('Y-m-d') }} -
{{ $timeEntry->end->timezone($timezone)->format('Y-m-d') }} + {{ $localization->formatDate($timeEntry->start->timezone($timezone)) }} -
{{ $localization->formatDate($timeEntry->end->timezone($timezone)) }} @endif
- {{ $timeEntry->start->timezone($timezone)->format('H:i:s') }} - {{ $timeEntry->end->timezone($timezone)->format('H:i:s') }} + {{ $localization->formatDate($timeEntry->start->timezone($timezone)) }} - {{ $localization->formatDate($timeEntry->end->timezone($timezone)) }} - {{ $interval->format($timeEntry->getDuration()) }} + {{ $localization->formatInterval($timeEntry->getDuration()) }} {{ $timeEntry->billable ? 'Yes' : 'No' }} {{ count($timeEntry->tagsRelation) === 0 ? '-' : $timeEntry->tagsRelation->implode('name', ', ') }} diff --git a/tests/Unit/Endpoint/Api/V1/OrganizationEndpointTest.php b/tests/Unit/Endpoint/Api/V1/OrganizationEndpointTest.php index d8599cbc..72ed9c53 100644 --- a/tests/Unit/Endpoint/Api/V1/OrganizationEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/OrganizationEndpointTest.php @@ -110,7 +110,7 @@ class OrganizationEndpointTest extends ApiEndpointTestAbstract $response->assertForbidden(); } - public function test_update_endpoint_updates_project(): void + public function test_update_endpoint_can_update_the_organization_name(): void { // Arrange $data = $this->createUserWithPermission([ @@ -123,14 +123,55 @@ class OrganizationEndpointTest extends ApiEndpointTestAbstract // Act $response = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [ 'name' => $organizationFake->name, - 'billable_rate' => $organizationFake->billable_rate, + 'billable_rate' => null, ]); // Assert $response->assertStatus(200); $this->assertDatabaseHas(Organization::class, [ 'name' => $organizationFake->name, - 'billable_rate' => $organizationFake->billable_rate, + ]); + } + + public function test_update_endpoint_can_update_formats(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'organizations:update', + ]); + $this->assertBillableRateServiceIsUnused(); + $organizationFake = Organization::factory()->make(); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [ + 'name' => $organizationFake->name, + 'number_format' => $organizationFake->number_format->value, + 'currency_format' => $organizationFake->currency_format->value, + 'date_format' => $organizationFake->date_format->value, + 'interval_format' => $organizationFake->interval_format->value, + 'time_format' => $organizationFake->time_format->value, + ]); + + // Assert + $response->assertStatus(200); + $response->assertJson([ + 'data' => [ + 'id' => $data->organization->getKey(), + 'number_format' => $organizationFake->number_format->value, + 'currency_format' => $organizationFake->currency_format->value, + 'date_format' => $organizationFake->date_format->value, + 'interval_format' => $organizationFake->interval_format->value, + 'time_format' => $organizationFake->time_format->value, + ], + ]); + $this->assertDatabaseHas(Organization::class, [ + 'name' => $organizationFake->name, + 'number_format' => $organizationFake->number_format, + 'currency_format' => $organizationFake->currency_format, + 'date_format' => $organizationFake->date_format, + 'interval_format' => $organizationFake->interval_format, + 'time_format' => $organizationFake->time_format, ]); } @@ -146,14 +187,21 @@ class OrganizationEndpointTest extends ApiEndpointTestAbstract // Act $response = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [ - 'name' => $organizationFake->name, 'billable_rate' => $organizationFake->billable_rate, ]); // Assert $response->assertStatus(200); + $response->assertJson([ + 'data' => [ + 'id' => $data->organization->getKey(), + 'name' => $data->organization->name, + 'billable_rate' => $organizationFake->billable_rate, + ], + ]); $this->assertDatabaseHas(Organization::class, [ - 'name' => $organizationFake->name, + 'id' => $data->organization->getKey(), + 'name' => $data->organization->name, 'billable_rate' => $organizationFake->billable_rate, ]); } diff --git a/tests/Unit/Service/CurrencyServiceTest.php b/tests/Unit/Service/CurrencyServiceTest.php new file mode 100644 index 00000000..44a6889e --- /dev/null +++ b/tests/Unit/Service/CurrencyServiceTest.php @@ -0,0 +1,97 @@ +currencyService = new CurrencyService; + } + + public function test_get_currency_symbol_for_currency_eur(): void + { + // Arrange + $money = Money::of(1, Currency::of('EUR')); + + // Act + $symbol = $this->currencyService->getCurrencySymbolForMoney($money); + + // Assert + $this->assertSame('€', $symbol); + } + + public function test_get_currency_symbol_for_currency_usd(): void + { + // Arrange + $money = Money::of(1, Currency::of('USD')); + + // Act + $symbol = $this->currencyService->getCurrencySymbolForMoney($money); + + // Assert + $this->assertSame('$', $symbol); + } + + public function test_get_currency_symbol_for_currency_gbp(): void + { + // Arrange + $money = Money::of(1, Currency::of('GBP')); + + // Act + $symbol = $this->currencyService->getCurrencySymbolForMoney($money); + + // Assert + $this->assertSame('£', $symbol); + } + + public function test_get_currency_symbol_for_currency_cad(): void + { + // Arrange + $money = Money::of(1, Currency::of('CAD')); + + // Act + $symbol = $this->currencyService->getCurrencySymbolForMoney($money); + + // Assert + $this->assertSame('$', $symbol); + } + + public function test_get_currency_symbol_for_currency_cop(): void + { + // Arrange + $money = Money::of(1, Currency::of('COP')); + + // Act + $symbol = $this->currencyService->getCurrencySymbolForMoney($money); + + // Assert + $this->assertSame('$', $symbol); + } + + public function test_get_currency_symbol_for_currency_without_known_symbol(): void + { + // Arrange + $currency = 'XXX'; + + // Act + $symbol = $this->currencyService->getCurrencySymbol($currency); + + // Assert + $this->assertSame('XXX', $symbol); + } +} diff --git a/tests/Unit/Service/LocalizationServiceTest.php b/tests/Unit/Service/LocalizationServiceTest.php new file mode 100644 index 00000000..3bbd8057 --- /dev/null +++ b/tests/Unit/Service/LocalizationServiceTest.php @@ -0,0 +1,256 @@ +localizationService = new LocalizationService( + CurrencyFormat::SymbolAfterWithSpace, + DateFormat::PointSeperatedDMYYYY, + TimeFormat::TwelveHours, + NumberFormat::ThousandsPointDecimalComma, + IntervalFormat::Decimal, + ); + } + + public function test_format_interval_with_type_decimal_and_number_format_thousands_comma_decimal_point(): void + { + // Arrange + $interval = CarbonInterval::seconds(4 + (60 * 3) + (60 * 60 * 30001)); + $this->localizationService->setIntervalFormat(IntervalFormat::Decimal); + $this->localizationService->setNumberFormat(NumberFormat::ThousandsCommaDecimalPoint); + + // Act + $formatted = $this->localizationService->formatInterval($interval); + + // Assert + $this->assertSame('30,001.05', $formatted); + } + + public function test_format_interval_with_type_decimal_and_number_format_thousands_space_decimal_point(): void + { + // Arrange + $interval = CarbonInterval::seconds(4 + (60 * 3) + (60 * 60 * 30001)); + $this->localizationService->setIntervalFormat(IntervalFormat::Decimal); + $this->localizationService->setNumberFormat(NumberFormat::ThousandsSpaceDecimalPoint); + + // Act + $formatted = $this->localizationService->formatInterval($interval); + + // Assert + $this->assertSame('30 001.05', $formatted); + } + + public function test_format_interval_with_type_decimal_and_number_format_thousands_point_decimal_comma(): void + { + // Arrange + $interval = CarbonInterval::seconds(4 + (60 * 3) + (60 * 60 * 30001)); + $this->localizationService->setIntervalFormat(IntervalFormat::Decimal); + $this->localizationService->setNumberFormat(NumberFormat::ThousandsPointDecimalComma); + + // Act + $formatted = $this->localizationService->formatInterval($interval); + + // Assert + $this->assertSame('30.001,05', $formatted); + } + + public function test_format_interval_with_type_decimal_and_number_format_thousands_apostrophe_decimal_point(): void + { + // Arrange + $interval = CarbonInterval::seconds(4 + (60 * 3) + (60 * 60 * 30001)); + $this->localizationService->setIntervalFormat(IntervalFormat::Decimal); + $this->localizationService->setNumberFormat(NumberFormat::ThousandsApostropheDecimalPoint); + + // Act + $formatted = $this->localizationService->formatInterval($interval); + + // Assert + $this->assertSame('30\'001.05', $formatted); + } + + public function test_format_interval_with_type_hours_minutes(): void + { + // Arrange + $interval = CarbonInterval::seconds(4 + (60 * 3) + (60 * 60 * 30001)); + $this->localizationService->setIntervalFormat(IntervalFormat::HoursMinutes); + + // Act + $formatted = $this->localizationService->formatInterval($interval); + + // Assert + $this->assertSame('30001h 03m', $formatted); + } + + public function test_format_interval_with_type_hours_minutes_colon_seperated(): void + { + // Arrange + $interval = CarbonInterval::seconds(4 + (60 * 3) + (60 * 60 * 30001)); + $this->localizationService->setIntervalFormat(IntervalFormat::HoursMinutesColonSeperated); + + // Act + $formatted = $this->localizationService->formatInterval($interval); + + // Assert + $this->assertSame('30001:03', $formatted); + } + + public function test_format_interval_with_type_hours_minutes_seconds_colon_seperated(): void + { + // Arrange + $interval = CarbonInterval::seconds(4 + (60 * 3) + (60 * 60 * 30001)); + $this->localizationService->setIntervalFormat(IntervalFormat::HoursMinutesSecondsColonSeperated); + + // Act + $formatted = $this->localizationService->formatInterval($interval); + + // Assert + $this->assertSame('30001:03:04', $formatted); + } + + public function test_format_currency_with_type_symbol_after_with_space_and_number_format_thousands_space_decimal_comma(): void + { + // Arrange + $this->localizationService->setCurrencyFormat(CurrencyFormat::SymbolAfterWithSpace); + $this->localizationService->setNumberFormat(NumberFormat::ThousandsSpaceDecimalComma); + $money = Money::of(1234567.89, Currency::of('EUR')); + + // Act + $formatted = $this->localizationService->formatCurrency($money); + + // Assert + $this->assertSame('1 234 567,89 €', $formatted); + } + + public function test_format_currency_with_type_symbol_before_with_space_and_number_format_thousands_space_decimal_comma(): void + { + // Arrange + $this->localizationService->setCurrencyFormat(CurrencyFormat::SymbolBeforeWithSpace); + $this->localizationService->setNumberFormat(NumberFormat::ThousandsSpaceDecimalComma); + $money = Money::of(1234567.89, Currency::of('EUR')); + + // Act + $formatted = $this->localizationService->formatCurrency($money); + + // Assert + $this->assertSame('€ 1 234 567,89', $formatted); + } + + public function test_format_currency_with_type_symbol_before_and_number_format_thousands_space_decimal_comma(): void + { + // Arrange + $this->localizationService->setCurrencyFormat(CurrencyFormat::SymbolBefore); + $this->localizationService->setNumberFormat(NumberFormat::ThousandsSpaceDecimalComma); + $money = Money::of(1234567.89, Currency::of('EUR')); + + // Act + $formatted = $this->localizationService->formatCurrency($money); + + // Assert + $this->assertSame('€1 234 567,89', $formatted); + } + + public function test_format_currency_with_type_symbol_after_and_number_format_thousands_space_decimal_comma(): void + { + // Arrange + $this->localizationService->setCurrencyFormat(CurrencyFormat::SymbolAfter); + $this->localizationService->setNumberFormat(NumberFormat::ThousandsSpaceDecimalComma); + $money = Money::of(1234567.89, Currency::of('EUR')); + + // Act + $formatted = $this->localizationService->formatCurrency($money); + + // Assert + $this->assertSame('1 234 567,89€', $formatted); + } + + public function test_format_currency_with_type_iso_code_after_with_space_and_number_format_thousands_space_decimal_comma(): void + { + // Arrange + $this->localizationService->setCurrencyFormat(CurrencyFormat::ISOCodeAfterWithSpace); + $this->localizationService->setNumberFormat(NumberFormat::ThousandsSpaceDecimalComma); + $money = Money::of(1234567.89, Currency::of('EUR')); + + // Act + $formatted = $this->localizationService->formatCurrency($money); + + // Assert + $this->assertSame('1 234 567,89 EUR', $formatted); + } + + public function test_format_currency_with_type_iso_code_before_with_space_and_number_format_thousands_space_decimal_comma(): void + { + // Arrange + $this->localizationService->setCurrencyFormat(CurrencyFormat::ISOCodeBeforeWithSpace); + $this->localizationService->setNumberFormat(NumberFormat::ThousandsSpaceDecimalComma); + $money = Money::of(1234567.89, Currency::of('EUR')); + + // Act + $formatted = $this->localizationService->formatCurrency($money); + + // Assert + $this->assertSame('EUR 1 234 567,89', $formatted); + } + + public function test_format_date_with_type_slash_seperated_ddmmy(): void + { + // Arrange + $this->localizationService->setDateFormat(DateFormat::SlashSeperatedDDMMYYYY); + $date = Carbon::createFromDate(2001, 2, 3); + + // Act + $formatted = $this->localizationService->formatDate($date); + + // Assert + $this->assertSame('03/02/2001', $formatted); + } + + public function test_format_time_with_type_twelve_hours(): void + { + // Arrange + $this->localizationService->setTimeFormat(TimeFormat::TwelveHours); + $time = Carbon::createFromTime(19, 9, 8); + + // Act + $formatted = $this->localizationService->formatTime($time); + + // Assert + $this->assertSame('07:09 pm', $formatted); + } + + public function test_format_time_with_type_twenty_four_hours(): void + { + // Arrange + $this->localizationService->setTimeFormat(TimeFormat::TwentyFourHours); + $time = Carbon::createFromTime(14, 9, 8); + + // Act + $formatted = $this->localizationService->formatTime($time); + + // Assert + $this->assertSame('14:09', $formatted); + } +}