Add localization settings

This commit is contained in:
Constantin Graf
2025-04-13 16:26:31 +02:00
committed by Gregor Vostrak
parent 3c9160a08a
commit ae00fdb0e9
33 changed files with 1526 additions and 89 deletions

View File

@@ -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
);
});

View File

@@ -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);

View File

@@ -64,8 +64,8 @@ class UserCreateCommand extends Command
$password,
'UTC',
Weekday::Monday,
'EUR',
$verifyEmail
null,
verifyEmail: $verifyEmail
);
});
/** @var Organization|null $organization */

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum CurrencyFormat: string
{
use LaravelEnumHelper;
case ISOCodeBeforeWithSpace = 'iso-code-before-with-space';
case ISOCodeAfterWithSpace = 'iso-code-after-with-space';
case SymbolBefore = 'symbol-before';
case SymbolAfter = 'symbol-after';
case SymbolBeforeWithSpace = 'symbol-before-with-space';
case SymbolAfterWithSpace = 'symbol-after-with-space';
/**
* @return array<string, string>
*/
public static function toSelectArray(): array
{
$selectArray = [];
foreach (self::values() as $value) {
$selectArray[(string) $value] = (string) __('enum.currency_format.'.$value);
}
return $selectArray;
}
}

48
app/Enums/DateFormat.php Normal file
View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum DateFormat: string
{
use LaravelEnumHelper;
case PointSeperatedDMYYYY = 'point-seperated-d-m-yyyy';
case SlashSeperatedMMDDYYYY = 'slash-seperated-mm-dd-yyyy';
case SlashSeperatedDDMMYYYY = 'slash-seperated-dd-mm-yyyy';
case HyphenSeperatedDDMMYYY = 'hyphen-seperated-dd-mm-yyyy';
case HyphenSeperatedMMDDDYYYY = 'hyphen-seperated-mm-dd-yyyy';
case HyphenSeperatedYYYYMMDD = 'hyphen-seperated-yyyy-mm-dd';
public function toCarbonFormat(): string
{
return match ($this->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<string, string>
*/
public static function toSelectArray(): array
{
$selectArray = [];
foreach (self::values() as $value) {
$selectArray[(string) $value] = (string) __('enum.date_format.'.$value);
}
return $selectArray;
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum IntervalFormat: string
{
use LaravelEnumHelper;
case Decimal = 'decimal';
case HoursMinutes = 'hours-minutes';
case HoursMinutesColonSeperated = 'hours-minutes-colon-seperated';
case HoursMinutesSecondsColonSeperated = 'hours-minutes-seconds-colon-seperated';
/**
* @return array<string, string>
*/
public static function toSelectArray(): array
{
$selectArray = [];
foreach (self::values() as $value) {
$selectArray[(string) $value] = (string) __('enum.interval_format.'.$value);
}
return $selectArray;
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
/**
* @info https://en.wikipedia.org/wiki/Decimal_separator
*/
enum NumberFormat: string
{
use LaravelEnumHelper;
case ThousandsPointDecimalComma = 'point-comma';
case ThousandsCommaDecimalPoint = 'comma-point';
case ThousandsSpaceDecimalComma = 'space-comma';
case ThousandsSpaceDecimalPoint = 'space-point';
case ThousandsApostropheDecimalPoint = 'apostrophe-point';
/**
* @return array<string, string>
*/
public static function toSelectArray(): array
{
$selectArray = [];
foreach (self::values() as $value) {
$selectArray[(string) $value] = (string) __('enum.number_format.'.$value);
}
return $selectArray;
}
}

28
app/Enums/TimeFormat.php Normal file
View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum TimeFormat: string
{
use LaravelEnumHelper;
case TwelveHours = '12-hours';
case TwentyFourHours = '24-hours';
/**
* @return array<string, string>
*/
public static function toSelectArray(): array
{
$selectArray = [];
foreach (self::values() as $value) {
$selectArray[(string) $value] = (string) __('enum.time_format.'.$value);
}
return $selectArray;
}
}

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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->save();
if ($oldBillableRate !== $request->getBillableRate()) {
if ($hasBillableRate && $oldBillableRate !== $request->getBillableRate()) {
$billableRateService->updateTimeEntriesBillableRateForOrganization($organization);
}

View File

@@ -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) {

View File

@@ -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();

View File

@@ -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<string, array<string|ValidationRule>>
* @return array<string, array<string|\Illuminate\Contracts\Validation\Rule>>
*/
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;
}
}

View File

@@ -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,
];
}
}

View File

@@ -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<string>|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),
];
}
}

View File

@@ -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,

View File

@@ -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),
];
}
}

View File

@@ -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<int, User> $realUsers
* @property-read Collection<int, OrganizationInvitation> $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<OrganizationInvitation> 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<string, mixed>
*/
protected $attributes = [
'currency' => 'EUR',
];
/**

View File

@@ -0,0 +1,377 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Brick\Money\Money;
class CurrencyService
{
/**
* @source https://gist.github.com/stephenfrank/a8245c2486f3e546107c5363706ac93e
*
* @const array<string, array<{ symbol: string }>>
*/
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;
}
}

View File

@@ -0,0 +1,144 @@
<?php
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\TimeFormat;
use App\Models\Organization;
use Brick\Math\BigDecimal;
use Brick\Money\Money;
use Carbon\CarbonInterface;
use Carbon\CarbonInterval;
class LocalizationService
{
private CurrencyFormat $currencyFormat;
private IntervalFormat $intervalFormat;
private DateFormat $dateFormat;
private TimeFormat $timeFormat;
private NumberFormat $numberFormat;
public function __construct(CurrencyFormat $currencyFormat, DateFormat $dateFormat, TimeFormat $timeFormat, NumberFormat $numberFormat, IntervalFormat $intervalFormat)
{
$this->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;
}
}

View File

@@ -0,0 +1,68 @@
<?php
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\Models\Organization;
use App\Models\User;
class OrganizationService
{
public function createOrganization(
string $name,
User $owner,
bool $personalOrganization,
?string $currency = null,
?NumberFormat $numberFormat = null,
?CurrencyFormat $currencyFormat = null,
?DateFormat $dateFormat = null,
?IntervalFormat $intervalFormat = null,
?TimeFormat $timeFormat = null,
): Organization {
$organization = new Organization;
$organization->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;
}
}

View File

@@ -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<TimeEntry> $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(', '),

View File

@@ -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) {

View File

@@ -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

View File

@@ -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()),
];
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('organizations', function (Blueprint $table): void {
$table->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');
});
}
};

View File

@@ -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 €',
],
];

View File

@@ -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')
<!DOCTYPE html>
<html lang="en">
@@ -139,7 +138,7 @@
<div>
<p style="font-size: 32px; font-weight: 600; margin-bottom: 5px;">Report</p>
<div style="font-size: 16px; font-weight: 600; color: #71717a;">
<span>{{ $start->timezone($timezone)->format('d.m.Y') }} - {{ $end->timezone($timezone)->format('d.m.Y') }}</span><br><br>
<span>{{ $localization->formatDate($start->timezone($timezone)) }} - {{ $localization->formatDate($end->timezone($timezone)) }}</span><br><br>
</div>
</div>
@@ -151,12 +150,12 @@
<div style="padding: 8px 12px; border-radius: 8px;">
<div style="color: #71717a; font-weight: 600;">Duration</div>
<div
style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $interval->format(CarbonInterval::seconds($aggregatedData['seconds'])) }} </div>
style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $localization->formatInterval(CarbonInterval::seconds($aggregatedData['seconds'])) }} </div>
</div>
<div style="padding: 8px 12px; border-radius: 8px;">
<div style="color: #71717a; font-weight: 600;">Total cost</div>
<div
style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ Money::of(BigDecimal::ofUnscaledValue($aggregatedData['cost'], 2)->__toString(), $currency)->formatTo('en_US') }} </div>
style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $localization->formatCurrency(Money::of(BigDecimal::ofUnscaledValue($aggregatedData['cost'], 2)->__toString(), $currency)) }} </div>
</div>
</div>
@@ -200,10 +199,10 @@
</span>
</td>
<td style="text-align: left;">
{{ $interval->format(CarbonInterval::seconds($group1Entry['seconds'])) }}
{{ $localization->formatInterval(CarbonInterval::seconds($group1Entry['seconds'])) }}
</td>
<td style="text-align: right;">
{{ Money::of(BigDecimal::ofUnscaledValue($group1Entry['cost'], 2)->__toString(), $currency)->formatTo('en_US') }}
{{ $localization->formatCurrency(Money::of(BigDecimal::ofUnscaledValue($group1Entry['cost'], 2)->__toString(), $currency)) }}
</td>
</tr>
@@ -214,10 +213,10 @@
Total
</td>
<td style="font-weight: 500;color: #18181b;">
{{ $interval->format(CarbonInterval::seconds($aggregatedData['seconds'])) }}
{{ $localization->formatInterval(CarbonInterval::seconds($aggregatedData['seconds'])) }}
</td>
<td style="text-align: right; font-weight: 500;color: #18181b;">
{{ Money::of(BigDecimal::ofUnscaledValue($aggregatedData['cost'], 2)->__toString(), $currency)->formatTo('en_US') }}
{{ $localization->formatCurrency(Money::of(BigDecimal::ofUnscaledValue($aggregatedData['cost'], 2)->__toString(), $currency)) }}
</td>
</tr>
</tfoot>
@@ -278,13 +277,13 @@
@endif
</td>
<td>
{{ $interval->format($duration) }}
{{ $localization->formatInterval($duration) }}
</td>
<td>
{{ round($duration->totalHours, 2) }}
{{ $localization->formatNumber($duration->totalHours) }}
</td>
<td>
{{ Money::of(BigDecimal::ofUnscaledValue($group2Entry['cost'], 2)->__toString(), $currency)->formatTo('en_US') }}
{{ $localization->formatCurrency(Money::of(BigDecimal::ofUnscaledValue($group2Entry['cost'], 2)->__toString(), $currency)) }}
</td>
</tr>
@php

View File

@@ -130,7 +130,7 @@
<div>
<p style="font-size: 32px; font-weight: 600; margin-bottom: 5px;">Detailed Report</p>
<div style="font-size: 16px; font-weight: 600; color: #71717a;">
<span>{{ $start->timezone($timezone)->format('d.m.Y') }} - {{ $end->timezone($timezone)->format('d.m.Y') }}</span><br><br>
<span>{{ $localization->formatDate($start->timezone($timezone)) }} - {{ $localization->formatDate($end->timezone($timezone)) }}</span><br><br>
</div>
</div>
<div class="table-wrapper">
@@ -139,12 +139,12 @@
<div style="padding: 8px 12px; border-radius: 8px;">
<div style="color: #71717a; font-weight: 600;">Duration</div>
<div
style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $interval->format(CarbonInterval::seconds($aggregatedData['seconds'])) }} </div>
style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $localization->formatInterval(CarbonInterval::seconds($aggregatedData['seconds'])) }} </div>
</div>
<div style="padding: 8px 12px; border-radius: 8px;">
<div style="color: #71717a; font-weight: 600;">Total cost</div>
<div
style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ Money::of(BigDecimal::ofUnscaledValue($aggregatedData['cost'], 2)->__toString(), $currency)->formatTo('en_US') }} </div>
style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $localization->formatCurrency(Money::of(BigDecimal::ofUnscaledValue($aggregatedData['cost'], 2)->__toString(), $currency)) }} </div>
</div>
</div>
@@ -180,15 +180,15 @@
<td style="overflow-wrap: break-word; min-width: 75px;">{{ $timeEntry->user->name }}</td>
<td style="overflow-wrap: break-word; min-width: 150px; text-align: center;">
@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') }} - <br> {{ $timeEntry->end->timezone($timezone)->format('Y-m-d') }}
{{ $localization->formatDate($timeEntry->start->timezone($timezone)) }} - <br> {{ $localization->formatDate($timeEntry->end->timezone($timezone)) }}
@endif
<br>
{{ $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)) }}
</td>
<td style="overflow-wrap: break-word; min-width: 75px;">
{{ $interval->format($timeEntry->getDuration()) }}
{{ $localization->formatInterval($timeEntry->getDuration()) }}
</td>
<td style="overflow-wrap: break-word;">{{ $timeEntry->billable ? 'Yes' : 'No' }}</td>
<td style="overflow-wrap: break-word; min-width: 75px;">{{ count($timeEntry->tagsRelation) === 0 ? '-' : $timeEntry->tagsRelation->implode('name', ', ') }}</td>

View File

@@ -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,
]);
}

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Service;
use App\Service\CurrencyService;
use Brick\Money\Currency;
use Brick\Money\Money;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\UsesClass;
use Tests\TestCaseWithDatabase;
#[CoversClass(CurrencyService::class)]
#[UsesClass(CurrencyService::class)]
class CurrencyServiceTest extends TestCaseWithDatabase
{
private CurrencyService $currencyService;
protected function setUp(): void
{
parent::setUp();
$this->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);
}
}

View File

@@ -0,0 +1,256 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Service;
use App\Enums\CurrencyFormat;
use App\Enums\DateFormat;
use App\Enums\IntervalFormat;
use App\Enums\NumberFormat;
use App\Enums\TimeFormat;
use App\Service\LocalizationService;
use Brick\Money\Currency;
use Brick\Money\Money;
use Carbon\CarbonInterval;
use Illuminate\Support\Carbon;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\UsesClass;
use Tests\TestCaseWithDatabase;
#[CoversClass(LocalizationService::class)]
#[UsesClass(LocalizationService::class)]
class LocalizationServiceTest extends TestCaseWithDatabase
{
private LocalizationService $localizationService;
protected function setUp(): void
{
parent::setUp();
$this->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);
}
}