Compare commits

..

20 Commits

Author SHA1 Message Date
Constantin Graf
d68c30476e Add base request class with generic rule sets 2025-05-14 15:52:26 +02:00
Gregor Vostrak
a69fb9c551 make client deselectable for projects, fixes #333 2025-05-14 15:27:28 +02:00
Gregor Vostrak
62b5730fa8 fix contrast on select and dropdown foreground colors, add missing placeholder in billable input 2025-05-14 14:09:19 +02:00
Gregor Vostrak
098ead8da6 change billable rate input to use shadcn component 2025-05-13 18:51:36 +02:00
Constantin Graf
d49082d7f3 Fixed localization in PDF reports 2025-05-13 18:48:37 +02:00
Gregor Vostrak
cc88f034c7 fix sharedreport date_format provide 2025-05-13 17:45:02 +02:00
Gregor Vostrak
9620c89545 migrate daterange picker to shadcn component 2025-05-13 16:32:11 +02:00
Gregor Vostrak
f9c3f42289 improve time entry range design issue in 12-h format 2025-05-13 16:32:11 +02:00
Gregor Vostrak
fca4c26cfc add support for timeFormat in the frontend 2025-05-13 16:32:11 +02:00
Gregor Vostrak
d8f4ba1517 add format options for number field component 2025-05-13 16:32:11 +02:00
Constantin Graf
284d8cd786 Add unit test for currency endpoint 2025-05-13 16:32:11 +02:00
Gregor Vostrak
411fc6ea5e add e2e tests for organization format settings 2025-05-13 16:32:11 +02:00
Gregor Vostrak
02a8367d16 change e2e tests to use organization default values for money formatting 2025-05-13 16:32:11 +02:00
Gregor Vostrak
68f636c8ff fix shared report endpoint test to check new structure that includes organization format properties, format 2025-05-13 16:32:11 +02:00
Gregor Vostrak
9c44abf7aa update api client, add api types, fix activitygraphcard formatting 2025-05-13 16:32:11 +02:00
Gregor Vostrak
b1ff97a82f add frontend support for the date formatting option 2025-05-13 16:32:11 +02:00
Gregor Vostrak
ed32c6b217 add frontend format support for currencies, add currencies endpoint 2025-05-13 16:32:11 +02:00
Gregor Vostrak
8b950d99d6 add support for interval / duration format in frontend views 2025-05-13 16:32:11 +02:00
Constantin Graf
e374d8b3de Fixed typos in organization format settings 2025-05-13 16:32:11 +02:00
Gregor Vostrak
301d09e830 add formating options to organization settings 2025-05-13 16:32:11 +02:00
50 changed files with 295 additions and 346 deletions

View File

@@ -4,9 +4,9 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\ApiToken;
use Illuminate\Foundation\Http\FormRequest;
use App\Http\Requests\V1\BaseFormRequest;
class ApiTokenStoreRequest extends FormRequest
class ApiTokenStoreRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1;
use Illuminate\Foundation\Http\FormRequest;
class BaseFormRequest extends FormRequest
{
/**
* @param bool $bigInt
* @return list<string>
*/
protected function moneyRules(bool $bigInt = false): array
{
$rules = [
'integer',
'min:0',
];
if ($bigInt) {
$rules[] = 'max:9223372036854775807';
} else {
$rules[] = 'max:2147483647';
}
return $rules;
}
}

View File

@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Client;
use App\Http\Requests\V1\BaseFormRequest;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class ClientIndexRequest extends FormRequest
class ClientIndexRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,17 +4,17 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Client;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class ClientStoreRequest extends FormRequest
class ClientStoreRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,18 +4,18 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Client;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property Organization $organization Organization from model binding
* @property Client|null $client Client from model binding
*/
class ClientUpdateRequest extends FormRequest
class ClientUpdateRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Import;
use App\Http\Requests\V1\BaseFormRequest;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class ImportRequest extends FormRequest
class ImportRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,14 +4,14 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Invitation;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* @property Organization $organization
*/
class InvitationIndexRequest extends FormRequest
class InvitationIndexRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -5,18 +5,18 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Invitation;
use App\Enums\Role;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use App\Models\OrganizationInvitation;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property Organization $organization
*/
class InvitationStoreRequest extends FormRequest
class InvitationStoreRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,14 +4,14 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Member;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* @property Organization $organization
*/
class MemberIndexRequest extends FormRequest
class MemberIndexRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,17 +4,17 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Member;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization
*/
class MemberMergeIntoRequest extends FormRequest
class MemberMergeIntoRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -5,15 +5,15 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Member;
use App\Enums\Role;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* @property Organization $organization
*/
class MemberUpdateRequest extends FormRequest
class MemberUpdateRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
@@ -27,12 +27,12 @@ class MemberUpdateRequest extends FormRequest
'string',
Rule::enum(Role::class),
],
'billable_rate' => [
'nullable',
'integer',
'min:0',
'max:2147483647',
],
'billable_rate' => array_merge(
[
'nullable',
],
$this->moneyRules()
),
];
}

View File

@@ -9,14 +9,14 @@ use App\Enums\DateFormat;
use App\Enums\IntervalFormat;
use App\Enums\NumberFormat;
use App\Enums\TimeFormat;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* @property Organization $organization Organization from model binding
*/
class OrganizationUpdateRequest extends FormRequest
class OrganizationUpdateRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
@@ -30,12 +30,12 @@ class OrganizationUpdateRequest extends FormRequest
'string',
'max:255',
],
'billable_rate' => [
'nullable',
'integer',
'min:0',
'max:2147483647',
],
'billable_rate' => array_merge(
[
'nullable',
],
$this->moneyRules()
),
'employees_can_see_billable_rates' => [
'boolean',
],

View File

@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Project;
use App\Http\Requests\V1\BaseFormRequest;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class ProjectIndexRequest extends FormRequest
class ProjectIndexRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,13 +4,13 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Project;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Organization;
use App\Models\Project;
use App\Rules\ColorRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
@@ -18,7 +18,7 @@ use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class ProjectStoreRequest extends FormRequest
class ProjectStoreRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
@@ -55,12 +55,12 @@ class ProjectStoreRequest extends FormRequest
'required',
'boolean',
],
'billable_rate' => [
'nullable',
'integer',
'min:0',
'max:2147483647',
],
'billable_rate' => array_merge(
[
'nullable',
],
$this->moneyRules()
),
// ID of the client
'client_id' => [
'present',

View File

@@ -4,13 +4,13 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Project;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Organization;
use App\Models\Project;
use App\Rules\ColorRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
@@ -19,7 +19,7 @@ use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
* @property Organization $organization Organization from model binding
* @property Project|null $project Project from model binding
*/
class ProjectUpdateRequest extends FormRequest
class ProjectUpdateRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
@@ -68,12 +68,11 @@ class ProjectUpdateRequest extends FormRequest
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
'billable_rate' => [
'billable_rate' => array_merge([
'nullable',
'integer',
'min:0',
'max:2147483647',
],
$this->moneyRules()
),
// Estimated time in seconds
'estimated_time' => [
'nullable',

View File

@@ -4,17 +4,17 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\ProjectMember;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class ProjectMemberStoreRequest extends FormRequest
class ProjectMemberStoreRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
@@ -31,12 +31,12 @@ class ProjectMemberStoreRequest extends FormRequest
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
'billable_rate' => [
'nullable',
'integer',
'min:0',
'max:2147483647',
],
'billable_rate' => array_merge(
[
'nullable',
],
$this->moneyRules()
),
];
}

View File

@@ -4,14 +4,14 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\ProjectMember;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* @property Organization $organization Organization from model binding
*/
class ProjectMemberUpdateRequest extends FormRequest
class ProjectMemberUpdateRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
@@ -21,12 +21,12 @@ class ProjectMemberUpdateRequest extends FormRequest
public function rules(): array
{
return [
'billable_rate' => [
'nullable',
'integer',
'min:0',
'max:2147483647',
],
'billable_rate' => array_merge(
[
'nullable',
],
$this->moneyRules()
),
];
}

View File

@@ -7,17 +7,17 @@ namespace App\Http\Requests\V1\Report;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\Weekday;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use Illuminate\Contracts\Validation\Rule as LegacyValidationRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
/**
* @property Organization $organization Organization from model binding
*/
class ReportStoreRequest extends FormRequest
class ReportStoreRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,15 +4,15 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Report;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Carbon;
/**
* @property Organization $organization Organization from model binding
*/
class ReportUpdateRequest extends FormRequest
class ReportUpdateRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,17 +4,17 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Tag;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use App\Models\Tag;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class TagStoreRequest extends FormRequest
class TagStoreRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,18 +4,18 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Tag;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use App\Models\Tag;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property Organization $organization Organization from model binding
* @property Tag|null $tag Tag from model binding
*/
class TagUpdateRequest extends FormRequest
class TagUpdateRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,19 +4,19 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Task;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use App\Models\Project;
use App\Service\PermissionStore;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class TaskIndexRequest extends FormRequest
class TaskIndexRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,19 +4,19 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Task;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class TaskStoreRequest extends FormRequest
class TaskStoreRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,18 +4,18 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Task;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property Organization $organization Organization from model binding
* @property Task|null $task Task from model binding
*/
class TaskUpdateRequest extends FormRequest
class TaskUpdateRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -7,6 +7,7 @@ namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Member;
use App\Models\Organization;
@@ -16,7 +17,6 @@ use App\Models\Task;
use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
@@ -24,7 +24,7 @@ use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization
*/
class TimeEntryAggregateExportRequest extends FormRequest
class TimeEntryAggregateExportRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TimeEntryAggregationType;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Member;
use App\Models\Organization;
@@ -14,7 +15,6 @@ use App\Models\Task;
use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
@@ -22,7 +22,7 @@ use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization
*/
class TimeEntryAggregateRequest extends FormRequest
class TimeEntryAggregateRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,14 +4,14 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* @property Organization $organization Organization from model binding
*/
class TimeEntryDestroyMultipleRequest extends FormRequest
class TimeEntryDestroyMultipleRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Member;
use App\Models\Organization;
@@ -12,13 +13,12 @@ use App\Models\Tag;
use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization
*/
class TimeEntryIndexRequest extends FormRequest
class TimeEntryIndexRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
@@ -11,13 +12,12 @@ use App\Models\Tag;
use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class TimeEntryStoreRequest extends FormRequest
class TimeEntryStoreRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
@@ -11,13 +12,12 @@ use App\Models\Tag;
use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class TimeEntryUpdateMultipleRequest extends FormRequest
class TimeEntryUpdateMultipleRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
@@ -11,13 +12,12 @@ use App\Models\Tag;
use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class TimeEntryUpdateRequest extends FormRequest
class TimeEntryUpdateRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -80,7 +80,7 @@ class LocalizationService
if ($this->intervalFormat === IntervalFormat::Decimal) {
$interval->cascade();
return $this->formatNumber($interval->totalHours);
return $this->formatNumber($interval->totalHours).' h';
} elseif ($this->intervalFormat === IntervalFormat::HoursMinutes) {
$interval->cascade();

View File

@@ -52,7 +52,7 @@ test('test that organization billable rate can be updated with all existing time
await page
.locator('form')
.filter({ hasText: 'Organization Billable' })
.getByRole('button')
.getByRole('button', { name: 'Save' })
.click();
await Promise.all([
@@ -179,7 +179,9 @@ test('test that format settings are reflected in the dashboard', async ({
page,
}) => {
// check that 0h 00min is displayed
await expect(page.getByText('0h 00min', { exact: true }).nth(0)).toBeVisible();
await expect(
page.getByText('0h 00min', { exact: true }).nth(0)
).toBeVisible();
// First set the format settings
await goToOrganizationSettings(page);
@@ -230,11 +232,17 @@ test('test that format settings are reflected in the dashboard', async ({
// check that 00:00 is displayed
await expect(page.getByText('0:00', { exact: true }).nth(0)).toBeVisible();
// check that 0h 00min is not displayed
await expect(page.getByText('0h 00min', { exact: true }).nth(0)).not.toBeVisible();
await expect(
page.getByText('0h 00min', { exact: true }).nth(0)
).not.toBeVisible();
// check that the current date is displayed in the dd/mm/yyyy format on the time page
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await expect(page.getByText(new Date().toLocaleDateString('en-GB'), { exact: true }).nth(0)).toBeVisible();
await expect(
page
.getByText(new Date().toLocaleDateString('en-GB'), { exact: true })
.nth(0)
).toBeVisible();
});
// TODO: Test 12-hour clock format
// TODO: Test 12-hour clock format

View File

@@ -43,6 +43,9 @@
--theme-color-input-select-active: rgb(var(--color-accent-300));
--theme-color-input-select-active-hover: rgb(var(--color-accent-200));
--color-accent-default: rgb(var(--color-accent-900));
--color-accent-foreground: rgb(var(--color-accent-100));
}
:root.light {
@@ -86,6 +89,9 @@
--theme-color-input-select-active: rgb(var(--color-accent-400));
--theme-color-input-select-active-hover: rgb(var(--color-accent-500));
--color-accent-default: rgb(var(--color-accent-100));
--color-accent-foreground: rgb(var(--color-accent-800));
}
:root {

View File

@@ -26,10 +26,12 @@ const createClient = ref(false);
<ClientTableHeading></ClientTableHeading>
<div
v-if="clients.length === 0"
class="col-span-2 py-24 text-center">
class="col-span-3 py-24 text-center">
<UserCircleIcon
class="w-8 text-icon-default inline pb-2"></UserCircleIcon>
<h3 class="text-text-primary font-semibold">No clients found</h3>
<h3 class="text-text-primary font-semibold">
No clients found
</h3>
<p v-if="canCreateClients()" class="pb-5">
Create your first client now!
</p>

View File

@@ -154,7 +154,6 @@ const roleDescription = computed(() => {
class="flex-1">
<InputLabel
for="memberBillableRate"
class="mb-2"
value="Billable Rate" />
<BillableRateInput
v-model="

View File

@@ -48,7 +48,7 @@ const project = ref<CreateProjectBody>({
async function submit() {
if (props.originalProject.billable_rate !== project.value.billable_rate) {
//
// make sure that the alert modal is not immediately submitted when user presses enter
setTimeout(() => {
showBillableRateModal.value = true;
}, 0);
@@ -133,7 +133,7 @@ async function submitBillableRate() {
</ClientDropdown>
</div>
</div>
<div class="lg:grid grid-cols-2 gap-12">
<div>
<div>
<ProjectEditBillableSection
v-model:is-billable="project.is_billable"

View File

@@ -43,7 +43,9 @@ const xAxisLabels = computed(() => {
if (props.groupedType === 'week') {
return props?.groupedData?.map((el) => formatWeek(el.key));
}
return props?.groupedData?.map((el) => formatDate(el.key ?? '', organization?.value?.date_format));
return props?.groupedData?.map((el) =>
formatDate(el.key ?? '', organization?.value?.date_format)
);
});
const accentColor = useCssVar('--theme-color-chart', null, { observe: true });
const labelColor = useCssVar('--color-text-secondary', null, { observe: true });

View File

@@ -28,8 +28,10 @@ const activeClass = computed(() => {
activeClass
)
">
<component :is="icon" class="-ml-0.5 h-4 w-4 text-text-quaternary"></component>
<span> {{ title }} </span>
<component
:is="icon"
class="-ml-0.5 h-4 w-4 text-text-quaternary"></component>
<span class="text-nowrap"> {{ title }} </span>
<div
v-if="count"
class="bg-accent-300/20 w-5 h-5 font-medium rounded flex items-center transition justify-center">

View File

@@ -20,7 +20,7 @@ const forwardedProps = useForwardProps(delegatedProps)
:class="cn(
buttonVariants({ variant: 'ghost' }),
'h-8 w-8 p-0 font-normal',
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground',
'[&[data-today]:not([data-selected])]:border-accent [&[data-today]:not([data-selected])]:border [&[data-today]:not([data-selected])]:text-accent-foreground',
// Selected
'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:opacity-100 data-[selected]:hover:bg-primary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground',
// Disabled

View File

@@ -20,7 +20,7 @@ const forwardedProps = useForwardProps(delegatedProps)
:class="cn(
buttonVariants({ variant: 'ghost' }),
'h-8 w-8 p-0 font-normal data-[selected]:opacity-100',
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground',
'[&[data-today]:not([data-selected])]:border-accent [&[data-today]:not([data-selected])]:border [&[data-today]:not([data-selected])]:text-accent-foreground',
// Selection Start
'data-[selection-start]:bg-primary data-[selection-start]:text-primary-foreground data-[selection-start]:hover:bg-primary data-[selection-start]:hover:text-primary-foreground data-[selection-start]:focus:bg-primary data-[selection-start]:focus:text-primary-foreground',
// Selection End

View File

@@ -57,7 +57,12 @@ const reportNumberFormat = computed(() => {
});
const reportCurrencyFormat = computed(() => {
return (sharedReportResponseData.value?.currency_format ?? 'symbol-before') as CurrencyFormat;
return (sharedReportResponseData.value?.currency_format ??
'symbol-before') as CurrencyFormat;
});
const reportDateFormat = computed(() => {
return sharedReportResponseData.value?.date_format;
});
const reportCurrencySymbol = computed(() => {
@@ -71,6 +76,7 @@ provide(
'interval_format': reportIntervalFormat.value,
'currency_format': reportCurrencyFormat.value,
'currency_symbol': reportCurrencySymbol.value,
'date_format': reportDateFormat.value,
}))
);
@@ -252,7 +258,7 @@ onMounted(async () => {
aggregatedTableTimeEntries.cost,
reportCurrency,
reportCurrencyFormat,
reportCurrencySymbol,
reportCurrencySymbol
)
}}
</div>

View File

@@ -1,9 +1,18 @@
<script setup lang="ts">
import { PlusCircleIcon } from '@heroicons/vue/20/solid';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import { type Component, computed, nextTick, ref, watch } from 'vue';
import { computed, nextTick, ref, watch } from 'vue';
import ClientDropdownItem from '@/packages/ui/src/Client/ClientDropdownItem.vue';
import type { CreateClientBody, Client } from '@/packages/api/src';
import {
ComboboxAnchor,
ComboboxContent,
ComboboxInput,
ComboboxItem,
ComboboxRoot,
ComboboxViewport,
} from 'radix-vue';
import { UseFocusTrap } from '@vueuse/integrations/useFocusTrap/component';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
const model = defineModel<string | null>({
default: null,
@@ -14,10 +23,8 @@ const props = defineProps<{
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
}>();
const searchInput = ref<HTMLInputElement | null>(null);
const searchInput = ref<HTMLElement | null>(null);
const open = ref(false);
const dropdownViewport = ref<Component | null>(null);
const searchValue = ref('');
function isClientSelected(id: string) {
@@ -27,7 +34,8 @@ function isClientSelected(id: string) {
watch(open, (isOpen) => {
if (isOpen) {
nextTick(() => {
searchInput.value?.focus();
// @ts-expect-error We need to access the actual HTML Element to focus as radix-vue does not support any other way right now
searchInput.value?.$el?.focus();
});
}
});
@@ -48,132 +56,89 @@ async function addClientIfNoneExists() {
if (newClient) {
model.value = newClient.id;
searchValue.value = '';
}
} else {
if (highlightedItemId.value) {
model.value = highlightedItemId.value;
open.value = false;
}
}
}
watch(filteredClients, () => {
if (filteredClients.value.length > 0) {
highlightedItemId.value = filteredClients.value[0].id;
}
const currentClient = computed(() => {
return (
props.clients.find((client) => client.id === model.value) ?? {
id: null,
name: 'No Client',
}
);
});
function updateSearchValue(event: Event) {
const newInput = (event.target as HTMLInputElement).value;
if (newInput === ' ') {
searchValue.value = '';
const highlightedClientId = highlightedItemId.value;
if (highlightedClientId) {
const highlightedClient = props.clients.find(
(client) => client.id === highlightedClientId
);
if (highlightedClient) {
model.value = highlightedClient.id;
}
}
} else {
searchValue.value = newInput;
}
}
const emit = defineEmits(['update:modelValue', 'changed']);
function updateClient(newValue: string) {
model.value = newValue;
nextTick(() => {
emit('changed');
});
function updateValue(client: { id: string | null; name: string }) {
model.value = client.id;
emit('changed');
}
function moveHighlightUp() {
if (highlightedItem.value) {
const currentHightlightedIndex = filteredClients.value.indexOf(
highlightedItem.value
);
if (currentHightlightedIndex === 0) {
highlightedItemId.value =
filteredClients.value[filteredClients.value.length - 1].id;
} else {
highlightedItemId.value =
filteredClients.value[currentHightlightedIndex - 1].id;
}
}
}
function moveHighlightDown() {
if (highlightedItem.value) {
const currentHightlightedIndex = filteredClients.value.indexOf(
highlightedItem.value
);
if (currentHightlightedIndex === filteredClients.value.length - 1) {
highlightedItemId.value = filteredClients.value[0].id;
} else {
highlightedItemId.value =
filteredClients.value[currentHightlightedIndex + 1].id;
}
}
}
const highlightedItemId = ref<string | null>(null);
const highlightedItem = computed(() => {
return props.clients.find(
(client) => client.id === highlightedItemId.value
);
});
</script>
<template>
<Dropdown v-model="open" width="120" :close-on-content-click="true">
<Dropdown v-model="open" align="start" width="60">
<template #trigger>
<slot name="trigger"></slot>
</template>
<template #content>
<input
ref="searchInput"
:value="searchValue"
data-testid="client_dropdown_search"
class="bg-card-background border-0 placeholder-muted text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
placeholder="Search for a client..."
@input="updateSearchValue"
@keydown.enter="addClientIfNoneExists"
@keydown.up.prevent="moveHighlightUp"
@keydown.down.prevent="moveHighlightDown" />
<div ref="dropdownViewport" class="w-60 max-h-60 overflow-y-scroll">
<div
v-if="
searchValue.length > 0 && filteredClients.length === 0
"
class="bg-card-background-active"
@click="addClientIfNoneExists">
<div
class="flex space-x-3 items-center px-4 py-3 text-xs text-text-primary font-medium border-t rounded-b-lg border-card-background-separator">
<PlusCircleIcon
class="w-5 flex-shrink-0"></PlusCircleIcon>
<span>Add "{{ searchValue }}" as a new Client</span>
</div>
</div>
<div v-else></div>
<div
v-for="client in filteredClients"
:key="client.id"
role="option"
:value="client.id"
:class="{
'bg-card-background-active':
client.id === highlightedItemId,
}"
data-testid="client_dropdown_entries"
:data-client-id="client.id">
<ClientDropdownItem
:selected="isClientSelected(client.id)"
:name="client.name"
@click="updateClient(client.id)"></ClientDropdownItem>
</div>
</div>
<UseFocusTrap
v-if="open"
:options="{ immediate: true, allowOutsideClick: true }">
<ComboboxRoot
v-model:search-term="searchValue"
:open="open"
:model-value="currentClient"
class="relative"
@update:model-value="updateValue">
<ComboboxAnchor>
<ComboboxInput
ref="searchInput"
class="bg-card-background border-0 placeholder-muted text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
placeholder="Search for a client..." />
</ComboboxAnchor>
<ComboboxContent>
<ComboboxViewport
class="w-60 max-h-60 overflow-y-scroll">
<ComboboxItem
:value="{ id: null, name: 'No Client' }"
class="data-[highlighted]:bg-card-background-active">
<ClientDropdownItem
:selected="model === null"
name="No Client" />
</ComboboxItem>
<ComboboxItem
v-for="client in filteredClients"
:key="client.id"
:value="client"
class="data-[highlighted]:bg-card-background-active"
:data-client-id="client.id">
<ClientDropdownItem
:selected="isClientSelected(client.id)"
:name="client.name" />
</ComboboxItem>
<div
v-if="
searchValue.length > 0 &&
filteredClients.length === 0
"
class="bg-card-background-active">
<div
class="flex space-x-3 items-center px-4 py-3 text-xs text-text-primary font-medium border-t rounded-b-lg border-card-background-separator"
@click="addClientIfNoneExists">
<PlusCircleIcon class="w-5 flex-shrink-0" />
<span
>Add "{{ searchValue }}" as a new
Client</span
>
</div>
</div>
</ComboboxViewport>
</ComboboxContent>
</ComboboxRoot>
</UseFocusTrap>
</template>
</Dropdown>
</template>

View File

@@ -1,9 +1,13 @@
<script setup lang="ts">
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import { formatCents } from '@/packages/ui/src/utils/money';
import { ref, watch, inject, type ComputedRef } from 'vue';
import { ref } from 'vue';
import { useFocus } from '@vueuse/core';
import type { Organization } from '@/packages/api/src';
import {
NumberField,
NumberFieldContent,
NumberFieldDecrement,
NumberFieldIncrement,
NumberFieldInput,
} from '@/Components/ui/number-field';
const props = defineProps<{
name: string;
@@ -11,8 +15,6 @@ const props = defineProps<{
currency: string;
}>();
const organization = inject<ComputedRef<Organization>>('organization');
const model = defineModel<number | null>({
default: null,
});
@@ -20,82 +22,32 @@ const model = defineModel<number | null>({
const billableRateInput = ref<HTMLInputElement | null>(null);
useFocus(billableRateInput, { initialValue: props.focus });
function cleanUpDecimalValue(value: string) {
value = value.replace(/,/g, '');
value = value.replace(props.currency, '');
return value.replace(/\./g, '');
}
function updateRate(value: string) {
value = value.trim();
if (value.includes(',')) {
const parts = value.split(',');
const lastPart = (parts[parts.length - 1] = parts[parts.length - 1]);
if (lastPart.length === 2) {
// we detected a decimal number with 2 digits after the comma
value = cleanUpDecimalValue(value);
model.value = parseInt(value);
}
} else if (value.includes('.')) {
const parts = value.split('.');
const lastPart = (parts[parts.length - 1] = parts[parts.length - 1]);
if (lastPart.length === 2) {
value = cleanUpDecimalValue(value);
model.value = parseInt(value);
}
} else if (value === '') {
model.value = 0;
} else {
// if it doesn't contain a comma or a dot, it's probably a whole number so let's convert it to cents
const parsedValue = parseInt(cleanUpDecimalValue(value)) * 100;
if (parsedValue) {
model.value = parsedValue;
} else {
model.value = 0;
}
}
inputValue.value = formatValue(model.value);
}
function formatValue(modelValue: number | null) {
const formattedValue = formatCents(
modelValue ?? 0,
props.currency,
organization?.value?.currency_format,
organization?.value?.currency_symbol,
organization?.value?.number_format
);
return formattedValue
?.replace(organization?.value?.currency_symbol ?? '', '')
.trim();
return modelValue ? modelValue / 100 : 0;
}
watch(model, (newValue) => {
inputValue.value = formatValue(newValue);
});
const inputValue = ref(formatValue(model.value));
</script>
<template>
<div class="relative">
<TextInput
<NumberField
:id="name"
ref="billableRateInput"
v-model="inputValue"
type="text"
:name="name"
placeholder="Billable Rate"
:model-value="formatValue(model)"
:step-snapping="false"
class="block w-full"
autocomplete="teamMemberRate"
@blur="updateRate($event.target.value)"
@keydown.enter="updateRate($event.target.value)" />
<div
class="absolute top-0 right-0 h-full flex items-center px-4 font-medium pointer-events-none">
<span>
{{ currency }}
</span>
</div>
:format-options="{
style: 'currency',
currency: currency,
currencyDisplay: 'code',
currencySign: 'accounting',
}"
@update:model-value="(value) => (model = value * 100)">
<NumberFieldContent>
<NumberFieldDecrement />
<NumberFieldInput placeholder="Billable Rate" />
<NumberFieldIncrement />
</NumberFieldContent>
</NumberField>
</div>
</template>

View File

@@ -128,7 +128,7 @@ const currentClientName = computed(() => {
</ClientDropdown>
</div>
</div>
<div class="lg:grid grid-cols-2 gap-12">
<div>
<div>
<ProjectEditBillableSection
v-model:is-billable="project.is_billable"

View File

@@ -66,9 +66,7 @@ const emit = defineEmits(['submit']);
v-model="billableRateSelect"
class="mt-2"></ProjectBillableSelect>
</div>
<div
v-if="billableRateSelect === 'custom-rate'"
class="sm:max-w-[120px]">
<div v-if="billableRateSelect === 'custom-rate'">
<InputLabel for="billableRate" value="Billable Rate" class="mb-2" />
<BillableRateInput
v-model="billableRate"

View File

@@ -1,12 +1,12 @@
@use('Brick\Math\BigDecimal')
@use('Brick\Money\Money')
@use('PhpOffice\PhpSpreadsheet\Cell\DataType')
@use('Illuminate\Support\Carbon')
@use('Carbon\CarbonInterval')
@inject('colorService', 'App\Service\ColorService')
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta charset="utf-8"/>
<title>Report</title>
<style>
html, body, div, span, applet, object, iframe,
@@ -130,7 +130,7 @@
@if($debug)
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=outfit:200,300,400,500,600,700,800" rel="stylesheet" />
<link href="https://fonts.bunny.net/css?family=outfit:200,300,400,500,600,700,800" rel="stylesheet"/>
@endif
</head>
@@ -356,7 +356,12 @@
animation: false,
tooltip: {},
xAxis: {
data: ['{!! collect($dataHistoryChart['grouped_data'])->pluck('key')->implode("', '") !!}'],
data: {!!
json_encode(collect($dataHistoryChart['grouped_data'])
->pluck('key')
->map(fn($value) => $localization->formatDate(Carbon::parse($value)))
->toArray())
!!},
axisLabel: {
fontSize: 10,
fontWeight: 400,
@@ -381,26 +386,16 @@
axisLabel: {
show: false,
inside: true,
formatter: function(value, index) {
let totalSeconds = value;
let hours = Math.floor(totalSeconds / 3600);
if (hours < 10) {
hours = "0" + hours;
}
totalSeconds %= 3600;
let minutes = Math.floor(totalSeconds / 60);
if (minutes < 10) {
minutes = "0" + minutes;
}
return hours + ":" + minutes;
}
}
},
series: [
{
name: "time",
type: "bar",
data: [{!! collect($dataHistoryChart['grouped_data'])->pluck('seconds')->implode(', ') !!}],
data: {!! json_encode(collect($dataHistoryChart['grouped_data'])->map(fn($value) => (object) [
'value' => $value['seconds'],
'name' => ((int) $value['seconds']) === 0 ? '' : $localization->formatInterval(CarbonInterval::seconds((int) $value['seconds']))
])->toArray()) !!},
itemStyle: {
borderColor: "#7dd3fc",
color: "#7dd3fc"
@@ -413,22 +408,8 @@
@endif
fontSize: 10,
position: "top",
formatter: function(params) {
let value = params.value;
if (value === 0) {
return "";
}
let totalSeconds = value;
let hours = Math.floor(totalSeconds / 3600);
if (hours < 10) {
hours = "0" + hours;
}
totalSeconds %= 3600;
let minutes = Math.floor(totalSeconds / 60);
if (minutes < 10) {
minutes = "0" + minutes;
}
return hours + ":" + minutes;
formatter: function (params) {
return params.name;
}
}
}

View File

@@ -1,6 +1,5 @@
@use('Brick\Math\BigDecimal')
@use('Brick\Money\Money')
@use('PhpOffice\PhpSpreadsheet\Cell\DataType')
@use('Carbon\CarbonInterval')
@inject('interval', 'App\Service\IntervalService')
<!DOCTYPE html>

View File

@@ -87,8 +87,8 @@ export default {
"800": "rgba(var(--color-accent-800), <alpha-value>)",
"900": "rgba(var(--color-accent-900), <alpha-value>)",
"950": "rgba(var(--color-accent-950), <alpha-value>)",
DEFAULT: "var(--color-bg-tertiary)",
foreground: "rgb(var(--color-accent-200))"
DEFAULT: "var(--color-accent-default)",
foreground: "var(--color-accent-foreground)",
},
"button-primary-background": "var(--theme-color-button-primary-background)",
"button-primary-background-hover": "var(--theme-color-button-primary-background-hover)",

View File

@@ -47,7 +47,7 @@ class LocalizationServiceTest extends TestCaseWithDatabase
$formatted = $this->localizationService->formatInterval($interval);
// Assert
$this->assertSame('30,001.05', $formatted);
$this->assertSame('30,001.05 h', $formatted);
}
public function test_format_interval_with_type_decimal_and_number_format_thousands_space_decimal_point(): void
@@ -61,7 +61,7 @@ class LocalizationServiceTest extends TestCaseWithDatabase
$formatted = $this->localizationService->formatInterval($interval);
// Assert
$this->assertSame('30 001.05', $formatted);
$this->assertSame('30 001.05 h', $formatted);
}
public function test_format_interval_with_type_decimal_and_number_format_thousands_point_decimal_comma(): void
@@ -75,7 +75,7 @@ class LocalizationServiceTest extends TestCaseWithDatabase
$formatted = $this->localizationService->formatInterval($interval);
// Assert
$this->assertSame('30.001,05', $formatted);
$this->assertSame('30.001,05 h', $formatted);
}
public function test_format_interval_with_type_decimal_and_number_format_thousands_apostrophe_decimal_point(): void
@@ -89,7 +89,7 @@ class LocalizationServiceTest extends TestCaseWithDatabase
$formatted = $this->localizationService->formatInterval($interval);
// Assert
$this->assertSame('30\'001.05', $formatted);
$this->assertSame('30\'001.05 h', $formatted);
}
public function test_format_interval_with_type_hours_minutes(): void