Compare commits

..

25 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
Constantin Graf
49af3d4371 Fixed missing time in pdf report 2025-05-07 22:13:27 +02:00
Gregor Vostrak
b4a6145f40 fix tanstack query store invalidation on detailed view update 2025-05-07 15:21:23 +02:00
Gregor Vostrak
06c6c874eb respect organization currency setting in shared report 2025-05-06 12:51:28 +02:00
Gregor Vostrak
b796d232f5 add reporting tests for detailed, project filter, billable filter, tag filter 2025-05-05 21:30:18 +02:00
Gregor Vostrak
26c50867b3 fix layout shift in shared reporting view 2025-05-01 12:35:51 +02:00
107 changed files with 2803 additions and 1429 deletions

View File

@@ -10,26 +10,26 @@ enum DateFormat: string
{ {
use LaravelEnumHelper; use LaravelEnumHelper;
case PointSeperatedDMYYYY = 'point-seperated-d-m-yyyy'; case PointSeparatedDMYYYY = 'point-separated-d-m-yyyy';
case SlashSeperatedMMDDYYYY = 'slash-seperated-mm-dd-yyyy'; case SlashSeparatedMMDDYYYY = 'slash-separated-mm-dd-yyyy';
case SlashSeperatedDDMMYYYY = 'slash-seperated-dd-mm-yyyy'; case SlashSeparatedDDMMYYYY = 'slash-separated-dd-mm-yyyy';
case HyphenSeperatedDDMMYYY = 'hyphen-seperated-dd-mm-yyyy'; case HyphenSeparatedDDMMYYY = 'hyphen-separated-dd-mm-yyyy';
case HyphenSeperatedMMDDDYYYY = 'hyphen-seperated-mm-dd-yyyy'; case HyphenSeparatedMMDDDYYYY = 'hyphen-separated-mm-dd-yyyy';
case HyphenSeperatedYYYYMMDD = 'hyphen-seperated-yyyy-mm-dd'; case HyphenSeparatedYYYYMMDD = 'hyphen-separated-yyyy-mm-dd';
public function toCarbonFormat(): string public function toCarbonFormat(): string
{ {
return match ($this->value) { return match ($this->value) {
self::PointSeperatedDMYYYY->value => 'j.n.Y', self::PointSeparatedDMYYYY->value => 'j.n.Y',
self::SlashSeperatedMMDDYYYY->value => 'm/d/Y', self::SlashSeparatedMMDDYYYY->value => 'm/d/Y',
self::SlashSeperatedDDMMYYYY->value => 'd/m/Y', self::SlashSeparatedDDMMYYYY->value => 'd/m/Y',
self::HyphenSeperatedDDMMYYY->value => 'd-m-Y', self::HyphenSeparatedDDMMYYY->value => 'd-m-Y',
self::HyphenSeperatedMMDDDYYYY->value => 'm-d-Y', self::HyphenSeparatedMMDDDYYYY->value => 'm-d-Y',
self::HyphenSeperatedYYYYMMDD->value => 'Y-m-d', self::HyphenSeparatedYYYYMMDD->value => 'Y-m-d',
}; };
} }

View File

@@ -13,9 +13,9 @@ enum IntervalFormat: string
case Decimal = 'decimal'; case Decimal = 'decimal';
case HoursMinutes = 'hours-minutes'; case HoursMinutes = 'hours-minutes';
case HoursMinutesColonSeperated = 'hours-minutes-colon-seperated'; case HoursMinutesColonSeparated = 'hours-minutes-colon-separated';
case HoursMinutesSecondsColonSeperated = 'hours-minutes-seconds-colon-seperated'; case HoursMinutesSecondsColonSeparated = 'hours-minutes-seconds-colon-separated';
/** /**
* @return array<string, string> * @return array<string, string>

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Service\CurrencyService;
use Brick\Money\Currency;
use Brick\Money\ISOCurrencyProvider;
use Illuminate\Http\JsonResponse;
class CurrencyController extends Controller
{
/**
* Get all currencies
*
* @response array{code: string, name: string, symbol: string}[]
*
* @operationId getCurrencies
*/
public function index(): JsonResponse
{
$currencyService = app(CurrencyService::class);
$currencies = array_values(array_map(
fn (Currency $currency): array => [
'code' => $currency->getCurrencyCode(),
'name' => $currency->getName(),
'symbol' => $currencyService->getCurrencySymbol($currency->getCurrencyCode()),
],
ISOCurrencyProvider::getInstance()->getAvailableCurrencies()
));
return response()->json($currencies);
}
}

View File

@@ -4,9 +4,9 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\ApiToken; 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. * 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; namespace App\Http\Requests\V1\Client;
use App\Http\Requests\V1\BaseFormRequest;
use Illuminate\Contracts\Validation\ValidationRule; 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. * 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; namespace App\Http\Requests\V1\Client;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client; use App\Models\Client;
use App\Models\Organization; use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/** /**
* @property Organization $organization Organization from model binding * @property Organization $organization Organization from model binding
*/ */
class ClientStoreRequest extends FormRequest class ClientStoreRequest extends BaseFormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * 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; namespace App\Http\Requests\V1\Client;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client; use App\Models\Client;
use App\Models\Organization; use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/** /**
* @property Organization $organization Organization from model binding * @property Organization $organization Organization from model binding
* @property Client|null $client Client 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. * 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; namespace App\Http\Requests\V1\Import;
use App\Http\Requests\V1\BaseFormRequest;
use Illuminate\Contracts\Validation\ValidationRule; 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. * 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; namespace App\Http\Requests\V1\Invitation;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization; use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/** /**
* @property Organization $organization * @property Organization $organization
*/ */
class InvitationIndexRequest extends FormRequest class InvitationIndexRequest extends BaseFormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * 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; namespace App\Http\Requests\V1\Invitation;
use App\Enums\Role; use App\Enums\Role;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization; use App\Models\Organization;
use App\Models\OrganizationInvitation; use App\Models\OrganizationInvitation;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/** /**
* @property Organization $organization * @property Organization $organization
*/ */
class InvitationStoreRequest extends FormRequest class InvitationStoreRequest extends BaseFormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * 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; namespace App\Http\Requests\V1\Member;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization; use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/** /**
* @property Organization $organization * @property Organization $organization
*/ */
class MemberIndexRequest extends FormRequest class MemberIndexRequest extends BaseFormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * 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; namespace App\Http\Requests\V1\Member;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/** /**
* @property Organization $organization * @property Organization $organization
*/ */
class MemberMergeIntoRequest extends FormRequest class MemberMergeIntoRequest extends BaseFormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * 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; namespace App\Http\Requests\V1\Member;
use App\Enums\Role; use App\Enums\Role;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization; use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
/** /**
* @property Organization $organization * @property Organization $organization
*/ */
class MemberUpdateRequest extends FormRequest class MemberUpdateRequest extends BaseFormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * Get the validation rules that apply to the request.
@@ -27,12 +27,12 @@ class MemberUpdateRequest extends FormRequest
'string', 'string',
Rule::enum(Role::class), Rule::enum(Role::class),
], ],
'billable_rate' => [ 'billable_rate' => array_merge(
'nullable', [
'integer', 'nullable',
'min:0', ],
'max:2147483647', $this->moneyRules()
], ),
]; ];
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,17 +7,17 @@ namespace App\Http\Requests\V1\Report;
use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization; use App\Models\Organization;
use Illuminate\Contracts\Validation\Rule as LegacyValidationRule; use Illuminate\Contracts\Validation\Rule as LegacyValidationRule;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
/** /**
* @property Organization $organization Organization from model binding * @property Organization $organization Organization from model binding
*/ */
class ReportStoreRequest extends FormRequest class ReportStoreRequest extends BaseFormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * 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; namespace App\Http\Requests\V1\Report;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization; use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
/** /**
* @property Organization $organization Organization from model binding * @property Organization $organization Organization from model binding
*/ */
class ReportUpdateRequest extends FormRequest class ReportUpdateRequest extends BaseFormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * 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; namespace App\Http\Requests\V1\Tag;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Tag; use App\Models\Tag;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/** /**
* @property Organization $organization Organization from model binding * @property Organization $organization Organization from model binding
*/ */
class TagStoreRequest extends FormRequest class TagStoreRequest extends BaseFormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * 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; namespace App\Http\Requests\V1\Tag;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Tag; use App\Models\Tag;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/** /**
* @property Organization $organization Organization from model binding * @property Organization $organization Organization from model binding
* @property Tag|null $tag Tag 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. * 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; namespace App\Http\Requests\V1\Task;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
use App\Service\PermissionStore; use App\Service\PermissionStore;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/** /**
* @property Organization $organization Organization from model binding * @property Organization $organization Organization from model binding
*/ */
class TaskIndexRequest extends FormRequest class TaskIndexRequest extends BaseFormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * 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; namespace App\Http\Requests\V1\Task;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
use App\Models\Task; use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/** /**
* @property Organization $organization Organization from model binding * @property Organization $organization Organization from model binding
*/ */
class TaskStoreRequest extends FormRequest class TaskStoreRequest extends BaseFormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * 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; namespace App\Http\Requests\V1\Task;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Task; use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/** /**
* @property Organization $organization Organization from model binding * @property Organization $organization Organization from model binding
* @property Task|null $task Task 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. * 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\ExportFormat;
use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\TimeEntryAggregationTypeInterval;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client; use App\Models\Client;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
@@ -16,7 +17,6 @@ use App\Models\Task;
use App\Models\User; use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
@@ -24,7 +24,7 @@ use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/** /**
* @property Organization $organization * @property Organization $organization
*/ */
class TimeEntryAggregateExportRequest extends FormRequest class TimeEntryAggregateExportRequest extends BaseFormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * 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; namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationType;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client; use App\Models\Client;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
@@ -14,7 +15,6 @@ use App\Models\Task;
use App\Models\User; use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
@@ -22,7 +22,7 @@ use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/** /**
* @property Organization $organization * @property Organization $organization
*/ */
class TimeEntryAggregateRequest extends FormRequest class TimeEntryAggregateRequest extends BaseFormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * 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; namespace App\Http\Requests\V1\TimeEntry;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization; use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/** /**
* @property Organization $organization Organization from model binding * @property Organization $organization Organization from model binding
*/ */
class TimeEntryDestroyMultipleRequest extends FormRequest class TimeEntryDestroyMultipleRequest extends BaseFormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * 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; namespace App\Http\Requests\V1\TimeEntry;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client; use App\Models\Client;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
@@ -12,13 +13,12 @@ use App\Models\Tag;
use App\Models\Task; use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/** /**
* @property Organization $organization * @property Organization $organization
*/ */
class TimeEntryIndexRequest extends FormRequest class TimeEntryIndexRequest extends BaseFormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * 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; namespace App\Http\Requests\V1\TimeEntry;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
@@ -11,13 +12,12 @@ use App\Models\Tag;
use App\Models\Task; use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/** /**
* @property Organization $organization Organization from model binding * @property Organization $organization Organization from model binding
*/ */
class TimeEntryStoreRequest extends FormRequest class TimeEntryStoreRequest extends BaseFormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * 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; namespace App\Http\Requests\V1\TimeEntry;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
@@ -11,13 +12,12 @@ use App\Models\Tag;
use App\Models\Task; use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/** /**
* @property Organization $organization Organization from model binding * @property Organization $organization Organization from model binding
*/ */
class TimeEntryUpdateMultipleRequest extends FormRequest class TimeEntryUpdateMultipleRequest extends BaseFormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * 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; namespace App\Http\Requests\V1\TimeEntry;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
@@ -11,13 +12,12 @@ use App\Models\Tag;
use App\Models\Task; use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/** /**
* @property Organization $organization Organization from model binding * @property Organization $organization Organization from model binding
*/ */
class TimeEntryUpdateRequest extends FormRequest class TimeEntryUpdateRequest extends BaseFormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * Get the validation rules that apply to the request.

View File

@@ -4,8 +4,14 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Organization; namespace App\Http\Resources\V1\Organization;
use App\Enums\CurrencyFormat;
use App\Enums\DateFormat;
use App\Enums\IntervalFormat;
use App\Enums\NumberFormat;
use App\Enums\TimeFormat;
use App\Http\Resources\V1\BaseResource; use App\Http\Resources\V1\BaseResource;
use App\Models\Organization; use App\Models\Organization;
use App\Service\CurrencyService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
/** /**
@@ -34,6 +40,8 @@ class OrganizationResource extends BaseResource
*/ */
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
$currencyService = app(CurrencyService::class);
return [ return [
/** @var string $id ID */ /** @var string $id ID */
'id' => $this->resource->id, 'id' => $this->resource->id,
@@ -47,15 +55,17 @@ class OrganizationResource extends BaseResource
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates, 'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
/** @var string $currency Currency code (ISO 4217) */ /** @var string $currency Currency code (ISO 4217) */
'currency' => $this->resource->currency, 'currency' => $this->resource->currency,
/** @var string $number_format Number format */ /** @var string $currency_symbol Currency symbol */
'currency_symbol' => $currencyService->getCurrencySymbol($this->resource->currency),
/** @var NumberFormat $number_format Number format */
'number_format' => $this->resource->number_format->value, 'number_format' => $this->resource->number_format->value,
/** @var string $currency_format Currency format */ /** @var CurrencyFormat $currency_format Currency format */
'currency_format' => $this->resource->currency_format->value, 'currency_format' => $this->resource->currency_format->value,
/** @var string $date_format Date format */ /** @var DateFormat $date_format Date format */
'date_format' => $this->resource->date_format->value, 'date_format' => $this->resource->date_format->value,
/** @var string $interval_format Interval format */ /** @var IntervalFormat $interval_format Interval format */
'interval_format' => $this->resource->interval_format->value, 'interval_format' => $this->resource->interval_format->value,
/** @var string $time_format Time format */ /** @var TimeFormat $time_format Time format */
'time_format' => $this->resource->time_format->value, 'time_format' => $this->resource->time_format->value,
]; ];
} }

View File

@@ -4,8 +4,14 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Report; namespace App\Http\Resources\V1\Report;
use App\Enums\CurrencyFormat;
use App\Enums\DateFormat;
use App\Enums\IntervalFormat;
use App\Enums\NumberFormat;
use App\Enums\TimeFormat;
use App\Http\Resources\V1\BaseResource; use App\Http\Resources\V1\BaseResource;
use App\Models\Report; use App\Models\Report;
use App\Service\CurrencyService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
/** /**
@@ -64,6 +70,8 @@ class DetailedWithDataReportResource extends BaseResource
*/ */
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
$currencyService = app(CurrencyService::class);
return [ return [
/** @var string $name Name */ /** @var string $name Name */
'name' => $this->resource->name, 'name' => $this->resource->name,
@@ -73,6 +81,18 @@ class DetailedWithDataReportResource extends BaseResource
'public_until' => $this->formatDateTime($this->resource->public_until), 'public_until' => $this->formatDateTime($this->resource->public_until),
/** @var string $currency Currency code (ISO 4217) */ /** @var string $currency Currency code (ISO 4217) */
'currency' => $this->resource->organization->currency, 'currency' => $this->resource->organization->currency,
/** @var NumberFormat $number_format Number format */
'number_format' => $this->resource->organization->number_format->value,
/** @var CurrencyFormat $currency_format Currency format */
'currency_format' => $this->resource->organization->currency_format->value,
/** @var string $currency_symbol Currency symbol */
'currency_symbol' => $currencyService->getCurrencySymbol($this->resource->organization->currency),
/** @var DateFormat $date_format Date format */
'date_format' => $this->resource->organization->date_format->value,
/** @var IntervalFormat $interval_format Interval format */
'interval_format' => $this->resource->organization->interval_format->value,
/** @var TimeFormat $time_format Time format */
'time_format' => $this->resource->organization->time_format->value,
'properties' => [ 'properties' => [
/** @var string $group Type of first grouping */ /** @var string $group Type of first grouping */
'group' => $this->resource->properties->group->value, 'group' => $this->resource->properties->group->value,

View File

@@ -80,16 +80,16 @@ class LocalizationService
if ($this->intervalFormat === IntervalFormat::Decimal) { if ($this->intervalFormat === IntervalFormat::Decimal) {
$interval->cascade(); $interval->cascade();
return $this->formatNumber($interval->totalHours); return $this->formatNumber($interval->totalHours).' h';
} elseif ($this->intervalFormat === IntervalFormat::HoursMinutes) { } elseif ($this->intervalFormat === IntervalFormat::HoursMinutes) {
$interval->cascade(); $interval->cascade();
return ((int) floor($interval->totalHours)).'h '.$interval->format('%I').'m'; return ((int) floor($interval->totalHours)).'h '.$interval->format('%I').'m';
} elseif ($this->intervalFormat === IntervalFormat::HoursMinutesColonSeperated) { } elseif ($this->intervalFormat === IntervalFormat::HoursMinutesColonSeparated) {
$interval->cascade(); $interval->cascade();
return ((int) floor($interval->totalHours)).':'.$interval->format('%I'); return ((int) floor($interval->totalHours)).':'.$interval->format('%I');
} elseif ($this->intervalFormat === IntervalFormat::HoursMinutesSecondsColonSeperated) { } elseif ($this->intervalFormat === IntervalFormat::HoursMinutesSecondsColonSeparated) {
$interval->cascade(); $interval->cascade();
return ((int) floor($interval->totalHours)).':'.$interval->format('%I:%S'); return ((int) floor($interval->totalHours)).':'.$interval->format('%I:%S');

View File

@@ -147,7 +147,7 @@ return [
'default_currency' => env('LOCALIZATION_DEFAULT_CURRENCY', 'EUR'), 'default_currency' => env('LOCALIZATION_DEFAULT_CURRENCY', 'EUR'),
'default_number_format' => env('LOCALIZATION_DEFAULT_NUMBER_FORMAT', NumberFormat::ThousandsPointDecimalComma->value), 'default_number_format' => env('LOCALIZATION_DEFAULT_NUMBER_FORMAT', NumberFormat::ThousandsPointDecimalComma->value),
'default_currency_format' => env('LOCALIZATION_DEFAULT_CURRENCY_FORMAT', CurrencyFormat::ISOCodeAfterWithSpace->value), 'default_currency_format' => env('LOCALIZATION_DEFAULT_CURRENCY_FORMAT', CurrencyFormat::ISOCodeAfterWithSpace->value),
'default_date_format' => env('LOCALIZATION_DEFAULT_DATE_FORMAT', DateFormat::HyphenSeperatedYYYYMMDD->value), 'default_date_format' => env('LOCALIZATION_DEFAULT_DATE_FORMAT', DateFormat::HyphenSeparatedYYYYMMDD->value),
'default_time_format' => env('LOCALIZATION_DEFAULT_TIME_FORMAT', TimeFormat::TwentyFourHours->value), 'default_time_format' => env('LOCALIZATION_DEFAULT_TIME_FORMAT', TimeFormat::TwentyFourHours->value),
'default_interval_format' => env('LOCALIZATION_DEFAULT_INTERVAL_FORMAT', IntervalFormat::HoursMinutes->value), 'default_interval_format' => env('LOCALIZATION_DEFAULT_INTERVAL_FORMAT', IntervalFormat::HoursMinutes->value),
], ],

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// date_format
DB::statement("update organizations set date_format = 'point-separated-d-m-yyyy' where date_format = 'point-seperated-d-m-yyyy'");
DB::statement("update organizations set date_format = 'slash-separated-mm-dd-yyyy' where date_format = 'slash-seperated-mm-dd-yyyy'");
DB::statement("update organizations set date_format = 'slash-separated-dd-mm-yyyy' where date_format = 'slash-seperated-dd-mm-yyyy'");
DB::statement("update organizations set date_format = 'hyphen-separated-dd-mm-yyyy'where date_format = 'hyphen-seperated-dd-mm-yyyy'");
DB::statement("update organizations set date_format = 'hyphen-separated-mm-dd-yyyy' where date_format = 'hyphen-seperated-mm-dd-yyyy'");
DB::statement("update organizations set date_format = 'hyphen-separated-yyyy-mm-dd' where date_format = 'hyphen-seperated-yyyy-mm-dd'");
// interval_format
DB::statement("update organizations set interval_format = 'hours-minutes-colon-separated' where interval_format = 'hours-minutes-colon-seperated'");
DB::statement("update organizations set interval_format = 'hours-minutes-seconds-colon-separated' where interval_format = 'hours-minutes-seconds-colon-seperated'");
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// date_format
DB::statement("update organizations set date_format = 'point-seperated-d-m-yyyy' where date_format = 'point-separated-d-m-yyyy'");
DB::statement("update organizations set date_format = 'slash-seperated-mm-dd-yyyy' where date_format = 'slash-separated-mm-dd-yyyy'");
DB::statement("update organizations set date_format = 'slash-seperated-dd-mm-yyyy' where date_format = 'slash-separated-dd-mm-yyyy'");
DB::statement("update organizations set date_format = 'hyphen-seperated-dd-mm-yyyy'where date_format = 'hyphen-separated-dd-mm-yyyy'");
DB::statement("update organizations set date_format = 'hyphen-seperated-mm-dd-yyyy' where date_format = 'hyphen-separated-mm-dd-yyyy'");
DB::statement("update organizations set date_format = 'hyphen-seperated-yyyy-mm-dd' where date_format = 'hyphen-separated-yyyy-mm-dd'");
// interval_format
DB::statement("update organizations set interval_format = 'hours-minutes-colon-seperated' where interval_format = 'hours-minutes-colon-separated'");
DB::statement("update organizations set interval_format = 'hours-minutes-seconds-colon-seperated' where interval_format = 'hours-minutes-seconds-colon-separated'");
}
};

View File

@@ -7,6 +7,29 @@ async function goToOrganizationSettings(page) {
await page.getByText('Organization Settings').click(); await page.getByText('Organization Settings').click();
} }
async function createTimeEntry(page, duration: string) {
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await page.getByRole('button', { name: 'Manual time entry' }).click();
// Fill in the time entry details
await page.getByTestId('time_entry_description').fill('Test time entry');
// Set duration
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
// Submit the time entry
await Promise.all([
page.getByRole('button', { name: 'Create Time Entry' }).click(),
page.waitForResponse(
async (response) =>
response.url().includes('/time-entries') &&
response.request().method() === 'POST' &&
response.status() === 201
),
]);
}
test('test that organization name can be updated', async ({ page }) => { test('test that organization name can be updated', async ({ page }) => {
await goToOrganizationSettings(page); await goToOrganizationSettings(page);
await page.getByLabel('Organization Name').fill('NEW ORG NAME'); await page.getByLabel('Organization Name').fill('NEW ORG NAME');
@@ -27,9 +50,11 @@ test('test that organization billable rate can be updated with all existing time
.getByLabel('Organization Billable Rate') .getByLabel('Organization Billable Rate')
.fill(newBillableRate.toString()); .fill(newBillableRate.toString());
await page await page
.locator('button') .locator('form')
.filter({ hasText: /^Save$/ }) .filter({ hasText: 'Organization Billable' })
.getByRole('button', { name: 'Save' })
.click(); .click();
await Promise.all([ await Promise.all([
page page
.getByRole('button', { name: 'Yes, update existing time entries' }) .getByRole('button', { name: 'Yes, update existing time entries' })
@@ -51,4 +76,173 @@ test('test that organization billable rate can be updated with all existing time
]); ]);
}); });
// TODO: Add Test for import test('test that organization format settings can be updated', async ({
page,
}) => {
await goToOrganizationSettings(page);
// Test number format
await page.getByLabel('Number Format').click();
await page.getByRole('option', { name: '1,111.11' }).click();
await Promise.all([
page
.locator('form')
.filter({ hasText: 'Number Format' })
.getByRole('button', { name: 'Save' })
.click(),
page.waitForResponse(
async (response) =>
response.url().includes('/organizations/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.number_format === 'comma-point'
),
]);
// Test currency format
await page.getByLabel('Currency Format').click();
await page.getByRole('option', { name: '111 EUR' }).click();
await Promise.all([
page
.locator('form')
.filter({ hasText: 'Currency Format' })
.getByRole('button', { name: 'Save' })
.click(),
page.waitForResponse(
async (response) =>
response.url().includes('/organizations/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.currency_format ===
'iso-code-after-with-space'
),
]);
// Test date format
await page.getByLabel('Date Format').click();
await page.getByRole('option', { name: 'DD/MM/YYYY' }).click();
await Promise.all([
page
.locator('form')
.filter({ hasText: 'Date Format' })
.getByRole('button', { name: 'Save' })
.click(),
page.waitForResponse(
async (response) =>
response.url().includes('/organizations/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.date_format ===
'slash-separated-dd-mm-yyyy'
),
]);
// Test time format
await page.getByLabel('Time Format').click();
await page.getByRole('option', { name: '24-hour clock' }).click();
await Promise.all([
page
.locator('form')
.filter({ hasText: 'Time Format' })
.getByRole('button', { name: 'Save' })
.click(),
page.waitForResponse(
async (response) =>
response.url().includes('/organizations/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.time_format === '24-hours'
),
]);
// Test interval format
await page.getByLabel('Time Duration Format').click();
await page.getByRole('option', { name: '12:03', exact: true }).click();
await Promise.all([
page
.locator('form')
.filter({ hasText: 'Time Duration Format' })
.getByRole('button', { name: 'Save' })
.click(),
page.waitForResponse(
async (response) =>
response.url().includes('/organizations/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.interval_format ===
'hours-minutes-colon-separated'
),
]);
});
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();
// First set the format settings
await goToOrganizationSettings(page);
// Set number format to comma-point
await page.getByLabel('Number Format').click();
await page.getByRole('option', { name: '1,111.11' }).click();
// Set currency format to symbol-after
await page.getByLabel('Currency Format').click();
await page.getByRole('option', { name: '111€' }).click();
// Set interval format to hours-minutes-colon-separated
await page.getByLabel('Time Duration Format').click();
await page.getByRole('option', { name: '12:03', exact: true }).click();
// Set date format to DD/MM/YYYY
await page.getByLabel('Date Format').click();
await page.getByRole('option', { name: 'DD/MM/YYYY' }).click();
await Promise.all([
page
.locator('form')
.filter({ hasText: 'Time Duration Format' })
.getByRole('button', { name: 'Save' })
.click(),
page.waitForResponse(
async (response) =>
response.url().includes('/organizations/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.interval_format ===
'hours-minutes-colon-separated' &&
(await response.json()).data.currency_format ===
'symbol-after' &&
(await response.json()).data.number_format === 'comma-point'
),
]);
await createTimeEntry(page, '00:00');
// Go to dashboard and check the formats
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
// Check billable amount format (number and currency)
await expect(page.getByText('0.00€')).toBeVisible();
// 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();
// 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();
});
// TODO: Test 12-hour clock format

View File

@@ -1,7 +1,9 @@
import { expect, Page } from '@playwright/test'; import { expect, Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures'; import { test } from '../playwright/fixtures';
import { formatCents } from '../resources/js/packages/ui/src/utils/money'; import { formatCentsWithOrganizationDefaults } from './utils/money';
import type { CurrencyFormat } from '../resources/js/packages/ui/src/utils/money';
import { NumberFormat } from '@/packages/ui/src/utils/number';
async function goToProjectsOverview(page: Page) { async function goToProjectsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/projects'); await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
@@ -61,6 +63,6 @@ test('test that updating project member billable rate works for existing time en
page page
.getByRole('row') .getByRole('row')
.first() .first()
.getByText(formatCents(newBillableRate * 100, 'EUR')) .getByText(formatCentsWithOrganizationDefaults(newBillableRate * 100))
).toBeVisible(); ).toBeVisible();
}); });

View File

@@ -1,7 +1,8 @@
import { expect, Page } from '@playwright/test'; import { expect, Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures'; import { test } from '../playwright/fixtures';
import { formatCents } from '../resources/js/packages/ui/src/utils/money'; import { formatCentsWithOrganizationDefaults } from './utils/money';
import type { CurrencyFormat } from '../resources/js/packages/ui/src/utils/money';
async function goToProjectsOverview(page: Page) { async function goToProjectsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/projects'); await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
@@ -131,7 +132,7 @@ test('test that updating billable rate works with existing time entries', async
page page
.getByRole('row') .getByRole('row')
.first() .first()
.getByText(formatCents(newBillableRate * 100, 'EUR')) .getByText(formatCentsWithOrganizationDefaults(newBillableRate * 100))
).toBeVisible(); ).toBeVisible();
}); });

View File

@@ -1,5 +1,210 @@
// TODO: Test filter import { expect, Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
// TODO: Test date range
// TODO: Test grouping and sub-grouping
async function goToTimeOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
}
async function goToReporting(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/reporting');
}
async function goToReportingDetailed(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/reporting/detailed');
}
async function createTimeEntryWithProject(page: Page, projectName: string, duration: string) {
// First create the project through the Projects page
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(projectName);
await page.getByRole('dialog').getByRole('button', { name: 'Create Project' }).click();
// Wait for the project to be created and visible in the list
await page.getByText(projectName).waitFor({ state: 'visible' });
// Then create the time entry
await goToTimeOverview(page);
await page.getByRole('button', { name: 'Manual time entry' }).click();
// Fill in the time entry details
await page.getByTestId('time_entry_description').fill(`Time entry for ${projectName}`);
await page.getByRole('button', { name: 'No Project' }).click();
await page.getByText(projectName).click();
// Set duration
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
// Submit the time entry
await Promise.all([
page.getByRole('button', { name: 'Create Time Entry' }).click(),
page.waitForResponse(response => response.url().includes('/time-entries') && response.status() === 201)
]);
}
async function createTimeEntryWithTag(page: Page, tagName: string, duration: string) {
await goToTimeOverview(page);
await page.getByRole('button', { name: 'Manual time entry' }).click();
// Fill in the time entry details
await page.getByTestId('time_entry_description').fill(`Time entry with tag ${tagName}`);
// Add tag
await page.getByRole('button', { name: 'Tags' }).click();
await page.getByText('Create new tag').click();
await page.getByPlaceholder('Tag Name').fill(tagName);
await page.getByRole('button', { name: 'Create Tag' }).click();
await page.waitForLoadState('networkidle');
// Set duration
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
// Submit the time entry
await page.getByRole('button', { name: 'Create Time Entry' }).click();
}
async function createTimeEntryWithBillableStatus(page: Page, isBillable: boolean, duration: string) {
await goToTimeOverview(page);
await page.getByRole('button', { name: 'Manual time entry' }).click();
// Fill in the time entry details
await page.getByTestId('time_entry_description').fill(`Time entry ${isBillable ? 'billable' : 'non-billable'}`);
// Set billable status
await page.getByRole('button', { name: 'Non-Billable' }).click();
if (!isBillable) {
await page.getByRole('option', { name: 'Non Billable', exact: true }).click();
} else {
await page.getByRole('option', { name: 'Billable', exact: true }).click();
}
// Set duration
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
// Submit the time entry
await page.getByRole('button', { name: 'Create Time Entry' }).click();
}
test('test that project filtering works in reporting', async ({ page }) => {
const project1 = 'Test Project 1 ' + Math.floor(Math.random() * 10000);
const project2 = 'Test Project 2 ' + Math.floor(Math.random() * 10000);
// Create time entries for both projects
await createTimeEntryWithProject(page, project1, '1h');
await createTimeEntryWithProject(page, project2, '2h');
// Go to reporting and filter by project1
await goToReporting(page);
await page.getByRole('button', { name: 'Project' }).nth(0).click();
await page.getByText(project1).click();
await Promise.all([
// escape
page.keyboard.press('Escape'),
// wait for API request to finish
page.waitForResponse(response => response.url().includes('/time-entries/aggregate') && response.status() === 200)
]);
await page.waitForLoadState('networkidle');
// Verify only project1 time entries are shown
await expect(page.getByText(project1)).toBeVisible();
await expect(page.getByText(project2)).not.toBeVisible();
});
test('test that tag filtering works in reporting', async ({ page }) => {
const tag1 = 'Test Tag 1 ' + Math.floor(Math.random() * 10000);
const tag2 = 'Test Tag 2 ' + Math.floor(Math.random() * 10000);
// Create time entries with different tags
await createTimeEntryWithTag(page, tag1, '1h');
await createTimeEntryWithTag(page, tag2, '2h');
// Go to reporting and filter by tag1
await goToReporting(page);
// wait for all requests to finish
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: 'Tags' }).click();
await page.getByText(tag1).click();
await Promise.all([
// escape
page.keyboard.press('Escape'),
// wait for API request to finish
page.waitForResponse(response => response.url().includes('/time-entries/aggregate') && response.status() === 200)
]);
// Verify only time entries with tag1 are shown
await expect(page.getByText('1h 00min').first()).toBeVisible();
});
test('test that billable status filtering works in reporting', async ({ page }) => {
// Create billable and non-billable time entries
await createTimeEntryWithBillableStatus(page, true, '1h');
await createTimeEntryWithBillableStatus(page, false, '2h');
// Go to reporting and filter by billable
await goToReporting(page);
await page.getByRole('button', { name: 'Billable' }).click();
await page.getByRole('option', { name: 'Billable', exact: true }).click();
await Promise.all([
// escape
page.keyboard.press('Escape'),
// wait for API request to finish
page.waitForResponse(response => response.url().includes('/time-entries/aggregate') && response.status() === 200)
]);
await page.waitForLoadState('networkidle');
await expect(page.getByText('1h 00min').first()).toBeVisible();
});
test('test that detailed view shows time entries correctly', async ({ page }) => {
const projectName = 'Detailed View Project ' + Math.floor(Math.random() * 10000);
// Create a time entry
await createTimeEntryWithProject(page, projectName, '1h');
// Go to detailed reporting view
await goToReportingDetailed(page);
// Verify the time entry is shown with all details
await expect(page.getByText(projectName, { exact: true })).toBeVisible();
await expect(page.locator('input[name="Duration"]')).toHaveValue('1h 00min');
await expect(page.getByText('Time entry for ' + projectName, { exact: true })).toBeVisible();
});
test('test that updating duration in detailed view works correctly', async ({ page }) => {
const projectName = 'Duration Update Project ' + Math.floor(Math.random() * 10000);
const initialDuration = '1h';
const updatedDuration = '2h 30min';
// Create a time entry with initial duration
await createTimeEntryWithProject(page, projectName, initialDuration);
// Go to detailed reporting view
await goToReportingDetailed(page);
// Find and update the duration
const durationInput = page.locator('input[name="Duration"]').first();
await durationInput.click();
await durationInput.fill(updatedDuration);
await durationInput.press('Enter');
// Wait for the update to be processed
await page.waitForLoadState('networkidle');
// Verify the new duration is displayed
await expect(durationInput).toHaveValue(updatedDuration);
});
// TODO: test that date range filtering works in reporting

View File

@@ -218,9 +218,7 @@ test('test that updating a the duration in the overview works on blur', async ({
const newTimeEntry = timeEntryRows.first(); const newTimeEntry = timeEntryRows.first();
await assertThatTimeEntryRowIsStopped(newTimeEntry); await assertThatTimeEntryRowIsStopped(newTimeEntry);
await page.waitForTimeout(1500); await page.waitForTimeout(1500);
const timeEntryDurationInput = newTimeEntry.getByTestId( const timeEntryDurationInput = newTimeEntry.locator('input[name="Duration"]');
'time_entry_duration_input'
);
await timeEntryDurationInput.fill('20min'); await timeEntryDurationInput.fill('20min');
await Promise.all([ await Promise.all([
@@ -238,9 +236,7 @@ test('test that updating a the duration in the overview works on blur', async ({
timeEntryDurationInput.press('Tab'), timeEntryDurationInput.press('Tab'),
]); ]);
await expect( await expect(timeEntryDurationInput).toHaveValue('0h 20min');
newTimeEntry.getByTestId('time_entry_duration_input')
).toHaveValue('0h 20min');
}); });
// Test that start stop button stops running timer // Test that start stop button stops running timer

17
e2e/utils/money.ts Normal file
View File

@@ -0,0 +1,17 @@
import { formatCents } from '../../resources/js/packages/ui/src/utils/money';
import type { CurrencyFormat } from '../../resources/js/packages/ui/src/utils/money';
import { NumberFormat } from '../../resources/js/packages/ui/src/utils/number';
export function formatCentsWithOrganizationDefaults(
cents: number,
currencyCode: string = 'EUR',
currencySymbol: string = '€'
): string {
return formatCents(
cents,
currencyCode,
'iso-code-after-with-space' as CurrencyFormat,
currencySymbol,
'point-comma' as NumberFormat
);
}

View File

@@ -30,12 +30,12 @@ return [
], ],
'date_format' => [ 'date_format' => [
DateFormat::PointSeperatedDMYYYY->value => 'D.M.YYYY', DateFormat::PointSeparatedDMYYYY->value => 'D.M.YYYY',
DateFormat::SlashSeperatedMMDDYYYY->value => 'MM/DD/YYYY', DateFormat::SlashSeparatedMMDDYYYY->value => 'MM/DD/YYYY',
DateFormat::SlashSeperatedDDMMYYYY->value => 'DD/MM/YYYY', DateFormat::SlashSeparatedDDMMYYYY->value => 'DD/MM/YYYY',
DateFormat::HyphenSeperatedDDMMYYY->value => 'DD-MM-YYYY', DateFormat::HyphenSeparatedDDMMYYY->value => 'DD-MM-YYYY',
DateFormat::HyphenSeperatedMMDDDYYYY->value => 'MM-DD-YYYY', DateFormat::HyphenSeparatedMMDDDYYYY->value => 'MM-DD-YYYY',
DateFormat::HyphenSeperatedYYYYMMDD->value => 'YYYY-MM-DD', DateFormat::HyphenSeparatedYYYYMMDD->value => 'YYYY-MM-DD',
], ],
'time_format' => [ 'time_format' => [
@@ -46,8 +46,8 @@ return [
'interval_format' => [ 'interval_format' => [
IntervalFormat::Decimal->value => 'Decimal', IntervalFormat::Decimal->value => 'Decimal',
IntervalFormat::HoursMinutes->value => '12h 3m', IntervalFormat::HoursMinutes->value => '12h 3m',
IntervalFormat::HoursMinutesColonSeperated->value => '12:03', IntervalFormat::HoursMinutesColonSeparated->value => '12:03',
IntervalFormat::HoursMinutesSecondsColonSeperated->value => '12:03:45', IntervalFormat::HoursMinutesSecondsColonSeparated->value => '12:03:45',
], ],
'currency_format' => [ 'currency_format' => [

View File

@@ -43,6 +43,9 @@
--theme-color-input-select-active: rgb(var(--color-accent-300)); --theme-color-input-select-active: rgb(var(--color-accent-300));
--theme-color-input-select-active-hover: rgb(var(--color-accent-200)); --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 { :root.light {
@@ -86,6 +89,9 @@
--theme-color-input-select-active: rgb(var(--color-accent-400)); --theme-color-input-select-active: rgb(var(--color-accent-400));
--theme-color-input-select-active-hover: rgb(var(--color-accent-500)); --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 { :root {

View File

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

View File

@@ -2,10 +2,14 @@
import { getOrganizationCurrencyString } from '@/utils/money'; import { getOrganizationCurrencyString } from '@/utils/money';
import BillableRateModal from '@/packages/ui/src/BillableRateModal.vue'; import BillableRateModal from '@/packages/ui/src/BillableRateModal.vue';
import { formatCents } from '@/packages/ui/src/utils/money'; import { formatCents } from '@/packages/ui/src/utils/money';
import { inject, type ComputedRef } from 'vue';
import type { Organization } from '@/packages/api/src';
const show = defineModel('show', { default: false }); const show = defineModel('show', { default: false });
const saving = defineModel('saving', { default: false }); const saving = defineModel('saving', { default: false });
const organization = inject<ComputedRef<Organization>>('organization');
defineProps<{ defineProps<{
newBillableRate?: number | null; newBillableRate?: number | null;
memberName: string; memberName: string;
@@ -28,7 +32,10 @@ defineEmits<{
newBillableRate newBillableRate
? formatCents( ? formatCents(
newBillableRate, newBillableRate,
getOrganizationCurrencyString() getOrganizationCurrencyString(),
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
) )
: ' the default rate of the organization' : ' the default rate of the organization'
}}</strong }}</strong

View File

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

View File

@@ -1,26 +1,27 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Member } from '@/packages/api/src'; import type { Member, Organization } from '@/packages/api/src';
import { api } from '@/packages/api/src';
import { CheckCircleIcon, UserCircleIcon } from '@heroicons/vue/20/solid'; import { CheckCircleIcon, UserCircleIcon } from '@heroicons/vue/20/solid';
import MemberMoreOptionsDropdown from '@/Components/Common/Member/MemberMoreOptionsDropdown.vue'; import MemberMoreOptionsDropdown from '@/Components/Common/Member/MemberMoreOptionsDropdown.vue';
import TableRow from '@/Components/TableRow.vue'; import TableRow from '@/Components/TableRow.vue';
import { capitalizeFirstLetter } from '../../../utils/format';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue'; import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { api } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification'; import { useNotificationsStore } from '@/utils/notification';
import { canInvitePlaceholderMembers } from '@/utils/permissions'; import { canInvitePlaceholderMembers } from '@/utils/permissions';
import { useMembersStore } from '@/utils/useMembers'; import { useMembersStore } from '@/utils/useMembers';
import {computed, ref} from 'vue'; import { computed, type ComputedRef, inject, ref } from 'vue';
import MemberEditModal from '@/Components/Common/Member/MemberEditModal.vue'; import MemberEditModal from '@/Components/Common/Member/MemberEditModal.vue';
import { getOrganizationCurrencyString } from '@/utils/money'; import MemberMergeModal from '@/Components/Common/Member/MemberMergeModal.vue';
import { formatCents } from '@/packages/ui/src/utils/money'; import MemberMakePlaceholderModal from '@/Components/Common/Member/MemberMakePlaceholderModal.vue';
import MemberMergeModal from "@/Components/Common/Member/MemberMergeModal.vue"; import { capitalizeFirstLetter } from '../../../utils/format';
import MemberMakePlaceholderModal from "@/Components/Common/Member/MemberMakePlaceholderModal.vue"; import { formatCents } from '../../../packages/ui/src/utils/money';
const props = defineProps<{ const props = defineProps<{
member: Member; member: Member;
}>(); }>();
const organization = inject<ComputedRef<Organization>>('organization');
const showEditMemberModal = ref(false); const showEditMemberModal = ref(false);
const showMergeMemberModal = ref(false); const showMergeMemberModal = ref(false);
const showMakeMemberPlaceholderModal = ref(false); const showMakeMemberPlaceholderModal = ref(false);
@@ -35,15 +36,12 @@ async function invitePlaceholder(id: string) {
if (organizationId) { if (organizationId) {
await handleApiRequestNotifications( await handleApiRequestNotifications(
() => () =>
api.invitePlaceholder( api.invitePlaceholder(undefined, {
undefined, params: {
{ organization: organizationId,
params: { member: id,
organization: organizationId, },
member: id, }),
},
}
),
'Member invited successfully', 'Member invited successfully',
'Error inviting member' 'Error inviting member'
); );
@@ -52,8 +50,7 @@ async function invitePlaceholder(id: string) {
const userHasValidMailAddress = computed(() => { const userHasValidMailAddress = computed(() => {
return !props.member.email.endsWith('@solidtime-import.test'); return !props.member.email.endsWith('@solidtime-import.test');
}) });
</script> </script>
<template> <template>
@@ -75,7 +72,10 @@ const userHasValidMailAddress = computed(() => {
member.billable_rate member.billable_rate
? formatCents( ? formatCents(
member.billable_rate, member.billable_rate,
getOrganizationCurrencyString() organization?.currency,
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
) )
: '--' : '--'
}} }}
@@ -101,21 +101,26 @@ const userHasValidMailAddress = computed(() => {
" "
size="small" size="small"
@click="invitePlaceholder(member.id)" @click="invitePlaceholder(member.id)"
>Invite</SecondaryButton >Invite
> </SecondaryButton>
<MemberMoreOptionsDropdown <MemberMoreOptionsDropdown
:member="member" :member="member"
@edit="showEditMemberModal = true" @edit="showEditMemberModal = true"
@delete="removeMember" @delete="removeMember"
@merge="showMergeMemberModal = true" @merge="showMergeMemberModal = true"
@make-placeholder="showMakeMemberPlaceholderModal = true" @make-placeholder="
></MemberMoreOptionsDropdown> showMakeMemberPlaceholderModal = true
"></MemberMoreOptionsDropdown>
</div> </div>
<MemberEditModal <MemberEditModal
v-model:show="showEditMemberModal" v-model:show="showEditMemberModal"
:member="member"></MemberEditModal> :member="member"></MemberEditModal>
<MemberMergeModal v-model:show="showMergeMemberModal" :member="member"></MemberMergeModal> <MemberMergeModal
<MemberMakePlaceholderModal v-model:show="showMakeMemberPlaceholderModal" :member="member"></MemberMakePlaceholderModal> v-model:show="showMergeMemberModal"
:member="member"></MemberMergeModal>
<MemberMakePlaceholderModal
v-model:show="showMakeMemberPlaceholderModal"
:member="member"></MemberMakePlaceholderModal>
</TableRow> </TableRow>
</template> </template>

View File

@@ -2,10 +2,14 @@
import { getOrganizationCurrencyString } from '@/utils/money'; import { getOrganizationCurrencyString } from '@/utils/money';
import BillableRateModal from '@/packages/ui/src/BillableRateModal.vue'; import BillableRateModal from '@/packages/ui/src/BillableRateModal.vue';
import { formatCents } from '@/packages/ui/src/utils/money'; import { formatCents } from '@/packages/ui/src/utils/money';
import { inject, type ComputedRef } from 'vue';
import type { Organization } from '@/packages/api/src';
const show = defineModel('show', { default: false }); const show = defineModel('show', { default: false });
const saving = defineModel('saving', { default: false }); const saving = defineModel('saving', { default: false });
const organization = inject<ComputedRef<Organization>>('organization');
defineProps<{ defineProps<{
newBillableRate?: number | null; newBillableRate?: number | null;
}>(); }>();
@@ -27,7 +31,10 @@ defineEmits<{
newBillableRate newBillableRate
? formatCents( ? formatCents(
newBillableRate, newBillableRate,
getOrganizationCurrencyString() getOrganizationCurrencyString(),
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
) )
: ' none.' : ' none.'
}}</strong }}</strong

View File

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

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import ProjectMoreOptionsDropdown from '@/Components/Common/Project/ProjectMoreOptionsDropdown.vue'; import ProjectMoreOptionsDropdown from '@/Components/Common/Project/ProjectMoreOptionsDropdown.vue';
import type { Project } from '@/packages/api/src'; import type { Project } from '@/packages/api/src';
import { computed, ref } from 'vue'; import { computed, ref, inject, type ComputedRef } from 'vue';
import { CheckCircleIcon } from '@heroicons/vue/20/solid'; import { CheckCircleIcon } from '@heroicons/vue/20/solid';
import { useClientsStore } from '@/utils/useClients'; import { useClientsStore } from '@/utils/useClients';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
@@ -15,6 +15,7 @@ import EstimatedTimeProgress from '@/packages/ui/src/EstimatedTimeProgress.vue';
import UpgradeBadge from '@/Components/Common/UpgradeBadge.vue'; import UpgradeBadge from '@/Components/Common/UpgradeBadge.vue';
import { formatHumanReadableDuration } from '../../../packages/ui/src/utils/time'; import { formatHumanReadableDuration } from '../../../packages/ui/src/utils/time';
import { isAllowedToPerformPremiumAction } from '@/utils/billing'; import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import type { Organization } from '@/packages/api/src';
const { clients } = storeToRefs(useClientsStore()); const { clients } = storeToRefs(useClientsStore());
const { tasks } = storeToRefs(useTasksStore()); const { tasks } = storeToRefs(useTasksStore());
@@ -46,12 +47,17 @@ function archiveProject() {
}); });
} }
const organization = inject<ComputedRef<Organization>>('organization');
const billableRateInfo = computed(() => { const billableRateInfo = computed(() => {
if (props.project.is_billable) { if (props.project.is_billable) {
if (props.project.billable_rate) { if (props.project.billable_rate) {
return formatCents( return formatCents(
props.project.billable_rate, props.project.billable_rate,
getOrganizationCurrencyString() getOrganizationCurrencyString(),
organization?.value?.currency_format,
organization?.value?.currency_symbol,
organization?.value?.number_format
); );
} else { } else {
return 'Default Rate'; return 'Default Rate';
@@ -61,6 +67,7 @@ const billableRateInfo = computed(() => {
}); });
const showEditProjectModal = ref(false); const showEditProjectModal = ref(false);
</script> </script>
<template> <template>
@@ -79,9 +86,12 @@ const showEditProjectModal = ref(false);
<span class="overflow-ellipsis overflow-hidden"> <span class="overflow-ellipsis overflow-hidden">
{{ project.name }} {{ project.name }}
</span> </span>
<span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span> <span class="text-text-secondary">
{{ projectTasksCount }} Tasks
</span>
</div> </div>
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-secondary"> <div
class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-secondary">
<div <div
v-if="project.client_id" v-if="project.client_id"
class="overflow-ellipsis overflow-hidden"> class="overflow-ellipsis overflow-hidden">
@@ -91,7 +101,13 @@ const showEditProjectModal = ref(false);
</div> </div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary"> <div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
<div v-if="project.spent_time"> <div v-if="project.spent_time">
{{ formatHumanReadableDuration(project.spent_time) }} {{
formatHumanReadableDuration(
project.spent_time,
organization?.interval_format,
organization?.number_format
)
}}
</div> </div>
<div v-else>--</div> <div v-else>--</div>
</div> </div>

View File

@@ -2,10 +2,14 @@
import { getOrganizationCurrencyString } from '@/utils/money'; import { getOrganizationCurrencyString } from '@/utils/money';
import BillableRateModal from '@/packages/ui/src/BillableRateModal.vue'; import BillableRateModal from '@/packages/ui/src/BillableRateModal.vue';
import { formatCents } from '@/packages/ui/src/utils/money'; import { formatCents } from '@/packages/ui/src/utils/money';
import { inject, type ComputedRef } from 'vue';
import type { Organization } from '@/packages/api/src';
const show = defineModel('show', { default: false }); const show = defineModel('show', { default: false });
const saving = defineModel('saving', { default: false }); const saving = defineModel('saving', { default: false });
const organization = inject<ComputedRef<Organization>>('organization');
defineProps<{ defineProps<{
newBillableRate?: number | null; newBillableRate?: number | null;
memberName?: string; memberName?: string;
@@ -28,7 +32,10 @@ defineEmits<{
newBillableRate newBillableRate
? formatCents( ? formatCents(
newBillableRate, newBillableRate,
getOrganizationCurrencyString() getOrganizationCurrencyString(),
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
) )
: ' the default rate of the project' : ' the default rate of the project'
}}</strong }}</strong

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ProjectMember } from '@/packages/api/src'; import type { ProjectMember } from '@/packages/api/src';
import { computed, ref } from 'vue'; import { computed, ref, inject, type ComputedRef } from 'vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import TableRow from '@/Components/TableRow.vue'; import TableRow from '@/Components/TableRow.vue';
import { useMembersStore } from '@/utils/useMembers'; import { useMembersStore } from '@/utils/useMembers';
@@ -10,10 +10,14 @@ import { formatCents } from '@/packages/ui/src/utils/money';
import { capitalizeFirstLetter } from '@/utils/format'; import { capitalizeFirstLetter } from '@/utils/format';
import ProjectMemberEditModal from '@/Components/Common/ProjectMember/ProjectMemberEditModal.vue'; import ProjectMemberEditModal from '@/Components/Common/ProjectMember/ProjectMemberEditModal.vue';
import { getOrganizationCurrencyString } from '@/utils/money'; import { getOrganizationCurrencyString } from '@/utils/money';
import type { Organization } from '@/packages/api/src';
const props = defineProps<{ const props = defineProps<{
projectMember: ProjectMember; projectMember: ProjectMember;
}>(); }>();
const organization = inject<ComputedRef<Organization>>('organization');
function deleteProjectMember() { function deleteProjectMember() {
useProjectMembersStore().deleteProjectMember( useProjectMembersStore().deleteProjectMember(
props.projectMember.project_id, props.projectMember.project_id,
@@ -51,7 +55,10 @@ const showEditModal = ref(false);
projectMember.billable_rate projectMember.billable_rate
? formatCents( ? formatCents(
projectMember.billable_rate, projectMember.billable_rate,
getOrganizationCurrencyString() getOrganizationCurrencyString(),
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
) )
: '--' : '--'
}} }}

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import VChart, { THEME_KEY } from 'vue-echarts'; import VChart, { THEME_KEY } from 'vue-echarts';
import { computed, provide } from 'vue'; import { computed, provide, inject, shallowRef, type ComputedRef } from 'vue';
import LinearGradient from 'zrender/lib/graphic/LinearGradient'; import LinearGradient from 'zrender/lib/graphic/LinearGradient';
import { import {
formatDate, formatDate,
@@ -16,7 +16,7 @@ import {
TitleComponent, TitleComponent,
TooltipComponent, TooltipComponent,
} from 'echarts/components'; } from 'echarts/components';
import type { AggregatedTimeEntries } from '@/packages/api/src'; import type { AggregatedTimeEntries, Organization } from '@/packages/api/src';
import { useCssVar } from '@vueuse/core'; import { useCssVar } from '@vueuse/core';
use([ use([
@@ -30,6 +30,8 @@ use([
provide(THEME_KEY, 'dark'); provide(THEME_KEY, 'dark');
const organization = inject<ComputedRef<Organization>>('organization');
const chart = shallowRef(null);
type GroupedData = AggregatedTimeEntries['grouped_data']; type GroupedData = AggregatedTimeEntries['grouped_data'];
const props = defineProps<{ const props = defineProps<{
@@ -41,7 +43,9 @@ const xAxisLabels = computed(() => {
if (props.groupedType === 'week') { if (props.groupedType === 'week') {
return props?.groupedData?.map((el) => formatWeek(el.key)); return props?.groupedData?.map((el) => formatWeek(el.key));
} }
return props?.groupedData?.map((el) => formatDate(el.key ?? '')); return props?.groupedData?.map((el) =>
formatDate(el.key ?? '', organization?.value?.date_format)
);
}); });
const accentColor = useCssVar('--theme-color-chart', null, { observe: true }); const accentColor = useCssVar('--theme-color-chart', null, { observe: true });
const labelColor = useCssVar('--color-text-secondary', null, { observe: true }); const labelColor = useCssVar('--color-text-secondary', null, { observe: true });
@@ -143,7 +147,11 @@ const option = computed(() => ({
type: 'bar', type: 'bar',
tooltip: { tooltip: {
valueFormatter: (value: number) => { valueFormatter: (value: number) => {
return formatHumanReadableDuration(value); return formatHumanReadableDuration(
value,
organization?.value?.interval_format,
organization?.value?.number_format
);
}, },
}, },
}, },
@@ -155,6 +163,7 @@ const option = computed(() => ({
<div class="w-[calc(100%-1px)]"> <div class="w-[calc(100%-1px)]">
<v-chart <v-chart
v-if="groupedData && groupedData?.length > 0" v-if="groupedData && groupedData?.length > 0"
ref="chart"
:autoresize="true" :autoresize="true"
class="chart" class="chart"
:option="option" /> :option="option" />

View File

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

View File

@@ -0,0 +1,504 @@
<script setup lang="ts">
import {
ChartBarIcon,
CheckCircleIcon,
TagIcon,
UserGroupIcon,
} from '@heroicons/vue/20/solid';
import { FolderIcon } from '@heroicons/vue/16/solid';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
import {
formatHumanReadableDuration,
getDayJsInstance,
getLocalizedDayJs,
} from '@/packages/ui/src/utils/time';
import { formatCents } from '@/packages/ui/src/utils/money';
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
import ReportingExportButton from '@/Components/Common/Reporting/ReportingExportButton.vue';
import TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue';
import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue';
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
import MemberMultiselectDropdown from '@/Components/Common/Member/MemberMultiselectDropdown.vue';
import ReportingFilterBadge from '@/Components/Common/Reporting/ReportingFilterBadge.vue';
import PageTitle from '@/Components/Common/PageTitle.vue';
import ProjectMultiselectDropdown from '@/Components/Common/Project/ProjectMultiselectDropdown.vue';
import ReportingChart from '@/Components/Common/Reporting/ReportingChart.vue';
import SelectDropdown from '../../../packages/ui/src/Input/SelectDropdown.vue';
import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue';
import MainContainer from '@/packages/ui/src/MainContainer.vue';
import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue';
import ReportingExportModal from '@/Components/Common/Reporting/ReportingExportModal.vue';
import ReportSaveButton from '@/Components/Common/Report/ReportSaveButton.vue';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue';
import { computed, type ComputedRef, inject, onMounted, ref } from 'vue';
import { type GroupingOption, useReportingStore } from '@/utils/useReporting';
import { storeToRefs } from 'pinia';
import {
type AggregatedTimeEntriesQueryParams,
api,
type CreateReportBodyProperties,
type Organization,
} from '@/packages/api/src';
import {
getCurrentMembershipId,
getCurrentOrganizationId,
getCurrentRole,
} from '@/utils/useUser';
import { useTagsStore } from '@/utils/useTags';
import { useSessionStorage, useStorage } from '@vueuse/core';
import { useNotificationsStore } from '@/utils/notification';
import type { ExportFormat } from '@/types/reporting';
import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';
import { useProjectsStore } from '@/utils/useProjects';
const { handleApiRequestNotifications } = useNotificationsStore();
const startDate = useSessionStorage<string>(
'reporting-start-date',
getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format()
);
const endDate = useSessionStorage<string>(
'reporting-end-date',
getLocalizedDayJs(getDayJsInstance()().format()).format()
);
const selectedTags = ref<string[]>([]);
const selectedProjects = ref<string[]>([]);
const selectedMembers = ref<string[]>([]);
const selectedTasks = ref<string[]>([]);
const selectedClients = ref<string[]>([]);
const billable = ref<'true' | 'false' | null>(null);
const group = useStorage<GroupingOption>('reporting-group', 'project');
const subGroup = useStorage<GroupingOption>('reporting-sub-group', 'task');
const reportingStore = useReportingStore();
const { aggregatedGraphTimeEntries, aggregatedTableTimeEntries } =
storeToRefs(reportingStore);
const { groupByOptions } = reportingStore;
const organization = inject<ComputedRef<Organization>>('organization');
function getFilterAttributes(): AggregatedTimeEntriesQueryParams {
let params: AggregatedTimeEntriesQueryParams = {
start: getLocalizedDayJs(startDate.value).startOf('day').utc().format(),
end: getLocalizedDayJs(endDate.value).endOf('day').utc().format(),
};
params = {
...params,
member_ids:
selectedMembers.value.length > 0
? selectedMembers.value
: undefined,
project_ids:
selectedProjects.value.length > 0
? selectedProjects.value
: undefined,
task_ids:
selectedTasks.value.length > 0 ? selectedTasks.value : undefined,
client_ids:
selectedClients.value.length > 0
? selectedClients.value
: undefined,
tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined,
billable: billable.value !== null ? billable.value : undefined,
};
return params;
}
function updateGraphReporting() {
const params = getFilterAttributes();
if (getCurrentRole() === 'employee') {
params.member_id = getCurrentMembershipId();
}
params.fill_gaps_in_time_groups = 'true';
params.group = getOptimalGroupingOption(startDate.value, endDate.value);
useReportingStore().fetchGraphReporting(params);
}
function updateTableReporting() {
const params = getFilterAttributes();
if (group.value === subGroup.value) {
const fallbackOption = groupByOptions.find(
(el) => el.value !== group.value
);
if (fallbackOption?.value) {
subGroup.value = fallbackOption.value;
}
}
if (getCurrentRole() === 'employee') {
params.member_id = getCurrentMembershipId();
}
params.group = group.value;
params.sub_group = subGroup.value;
useReportingStore().fetchTableReporting(params);
}
function updateReporting() {
updateGraphReporting();
updateTableReporting();
}
function getOptimalGroupingOption(
startDate: string,
endDate: string
): 'day' | 'week' | 'month' {
const diffInDays = getDayJsInstance()(endDate).diff(
getDayJsInstance()(startDate),
'd'
);
if (diffInDays <= 31) {
return 'day';
} else if (diffInDays <= 200) {
return 'week';
} else {
return 'month';
}
}
onMounted(() => {
updateGraphReporting();
updateTableReporting();
});
const { tags } = storeToRefs(useTagsStore());
async function createTag(tag: string) {
return await useTagsStore().createTag(tag);
}
const reportProperties = computed(() => {
return {
...getFilterAttributes(),
group: group.value,
sub_group: subGroup.value,
history_group: getOptimalGroupingOption(startDate.value, endDate.value),
} as CreateReportBodyProperties;
});
async function downloadExport(format: ExportFormat) {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
const response = await handleApiRequestNotifications(
() =>
api.exportAggregatedTimeEntries({
params: {
organization: organizationId,
},
queries: {
...getFilterAttributes(),
group: group.value,
sub_group: subGroup.value,
history_group: getOptimalGroupingOption(
startDate.value,
endDate.value
),
format: format,
},
}),
'Export successful',
'Export failed'
);
if (response?.download_url) {
showExportModal.value = true;
exportUrl.value = response.download_url as string;
}
}
}
const { getNameForReportingRowEntry, emptyPlaceholder } = useReportingStore();
const projectsStore = useProjectsStore();
const { projects } = storeToRefs(projectsStore);
const showExportModal = ref(false);
const exportUrl = ref<string | null>(null);
const groupedPieChartData = computed(() => {
return (
aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
const name = getNameForReportingRowEntry(
entry.key,
aggregatedTableTimeEntries.value?.grouped_type
);
let color = getRandomColorWithSeed(entry.key ?? 'none');
if (
name &&
aggregatedTableTimeEntries.value?.grouped_type &&
emptyPlaceholder[
aggregatedTableTimeEntries.value?.grouped_type
] === name
) {
color = '#CCCCCC';
} else if (
aggregatedTableTimeEntries.value?.grouped_type === 'project'
) {
color =
projects.value?.find((project) => project.id === entry.key)
?.color ?? '#CCCCCC';
}
return {
value: entry.seconds,
name:
getNameForReportingRowEntry(
entry.key,
aggregatedTableTimeEntries.value?.grouped_type
) ?? '',
color: color,
};
}) ?? []
);
});
const tableData = computed(() => {
return aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
return {
seconds: entry.seconds,
cost: entry.cost,
description: getNameForReportingRowEntry(
entry.key,
aggregatedTableTimeEntries.value?.grouped_type
),
grouped_data:
entry.grouped_data?.map((el) => {
return {
seconds: el.seconds,
cost: el.cost,
description: getNameForReportingRowEntry(
el.key,
entry.grouped_type
),
};
}) ?? [],
};
});
});
</script>
<template>
<ReportingExportModal
v-model:show="showExportModal"
:export-url="exportUrl"></ReportingExportModal>
<MainContainer
class="py-3 sm:py-5 border-b border-default-background-separator flex justify-between items-center">
<div class="flex items-center space-x-3 sm:space-x-6">
<PageTitle :icon="ChartBarIcon" title="Reporting"></PageTitle>
<ReportingTabNavbar active="reporting"></ReportingTabNavbar>
</div>
<div class="flex space-x-2">
<ReportingExportButton
:download="downloadExport"></ReportingExportButton>
<ReportSaveButton
:report-properties="reportProperties"></ReportSaveButton>
</div>
</MainContainer>
<div class="py-2.5 w-full border-b border-default-background-separator">
<MainContainer class="sm:flex space-y-4 sm:space-y-0 justify-between">
<div
class="flex flex-wrap items-center space-y-2 sm:space-y-0 space-x-4">
<div class="text-sm font-medium">Filters</div>
<MemberMultiselectDropdown
v-model="selectedMembers"
@submit="updateReporting">
<template #trigger>
<ReportingFilterBadge
:count="selectedMembers.length"
:active="selectedMembers.length > 0"
title="Members"
:icon="UserGroupIcon"></ReportingFilterBadge>
</template>
</MemberMultiselectDropdown>
<ProjectMultiselectDropdown
v-model="selectedProjects"
@submit="updateReporting">
<template #trigger>
<ReportingFilterBadge
:count="selectedProjects.length"
:active="selectedProjects.length > 0"
title="Projects"
:icon="FolderIcon"></ReportingFilterBadge>
</template>
</ProjectMultiselectDropdown>
<TaskMultiselectDropdown
v-model="selectedTasks"
@submit="updateReporting">
<template #trigger>
<ReportingFilterBadge
:count="selectedTasks.length"
:active="selectedTasks.length > 0"
title="Tasks"
:icon="CheckCircleIcon"></ReportingFilterBadge>
</template>
</TaskMultiselectDropdown>
<ClientMultiselectDropdown
v-model="selectedClients"
@submit="updateReporting">
<template #trigger>
<ReportingFilterBadge
:count="selectedClients.length"
:active="selectedClients.length > 0"
title="Clients"
:icon="FolderIcon"></ReportingFilterBadge>
</template>
</ClientMultiselectDropdown>
<TagDropdown
v-model="selectedTags"
:create-tag
:tags="tags"
@submit="updateReporting">
<template #trigger>
<ReportingFilterBadge
:count="selectedTags.length"
:active="selectedTags.length > 0"
title="Tags"
:icon="TagIcon"></ReportingFilterBadge>
</template>
</TagDropdown>
<SelectDropdown
v-model="billable"
:get-key-from-item="(item) => item.value"
:get-name-for-item="(item) => item.label"
:items="[
{
label: 'Both',
value: null,
},
{
label: 'Billable',
value: 'true',
},
{
label: 'Non Billable',
value: 'false',
},
]"
@changed="updateReporting">
<template #trigger>
<ReportingFilterBadge
:active="billable !== null"
:title="
billable === 'false'
? 'Non Billable'
: 'Billable'
"
:icon="BillableIcon"></ReportingFilterBadge>
</template>
</SelectDropdown>
</div>
<div>
<DateRangePicker
v-model:start="startDate"
v-model:end="endDate"
@submit="updateReporting"></DateRangePicker>
</div>
</MainContainer>
</div>
<MainContainer>
<div class="pt-10 w-full px-3 relative">
<ReportingChart
:grouped-type="aggregatedGraphTimeEntries?.grouped_type"
:grouped-data="
aggregatedGraphTimeEntries?.grouped_data
"></ReportingChart>
</div>
</MainContainer>
<MainContainer>
<div class="sm:grid grid-cols-4 pt-6 items-start">
<div
class="col-span-3 bg-card-background rounded-lg border border-card-border pt-3">
<div
class="text-sm flex text-text-primary items-center space-x-3 font-medium px-6 border-b border-card-background-separator pb-3">
<span>Group by</span>
<ReportingGroupBySelect
v-model="group"
:group-by-options="groupByOptions"
@changed="
updateTableReporting
"></ReportingGroupBySelect>
<span>and</span>
<ReportingGroupBySelect
v-model="subGroup"
:group-by-options="
groupByOptions.filter((el) => el.value !== group)
"
@changed="
updateTableReporting
"></ReportingGroupBySelect>
</div>
<div
class="grid items-center"
style="grid-template-columns: 1fr 100px 150px">
<div
class="contents [&>*]:border-card-background-separator [&>*]:border-b [&>*]:bg-tertiary [&>*]:pb-1.5 [&>*]:pt-1 text-text-secondary text-sm">
<div class="pl-6">Name</div>
<div class="text-right">Duration</div>
<div class="text-right pr-6">Cost</div>
</div>
<template
v-if="
aggregatedTableTimeEntries?.grouped_data &&
aggregatedTableTimeEntries.grouped_data?.length > 0
">
<ReportingRow
v-for="entry in tableData"
:key="entry.description ?? 'none'"
:currency="getOrganizationCurrencyString()"
:type="aggregatedTableTimeEntries.grouped_type"
:entry="entry"></ReportingRow>
<div
class="contents [&>*]:transition text-text-tertiary [&>*]:h-[50px]">
<div class="flex items-center pl-6 font-medium">
<span>Total</span>
</div>
<div
class="justify-end flex items-center font-medium">
{{
formatHumanReadableDuration(
aggregatedTableTimeEntries.seconds,
organization?.interval_format,
organization?.number_format
)
}}
</div>
<div
class="justify-end pr-6 flex items-center font-medium">
{{
aggregatedTableTimeEntries.cost
? formatCents(
aggregatedTableTimeEntries.cost,
getOrganizationCurrencyString(),
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
}}
</div>
</div>
</template>
<div
v-else
class="chart flex flex-col items-center justify-center py-12 col-span-3">
<p class="text-lg text-text-primary font-semibold">
No time entries found
</p>
<p>Try to change the filters and time range</p>
</div>
</div>
</div>
<div class="px-2 lg:px-4">
<ReportingPieChart
:data="groupedPieChartData"></ReportingPieChart>
</div>
</div>
</MainContainer>
</template>
<style scoped></style>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import VChart, { THEME_KEY } from 'vue-echarts'; import VChart, { THEME_KEY } from 'vue-echarts';
import { computed, provide } from 'vue'; import { computed, provide, inject, type ComputedRef } from 'vue';
import { use } from 'echarts/core'; import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers'; import { CanvasRenderer } from 'echarts/renderers';
import { PieChart } from 'echarts/charts'; import { PieChart } from 'echarts/charts';
@@ -11,7 +11,8 @@ import {
TooltipComponent, TooltipComponent,
} from 'echarts/components'; } from 'echarts/components';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time'; import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { useCssVar } from "@vueuse/core"; import { useCssVar } from '@vueuse/core';
import type { Organization } from '@/packages/api/src';
use([ use([
CanvasRenderer, CanvasRenderer,
@@ -24,6 +25,8 @@ use([
provide(THEME_KEY, 'dark'); provide(THEME_KEY, 'dark');
const organization = inject<ComputedRef<Organization>>('organization');
type ReportingChartDataEntry = { type ReportingChartDataEntry = {
value: number; value: number;
name: string; name: string;
@@ -71,7 +74,11 @@ const option = computed(() => ({
}, },
tooltip: { tooltip: {
valueFormatter: (value: number) => { valueFormatter: (value: number) => {
return formatHumanReadableDuration(value); return formatHumanReadableDuration(
value,
organization?.value?.interval_format,
organization?.value?.number_format
);
}, },
}, },
data: seriesData.value, data: seriesData.value,

View File

@@ -2,9 +2,9 @@
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time'; import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { formatCents } from '@/packages/ui/src/utils/money'; import { formatCents } from '@/packages/ui/src/utils/money';
import GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue'; import GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue';
import { ref } from 'vue'; import { ref, inject, type ComputedRef } from 'vue';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import { getOrganizationCurrencyString } from '@/utils/money'; import type { Organization } from '@/packages/api/src';
type AggregatedGroupedData = GroupedData & { type AggregatedGroupedData = GroupedData & {
grouped_data?: GroupedData[] | null; grouped_data?: GroupedData[] | null;
@@ -19,9 +19,12 @@ type GroupedData = {
const props = defineProps<{ const props = defineProps<{
entry: AggregatedGroupedData; entry: AggregatedGroupedData;
indent?: boolean; indent?: boolean;
currency: string;
}>(); }>();
const expanded = ref(false); const expanded = ref(false);
const organization = inject<ComputedRef<Organization>>('organization');
</script> </script>
<template> <template>
@@ -45,10 +48,22 @@ const expanded = ref(false);
</span> </span>
</div> </div>
<div class="justify-end flex items-center"> <div class="justify-end flex items-center">
{{ formatHumanReadableDuration(entry.seconds) }} {{
formatHumanReadableDuration(
entry.seconds,
organization?.interval_format,
organization?.number_format
)
}}
</div> </div>
<div class="justify-end pr-6 flex items-center"> <div class="justify-end pr-6 flex items-center">
{{entry.cost ? formatCents(entry.cost, getOrganizationCurrencyString()) : '--' }} {{ entry.cost ? formatCents(
entry.cost,
props.currency,
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
) : '--' }}
</div> </div>
</div> </div>
<div <div
@@ -58,6 +73,7 @@ const expanded = ref(false);
<ReportingRow <ReportingRow
v-for="subEntry in entry.grouped_data" v-for="subEntry in entry.grouped_data"
:key="subEntry.description ?? 'none'" :key="subEntry.description ?? 'none'"
:currency="props.currency"
indent indent
:entry="subEntry"></ReportingRow> :entry="subEntry"></ReportingRow>
</div> </div>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
defineProps<{ defineProps<{
title: string; title: string;
value: string; value?: string;
}>(); }>();
</script> </script>
@@ -10,7 +10,7 @@ defineProps<{
class="rounded-lg bg-card-background border-card-border shadow-card border px-3.5 py-2.5"> class="rounded-lg bg-card-background border-card-border shadow-card border px-3.5 py-2.5">
<dt class="font-semibold text-sm text-text-secondary">{{ title }}</dt> <dt class="font-semibold text-sm text-text-secondary">{{ title }}</dt>
<dd class="text-2xl text-text-primary pt-1 font-semibold"> <dd class="text-2xl text-text-primary pt-1 font-semibold">
{{ value }} {{ value ?? '--' }}
</dd> </dd>
</div> </div>
</template> </template>

View File

@@ -6,16 +6,19 @@ import TaskMoreOptionsDropdown from '@/Components/Common/Task/TaskMoreOptionsDro
import TableRow from '@/Components/TableRow.vue'; import TableRow from '@/Components/TableRow.vue';
import { canDeleteTasks } from '@/utils/permissions'; import { canDeleteTasks } from '@/utils/permissions';
import TaskEditModal from '@/Components/Common/Task/TaskEditModal.vue'; import TaskEditModal from '@/Components/Common/Task/TaskEditModal.vue';
import { ref } from 'vue'; import { ref, inject, type ComputedRef } from 'vue';
import { isAllowedToPerformPremiumAction } from '@/utils/billing'; import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import EstimatedTimeProgress from '@/packages/ui/src/EstimatedTimeProgress.vue'; import EstimatedTimeProgress from '@/packages/ui/src/EstimatedTimeProgress.vue';
import UpgradeBadge from '@/Components/Common/UpgradeBadge.vue'; import UpgradeBadge from '@/Components/Common/UpgradeBadge.vue';
import { formatHumanReadableDuration } from '../../../packages/ui/src/utils/time'; import { formatHumanReadableDuration } from '../../../packages/ui/src/utils/time';
import type { Organization } from '@/packages/api/src';
const props = defineProps<{ const props = defineProps<{
task: Task; task: Task;
}>(); }>();
const organization = inject<ComputedRef<Organization>>('organization');
function deleteTask() { function deleteTask() {
useTasksStore().deleteTask(props.task.id); useTasksStore().deleteTask(props.task.id);
} }
@@ -41,7 +44,13 @@ const showTaskEditModal = ref(false);
<div <div
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1 items-center font-medium"> class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1 items-center font-medium">
<span v-if="task.spent_time"> <span v-if="task.spent_time">
{{ formatHumanReadableDuration(task.spent_time) }} {{
formatHumanReadableDuration(
task.spent_time,
organization?.interval_format,
organization?.number_format
)
}}
</span> </span>
<span v-else> -- </span> <span v-else> -- </span>
</div> </div>

View File

@@ -3,9 +3,10 @@ import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { computed } from 'vue'; import { computed } from 'vue';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time'; import { formatDuration } from '@/packages/ui/src/utils/time';
import TimeTrackerStartStop from '@/packages/ui/src/TimeTrackerStartStop.vue'; import TimeTrackerStartStop from '@/packages/ui/src/TimeTrackerStartStop.vue';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';
const store = useCurrentTimeEntryStore(); const store = useCurrentTimeEntryStore();
const { currentTimeEntry, now, isActive } = storeToRefs(store); const { currentTimeEntry, now, isActive } = storeToRefs(store);
const { setActiveState } = store; const { setActiveState } = store;
@@ -14,10 +15,9 @@ const currentTime = computed(() => {
if (now.value && currentTimeEntry.value.start) { if (now.value && currentTimeEntry.value.start) {
const startTime = dayjs(currentTimeEntry.value.start); const startTime = dayjs(currentTimeEntry.value.start);
const diff = now.value.diff(startTime, 's'); const diff = now.value.diff(startTime, 's');
// return dayjs(diff).utc().format('HH:mm:ss'); return formatDuration(diff);
return formatHumanReadableDuration(diff);
} }
return formatHumanReadableDuration(0); return formatDuration(0);
}); });
const isRunningInDifferentOrganization = computed(() => { const isRunningInDifferentOrganization = computed(() => {
@@ -43,7 +43,9 @@ const isRunningInDifferentOrganization = computed(() => {
</div> </div>
</div> </div>
<div> <div>
<div class="text-text-secondary font-extrabold text-xs">Current Timer</div> <div class="text-text-secondary font-extrabold text-xs">
Current Timer
</div>
<div class="text-text-primary font-medium text-lg"> <div class="text-text-primary font-medium text-lg">
{{ currentTime }} {{ currentTime }}
</div> </div>

View File

@@ -1,44 +1,45 @@
<script lang="ts" setup> <script lang="ts" setup>
import VChart, { THEME_KEY } from "vue-echarts"; import VChart, { THEME_KEY } from 'vue-echarts';
import { provide, computed } from "vue"; import { provide, computed, inject, type ComputedRef } from 'vue';
import { use } from "echarts/core"; import { use } from 'echarts/core';
import DashboardCard from "@/Components/Dashboard/DashboardCard.vue"; import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
import { BoltIcon } from "@heroicons/vue/20/solid"; import { BoltIcon } from '@heroicons/vue/20/solid';
import { HeatmapChart } from "echarts/charts"; import { HeatmapChart } from 'echarts/charts';
import { import {
CalendarComponent, CalendarComponent,
TitleComponent, TitleComponent,
TooltipComponent, TooltipComponent,
VisualMapComponent VisualMapComponent,
} from "echarts/components"; } from 'echarts/components';
import { CanvasRenderer } from "echarts/renderers"; import { CanvasRenderer } from 'echarts/renderers';
import dayjs from "dayjs"; import dayjs from 'dayjs';
import { import {
firstDayIndex, firstDayIndex,
formatDate, formatDate,
formatHumanReadableDuration, formatHumanReadableDuration,
getDayJsInstance getDayJsInstance,
} from "@/packages/ui/src/utils/time"; } from '@/packages/ui/src/utils/time';
import { useCssVar } from "@vueuse/core"; import { useCssVar } from '@vueuse/core';
import { useQuery } from "@tanstack/vue-query"; import { useQuery } from '@tanstack/vue-query';
import { getCurrentOrganizationId } from "@/utils/useUser"; import { getCurrentOrganizationId } from '@/utils/useUser';
import { api } from "@/packages/api/src"; import { api, type Organization } from '@/packages/api/src';
import { LoadingSpinner } from "@/packages/ui/src"; import { LoadingSpinner } from '@/packages/ui/src';
const organization = inject<ComputedRef<Organization>>('organization');
// Get the organization ID using the utility function // Get the organization ID using the utility function
const organizationId = computed(() => getCurrentOrganizationId()); const organizationId = computed(() => getCurrentOrganizationId());
const { data: dailyHoursTracked, isLoading } = useQuery({ const { data: dailyHoursTracked, isLoading } = useQuery({
queryKey: ["dailyTrackedHours", organizationId], queryKey: ['dailyTrackedHours', organizationId],
queryFn: () => { queryFn: () => {
return api.dailyTrackedHours({ return api.dailyTrackedHours({
params: { params: {
organization: organizationId.value! organization: organizationId.value!,
} },
}); });
}, },
enabled: computed(() => !!organizationId.value) enabled: computed(() => !!organizationId.value),
}); });
use([ use([
@@ -47,96 +48,105 @@ use([
VisualMapComponent, VisualMapComponent,
CalendarComponent, CalendarComponent,
HeatmapChart, HeatmapChart,
CanvasRenderer CanvasRenderer,
]); ]);
provide(THEME_KEY, "dark"); provide(THEME_KEY, 'dark');
const max = computed(() => { const max = computed(() => {
if (!isLoading.value && dailyHoursTracked.value) { if (!isLoading.value && dailyHoursTracked.value) {
return Math.max( return Math.max(
Math.max(...dailyHoursTracked.value.map((el) => el.duration)), Math.max(...dailyHoursTracked.value.map((el) => el.duration)),
1 1
); );
} else { } else {
return 1; return 1;
}
} }
); });
const backgroundColor = useCssVar('--color-card-background', null, { observe: true }); const backgroundColor = useCssVar('--color-card-background', null, {
const itemBackgroundColor = useCssVar('--color-bg-tertiary', null, { observe: true }); observe: true,
});
const itemBackgroundColor = useCssVar('--color-bg-tertiary', null, {
observe: true,
});
const option = computed(() => { const option = computed(() => {
return { return {
tooltip: {}, tooltip: {},
visualMap: { visualMap: {
min: 0, min: 0,
max: max.value, max: max.value,
type: "piecewise", type: 'piecewise',
orient: "horizontal", orient: 'horizontal',
left: "center", left: 'center',
top: "center", top: 'center',
inRange: { inRange: {
color: [itemBackgroundColor.value, "#2DBE45"] color: [itemBackgroundColor.value, '#2DBE45'],
},
show: false
}, },
calendar: { show: false,
top: 40, },
bottom: 20, calendar: {
left: 40, top: 40,
right: 10, bottom: 20,
cellSize: [40, 40], left: 40,
dayLabel: { right: 10,
firstDay: firstDayIndex.value cellSize: [40, 40],
}, dayLabel: {
splitLine: { firstDay: firstDayIndex.value,
show: false
},
range: [
dayjs().format("YYYY-MM-DD"),
getDayJsInstance()()
.subtract(50, "day")
.startOf("week")
.format("YYYY-MM-DD")
],
itemStyle: {
color: "transparent",
borderWidth: 8,
borderColor: backgroundColor.value
},
yearLabel: { show: false }
}, },
series: { splitLine: {
type: "heatmap", show: false,
coordinateSystem: "calendar", },
data: dailyHoursTracked?.value?.map((el) => [el.date, el.duration]) ?? [], range: [
itemStyle: { dayjs().format('YYYY-MM-DD'),
borderRadius: 5, getDayJsInstance()()
borderColor: "rgba(255,255,255,0.05)", .subtract(50, 'day')
borderWidth: 1 .startOf('week')
}, .format('YYYY-MM-DD'),
tooltip: { ],
valueFormatter: (value: number, dataIndex: number) => { itemStyle: {
if(dailyHoursTracked?.value){ color: 'transparent',
return ( borderWidth: 8,
formatDate(dailyHoursTracked?.value[dataIndex].date) + borderColor: backgroundColor.value,
": " + },
formatHumanReadableDuration(value) yearLabel: { show: false },
); },
} series: {
else { type: 'heatmap',
return ""; coordinateSystem: 'calendar',
} data:
dailyHoursTracked?.value?.map((el) => [el.date, el.duration]) ??
[],
itemStyle: {
borderRadius: 5,
borderColor: 'rgba(255,255,255,0.05)',
borderWidth: 1,
},
tooltip: {
valueFormatter: (value: number, dataIndex: number) => {
if (dailyHoursTracked?.value) {
return (
formatDate(
dailyHoursTracked?.value[dataIndex].date,
organization?.value?.date_format
) +
': ' +
formatHumanReadableDuration(
value,
organization?.value?.interval_format,
organization?.value?.number_format
)
);
} else {
return '';
} }
} },
}, },
backgroundColor: "transparent" },
}; backgroundColor: 'transparent',
}); };
});
</script> </script>
<template> <template>

View File

@@ -1,15 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import DayOverviewCardChart from '@/Components/Dashboard/DayOverviewCardChart.vue'; import DayOverviewCardChart from '@/Components/Dashboard/DayOverviewCardChart.vue';
import {
formatHumanReadableDate,
formatHumanReadableDuration,
} from '@/packages/ui/src/utils/time';
import { inject, type ComputedRef } from 'vue';
import type { Organization } from '@/packages/api/src';
const organization = inject<ComputedRef<Organization>>('organization');
defineProps<{ defineProps<{
date: string; date: string;
duration: number; duration: number;
history: number[]; history: number[];
}>(); }>();
import {
formatHumanReadableDate,
formatHumanReadableDuration,
} from '@/packages/ui/src/utils/time';
</script> </script>
<template> <template>
@@ -25,7 +29,13 @@ import {
</div> </div>
<div <div
class="flex text-sm items-center justify-center text-text-secondary min-w-[65px] font-semibold"> class="flex text-sm items-center justify-center text-text-secondary min-w-[65px] font-semibold">
{{ formatHumanReadableDuration(duration) }} {{
formatHumanReadableDuration(
duration,
organization?.interval_format,
organization?.number_format
)
}}
</div> </div>
</div> </div>
</template> </template>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import VChart, { THEME_KEY } from 'vue-echarts'; import VChart, { THEME_KEY } from 'vue-echarts';
import { provide } from 'vue'; import { provide, inject, type ComputedRef } from 'vue';
import { use } from 'echarts/core'; import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers'; import { CanvasRenderer } from 'echarts/renderers';
import { PieChart } from 'echarts/charts'; import { PieChart } from 'echarts/charts';
@@ -12,6 +12,7 @@ import {
} from 'echarts/components'; } from 'echarts/components';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time'; import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { useCssVar } from "@vueuse/core"; import { useCssVar } from "@vueuse/core";
import type { Organization } from "@/packages/api/src";
use([ use([
CanvasRenderer, CanvasRenderer,
@@ -33,6 +34,8 @@ const props = defineProps<{
}[]; }[];
}>(); }>();
const organization = inject<ComputedRef<Organization>>('organization');
const seriesData = props.weeklyProjectOverview.map((el) => { const seriesData = props.weeklyProjectOverview.map((el) => {
return { return {
...el, ...el,
@@ -69,7 +72,7 @@ const option = computed(() => ({
}, },
tooltip: { tooltip: {
valueFormatter: (value: number) => { valueFormatter: (value: number) => {
return formatHumanReadableDuration(value); return formatHumanReadableDuration(value, organization?.value?.interval_format, organization?.value?.number_format);
}, },
}, },
data: seriesData, data: seriesData,

View File

@@ -1,23 +1,28 @@
<script setup lang="ts"> <script setup lang="ts">
import { use } from "echarts/core"; import { use } from 'echarts/core';
import { CanvasRenderer } from "echarts/renderers"; import { CanvasRenderer } from 'echarts/renderers';
import { BarChart } from "echarts/charts"; import { BarChart } from 'echarts/charts';
import { GridComponent, LegendComponent, TitleComponent, TooltipComponent } from "echarts/components"; import {
import VChart, { THEME_KEY } from "vue-echarts"; GridComponent,
import { computed, provide } from "vue"; LegendComponent,
import StatCard from "@/Components/Common/StatCard.vue"; TitleComponent,
import { ClockIcon } from "@heroicons/vue/20/solid"; TooltipComponent,
import CardTitle from "@/packages/ui/src/CardTitle.vue"; } from 'echarts/components';
import LinearGradient from "zrender/lib/graphic/LinearGradient"; import VChart, { THEME_KEY } from 'vue-echarts';
import ProjectsChartCard from "@/Components/Dashboard/ProjectsChartCard.vue"; import { computed, provide, inject, type ComputedRef } from 'vue';
import { formatHumanReadableDuration } from "@/packages/ui/src/utils/time"; import StatCard from '@/Components/Common/StatCard.vue';
import { formatCents } from "@/packages/ui/src/utils/money"; import { ClockIcon } from '@heroicons/vue/20/solid';
import { getWeekStart } from "@/packages/ui/src/utils/settings"; import CardTitle from '@/packages/ui/src/CardTitle.vue';
import { useCssVar } from "@vueuse/core"; import LinearGradient from 'zrender/lib/graphic/LinearGradient';
import { getOrganizationCurrencyString } from "@/utils/money"; import ProjectsChartCard from '@/Components/Dashboard/ProjectsChartCard.vue';
import { useQuery } from "@tanstack/vue-query"; import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { getCurrentOrganizationId } from "@/utils/useUser"; import { formatCents } from '@/packages/ui/src/utils/money';
import { api } from "@/packages/api/src"; import { getWeekStart } from '@/packages/ui/src/utils/settings';
import { useCssVar } from '@vueuse/core';
import { getOrganizationCurrencyString } from '@/utils/money';
import { useQuery } from '@tanstack/vue-query';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { api, type Organization } from '@/packages/api/src';
use([ use([
CanvasRenderer, CanvasRenderer,
@@ -25,21 +30,21 @@ use([
TitleComponent, TitleComponent,
GridComponent, GridComponent,
TooltipComponent, TooltipComponent,
LegendComponent LegendComponent,
]); ]);
provide(THEME_KEY, "dark"); provide(THEME_KEY, 'dark');
const weekdays = computed(() => { const weekdays = computed(() => {
const daysOrder = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; const daysOrder = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const dayMapping: Record<string, string> = { const dayMapping: Record<string, string> = {
monday: "Mon", monday: 'Mon',
tuesday: "Tue", tuesday: 'Tue',
wednesday: "Wed", wednesday: 'Wed',
thursday: "Thu", thursday: 'Thu',
friday: "Fri", friday: 'Fri',
saturday: "Sat", saturday: 'Sat',
sunday: "Sun" sunday: 'Sun',
}; };
if (dayMapping[getWeekStart()]) { if (dayMapping[getWeekStart()]) {
const customOrder = []; const customOrder = [];
@@ -53,78 +58,76 @@ const weekdays = computed(() => {
} else { } else {
return daysOrder; return daysOrder;
} }
}); });
const accentColor = useCssVar('--theme-color-chart', null, { observe: true });
const accentColor = useCssVar("--theme-color-chart", null, { observe: true });
// Get the organization ID using the utility function // Get the organization ID using the utility function
const organizationId = computed(() => getCurrentOrganizationId()); const organizationId = computed(() => getCurrentOrganizationId());
const organization = inject<ComputedRef<Organization>>('organization');
// Set up the queries // Set up the queries
const { data: weeklyProjectOverview } = useQuery({ const { data: weeklyProjectOverview } = useQuery({
queryKey: ["weeklyProjectOverview", organizationId], queryKey: ['weeklyProjectOverview', organizationId],
queryFn: () => { queryFn: () => {
return api.weeklyProjectOverview({ return api.weeklyProjectOverview({
params: { params: {
organization: organizationId.value! organization: organizationId.value!,
} },
}); });
}, },
enabled: computed(() => !!organizationId.value) enabled: computed(() => !!organizationId.value),
}); });
const { data: totalWeeklyTime } = useQuery({ const { data: totalWeeklyTime } = useQuery({
queryKey: ["totalWeeklyTime", organizationId], queryKey: ['totalWeeklyTime', organizationId],
queryFn: () => { queryFn: () => {
return api.totalWeeklyTime({ return api.totalWeeklyTime({
params: { params: {
organization: organizationId.value! organization: organizationId.value!,
} },
}); });
}, },
enabled: computed(() => !!organizationId.value) enabled: computed(() => !!organizationId.value),
}); });
const { data: totalWeeklyBillableTime } = useQuery({ const { data: totalWeeklyBillableTime } = useQuery({
queryKey: ["totalWeeklyBillableTime", organizationId], queryKey: ['totalWeeklyBillableTime', organizationId],
queryFn: () => { queryFn: () => {
return api.totalWeeklyBillableTime({ return api.totalWeeklyBillableTime({
params: { params: {
organization: organizationId.value! organization: organizationId.value!,
} },
}); });
}, },
enabled: computed(() => !!organizationId.value) enabled: computed(() => !!organizationId.value),
}); });
const { data: totalWeeklyBillableAmount } = useQuery({ const { data: totalWeeklyBillableAmount } = useQuery({
queryKey: ["totalWeeklyBillableAmount", organizationId], queryKey: ['totalWeeklyBillableAmount', organizationId],
queryFn: () => { queryFn: () => {
return api.totalWeeklyBillableAmount({ return api.totalWeeklyBillableAmount({
params: { params: {
organization: organizationId.value! organization: organizationId.value!,
} },
}); });
}, },
enabled: computed(() => !!organizationId.value) enabled: computed(() => !!organizationId.value),
}); });
const { data: weeklyHistory } = useQuery({ const { data: weeklyHistory } = useQuery({
queryKey: ["weeklyHistory", organizationId], queryKey: ['weeklyHistory', organizationId],
queryFn: () => { queryFn: () => {
return api.weeklyHistory({ return api.weeklyHistory({
params: { params: {
organization: organizationId.value! organization: organizationId.value!,
} },
}); });
}, },
enabled: computed(() => !!organizationId.value) enabled: computed(() => !!organizationId.value),
}); });
const seriesData = computed(() => { const seriesData = computed(() => {
if (!weeklyHistory.value) { if (!weeklyHistory.value) {
return []; return [];
@@ -137,101 +140,104 @@ const seriesData = computed(() => {
borderColor: new LinearGradient(0, 0, 0, 1, [ borderColor: new LinearGradient(0, 0, 0, 1, [
{ {
offset: 0, offset: 0,
color: "rgba(" + accentColor.value + ",0.7)" color: 'rgba(' + accentColor.value + ',0.7)',
}, },
{ {
offset: 1, offset: 1,
color: "rgba(" + accentColor.value + ",0.5)" color: 'rgba(' + accentColor.value + ',0.5)',
} },
]), ]),
emphasis: { emphasis: {
color: new LinearGradient(0, 0, 0, 1, [ color: new LinearGradient(0, 0, 0, 1, [
{ {
offset: 0, offset: 0,
color: "rgba(" + accentColor.value + ",0.9)" color: 'rgba(' + accentColor.value + ',0.9)',
}, },
{ {
offset: 1, offset: 1,
color: "rgba(" + accentColor.value + ",0.7)" color: 'rgba(' + accentColor.value + ',0.7)',
} },
]) ]),
}, },
borderRadius: [12, 12, 0, 0], borderRadius: [12, 12, 0, 0],
color: new LinearGradient(0, 0, 0, 1, [ color: new LinearGradient(0, 0, 0, 1, [
{ {
offset: 0, offset: 0,
color: "rgba(" + accentColor.value + ",0.7)" color: 'rgba(' + accentColor.value + ',0.7)',
}, },
{ {
offset: 1, offset: 1,
color: "rgba(" + accentColor.value + ",0.5)" color: 'rgba(' + accentColor.value + ',0.5)',
} },
]) ]),
} },
} },
}; };
}); });
}); });
const markLineColor = useCssVar('--color-border-secondary', null, {
const markLineColor = useCssVar("--color-border-secondary", null, { observe: true }); observe: true,
const labelColor = useCssVar("--color-text-secondary", null, { observe: true }); });
const labelColor = useCssVar('--color-text-secondary', null, { observe: true });
const option = computed(() => { const option = computed(() => {
return { return {
tooltip: { tooltip: {
trigger: "item" trigger: 'item',
}, },
grid: { grid: {
top: 0, top: 0,
right: 0, right: 0,
bottom: 50, bottom: 50,
left: 0 left: 0,
}, },
backgroundColor: "transparent", backgroundColor: 'transparent',
xAxis: { xAxis: {
type: "category", type: 'category',
data: weekdays.value, data: weekdays.value,
axisLine: { axisLine: {
lineStyle: { lineStyle: {
color: "transparent" // Set desired color here color: 'transparent', // Set desired color here
} },
}, },
axisLabel: { axisLabel: {
fontSize: 16, fontSize: 16,
fontWeight: 600, fontWeight: 600,
margin: 24, margin: 24,
fontFamily: "Outfit, sans-serif", fontFamily: 'Outfit, sans-serif',
color: labelColor.value color: labelColor.value,
}, },
axisTick: { axisTick: {
lineStyle: { lineStyle: {
color: "transparent" // Set desired color here color: 'transparent', // Set desired color here
} },
} },
}, },
yAxis: { yAxis: {
type: "value", type: 'value',
splitLine: { splitLine: {
lineStyle: { lineStyle: {
color: markLineColor.value color: markLineColor.value,
} },
} },
}, },
series: [ series: [
{ {
data: seriesData.value, data: seriesData.value,
type: "bar", type: 'bar',
tooltip: { tooltip: {
valueFormatter: (value: number) => { valueFormatter: (value: number) => {
return formatHumanReadableDuration(value); return formatHumanReadableDuration(
} value,
} organization?.value?.interval_format,
} organization?.value?.number_format
] );
},
},
},
],
}; };
}); });
</script> </script>
<template> <template>
@@ -244,28 +250,45 @@ const option = computed(() => {
:icon="ClockIcon"></CardTitle> :icon="ClockIcon"></CardTitle>
<v-chart <v-chart
v-if="weeklyHistory" v-if="weeklyHistory"
:autoresize="true" class="chart" :option="option" /> :autoresize="true"
class="chart"
:option="option" />
</div> </div>
<div class="space-y-6"> <div class="space-y-6">
<StatCard <StatCard
title="Spent Time" title="Spent Time"
:value=" :value="
totalWeeklyTime ? totalWeeklyTime
formatHumanReadableDuration(totalWeeklyTime) : '--'" /> ? formatHumanReadableDuration(
totalWeeklyTime,
organization?.interval_format,
organization?.number_format
)
: '--'
" />
<StatCard <StatCard
title="Billable Time" title="Billable Time"
:value=" :value="
totalWeeklyBillableTime ? totalWeeklyBillableTime
formatHumanReadableDuration(totalWeeklyBillableTime) : '--' ? formatHumanReadableDuration(
totalWeeklyBillableTime,
organization?.interval_format,
organization?.number_format
)
: '--'
" /> " />
<StatCard <StatCard
title="Billable Amount" title="Billable Amount"
:value=" :value="
totalWeeklyBillableAmount ? totalWeeklyBillableAmount
formatCents( ? formatCents(
totalWeeklyBillableAmount.value, totalWeeklyBillableAmount.value,
getOrganizationCurrencyString() getOrganizationCurrencyString(),
) : '--' organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
" /> " />
<ProjectsChartCard <ProjectsChartCard
v-if="weeklyProjectOverview" v-if="weeklyProjectOverview"

View File

@@ -20,7 +20,7 @@ const forwardedProps = useForwardProps(delegatedProps)
:class="cn( :class="cn(
buttonVariants({ variant: 'ghost' }), buttonVariants({ variant: 'ghost' }),
'h-8 w-8 p-0 font-normal', '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 // 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', '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 // Disabled

View File

@@ -9,7 +9,8 @@ import { Calendar } from '@/Components/ui/calendar';
import { CalendarIcon } from 'lucide-vue-next'; import { CalendarIcon } from 'lucide-vue-next';
import { formatDateLocalized } from '@/packages/ui/src/utils/time'; import { formatDateLocalized } from '@/packages/ui/src/utils/time';
import { parseDate } from '@internationalized/date'; import { parseDate } from '@internationalized/date';
import { computed } from 'vue'; import { computed, inject, type ComputedRef } from 'vue';
import { type Organization } from '@/packages/api/src';
const model = defineModel<string | null>(); const model = defineModel<string | null>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -27,6 +28,8 @@ const handleBlur = () => {
const date = computed(() => { const date = computed(() => {
return model.value ? parseDate(model.value) : undefined; return model.value ? parseDate(model.value) : undefined;
}); });
const organization = inject<ComputedRef<Organization>>('organization');
</script> </script>
<template> <template>
@@ -41,7 +44,7 @@ const date = computed(() => {
]" ]"
> >
<CalendarIcon class="mr-2 h-4 w-4" /> <CalendarIcon class="mr-2 h-4 w-4" />
{{ model ? formatDateLocalized(model) : 'Pick a date' }} {{ model ? formatDateLocalized(model, organization?.date_format) : 'Pick a date' }}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent class="w-auto p-0"> <PopoverContent class="w-auto p-0">

View File

@@ -2,22 +2,61 @@
import type { NumberFieldRootEmits, NumberFieldRootProps } from 'reka-ui' import type { NumberFieldRootEmits, NumberFieldRootProps } from 'reka-ui'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { NumberFieldRoot, useForwardPropsEmits } from 'reka-ui' import { NumberFieldRoot, useForwardPropsEmits } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue' import { computed, type HTMLAttributes, inject, type ComputedRef } from 'vue'
import type { Organization } from '@/packages/api/src'
const props = defineProps<NumberFieldRootProps & { class?: HTMLAttributes['class'] }>() const props = defineProps<NumberFieldRootProps & {
class?: HTMLAttributes['class']
formatOptions?: {
maximumFractionDigits?: number
minimumFractionDigits?: number
}
}>()
const emits = defineEmits<NumberFieldRootEmits>() const emits = defineEmits<NumberFieldRootEmits>()
const delegatedProps = computed(() => { const delegatedProps = computed(() => {
const { class: _, ...delegated } = props const { class: _, formatOptions: __, ...delegated } = props
return delegated return delegated
}) })
const organization = inject<ComputedRef<Organization>>('organization')
const locale = computed(() => {
const format = organization?.value?.number_format || 'comma-point'
// space poin is not supported in reka-ui
switch (format) {
case 'point-comma':
return 'de-DE' // Uses point for thousands and comma for decimal
case 'comma-point':
return 'en-US' // Uses comma for thousands and point for decimal
case 'space-comma':
return 'sv-SE' // Uses space for thousands and comma for decimal
case 'apostrophe-point':
return 'de-CH' // Uses apostrophe for thousands and point for decimal
default:
return 'en-US'
}
})
const defaultFormatOptions = {
maximumFractionDigits: 2
}
const formatOptions = computed(() => ({
...defaultFormatOptions,
...props.formatOptions
}))
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script> </script>
<template> <template>
<NumberFieldRoot v-bind="forwarded" :class="cn('grid gap-1.5', props.class)"> <NumberFieldRoot
v-bind="forwarded"
:locale="locale"
:format-options="formatOptions"
:class="cn('grid gap-1.5', props.class)">
<slot /> <slot />
</NumberFieldRoot> </NumberFieldRoot>
</template> </template>

View File

@@ -20,7 +20,7 @@ const forwardedProps = useForwardProps(delegatedProps)
:class="cn( :class="cn(
buttonVariants({ variant: 'ghost' }), buttonVariants({ variant: 'ghost' }),
'h-8 w-8 p-0 font-normal data-[selected]:opacity-100', '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 // 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', '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 // Selection End

View File

@@ -15,20 +15,22 @@ import {
UserCircleIcon, UserCircleIcon,
UserGroupIcon, UserGroupIcon,
XMarkIcon, XMarkIcon,
DocumentTextIcon DocumentTextIcon,
} from '@heroicons/vue/20/solid'; } from '@heroicons/vue/20/solid';
import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue'; import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue';
import UserSettingsIcon from '@/Components/UserSettingsIcon.vue'; import UserSettingsIcon from '@/Components/UserSettingsIcon.vue';
import MainContainer from '@/packages/ui/src/MainContainer.vue'; import MainContainer from '@/packages/ui/src/MainContainer.vue';
import { onMounted, ref } from "vue"; import { computed, onMounted, provide, ref } from 'vue';
import NotificationContainer from '@/Components/NotificationContainer.vue'; import NotificationContainer from '@/Components/NotificationContainer.vue';
import { initializeStores, refreshStores } from '@/utils/init'; import { initializeStores, refreshStores } from '@/utils/init';
import { import {
canManageBilling, canManageBilling,
canUpdateOrganization, canUpdateOrganization,
canViewClients, canViewInvoices, canViewClients,
canViewInvoices,
canViewMembers, canViewMembers,
canViewProjects, canViewReport, canViewProjects,
canViewReport,
canViewTags, canViewTags,
} from '@/utils/permissions'; } from '@/utils/permissions';
import { isBillingActivated, isInvoicingActivated } from '@/utils/billing'; import { isBillingActivated, isInvoicingActivated } from '@/utils/billing';
@@ -37,7 +39,11 @@ import { ArrowsRightLeftIcon } from '@heroicons/vue/16/solid';
import { fetchToken, isTokenValid } from '@/utils/session'; import { fetchToken, isTokenValid } from '@/utils/session';
import UpdateSidebarNotification from '@/Components/UpdateSidebarNotification.vue'; import UpdateSidebarNotification from '@/Components/UpdateSidebarNotification.vue';
import BillingBanner from '@/Components/Billing/BillingBanner.vue'; import BillingBanner from '@/Components/Billing/BillingBanner.vue';
import { useTheme } from "@/utils/theme"; import { useTheme } from '@/utils/theme';
import { useQuery } from '@tanstack/vue-query';
import { api } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser';
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
defineProps({ defineProps({
title: String, title: String,
@@ -45,9 +51,25 @@ defineProps({
const showSidebarMenu = ref(false); const showSidebarMenu = ref(false);
const isUnloading = ref(false); const isUnloading = ref(false);
onMounted(async () => {
useTheme() const { data: organization, isLoading: isOrganizationLoading } = useQuery({
queryKey: ['organization', getCurrentOrganizationId()],
queryFn: () =>
api.getOrganization({
params: {
organization: getCurrentOrganizationId()!,
},
}),
enabled: !!getCurrentOrganizationId(),
});
provide(
'organization',
computed(() => organization.value?.data)
);
onMounted(async () => {
useTheme();
// make sure that the initial requests are only loaded once, this can be removed once we move away from inertia // make sure that the initial requests are only loaded once, this can be removed once we move away from inertia
if (window.initialDataLoaded !== true) { if (window.initialDataLoaded !== true) {
window.initialDataLoaded = true; window.initialDataLoaded = true;
@@ -77,7 +99,9 @@ const page = usePage<{
</script> </script>
<template> <template>
<div v-bind="$attrs" class="flex flex-wrap bg-background text-text-secondary"> <div
v-bind="$attrs"
class="flex flex-wrap bg-background text-text-secondary">
<div <div
:class="{ :class="{
'!flex bg-default-background w-full z-[9999999999]': '!flex bg-default-background w-full z-[9999999999]':
@@ -122,17 +146,17 @@ const page = usePage<{
{ {
title: 'Overview', title: 'Overview',
route: 'reporting', route: 'reporting',
show: true show: true,
}, },
{ {
title: 'Detailed', title: 'Detailed',
route: 'reporting.detailed', route: 'reporting.detailed',
show: true show: true,
}, },
{ {
title: 'Shared', title: 'Shared',
route: 'reporting.shared', route: 'reporting.shared',
show: canViewReport() show: canViewReport(),
}, },
]" ]"
:current=" :current="
@@ -183,7 +207,9 @@ const page = usePage<{
:current="route().current('tags')" :current="route().current('tags')"
:href="route('tags')"></NavigationSidebarItem> :href="route('tags')"></NavigationSidebarItem>
<NavigationSidebarItem <NavigationSidebarItem
v-if="isInvoicingActivated() && canViewInvoices()" v-if="
isInvoicingActivated() && canViewInvoices()
"
title="Invoices" title="Invoices"
:icon="DocumentTextIcon" :icon="DocumentTextIcon"
:current="route().current('invoices')" :current="route().current('invoices')"
@@ -276,8 +302,12 @@ const page = usePage<{
<!-- Page Content --> <!-- Page Content -->
<main class="pb-28 flex-1"> <main class="pb-28 flex-1">
<slot /> <div
v-if="isOrganizationLoading"
class="flex items-center justify-center h-screen">
<LoadingSpinner />
</div>
<slot v-else />
</main> </main>
</div> </div>
</div> </div>

View File

@@ -241,25 +241,6 @@ const page = usePage<{
</select> </select>
<InputError :message="form.errors.week_start" class="mt-2" /> <InputError :message="form.errors.week_start" class="mt-2" />
</div> </div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="week_start" value="Start of the week" />
<select
id="week_start"
v-model="form.week_start"
name="week_start"
required
class="mt-1 block w-full border-input-border bg-input-background text-text-primary focus:border-input-border-active rounded-md shadow-sm">
<option value="" disabled>Select a week day</option>
<option
v-for="(weekdayTranslated, weekdayKey) in $page.props
.weekdays"
:key="weekdayKey"
:value="weekdayKey">
{{ weekdayTranslated }}
</option>
</select>
<InputError :message="form.errors.week_start" class="mt-2" />
</div>
</template> </template>
<template #actions> <template #actions>

View File

@@ -3,7 +3,7 @@ import MainContainer from '@/packages/ui/src/MainContainer.vue';
import AppLayout from '@/Layouts/AppLayout.vue'; import AppLayout from '@/Layouts/AppLayout.vue';
import { FolderIcon, PlusIcon } from '@heroicons/vue/16/solid'; import { FolderIcon, PlusIcon } from '@heroicons/vue/16/solid';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue'; import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref, inject, type ComputedRef } from 'vue';
import { useProjectsStore } from '@/utils/useProjects'; import { useProjectsStore } from '@/utils/useProjects';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { import {
@@ -33,9 +33,12 @@ import ProjectEditModal from '@/Components/Common/Project/ProjectEditModal.vue';
import { Badge } from '@/packages/ui/src'; import { Badge } from '@/packages/ui/src';
import { formatCents } from '../packages/ui/src/utils/money'; import { formatCents } from '../packages/ui/src/utils/money';
import { getOrganizationCurrencyString } from '../utils/money'; import { getOrganizationCurrencyString } from '../utils/money';
import type { Organization } from '@/packages/api/src';
const { projects } = storeToRefs(useProjectsStore()); const { projects } = storeToRefs(useProjectsStore());
const organization = inject<ComputedRef<Organization>>('organization');
const project = computed(() => { const project = computed(() => {
return ( return (
projects.value.find( projects.value.find(
@@ -112,7 +115,10 @@ const shownTasks = computed(() => {
{{ {{
formatCents( formatCents(
project?.billable_rate ?? 0, project?.billable_rate ?? 0,
getOrganizationCurrencyString() getOrganizationCurrencyString(),
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
) )
}} }}
/ h / h
@@ -145,15 +151,11 @@ const shownTasks = computed(() => {
<div <div
class="w-full items-center flex justify-between"> class="w-full items-center flex justify-between">
<div class="pl-6"> <div class="pl-6">
<TabBar <TabBar v-model="activeTab">
v-model="activeTab" <TabBarItem value="active"
>
<TabBarItem
value="active"
>Active >Active
</TabBarItem> </TabBarItem>
<TabBarItem <TabBarItem value="done"
value="done"
>Done >Done
</TabBarItem> </TabBarItem>
</TabBar> </TabBar>
@@ -185,11 +187,11 @@ const shownTasks = computed(() => {
Add Member Add Member
</SecondaryButton> </SecondaryButton>
<ProjectMemberCreateModal <ProjectMemberCreateModal
v-model:show=" v-model:show="createProjectMember"
createProjectMember
"
:project-id="projectId" :project-id="projectId"
:existing-members="projectMembers"></ProjectMemberCreateModal> :existing-members="
projectMembers
"></ProjectMemberCreateModal>
</template> </template>
</CardTitle> </CardTitle>
<Card> <Card>

View File

@@ -1,277 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import MainContainer from '@/packages/ui/src/MainContainer.vue';
import AppLayout from '@/Layouts/AppLayout.vue'; import AppLayout from '@/Layouts/AppLayout.vue';
import { FolderIcon } from '@heroicons/vue/16/solid'; import ReportingOverview from "@/Components/Common/Reporting/ReportingOverview.vue";
import PageTitle from '@/Components/Common/PageTitle.vue';
import {
ChartBarIcon,
UserGroupIcon,
CheckCircleIcon,
TagIcon,
} from '@heroicons/vue/20/solid';
import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue';
import ReportingChart from '@/Components/Common/Reporting/ReportingChart.vue';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import { computed, onMounted, ref } from 'vue';
import {
formatHumanReadableDuration,
getDayJsInstance,
getLocalizedDayJs,
} from '@/packages/ui/src/utils/time';
import { type GroupingOption, useReportingStore } from '@/utils/useReporting';
import { storeToRefs } from 'pinia';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import {
type AggregatedTimeEntriesQueryParams,
type CreateReportBodyProperties,
api,
} from '@/packages/api/src';
import ReportingFilterBadge from '@/Components/Common/Reporting/ReportingFilterBadge.vue';
import ProjectMultiselectDropdown from '@/Components/Common/Project/ProjectMultiselectDropdown.vue';
import MemberMultiselectDropdown from '@/Components/Common/Member/MemberMultiselectDropdown.vue';
import TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue';
import SelectDropdown from '@/packages/ui/src/Input/SelectDropdown.vue';
import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue';
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue';
import {
getCurrentMembershipId,
getCurrentOrganizationId,
getCurrentRole,
} from '@/utils/useUser';
import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue';
import { useTagsStore } from '@/utils/useTags';
import { formatCents } from '@/packages/ui/src/utils/money';
import { useSessionStorage, useStorage } from '@vueuse/core';
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
import { useNotificationsStore } from '@/utils/notification';
import ReportingExportButton from '@/Components/Common/Reporting/ReportingExportButton.vue';
import type { ExportFormat } from '@/types/reporting';
import ReportSaveButton from '@/Components/Common/Report/ReportSaveButton.vue';
import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';
const { handleApiRequestNotifications } = useNotificationsStore();
const startDate = useSessionStorage<string>(
'reporting-start-date',
getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format()
);
const endDate = useSessionStorage<string>(
'reporting-end-date',
getLocalizedDayJs(getDayJsInstance()().format()).format()
);
const selectedTags = ref<string[]>([]);
const selectedProjects = ref<string[]>([]);
const selectedMembers = ref<string[]>([]);
const selectedTasks = ref<string[]>([]);
const selectedClients = ref<string[]>([]);
const billable = ref<'true' | 'false' | null>(null);
const group = useStorage<GroupingOption>('reporting-group', 'project');
const subGroup = useStorage<GroupingOption>('reporting-sub-group', 'task');
const reportingStore = useReportingStore();
const { aggregatedGraphTimeEntries, aggregatedTableTimeEntries } =
storeToRefs(reportingStore);
const { groupByOptions } = reportingStore;
function getFilterAttributes(): AggregatedTimeEntriesQueryParams {
let params: AggregatedTimeEntriesQueryParams = {
start: getLocalizedDayJs(startDate.value).startOf('day').utc().format(),
end: getLocalizedDayJs(endDate.value).endOf('day').utc().format(),
};
params = {
...params,
member_ids:
selectedMembers.value.length > 0
? selectedMembers.value
: undefined,
project_ids:
selectedProjects.value.length > 0
? selectedProjects.value
: undefined,
task_ids:
selectedTasks.value.length > 0 ? selectedTasks.value : undefined,
client_ids:
selectedClients.value.length > 0
? selectedClients.value
: undefined,
tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined,
billable: billable.value !== null ? billable.value : undefined,
};
return params;
}
function updateGraphReporting() {
const params = getFilterAttributes();
if (getCurrentRole() === 'employee') {
params.member_id = getCurrentMembershipId();
}
params.fill_gaps_in_time_groups = 'true';
params.group = getOptimalGroupingOption(startDate.value, endDate.value);
useReportingStore().fetchGraphReporting(params);
}
function updateTableReporting() {
const params = getFilterAttributes();
if (group.value === subGroup.value) {
const fallbackOption = groupByOptions.find(
(el) => el.value !== group.value
);
if (fallbackOption?.value) {
subGroup.value = fallbackOption.value;
}
}
if (getCurrentRole() === 'employee') {
params.member_id = getCurrentMembershipId();
}
params.group = group.value;
params.sub_group = subGroup.value;
useReportingStore().fetchTableReporting(params);
}
function updateReporting() {
updateGraphReporting();
updateTableReporting();
}
function getOptimalGroupingOption(
startDate: string,
endDate: string
): 'day' | 'week' | 'month' {
const diffInDays = getDayJsInstance()(endDate).diff(
getDayJsInstance()(startDate),
'd'
);
if (diffInDays <= 31) {
return 'day';
} else if (diffInDays <= 200) {
return 'week';
} else {
return 'month';
}
}
onMounted(() => {
updateGraphReporting();
updateTableReporting();
});
const { tags } = storeToRefs(useTagsStore());
async function createTag(tag: string) {
return await useTagsStore().createTag(tag);
}
const reportProperties = computed(() => {
return {
...getFilterAttributes(),
group: group.value,
sub_group: subGroup.value,
history_group: getOptimalGroupingOption(startDate.value, endDate.value),
} as CreateReportBodyProperties;
});
async function downloadExport(format: ExportFormat) {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
const response = await handleApiRequestNotifications(
() =>
api.exportAggregatedTimeEntries({
params: {
organization: organizationId,
},
queries: {
...getFilterAttributes(),
group: group.value,
sub_group: subGroup.value,
history_group: getOptimalGroupingOption(
startDate.value,
endDate.value
),
format: format,
},
}),
'Export successful',
'Export failed'
);
if (response?.download_url) {
showExportModal.value = true;
exportUrl.value = response.download_url as string;
}
}
}
const { getNameForReportingRowEntry, emptyPlaceholder } = useReportingStore();
import { useProjectsStore } from '@/utils/useProjects';
import ReportingExportModal from '@/Components/Common/Reporting/ReportingExportModal.vue';
const projectsStore = useProjectsStore();
const { projects } = storeToRefs(projectsStore);
const showExportModal = ref(false);
const exportUrl = ref<string | null>(null);
const groupedPieChartData = computed(() => {
return (
aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
const name = getNameForReportingRowEntry(
entry.key,
aggregatedTableTimeEntries.value?.grouped_type
);
let color = getRandomColorWithSeed(entry.key ?? 'none');
if (
name &&
aggregatedTableTimeEntries.value?.grouped_type &&
emptyPlaceholder[
aggregatedTableTimeEntries.value?.grouped_type
] === name
) {
color = '#CCCCCC';
} else if (
aggregatedTableTimeEntries.value?.grouped_type === 'project'
) {
color =
projects.value?.find((project) => project.id === entry.key)
?.color ?? '#CCCCCC';
}
return {
value: entry.seconds,
name:
getNameForReportingRowEntry(
entry.key,
aggregatedTableTimeEntries.value?.grouped_type
) ?? '',
color: color,
};
}) ?? []
);
});
const tableData = computed(() => {
return aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
return {
seconds: entry.seconds,
cost: entry.cost,
description: getNameForReportingRowEntry(
entry.key,
aggregatedTableTimeEntries.value?.grouped_type
),
grouped_data:
entry.grouped_data?.map((el) => {
return {
seconds: el.seconds,
cost: el.cost,
description: getNameForReportingRowEntry(
el.key,
entry.grouped_type
),
};
}) ?? [],
};
});
});
</script> </script>
<template> <template>
@@ -279,217 +10,6 @@ const tableData = computed(() => {
title="Reporting" title="Reporting"
data-testid="reporting_view" data-testid="reporting_view"
class="overflow-hidden"> class="overflow-hidden">
<ReportingExportModal <ReportingOverview></ReportingOverview>
v-model:show="showExportModal"
:export-url="exportUrl"></ReportingExportModal>
<MainContainer
class="py-3 sm:py-5 border-b border-default-background-separator flex justify-between items-center">
<div class="flex items-center space-x-3 sm:space-x-6">
<PageTitle :icon="ChartBarIcon" title="Reporting"></PageTitle>
<ReportingTabNavbar active="reporting"></ReportingTabNavbar>
</div>
<div class="flex space-x-2">
<ReportingExportButton
:download="downloadExport"></ReportingExportButton>
<ReportSaveButton
:report-properties="reportProperties"></ReportSaveButton>
</div>
</MainContainer>
<div class="py-2.5 w-full border-b border-default-background-separator">
<MainContainer
class="sm:flex space-y-4 sm:space-y-0 justify-between">
<div
class="flex flex-wrap items-center space-y-2 sm:space-y-0 space-x-4">
<div class="text-sm font-medium">Filters</div>
<MemberMultiselectDropdown
v-model="selectedMembers"
@submit="updateReporting">
<template #trigger>
<ReportingFilterBadge
:count="selectedMembers.length"
:active="selectedMembers.length > 0"
title="Members"
:icon="UserGroupIcon"></ReportingFilterBadge>
</template>
</MemberMultiselectDropdown>
<ProjectMultiselectDropdown
v-model="selectedProjects"
@submit="updateReporting">
<template #trigger>
<ReportingFilterBadge
:count="selectedProjects.length"
:active="selectedProjects.length > 0"
title="Projects"
:icon="FolderIcon"></ReportingFilterBadge>
</template>
</ProjectMultiselectDropdown>
<TaskMultiselectDropdown
v-model="selectedTasks"
@submit="updateReporting">
<template #trigger>
<ReportingFilterBadge
:count="selectedTasks.length"
:active="selectedTasks.length > 0"
title="Tasks"
:icon="CheckCircleIcon"></ReportingFilterBadge>
</template>
</TaskMultiselectDropdown>
<ClientMultiselectDropdown
v-model="selectedClients"
@submit="updateReporting">
<template #trigger>
<ReportingFilterBadge
:count="selectedClients.length"
:active="selectedClients.length > 0"
title="Clients"
:icon="FolderIcon"></ReportingFilterBadge>
</template>
</ClientMultiselectDropdown>
<TagDropdown
v-model="selectedTags"
:create-tag
:tags="tags"
@submit="updateReporting">
<template #trigger>
<ReportingFilterBadge
:count="selectedTags.length"
:active="selectedTags.length > 0"
title="Tags"
:icon="TagIcon"></ReportingFilterBadge>
</template>
</TagDropdown>
<SelectDropdown
v-model="billable"
:get-key-from-item="(item) => item.value"
:get-name-for-item="(item) => item.label"
:items="[
{
label: 'Both',
value: null,
},
{
label: 'Billable',
value: 'true',
},
{
label: 'Non Billable',
value: 'false',
},
]"
@changed="updateReporting">
<template #trigger>
<ReportingFilterBadge
:active="billable !== null"
:title="
billable === 'false'
? 'Non Billable'
: 'Billable'
"
:icon="BillableIcon"></ReportingFilterBadge>
</template>
</SelectDropdown>
</div>
<div>
<DateRangePicker
v-model:start="startDate"
v-model:end="endDate"
@submit="updateReporting"></DateRangePicker>
</div>
</MainContainer>
</div>
<MainContainer>
<div class="pt-10 w-full px-3 relative">
<ReportingChart
:grouped-type="aggregatedGraphTimeEntries?.grouped_type"
:grouped-data="
aggregatedGraphTimeEntries?.grouped_data
"></ReportingChart>
</div>
</MainContainer>
<MainContainer>
<div class="sm:grid grid-cols-4 pt-6 items-start">
<div
class="col-span-3 bg-card-background rounded-lg border border-card-border pt-3">
<div
class="text-sm flex text-text-primary items-center space-x-3 font-medium px-6 border-b border-card-background-separator pb-3">
<span>Group by</span>
<ReportingGroupBySelect
v-model="group"
:group-by-options="groupByOptions"
@changed="updateTableReporting"></ReportingGroupBySelect>
<span>and</span>
<ReportingGroupBySelect
v-model="subGroup"
:group-by-options="
groupByOptions.filter(
(el) => el.value !== group
)
"
@changed="updateTableReporting"></ReportingGroupBySelect>
</div>
<div
class="grid items-center"
style="grid-template-columns: 1fr 100px 150px">
<div
class="contents [&>*]:border-card-background-separator [&>*]:border-b [&>*]:bg-tertiary [&>*]:pb-1.5 [&>*]:pt-1 text-text-secondary text-sm">
<div class="pl-6">Name</div>
<div class="text-right">Duration</div>
<div class="text-right pr-6">Cost</div>
</div>
<template
v-if="
aggregatedTableTimeEntries?.grouped_data &&
aggregatedTableTimeEntries.grouped_data
?.length > 0
">
<ReportingRow
v-for="entry in tableData"
:key="entry.description ?? 'none'"
:entry="entry"
:type="
aggregatedTableTimeEntries.grouped_type
"></ReportingRow>
<div
class="contents [&>*]:transition text-text-tertiary [&>*]:h-[50px]">
<div class="flex items-center pl-6 font-medium">
<span>Total</span>
</div>
<div
class="justify-end flex items-center font-medium">
{{
formatHumanReadableDuration(
aggregatedTableTimeEntries.seconds
)
}}
</div>
<div
class="justify-end pr-6 flex items-center font-medium">
{{
aggregatedTableTimeEntries.cost ?
formatCents(
aggregatedTableTimeEntries.cost,
getOrganizationCurrencyString()
) : '--'
}}
</div>
</div>
</template>
<div
v-else
class="chart flex flex-col items-center justify-center py-12 col-span-3">
<p class="text-lg text-text-primary font-semibold">
No time entries found
</p>
<p>Try to change the filters and time range</p>
</div>
</div>
</div>
<div class="px-2 lg:px-4">
<ReportingPieChart
:data="groupedPieChartData"></ReportingPieChart>
</div>
</div>
</MainContainer>
</AppLayout> </AppLayout>
</template> </template>

View File

@@ -75,7 +75,7 @@ watch(currentPage, () => {
data-testid="reporting_view" data-testid="reporting_view"
class="overflow-hidden"> class="overflow-hidden">
<MainContainer <MainContainer
class="py-3 sm:py-5 border-b border-default-background-separator flex justify-between items-center"> class="py-3 sm:py-5 min-h-[79px] border-b border-default-background-separator flex justify-between items-center">
<div class="flex items-center space-x-3 sm:space-x-6"> <div class="flex items-center space-x-3 sm:space-x-6">
<PageTitle :icon="ChartBarIcon" title="Reporting"></PageTitle> <PageTitle :icon="ChartBarIcon" title="Reporting"></PageTitle>
<ReportingTabNavbar active="shared"></ReportingTabNavbar> <ReportingTabNavbar active="shared"></ReportingTabNavbar>

View File

@@ -5,16 +5,16 @@ import { ChartBarIcon } from '@heroicons/vue/20/solid';
import ReportingChart from '@/Components/Common/Reporting/ReportingChart.vue'; import ReportingChart from '@/Components/Common/Reporting/ReportingChart.vue';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time'; import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue'; import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue'; import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue';
import { formatCents } from '@/packages/ui/src/utils/money'; import { formatCents } from '@/packages/ui/src/utils/money';
import { computed, onMounted, ref } from 'vue'; import type { CurrencyFormat } from '@/packages/ui/src/utils/money';
import { computed, onMounted, provide, ref } from 'vue';
import { useQuery } from '@tanstack/vue-query'; import { useQuery } from '@tanstack/vue-query';
import { api } from '@/packages/api/src'; import { api } from '@/packages/api/src';
import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color'; import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';
import { useReportingStore } from '@/utils/useReporting'; import { useReportingStore } from '@/utils/useReporting';
import { Head } from '@inertiajs/vue3'; import { Head } from '@inertiajs/vue3';
import { useTheme } from "@/utils/theme"; import { useTheme } from '@/utils/theme';
const sharedSecret = ref<string | null>(null); const sharedSecret = ref<string | null>(null);
@@ -41,6 +41,45 @@ onMounted(() => {
} }
}); });
const reportCurrency = computed(() => {
if (sharedReportResponseData.value) {
return sharedReportResponseData.value?.currency;
}
return 'EUR';
});
const reportIntervalFormat = computed(() => {
return sharedReportResponseData.value?.interval_format;
});
const reportNumberFormat = computed(() => {
return sharedReportResponseData.value?.number_format;
});
const reportCurrencyFormat = computed(() => {
return (sharedReportResponseData.value?.currency_format ??
'symbol-before') as CurrencyFormat;
});
const reportDateFormat = computed(() => {
return sharedReportResponseData.value?.date_format;
});
const reportCurrencySymbol = computed(() => {
return sharedReportResponseData.value?.currency_symbol;
});
provide(
'organization',
computed(() => ({
'number_format': reportNumberFormat.value,
'interval_format': reportIntervalFormat.value,
'currency_format': reportCurrencyFormat.value,
'currency_symbol': reportCurrencySymbol.value,
'date_format': reportDateFormat.value,
}))
);
const aggregatedTableTimeEntries = computed(() => { const aggregatedTableTimeEntries = computed(() => {
if (sharedReportResponseData.value) { if (sharedReportResponseData.value) {
return sharedReportResponseData.value?.data; return sharedReportResponseData.value?.data;
@@ -132,15 +171,16 @@ const tableData = computed(() => {
}); });
const { groupByOptions } = useReportingStore(); const { groupByOptions } = useReportingStore();
function getGroupLabel(key: string) { function getGroupLabel(key: string) {
return groupByOptions.find((option) => { return groupByOptions.find((option) => {
return option.value === key; return option.value === key;
})?.label; })?.label;
} }
onMounted(async () => { onMounted(async () => {
useTheme(); useTheme();
}) });
</script> </script>
<template> <template>
@@ -193,10 +233,9 @@ onMounted(async () => {
<ReportingRow <ReportingRow
v-for="entry in tableData" v-for="entry in tableData"
:key="entry.description ?? 'none'" :key="entry.description ?? 'none'"
:entry="entry" :currency="reportCurrency"
:type=" :currency-format="reportCurrencyFormat"
aggregatedTableTimeEntries.grouped_type :entry="entry"></ReportingRow>
"></ReportingRow>
<div <div
class="contents [&>*]:transition text-text-tertiary [&>*]:h-[50px]"> class="contents [&>*]:transition text-text-tertiary [&>*]:h-[50px]">
<div class="flex items-center pl-6 font-medium"> <div class="flex items-center pl-6 font-medium">
@@ -206,7 +245,9 @@ onMounted(async () => {
class="justify-end flex items-center font-medium"> class="justify-end flex items-center font-medium">
{{ {{
formatHumanReadableDuration( formatHumanReadableDuration(
aggregatedTableTimeEntries.seconds aggregatedTableTimeEntries.seconds,
reportIntervalFormat,
reportNumberFormat
) )
}} }}
</div> </div>
@@ -215,7 +256,9 @@ onMounted(async () => {
{{ {{
formatCents( formatCents(
aggregatedTableTimeEntries.cost, aggregatedTableTimeEntries.cost,
getOrganizationCurrencyString() reportCurrency,
reportCurrencyFormat,
reportCurrencySymbol
) )
}} }}
</div> </div>

View File

@@ -0,0 +1,239 @@
<script setup lang="ts">
import FormSection from '@/Components/FormSection.vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { onMounted, ref } from 'vue';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import type { UpdateOrganizationBody } from '@/packages/api/src';
import { useOrganizationStore } from '@/utils/useOrganization';
import { storeToRefs } from 'pinia';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import { useMutation, useQueryClient } from '@tanstack/vue-query';
import type {
DateFormat,
TimeFormat,
IntervalFormat,
} from '@/packages/ui/src/utils/time';
import type { CurrencyFormat } from '@/packages/ui/src/utils/money';
import type { NumberFormat } from '@/packages/ui/src/utils/number';
interface FormValues {
number_format: NumberFormat | undefined;
currency_format: CurrencyFormat | undefined;
date_format: DateFormat | undefined;
time_format: TimeFormat | undefined;
interval_format: IntervalFormat | undefined;
}
const store = useOrganizationStore();
const { fetchOrganization, updateOrganization } = store;
const { organization } = storeToRefs(store);
const queryClient = useQueryClient();
const form = ref<FormValues>({
number_format: undefined,
currency_format: undefined,
date_format: undefined,
time_format: undefined,
interval_format: undefined,
});
const mutation = useMutation({
mutationFn: (values: FormValues) =>
updateOrganization(values as UpdateOrganizationBody),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['organization'] });
},
});
onMounted(async () => {
await fetchOrganization();
if (organization.value) {
form.value = {
number_format: organization.value.number_format as NumberFormat,
currency_format: organization.value
.currency_format as CurrencyFormat,
date_format: organization.value.date_format as DateFormat,
time_format: organization.value.time_format as TimeFormat,
interval_format: organization?.value
.interval_format as IntervalFormat,
};
}
});
async function submit() {
mutation.mutate(form.value);
}
</script>
<template>
<FormSection>
<template #title>Format Settings</template>
<template #description>
Configure the default format settings for the organization.
</template>
<template #form>
<!-- Number Format -->
<div class="col-span-6">
<div class="col-span-6 sm:col-span-4">
<InputLabel
for="numberFormat"
class="mb-2"
value="Number Format" />
<Select v-model="form.number_format">
<SelectTrigger id="numberFormat">
<SelectValue placeholder="Select number format" />
</SelectTrigger>
<SelectContent>
<SelectItem value="point-comma"
>1.111,11</SelectItem
>
<SelectItem value="comma-point"
>1,111.11</SelectItem
>
<SelectItem value="space-comma"
>1 111,11</SelectItem
>
<SelectItem value="space-point"
>1 111.11</SelectItem
>
<SelectItem value="apostrophe-point"
>1'111.11</SelectItem
>
</SelectContent>
</Select>
</div>
</div>
<!-- Currency Format -->
<div class="col-span-6">
<div class="col-span-6 sm:col-span-4">
<InputLabel
for="currencyFormat"
class="mb-2"
value="Currency Format" />
<Select v-model="form.currency_format">
<SelectTrigger id="currencyFormat">
<SelectValue placeholder="Select currency format" />
</SelectTrigger>
<SelectContent>
<SelectItem value="iso-code-before-with-space"
>EUR 111</SelectItem
>
<SelectItem value="iso-code-after-with-space"
>111 EUR</SelectItem
>
<SelectItem value="symbol-before">€111</SelectItem>
<SelectItem value="symbol-after">111€</SelectItem>
<SelectItem value="symbol-before-with-space"
>€ 111</SelectItem
>
<SelectItem value="symbol-after-with-space"
>111 €</SelectItem
>
</SelectContent>
</Select>
</div>
</div>
<!-- Date Format -->
<div class="col-span-6">
<div class="col-span-6 sm:col-span-4">
<InputLabel
for="dateFormat"
class="mb-2"
value="Date Format" />
<Select v-model="form.date_format">
<SelectTrigger id="dateFormat">
<SelectValue placeholder="Select date format" />
</SelectTrigger>
<SelectContent>
<SelectItem value="point-separated-d-m-yyyy"
>D.M.YYYY</SelectItem
>
<SelectItem value="slash-separated-mm-dd-yyyy"
>MM/DD/YYYY</SelectItem
>
<SelectItem value="slash-separated-dd-mm-yyyy"
>DD/MM/YYYY</SelectItem
>
<SelectItem value="hyphen-separated-dd-mm-yyyy"
>DD-MM-YYYY</SelectItem
>
<SelectItem value="hyphen-separated-mm-dd-yyyy"
>MM-DD-YYYY</SelectItem
>
<SelectItem value="hyphen-separated-yyyy-mm-dd"
>YYYY-MM-DD</SelectItem
>
</SelectContent>
</Select>
</div>
</div>
<!-- Time Format -->
<div class="col-span-6">
<div class="col-span-6 sm:col-span-4">
<InputLabel
for="timeFormat"
class="mb-2"
value="Time Format" />
<Select v-model="form.time_format">
<SelectTrigger id="timeFormat">
<SelectValue placeholder="Select time format" />
</SelectTrigger>
<SelectContent>
<SelectItem value="12-hours"
>12-hour clock</SelectItem
>
<SelectItem value="24-hours"
>24-hour clock</SelectItem
>
</SelectContent>
</Select>
</div>
</div>
<!-- Interval Format -->
<div class="col-span-6">
<div class="col-span-6 sm:col-span-4">
<InputLabel
for="intervalFormat"
class="mb-2"
value="Time Duration Format" />
<Select v-model="form.interval_format">
<SelectTrigger id="intervalFormat">
<SelectValue placeholder="Select interval format" />
</SelectTrigger>
<SelectContent>
<SelectItem value="decimal">Decimal</SelectItem>
<SelectItem value="hours-minutes"
>12h 3m</SelectItem
>
<SelectItem value="hours-minutes-colon-separated"
>12:03</SelectItem
>
<SelectItem
value="hours-minutes-seconds-colon-separated"
>12:03:45</SelectItem
>
</SelectContent>
</Select>
</div>
</div>
</template>
<template #actions>
<PrimaryButton :disabled="mutation.isPending.value" @click="submit">
{{ mutation.isPending.value ? 'Saving...' : 'Save' }}
</PrimaryButton>
</template>
</FormSection>
</template>

View File

@@ -7,6 +7,7 @@ import type { Organization } from '@/types/models';
import type { Permissions, Role } from '@/types/jetstream'; import type { Permissions, Role } from '@/types/jetstream';
import { canUpdateOrganization } from '@/utils/permissions'; import { canUpdateOrganization } from '@/utils/permissions';
import OrganizationBillableRate from '@/Pages/Teams/Partials/OrganizationBillableRate.vue'; import OrganizationBillableRate from '@/Pages/Teams/Partials/OrganizationBillableRate.vue';
import OrganizationFormatSettings from '@/Pages/Teams/Partials/OrganizationFormatSettings.vue';
defineProps<{ defineProps<{
team: Organization; team: Organization;
@@ -33,6 +34,11 @@ defineProps<{
:team="team" /> :team="team" />
<SectionBorder /> <SectionBorder />
<OrganizationFormatSettings
v-if="canUpdateOrganization()"
:team="team" />
<SectionBorder />
<template <template
v-if="permissions.canDeleteTeam && !team.personal_team"> v-if="permissions.canDeleteTeam && !team.personal_team">
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" /> <DeleteTeamForm class="mt-10 sm:mt-0" :team="team" />

View File

@@ -167,7 +167,10 @@ export type ApiTokenIndexResponse = ZodiosResponseByAlias<
'getApiTokens' 'getApiTokens'
>; >;
export type CreateApiTokenBody = ZodiosBodyByAlias<SolidTimeApi, 'createApiToken'>; export type CreateApiTokenBody = ZodiosBodyByAlias<
SolidTimeApi,
'createApiToken'
>;
export type ApiToken = ApiTokenIndexResponse['data'][0]; export type ApiToken = ApiTokenIndexResponse['data'][0];
export type DetailedInvoiceResponse = ZodiosResponseByAlias< export type DetailedInvoiceResponse = ZodiosResponseByAlias<
@@ -175,6 +178,26 @@ export type DetailedInvoiceResponse = ZodiosResponseByAlias<
'getInvoice' 'getInvoice'
>; >;
export type InvoiceIndexEntry = ZodiosResponseByAlias<
SolidTimeApi,
'getInvoices'
>['data'][0];
export type UpdateInvoiceSettings = ZodiosBodyByAlias<
SolidTimeApi,
'updateInvoiceSettings'
>;
export type CreateInvoiceBody = ZodiosBodyByAlias<
SolidTimeApi,
'createInvoice'
>;
export type UpdateInvoiceBody = ZodiosBodyByAlias<
SolidTimeApi,
'updateInvoice'
>;
const api = createApiClient('/api', { validate: 'none' }); const api = createApiClient('/api', { validate: 'none' });
export { createApiClient, api }; export { createApiClient, api };

View File

@@ -130,7 +130,7 @@ const InvoiceEntryResource = z
name: z.string(), name: z.string(),
description: z.union([z.string(), z.null()]), description: z.union([z.string(), z.null()]),
unit_price: z.number().int(), unit_price: z.number().int(),
quantity: z.string(), quantity: z.number(),
order_index: z.number().int(), order_index: z.number().int(),
created_at: z.union([z.string(), z.null()]), created_at: z.union([z.string(), z.null()]),
updated_at: z.union([z.string(), z.null()]), updated_at: z.union([z.string(), z.null()]),
@@ -164,8 +164,8 @@ const DetailedInvoiceResource = z
paid_at: z.union([z.string(), z.null()]), paid_at: z.union([z.string(), z.null()]),
due_at: z.string(), due_at: z.string(),
discount_type: z.string(), discount_type: z.string(),
discount_amount: z.string(), discount_amount: z.number().int(),
tax_rate: z.string(), tax_rate: z.number().int(),
status: z.string(), status: z.string(),
currency: z.string(), currency: z.string(),
date: z.string(), date: z.string(),
@@ -293,21 +293,6 @@ const MemberMergeIntoRequest = z
.object({ member_id: z.string() }) .object({ member_id: z.string() })
.partial() .partial()
.passthrough(); .passthrough();
const OrganizationResource = z
.object({
id: z.string(),
name: z.string(),
is_personal: z.boolean(),
billable_rate: z.union([z.number(), z.null()]),
employees_can_see_billable_rates: z.boolean(),
currency: z.string(),
number_format: z.string(),
currency_format: z.string(),
date_format: z.string(),
interval_format: z.string(),
time_format: z.string(),
})
.passthrough();
const NumberFormat = z.enum([ const NumberFormat = z.enum([
'point-comma', 'point-comma',
'comma-point', 'comma-point',
@@ -324,20 +309,36 @@ const CurrencyFormat = z.enum([
'symbol-after-with-space', 'symbol-after-with-space',
]); ]);
const DateFormat = z.enum([ const DateFormat = z.enum([
'point-seperated-d-m-yyyy', 'point-separated-d-m-yyyy',
'slash-seperated-mm-dd-yyyy', 'slash-separated-mm-dd-yyyy',
'slash-seperated-dd-mm-yyyy', 'slash-separated-dd-mm-yyyy',
'hyphen-seperated-dd-mm-yyyy', 'hyphen-separated-dd-mm-yyyy',
'hyphen-seperated-mm-dd-yyyy', 'hyphen-separated-mm-dd-yyyy',
'hyphen-seperated-yyyy-mm-dd', 'hyphen-separated-yyyy-mm-dd',
]); ]);
const IntervalFormat = z.enum([ const IntervalFormat = z.enum([
'decimal', 'decimal',
'hours-minutes', 'hours-minutes',
'hours-minutes-colon-seperated', 'hours-minutes-colon-separated',
'hours-minutes-seconds-colon-seperated', 'hours-minutes-seconds-colon-separated',
]); ]);
const TimeFormat = z.enum(['12-hours', '24-hours']); const TimeFormat = z.enum(['12-hours', '24-hours']);
const OrganizationResource = z
.object({
id: z.string(),
name: z.string(),
is_personal: z.boolean(),
billable_rate: z.union([z.number(), z.null()]),
employees_can_see_billable_rates: z.boolean(),
currency: z.string(),
currency_symbol: z.string(),
number_format: NumberFormat,
currency_format: CurrencyFormat,
date_format: DateFormat,
interval_format: IntervalFormat,
time_format: TimeFormat,
})
.passthrough();
const OrganizationUpdateRequest = z const OrganizationUpdateRequest = z
.object({ .object({
name: z.string().max(255), name: z.string().max(255),
@@ -524,6 +525,12 @@ const DetailedWithDataReportResource = z
description: z.union([z.string(), z.null()]), description: z.union([z.string(), z.null()]),
public_until: z.union([z.string(), z.null()]), public_until: z.union([z.string(), z.null()]),
currency: z.string(), currency: z.string(),
number_format: NumberFormat,
currency_format: CurrencyFormat,
currency_symbol: z.string(),
date_format: DateFormat,
interval_format: IntervalFormat,
time_format: TimeFormat,
properties: z properties: z
.object({ .object({
group: z.string(), group: z.string(),
@@ -769,12 +776,12 @@ export const schemas = {
Role, Role,
MemberUpdateRequest, MemberUpdateRequest,
MemberMergeIntoRequest, MemberMergeIntoRequest,
OrganizationResource,
NumberFormat, NumberFormat,
CurrencyFormat, CurrencyFormat,
DateFormat, DateFormat,
IntervalFormat, IntervalFormat,
TimeFormat, TimeFormat,
OrganizationResource,
OrganizationUpdateRequest, OrganizationUpdateRequest,
ProjectResource, ProjectResource,
ProjectStoreRequest, ProjectStoreRequest,
@@ -812,7 +819,9 @@ const endpoints = makeApi([
path: '/v1/countries', path: '/v1/countries',
alias: 'getCountries', alias: 'getCountries',
requestFormat: 'json', requestFormat: 'json',
response: z.string(), response: z.array(
z.object({ code: z.string(), name: z.string() }).passthrough()
),
errors: [ errors: [
{ {
status: 401, status: 401,
@@ -821,6 +830,21 @@ const endpoints = makeApi([
}, },
], ],
}, },
{
method: 'get',
path: '/v1/currencies',
alias: 'getCurrencies',
requestFormat: 'json',
response: z.array(
z
.object({
code: z.string(),
name: z.string(),
symbol: z.string(),
})
.passthrough()
),
},
{ {
method: 'get', method: 'get',
path: '/v1/organizations/:organization', path: '/v1/organizations/:organization',

View File

@@ -1,9 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { PlusCircleIcon } from '@heroicons/vue/20/solid'; import { PlusCircleIcon } from '@heroicons/vue/20/solid';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue'; import { computed, nextTick, ref, watch } from 'vue';
import { type Component, computed, nextTick, ref, watch } from 'vue';
import ClientDropdownItem from '@/packages/ui/src/Client/ClientDropdownItem.vue'; import ClientDropdownItem from '@/packages/ui/src/Client/ClientDropdownItem.vue';
import type { CreateClientBody, Client } from '@/packages/api/src'; 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>({ const model = defineModel<string | null>({
default: null, default: null,
@@ -14,10 +23,8 @@ const props = defineProps<{
createClient: (client: CreateClientBody) => Promise<Client | undefined>; createClient: (client: CreateClientBody) => Promise<Client | undefined>;
}>(); }>();
const searchInput = ref<HTMLInputElement | null>(null); const searchInput = ref<HTMLElement | null>(null);
const open = ref(false); const open = ref(false);
const dropdownViewport = ref<Component | null>(null);
const searchValue = ref(''); const searchValue = ref('');
function isClientSelected(id: string) { function isClientSelected(id: string) {
@@ -27,7 +34,8 @@ function isClientSelected(id: string) {
watch(open, (isOpen) => { watch(open, (isOpen) => {
if (isOpen) { if (isOpen) {
nextTick(() => { 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) { if (newClient) {
model.value = newClient.id; model.value = newClient.id;
searchValue.value = ''; searchValue.value = '';
} open.value = false;
} else {
if (highlightedItemId.value) {
model.value = highlightedItemId.value;
} }
} }
} }
watch(filteredClients, () => { const currentClient = computed(() => {
if (filteredClients.value.length > 0) { return (
highlightedItemId.value = filteredClients.value[0].id; 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']); const emit = defineEmits(['update:modelValue', 'changed']);
function updateClient(newValue: string) { function updateValue(client: { id: string | null; name: string }) {
model.value = newValue; model.value = client.id;
nextTick(() => { emit('changed');
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> </script>
<template> <template>
<Dropdown v-model="open" width="120" :close-on-content-click="true"> <Dropdown v-model="open" align="start" width="60">
<template #trigger> <template #trigger>
<slot name="trigger"></slot> <slot name="trigger"></slot>
</template> </template>
<template #content> <template #content>
<input <UseFocusTrap
ref="searchInput" v-if="open"
:value="searchValue" :options="{ immediate: true, allowOutsideClick: true }">
data-testid="client_dropdown_search" <ComboboxRoot
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" v-model:search-term="searchValue"
placeholder="Search for a client..." :open="open"
@input="updateSearchValue" :model-value="currentClient"
@keydown.enter="addClientIfNoneExists" class="relative"
@keydown.up.prevent="moveHighlightUp" @update:model-value="updateValue">
@keydown.down.prevent="moveHighlightDown" /> <ComboboxAnchor>
<div ref="dropdownViewport" class="w-60 max-h-60 overflow-y-scroll"> <ComboboxInput
<div ref="searchInput"
v-if=" 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"
searchValue.length > 0 && filteredClients.length === 0 placeholder="Search for a client..." />
" </ComboboxAnchor>
class="bg-card-background-active" <ComboboxContent>
@click="addClientIfNoneExists"> <ComboboxViewport
<div class="w-60 max-h-60 overflow-y-scroll">
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"> <ComboboxItem
<PlusCircleIcon :value="{ id: null, name: 'No Client' }"
class="w-5 flex-shrink-0"></PlusCircleIcon> class="data-[highlighted]:bg-card-background-active">
<span>Add "{{ searchValue }}" as a new Client</span> <ClientDropdownItem
</div> :selected="model === null"
</div> name="No Client" />
<div v-else></div> </ComboboxItem>
<div <ComboboxItem
v-for="client in filteredClients" v-for="client in filteredClients"
:key="client.id" :key="client.id"
role="option" :value="client"
:value="client.id" class="data-[highlighted]:bg-card-background-active"
:class="{ :data-client-id="client.id">
'bg-card-background-active': <ClientDropdownItem
client.id === highlightedItemId, :selected="isClientSelected(client.id)"
}" :name="client.name" />
data-testid="client_dropdown_entries" </ComboboxItem>
:data-client-id="client.id"> <div
<ClientDropdownItem v-if="
:selected="isClientSelected(client.id)" searchValue.length > 0 &&
:name="client.name" filteredClients.length === 0
@click="updateClient(client.id)"></ClientDropdownItem> "
</div> class="bg-card-background-active">
</div> <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> </template>
</Dropdown> </Dropdown>
</template> </template>

View File

@@ -1,11 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import TextInput from '@/packages/ui/src/Input/TextInput.vue'; import { ref } from 'vue';
import {
formatCents,
getOrganizationCurrencySymbol,
} from '@/packages/ui/src/utils/money';
import { ref, watch } from 'vue';
import { useFocus } from '@vueuse/core'; import { useFocus } from '@vueuse/core';
import {
NumberField,
NumberFieldContent,
NumberFieldDecrement,
NumberFieldIncrement,
NumberFieldInput,
} from '@/Components/ui/number-field';
const props = defineProps<{ const props = defineProps<{
name: string; name: string;
@@ -20,76 +22,32 @@ const model = defineModel<number | null>({
const billableRateInput = ref<HTMLInputElement | null>(null); const billableRateInput = ref<HTMLInputElement | null>(null);
useFocus(billableRateInput, { initialValue: props.focus }); 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) { function formatValue(modelValue: number | null) {
const formattedValue = formatCents(modelValue ?? 0, props.currency); return modelValue ? modelValue / 100 : 0;
return formattedValue
.replace(getOrganizationCurrencySymbol(props.currency), '')
.trim();
} }
watch(model, (newValue) => {
inputValue.value = formatValue(newValue);
});
const inputValue = ref(formatValue(model.value));
</script> </script>
<template> <template>
<div class="relative"> <div class="relative">
<TextInput <NumberField
:id="name" :id="name"
ref="billableRateInput" ref="billableRateInput"
v-model="inputValue" :model-value="formatValue(model)"
type="text" :step-snapping="false"
:name="name"
placeholder="Billable Rate"
class="block w-full" class="block w-full"
autocomplete="teamMemberRate" :format-options="{
@blur="updateRate($event.target.value)" style: 'currency',
@keydown.enter="updateRate($event.target.value)" /> currency: currency,
<div currencyDisplay: 'code',
class="absolute top-0 right-0 h-full flex items-center px-4 font-medium pointer-events-none"> currencySign: 'accounting',
<span> }"
{{ currency }} @update:model-value="(value) => (model = value * 100)">
</span> <NumberFieldContent>
</div> <NumberFieldDecrement />
<NumberFieldInput placeholder="Billable Rate" />
<NumberFieldIncrement />
</NumberFieldContent>
</NumberField>
</div> </div>
</template> </template>

View File

@@ -1,155 +1,259 @@
<script setup lang="ts"> <script setup lang="ts">
import { CalendarIcon } from '@heroicons/vue/20/solid';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
import { import {
formatDateLocalized, Popover,
PopoverContent,
PopoverTrigger,
} from '@/Components/ui/popover';
import { RangeCalendar } from '@/Components/ui/range-calendar';
import {
CalendarDate,
getLocalTimeZone,
} from '@internationalized/date';
import { CalendarIcon } from 'lucide-vue-next';
import { computed, ref, inject, type ComputedRef, watch } from 'vue';
import { twMerge } from 'tailwind-merge';
import {
getDayJsInstance, getDayJsInstance,
getLocalizedDayJs, getLocalizedDayJs,
} from '@/packages/ui/src/utils/time'; } from '@/packages/ui/src/utils/time';
import { ref } from 'vue'; import { formatDateLocalized } from '@/packages/ui/src/utils/time';
import { type Organization } from '@/packages/api/src';
const start = defineModel('start', { default: '' }); const props = defineProps<{
const end = defineModel('end', { default: '' }); start: string;
end: string;
}>();
const emit = defineEmits(['submit']); const emit = defineEmits<{
(e: 'update:start', value: string): void;
(e: 'update:end', value: string): void;
(e: 'submit'): void;
}>();
interface CalendarDateRange {
start: CalendarDate | undefined;
end: CalendarDate | undefined;
}
const today = computed(() => {
const now = getDayJsInstance()();
return new CalendarDate(now.year(), now.month() + 1, now.date());
});
const modelValue = computed<CalendarDateRange>({
get: () => ({
start: props.start
? new CalendarDate(
getLocalizedDayJs(props.start).year(),
getLocalizedDayJs(props.start).month() + 1,
getLocalizedDayJs(props.start).date()
)
: undefined,
end: props.end
? new CalendarDate(
getLocalizedDayJs(props.end).year(),
getLocalizedDayJs(props.end).month() + 1,
getLocalizedDayJs(props.end).date()
)
: undefined,
}),
set: (newValue) => {
if (newValue.start) {
const date = newValue.start.toDate(getLocalTimeZone());
emit('update:start', getDayJsInstance()(date).format('YYYY-MM-DD'));
}
if (newValue.end) {
const date = newValue.end.toDate(getLocalTimeZone());
emit('update:end', getDayJsInstance()(date).format('YYYY-MM-DD'));
}
},
});
const open = ref(false); const open = ref(false);
function setToday() { function setToday() {
start.value = getLocalizedDayJs().startOf('day').format(); emit(
end.value = getLocalizedDayJs().endOf('day').format(); 'update:start',
emit('submit'); getLocalizedDayJs().startOf('day').format('YYYY-MM-DD')
);
emit('update:end', getLocalizedDayJs().endOf('day').format('YYYY-MM-DD'));
open.value = false; open.value = false;
} }
function setThisWeek() { function setThisWeek() {
start.value = getLocalizedDayJs().startOf('week').format(); emit(
end.value = getLocalizedDayJs().endOf('week').format(); 'update:start',
emit('submit'); getLocalizedDayJs().startOf('week').format('YYYY-MM-DD')
);
emit('update:end', getLocalizedDayJs().endOf('week').format('YYYY-MM-DD'));
open.value = false; open.value = false;
} }
function setLastWeek() { function setLastWeek() {
start.value = getLocalizedDayJs() emit(
.subtract(1, 'week') 'update:start',
.startOf('week') getLocalizedDayJs()
.format(); .subtract(1, 'week')
end.value = getLocalizedDayJs().subtract(1, 'week').endOf('week').format(); .startOf('week')
emit('submit'); .format('YYYY-MM-DD')
);
emit(
'update:end',
getLocalizedDayJs()
.subtract(1, 'week')
.endOf('week')
.format('YYYY-MM-DD')
);
open.value = false; open.value = false;
} }
function setLast14Days() { function setLast14Days() {
start.value = getLocalizedDayJs().subtract(14, 'days').format(); emit(
end.value = getLocalizedDayJs().format(); 'update:start',
emit('submit'); getLocalizedDayJs().subtract(14, 'days').format('YYYY-MM-DD')
);
emit('update:end', getLocalizedDayJs().format('YYYY-MM-DD'));
open.value = false; open.value = false;
} }
function setThisMonth() { function setThisMonth() {
start.value = getLocalizedDayJs().startOf('month').format(); emit(
end.value = getLocalizedDayJs().endOf('month').format(); 'update:start',
emit('submit'); getLocalizedDayJs().startOf('month').format('YYYY-MM-DD')
);
emit('update:end', getLocalizedDayJs().endOf('month').format('YYYY-MM-DD'));
open.value = false; open.value = false;
} }
function setLastMonth() { function setLastMonth() {
start.value = getLocalizedDayJs() emit(
.subtract(1, 'month') 'update:start',
.startOf('month') getLocalizedDayJs()
.format(); .subtract(1, 'month')
end.value = getLocalizedDayJs() .startOf('month')
.subtract(1, 'month') .format('YYYY-MM-DD')
.endOf('month') );
.format(); emit(
emit('submit'); 'update:end',
getLocalizedDayJs()
.subtract(1, 'month')
.endOf('month')
.format('YYYY-MM-DD')
);
open.value = false; open.value = false;
} }
function setLast30Days() { function setLast30Days() {
start.value = getLocalizedDayJs().subtract(30, 'days').format(); emit(
end.value = getLocalizedDayJs().format(); 'update:start',
emit('submit'); getLocalizedDayJs().subtract(30, 'days').format('YYYY-MM-DD')
);
emit('update:end', getLocalizedDayJs().format('YYYY-MM-DD'));
open.value = false; open.value = false;
} }
function setLast90Days() { function setLast90Days() {
start.value = getDayJsInstance()().subtract(90, 'days').format(); emit(
end.value = getDayJsInstance()().format(); 'update:start',
emit('submit'); getDayJsInstance()().subtract(90, 'days').format('YYYY-MM-DD')
);
emit('update:end', getDayJsInstance()().format('YYYY-MM-DD'));
open.value = false; open.value = false;
} }
function setLast12Months() { function setLast12Months() {
start.value = getLocalizedDayJs().subtract(12, 'months').format(); emit(
end.value = getLocalizedDayJs().format(); 'update:start',
emit('submit'); getLocalizedDayJs().subtract(12, 'months').format('YYYY-MM-DD')
);
emit('update:end', getLocalizedDayJs().format('YYYY-MM-DD'));
open.value = false; open.value = false;
} }
function setThisYear() { function setThisYear() {
start.value = getLocalizedDayJs().startOf('year').format(); emit(
end.value = getLocalizedDayJs().endOf('year').format(); 'update:start',
emit('submit'); getLocalizedDayJs().startOf('year').format('YYYY-MM-DD')
);
emit('update:end', getLocalizedDayJs().endOf('year').format('YYYY-MM-DD'));
open.value = false; open.value = false;
} }
function setLastYear() { function setLastYear() {
start.value = getLocalizedDayJs() emit(
.subtract(1, 'year') 'update:start',
.startOf('year') getLocalizedDayJs()
.format(); .subtract(1, 'year')
end.value = getLocalizedDayJs().subtract(1, 'year').endOf('year').format(); .startOf('year')
emit('submit'); .format('YYYY-MM-DD')
);
emit(
'update:end',
getLocalizedDayJs()
.subtract(1, 'year')
.endOf('year')
.format('YYYY-MM-DD')
);
open.value = false; open.value = false;
} }
const organization = inject<ComputedRef<Organization>>('organization');
watch(open, (value) => {
if (value === false) {
emit('submit');
}
});
</script> </script>
<template> <template>
<Dropdown <Popover v-model:open="open">
v-model="open" <PopoverTrigger as-child>
:close-on-content-click="false"
align="end"
@submit="emit('submit')">
<template #trigger>
<button <button
class="px-2 py-1 bg-input-background border border-input-border font-medium rounded-lg flex items-center space-x-2"> :class="
<CalendarIcon class="w-5"></CalendarIcon> twMerge(
<div class="text-text-primary"> 'flex w-full items-center justify-between whitespace-nowrap rounded-md border border-input-border bg-input-background px-3 h-[34px] shadow-sm data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:truncate text-start',
{{ formatDateLocalized(start) }} !modelValue && 'text-muted-foreground'
<span class="px-1.5 text-text-secondary">-</span> )
{{ formatDateLocalized(end) }} ">
</div> <CalendarIcon class="mr-2 h-4 w-4" />
<template v-if="modelValue.start">
<template v-if="modelValue.end">
{{ formatDateLocalized(modelValue.start.toString(), organization?.date_format) }}
-
{{ formatDateLocalized(modelValue.end.toString(), organization?.date_format) }}
</template>
<template v-else>
{{ formatDateLocalized(modelValue.start.toString(), organization?.date_format) }}
</template>
</template>
<template v-else> Pick a date </template>
</button> </button>
</template> </PopoverTrigger>
<template #content> <PopoverContent class="w-auto p-0">
<div class="overflow-hidden w-[330px] px-3 py-1.5"> <div class="flex divide-x divide-border-secondary">
<div <div
class="flex divide-x divide-border-secondary justify-between"> class="text-text-primary text-sm flex flex-col space-y-0.5 items-start py-2 px-2 [&_button:hover]:bg-tertiary [&_button]:rounded [&_button]:px-2 [&_button]:py-1">
<div <button @click="setToday">Today</button>
class="text-text-primary text-sm flex flex-col space-y-0.5 items-start py-2 [&_button:hover]:bg-tertiary [&_button]:rounded [&_button]:px-2 [&_button]:py-1"> <button @click="setThisWeek">This Week</button>
<button @click="setToday">Today</button> <button @click="setLastWeek">Last Week</button>
<button @click="setThisWeek">This Week</button> <button @click="setLast14Days">Last 14 days</button>
<button @click="setLastWeek">Last Week</button> <button @click="setThisMonth">This Month</button>
<button @click="setLast14Days">Last 14 days</button> <button @click="setLastMonth">Last Month</button>
<button @click="setThisMonth">This Month</button> <button @click="setLast30Days">Last 30 days</button>
<button @click="setLastMonth">Last Month</button> <button @click="setLast90Days">Last 90 days</button>
<button @click="setLast30Days">Last 30 days</button> <button @click="setLast12Months">Last 12 months</button>
<button @click="setLast90Days">Last 90 days</button> <button @click="setThisYear">This year</button>
<button @click="setLast12Months">Last 12 months</button> <button @click="setLastYear">Last year</button>
<button @click="setThisYear">This year</button> </div>
<button @click="setLastYear">Last year</button> <div class="pl-2">
</div> <RangeCalendar
<div class="pl-5"> v-model="modelValue"
<div class="space-y-1 flex-col flex items-start"> initial-focus
<div class="text-xs font-semibold text-text-secondary"> :number-of-months="2"
Start Date :max-value="today" />
</div>
<DatePicker v-model="start"></DatePicker>
</div>
<div class="mt-2 space-y-1 flex-col flex items-start">
<div class="text-sm font-medium text-text-secondary">
End Date
</div>
<DatePicker v-model="end"></DatePicker>
</div>
</div>
</div> </div>
</div> </div>
</template> </PopoverContent>
</Dropdown> </Popover>
</template> </template>
<style scoped></style>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import parse from 'parse-duration'; import parse from 'parse-duration';
import { onMounted, ref, watch } from 'vue'; import { onMounted, ref, watch, inject } from 'vue';
import { import {
formatHumanReadableDuration, formatHumanReadableDuration,
getDayJsInstance, getDayJsInstance,
@@ -8,6 +8,9 @@ import {
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import { TextInput } from '@/packages/ui/src'; import { TextInput } from '@/packages/ui/src';
import type { Organization } from '@/packages/api/src';
import { type ComputedRef } from 'vue';
const temporaryCustomTimerEntry = ref<string>(''); const temporaryCustomTimerEntry = ref<string>('');
const start = defineModel('start', { const start = defineModel('start', {
@@ -18,6 +21,8 @@ const end = defineModel('end', {
default: '', default: '',
}); });
const organization = inject<ComputedRef<Organization>>('organization');
function isHHMM(value: string): boolean { function isHHMM(value: string): boolean {
return HHMMtimeRegex.test(value); return HHMMtimeRegex.test(value);
} }
@@ -70,7 +75,11 @@ function updateTimeEntryInputValue() {
if (start.value && end.value) { if (start.value && end.value) {
const startTime = dayjs(start.value); const startTime = dayjs(start.value);
const diff = getDayJsInstance()(end.value).diff(startTime, 'seconds'); const diff = getDayJsInstance()(end.value).diff(startTime, 'seconds');
temporaryCustomTimerEntry.value = formatHumanReadableDuration(diff); temporaryCustomTimerEntry.value = formatHumanReadableDuration(
diff,
organization?.value?.interval_format,
organization?.value?.number_format
);
} }
} }
</script> </script>

View File

@@ -1,10 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatCents } from '@/packages/ui/src/utils/money'; import { formatCents } from '@/packages/ui/src/utils/money';
import BillableRateModal from '@/packages/ui/src/BillableRateModal.vue'; import BillableRateModal from '@/packages/ui/src/BillableRateModal.vue';
import { inject, type ComputedRef } from 'vue';
import type { Organization } from '@/packages/api/src';
const show = defineModel('show', { default: false }); const show = defineModel('show', { default: false });
const saving = defineModel('saving', { default: false }); const saving = defineModel('saving', { default: false });
const organization = inject<ComputedRef<Organization>>('organization');
defineProps<{ defineProps<{
newBillableRate?: number | null; newBillableRate?: number | null;
projectName: string; projectName: string;
@@ -26,7 +30,13 @@ defineEmits<{
The billable rate of {{ projectName }} will be updated to The billable rate of {{ projectName }} will be updated to
<strong>{{ <strong>{{
newBillableRate newBillableRate
? formatCents(newBillableRate, currency) ? formatCents(
newBillableRate,
currency,
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: ' the default rate of the organization member' : ' the default rate of the organization member'
}}</strong }}</strong
>. >.

View File

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

View File

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

View File

@@ -9,13 +9,14 @@ import type {
Task, Task,
TimeEntry, TimeEntry,
Client, Client,
Organization,
} from '@/packages/api/src'; } from '@/packages/api/src';
import TimeEntryDescriptionInput from '@/packages/ui/src/TimeEntry/TimeEntryDescriptionInput.vue'; import TimeEntryDescriptionInput from '@/packages/ui/src/TimeEntry/TimeEntryDescriptionInput.vue';
import TimeEntryRowTagDropdown from '@/packages/ui/src/TimeEntry/TimeEntryRowTagDropdown.vue'; import TimeEntryRowTagDropdown from '@/packages/ui/src/TimeEntry/TimeEntryRowTagDropdown.vue';
import TimeEntryMoreOptionsDropdown from '@/packages/ui/src/TimeEntry/TimeEntryMoreOptionsDropdown.vue'; import TimeEntryMoreOptionsDropdown from '@/packages/ui/src/TimeEntry/TimeEntryMoreOptionsDropdown.vue';
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue'; import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import BillableToggleButton from '@/packages/ui/src/Input/BillableToggleButton.vue'; import BillableToggleButton from '@/packages/ui/src/Input/BillableToggleButton.vue';
import { ref } from 'vue'; import { ref, inject, type ComputedRef } from 'vue';
import { import {
formatHumanReadableDuration, formatHumanReadableDuration,
formatStartEnd, formatStartEnd,
@@ -24,7 +25,7 @@ import TimeEntryRow from '@/packages/ui/src/TimeEntry/TimeEntryRow.vue';
import GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue'; import GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue';
import type { TimeEntriesGroupedByType } from '@/types/time-entries'; import type { TimeEntriesGroupedByType } from '@/types/time-entries';
import { Checkbox } from '@/packages/ui/src'; import { Checkbox } from '@/packages/ui/src';
import { twMerge } from 'tailwind-merge';
const props = defineProps<{ const props = defineProps<{
timeEntry: TimeEntriesGroupedByType; timeEntry: TimeEntriesGroupedByType;
projects: Project[]; projects: Project[];
@@ -48,6 +49,8 @@ const emit = defineEmits<{
unselected: [TimeEntry[]]; unselected: [TimeEntry[]];
}>(); }>();
const organization = inject<ComputedRef<Organization>>('organization');
function updateTimeEntryDescription(description: string) { function updateTimeEntryDescription(description: string) {
props.updateTimeEntries( props.updateTimeEntries(
props.timeEntry.timeEntries.map((timeEntry: TimeEntry) => timeEntry.id), props.timeEntry.timeEntries.map((timeEntry: TimeEntry) => timeEntry.id),
@@ -113,10 +116,10 @@ function onSelectChange(checked: boolean) {
</GroupedItemsCountButton> </GroupedItemsCountButton>
<TimeEntryDescriptionInput <TimeEntryDescriptionInput
class="min-w-0 mr-4" class="min-w-0 mr-4"
:model-value=" :model-value="timeEntry.description"
timeEntry.description @changed="
" updateTimeEntryDescription
@changed="updateTimeEntryDescription"></TimeEntryDescriptionInput> "></TimeEntryDescriptionInput>
<TimeTrackerProjectTaskDropdown <TimeTrackerProjectTaskDropdown
:clients :clients
:create-project :create-project
@@ -128,10 +131,10 @@ function onSelectChange(checked: boolean) {
:project="timeEntry.project_id" :project="timeEntry.project_id"
:enable-estimated-time :enable-estimated-time
:currency="currency" :currency="currency"
:task=" :task="timeEntry.task_id"
timeEntry.task_id @changed="
" updateProjectAndTask
@changed="updateProjectAndTask"></TimeTrackerProjectTaskDropdown> "></TimeTrackerProjectTaskDropdown>
</div> </div>
</div> </div>
<div class="flex items-center font-medium lg:space-x-2"> <div class="flex items-center font-medium lg:space-x-2">
@@ -139,7 +142,9 @@ function onSelectChange(checked: boolean) {
:create-tag :create-tag
:tags="tags" :tags="tags"
:model-value="timeEntry.tags" :model-value="timeEntry.tags"
@changed="updateTimeEntryTags"></TimeEntryRowTagDropdown> @changed="
updateTimeEntryTags
"></TimeEntryRowTagDropdown>
<BillableToggleButton <BillableToggleButton
:model-value="timeEntry.billable" :model-value="timeEntry.billable"
class="opacity-50 focus-visible:opacity-100 group-hover:opacity-100" class="opacity-50 focus-visible:opacity-100 group-hover:opacity-100"
@@ -149,23 +154,29 @@ function onSelectChange(checked: boolean) {
"></BillableToggleButton> "></BillableToggleButton>
<div class="flex-1"> <div class="flex-1">
<button <button
class="hidden lg:block text-text-secondary w-[110px] px-1 py-1.5 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary" :class="twMerge('hidden lg:block text-text-secondary w-[110px] px-1 py-1.5 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary', organization?.time_format === '12-hours' ? 'w-[160px]' : 'w-[110px]')"
@click="expanded = !expanded"> @click="expanded = !expanded">
{{ formatStartEnd(timeEntry.start, timeEntry.end) }} {{ formatStartEnd(timeEntry.start, timeEntry.end, organization?.time_format) }}
</button> </button>
</div> </div>
<button <button
class="text-text-primary min-w-[90px] px-2 py-1.5 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-semibold focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary" class="text-text-primary min-w-[90px] px-2.5 py-1.5 bg-transparent text-right hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-semibold focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary"
@click="expanded = !expanded"> @click="expanded = !expanded">
{{ {{
formatHumanReadableDuration(timeEntry.duration ?? 0) formatHumanReadableDuration(
timeEntry.duration ?? 0,
organization?.interval_format,
organization?.number_format
)
}} }}
</button> </button>
<TimeTrackerStartStop <TimeTrackerStartStop
:active="!!(timeEntry.start && !timeEntry.end)" :active="!!(timeEntry.start && !timeEntry.end)"
class="opacity-20 hidden sm:flex group-hover:opacity-100 focus-visible:opacity-100" class="opacity-20 hidden sm:flex group-hover:opacity-100 focus-visible:opacity-100"
@changed="onStartStopClick(timeEntry)"></TimeTrackerStartStop> @changed="
onStartStopClick(timeEntry)
"></TimeTrackerStartStop>
<TimeEntryMoreOptionsDropdown <TimeEntryMoreOptionsDropdown
@delete=" @delete="
deleteTimeEntries(timeEntry?.timeEntries ?? []) deleteTimeEntries(timeEntry?.timeEntries ?? [])

View File

@@ -230,7 +230,8 @@ type BillableOption = {
<div class="space-y-2 mt-1 flex flex-col"> <div class="space-y-2 mt-1 flex flex-col">
<DurationHumanInput <DurationHumanInput
v-model:start="localStart" v-model:start="localStart"
v-model:end="localEnd"></DurationHumanInput> v-model:end="localEnd"
name="Duration"></DurationHumanInput>
<div class="text-sm flex space-x-1"> <div class="text-sm flex space-x-1">
<InformationCircleIcon <InformationCircleIcon
class="w-4 text-text-quaternary"></InformationCircleIcon> class="w-4 text-text-quaternary"></InformationCircleIcon>

View File

@@ -1,12 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue'; import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import { defineProps, ref } from 'vue'; import { defineProps, ref, inject, type ComputedRef } from 'vue';
import { import {
formatDateLocalized, formatDateLocalized,
formatStartEnd, formatStartEnd,
} from '@/packages/ui/src/utils/time'; } from '@/packages/ui/src/utils/time';
import TimeRangeSelector from '@/packages/ui/src/Input/TimeRangeSelector.vue'; import TimeRangeSelector from '@/packages/ui/src/Input/TimeRangeSelector.vue';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import type { Organization } from '@/packages/api/src';
defineProps<{ defineProps<{
start: string; start: string;
@@ -20,6 +21,9 @@ const emit = defineEmits<{
const open = ref(false); const open = ref(false);
const triggerElement = ref<HTMLButtonElement | null>(null); const triggerElement = ref<HTMLButtonElement | null>(null);
const organization = inject<ComputedRef<Organization>>('organization');
</script> </script>
<template> <template>
@@ -35,16 +39,17 @@ const triggerElement = ref<HTMLButtonElement | null>(null);
data-testid="time_entry_range_selector" data-testid="time_entry_range_selector"
:class=" :class="
twMerge( twMerge(
'text-text-secondary w-[110px] px-2 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:text-text-primary focus-visible:ring-ring focus-visible:bg-tertiary', 'text-text-secondary px-2 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:text-text-primary focus-visible:ring-ring focus-visible:bg-tertiary',
showDate showDate
? 'text-xs py-1.5 font-semibold' ? 'text-xs py-1.5 font-semibold'
: 'text-sm py-1.5 font-medium', : 'text-sm py-1.5 font-medium',
organization?.time_format === '12-hours' ? 'w-[160px]' : 'w-[110px]',
open && 'border-card-border bg-card-background' open && 'border-card-border bg-card-background'
) )
"> ">
{{ formatStartEnd(start, end) }} {{ formatStartEnd(start, end, organization?.time_format) }}
<span v-if="showDate" class="text-text-tertiary font-medium" <span v-if="showDate" class="text-text-tertiary font-medium"
>{{ formatDateLocalized(start) }} >{{ formatDateLocalized(start, organization?.date_format) }}
</span> </span>
</button> </button>
</template> </template>

View File

@@ -2,10 +2,18 @@
import { import {
calculateDifference, calculateDifference,
formatHumanReadableDuration, formatHumanReadableDuration,
parseTimeInput,
} from '@/packages/ui/src/utils/time'; } from '@/packages/ui/src/utils/time';
import { computed, defineProps, ref } from 'vue'; import { computed, defineProps, ref, inject, type ComputedRef } from 'vue';
import parse from 'parse-duration';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import type { Organization } from '@/packages/api/src';
const organization = inject<ComputedRef<Organization>>('organization');
const organizationSettings = computed(() => ({
intervalFormat: organization?.value?.interval_format ?? 'hours-minutes',
numberFormat: organization?.value?.number_format ?? 'point',
}));
const props = defineProps<{ const props = defineProps<{
start: string; start: string;
@@ -19,15 +27,22 @@ const temporaryCustomTimerEntry = ref<string>('');
const open = ref(false); const open = ref(false);
function updateTimerAndStartLiveTimerUpdate() { function updateTimerAndStartLiveTimerUpdate() {
const time = parse(temporaryCustomTimerEntry.value, 's'); const defaultUnit =
if (time && time > 0) { organizationSettings?.value?.intervalFormat === 'decimal'
? 'hours'
: 'minutes';
const { seconds } = parseTimeInput(
temporaryCustomTimerEntry.value,
defaultUnit
);
if (seconds && seconds > 0) {
let newEndDate = props.end; let newEndDate = props.end;
let newStartDate = props.start; let newStartDate = props.start;
if (props.end) { if (props.end) {
// only update end for time entries that are already finished // only update end for time entries that are already finished
newEndDate = dayjs(props.start).utc().add(time, 's').format(); newEndDate = dayjs(props.start).utc().add(seconds, 's').format();
} else { } else {
newStartDate = dayjs().utc().subtract(time, 's').format(); newStartDate = dayjs().utc().subtract(seconds, 's').format();
} }
emit('changed', newStartDate, newEndDate); emit('changed', newStartDate, newEndDate);
} }
@@ -40,7 +55,9 @@ const currentTime = computed({
return temporaryCustomTimerEntry.value; return temporaryCustomTimerEntry.value;
} }
return formatHumanReadableDuration( return formatHumanReadableDuration(
calculateDifference(props.start, props.end) calculateDifference(props.start, props.end),
organizationSettings.value.intervalFormat,
organizationSettings.value.numberFormat
); );
}, },
// setter // setter
@@ -64,7 +81,8 @@ function selectInput(event: Event) {
<input <input
v-model="currentTime" v-model="currentTime"
data-testid="time_entry_duration_input" data-testid="time_entry_duration_input"
class="text-text-primary w-[90px] px-2 py-1.5 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-semibold focus-visible:bg-tertiary focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring" name="Duration"
class="text-text-primary w-[90px] px-2.5 py-1.5 bg-transparent text-right hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-semibold focus-visible:bg-tertiary focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring"
@focus="selectInput" @focus="selectInput"
@keydown.tab="open = false" @keydown.tab="open = false"
@blur="updateTimerAndStartLiveTimerUpdate" @blur="updateTimerAndStartLiveTimerUpdate"

View File

@@ -6,6 +6,11 @@ import {
formatWeekday, formatWeekday,
} from '@/packages/ui/src/utils/time'; } from '@/packages/ui/src/utils/time';
import Checkbox from '../Input/Checkbox.vue'; import Checkbox from '../Input/Checkbox.vue';
import { inject, type ComputedRef } from 'vue';
import type { Organization } from '@/packages/api/src';
const organization = inject<ComputedRef<Organization>>('organization');
defineProps<{ defineProps<{
date: string; date: string;
duration: number; duration: number;
@@ -53,12 +58,18 @@ function selectUnselectAll(value: boolean) {
{{ formatWeekday(date) }} {{ formatWeekday(date) }}
</span> </span>
<span class="font-semibold text-text-secondary"> <span class="font-semibold text-text-secondary">
{{ formatDate(date) }} {{ formatDate(date, organization?.date_format) }}
</span> </span>
</div> </div>
<div class="text-text-secondary pr-[90px] lg:pr-[92px]"> <div class="text-text-secondary pr-[90px] lg:pr-[92px]">
<span class="font-semibold"> <span class="font-semibold">
{{ formatHumanReadableDuration(duration) }} {{
formatHumanReadableDuration(
duration,
organization?.interval_format,
organization?.number_format
)
}}
</span> </span>
</div> </div>
</div> </div>

View File

@@ -3,8 +3,11 @@ import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import TimeRangeSelector from '@/packages/ui/src/Input/TimeRangeSelector.vue'; import TimeRangeSelector from '@/packages/ui/src/Input/TimeRangeSelector.vue';
import dayjs, { Dayjs } from 'dayjs'; import dayjs, { Dayjs } from 'dayjs';
import parse from 'parse-duration'; import {
import { formatDuration, getDayJsInstance } from '@/packages/ui/src/utils/time'; formatDuration,
getDayJsInstance,
parseTimeInput,
} from '@/packages/ui/src/utils/time';
import type { TimeEntry } from '@/packages/api/src'; import type { TimeEntry } from '@/packages/api/src';
const currentTimeEntry = defineModel<TimeEntry>('currentTimeEntry', { const currentTimeEntry = defineModel<TimeEntry>('currentTimeEntry', {
@@ -28,6 +31,7 @@ function pauseLiveTimerUpdate(event: FocusEvent) {
function onTimeEntryEnterPress() { function onTimeEntryEnterPress() {
updateTimerAndStartLiveTimerUpdate(); updateTimerAndStartLiveTimerUpdate();
open.value = false;
const activeElement = document.activeElement as HTMLElement; const activeElement = document.activeElement as HTMLElement;
activeElement?.blur(); activeElement?.blur();
} }
@@ -55,36 +59,13 @@ const currentTime = computed({
}); });
function updateTimerAndStartLiveTimerUpdate() { function updateTimerAndStartLiveTimerUpdate() {
const time = parse(temporaryCustomTimerEntry.value, 's'); const { seconds } = parseTimeInput(
temporaryCustomTimerEntry.value,
'minutes'
);
if (isNumeric(temporaryCustomTimerEntry.value)) { if (seconds && seconds > 0) {
const newStartDate = dayjs().subtract( const newStartDate = dayjs().subtract(seconds, 's');
parseInt(temporaryCustomTimerEntry.value),
'm'
);
currentTimeEntry.value.start = newStartDate.utc().format();
if (currentTimeEntry.value.id !== '') {
emit('updateTimer');
} else {
emit('startTimer');
}
} else if (isHHMM(temporaryCustomTimerEntry.value)) {
const results = parseHHMM(temporaryCustomTimerEntry.value);
if (results) {
const newStartDate = dayjs()
.subtract(parseInt(results[1]), 'h')
.subtract(parseInt(results[2]), 'm');
currentTimeEntry.value.start = newStartDate.utc().format();
if (currentTimeEntry.value.id !== '') {
emit('updateTimer');
} else {
emit('startTimer');
}
}
}
// try to parse natural language like "1h 30m"
else if (time && time > 1) {
const newStartDate = dayjs().subtract(time, 's');
currentTimeEntry.value.start = newStartDate.utc().format(); currentTimeEntry.value.start = newStartDate.utc().format();
if (currentTimeEntry.value.id !== '') { if (currentTimeEntry.value.id !== '') {
emit('updateTimer'); emit('updateTimer');
@@ -92,26 +73,11 @@ function updateTimerAndStartLiveTimerUpdate() {
emit('startTimer'); emit('startTimer');
} }
} }
// fallback to minutes if just a number is given
now.value = dayjs().utc(); now.value = dayjs().utc();
temporaryCustomTimerEntry.value = ''; temporaryCustomTimerEntry.value = '';
emit('startLiveTimer'); emit('startLiveTimer');
} }
function isNumeric(value: string) {
return /^-?\d+$/.test(value);
}
const HHMMtimeRegex = /^([0-9]{1,2}):([0-5]?[0-9])$/;
function isHHMM(value: string): boolean {
return HHMMtimeRegex.test(value);
}
function parseHHMM(value: string): string[] | null {
return value.match(HHMMtimeRegex);
}
const temporaryCustomTimerEntry = ref<string>(''); const temporaryCustomTimerEntry = ref<string>('');
async function updateTimeRange(newStart: string) { async function updateTimeRange(newStart: string) {
@@ -161,8 +127,8 @@ function focusNextElement(e: KeyboardEvent) {
} }
function closeAndFocusInput() { function closeAndFocusInput() {
inputField.value?.focus();
open.value = false; open.value = false;
inputField.value?.focus();
} }
</script> </script>
@@ -173,7 +139,7 @@ function closeAndFocusInput() {
align="center" align="center"
:auto-focus="false" :auto-focus="false"
:close-on-content-click="false" :close-on-content-click="false"
@submit="open = false"> @submit="closeAndFocusInput">
<template #trigger> <template #trigger>
<input <input
ref="inputField" ref="inputField"

View File

@@ -1,12 +1,52 @@
function formatMoney(amount: number, currency: string) { import { formatNumber, type NumberFormat } from './number';
return new Intl.NumberFormat('de-DE', {
style: 'currency', export type CurrencyFormat =
currency: currency, | 'iso-code-before-with-space'
}).format(amount); | 'iso-code-after-with-space'
| 'symbol-before'
| 'symbol-after'
| 'symbol-before-with-space'
| 'symbol-after-with-space';
function formatMoney(
amount: number,
currency?: string,
format?: CurrencyFormat,
currencySymbol?: string,
numberFormat?: NumberFormat
) {
const formattedAmount = formatNumber(amount, numberFormat);
switch (format) {
case 'iso-code-before-with-space':
return `${currency} ${formattedAmount}`;
case 'iso-code-after-with-space':
return `${formattedAmount} ${currency}`;
case 'symbol-before':
return `${currencySymbol}${formattedAmount}`;
case 'symbol-after':
return `${formattedAmount}${currencySymbol}`;
case 'symbol-before-with-space':
return `${currencySymbol} ${formattedAmount}`;
case 'symbol-after-with-space':
return `${formattedAmount} ${currencySymbol}`;
}
} }
export function formatCents(amount: number, currency: string) { export function formatCents(
return formatMoney(amount / 100, currency); amount: number,
currency?: string,
format?: CurrencyFormat,
currencySymbol?: string,
numberFormat?: NumberFormat
) {
return formatMoney(
amount / 100,
currency,
format,
currencySymbol,
numberFormat
);
} }
export function getOrganizationCurrencySymbol(currency: string) { export function getOrganizationCurrencySymbol(currency: string) {

View File

@@ -0,0 +1,41 @@
export type NumberFormat =
| 'point-comma'
| 'comma-point'
| 'space-comma'
| 'space-point'
| 'apostrophe-point';
/**
* Formats a number according to the specified format
* @param value - The number to format
* @param format - The format to use
* @returns The formatted number as a string
*/
export function formatNumber(value: number, format?: string): string {
// Convert to fixed 2 decimal places first
const parts = value.toFixed(2).split('.');
const wholePart = parts[0];
const decimalPart = parts[1];
// Format the whole number part based on the format
let formattedWhole: string;
switch (format) {
case 'point-comma':
formattedWhole = wholePart.replace(/\B(?=(\d{3})+(?!\d))/g, '.');
return `${formattedWhole},${decimalPart}`;
case 'comma-point':
formattedWhole = wholePart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return `${formattedWhole}.${decimalPart}`;
case 'space-comma':
formattedWhole = wholePart.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
return `${formattedWhole},${decimalPart}`;
case 'space-point':
formattedWhole = wholePart.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
return `${formattedWhole}.${decimalPart}`;
case 'apostrophe-point':
formattedWhole = wholePart.replace(/\B(?=(\d{3})+(?!\d))/g, "'");
return `${formattedWhole}.${decimalPart}`;
default:
return value.toString();
}
}

View File

@@ -6,10 +6,38 @@ import isYesterday from 'dayjs/plugin/isYesterday';
import utc from 'dayjs/plugin/utc'; import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone'; import timezone from 'dayjs/plugin/timezone';
import weekOfYear from 'dayjs/plugin/weekOfYear'; import weekOfYear from 'dayjs/plugin/weekOfYear';
import parse from 'parse-duration';
import { getUserTimezone, getWeekStart } from './settings'; import { getUserTimezone, getWeekStart } from './settings';
import updateLocale from 'dayjs/plugin/updateLocale'; import updateLocale from 'dayjs/plugin/updateLocale';
import { computed } from 'vue'; import { computed } from 'vue';
import { formatNumber } from './number';
export type DateFormat =
| 'point-separated-d-m-yyyy'
| 'slash-separated-mm-dd-yyyy'
| 'slash-separated-dd-mm-yyyy'
| 'hyphen-separated-dd-mm-yyyy'
| 'hyphen-separated-mm-dd-yyyy'
| 'hyphen-separated-yyyy-mm-dd';
const dateFormatMap: Record<DateFormat, string> = {
'point-separated-d-m-yyyy': 'D.M.YYYY',
'slash-separated-mm-dd-yyyy': 'MM/DD/YYYY',
'slash-separated-dd-mm-yyyy': 'DD/MM/YYYY',
'hyphen-separated-dd-mm-yyyy': 'DD-MM-YYYY',
'hyphen-separated-mm-dd-yyyy': 'MM-DD-YYYY',
'hyphen-separated-yyyy-mm-dd': 'YYYY-MM-DD'
};
export type TimeFormat = '12-hours' | '24-hours';
export type IntervalFormat =
| 'decimal'
| 'hours-minutes'
| 'hours-minutes-colon-separated'
| 'hours-minutes-seconds-colon-separated';
export type TimeInputUnit = 'minutes' | 'hours';
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
dayjs.extend(isToday); dayjs.extend(isToday);
@@ -40,11 +68,28 @@ export const firstDayIndex = computed(() => {
return apiDayOrder.indexOf(getWeekStart()); return apiDayOrder.indexOf(getWeekStart());
}); });
export function formatHumanReadableDuration(duration: number): string { export function formatHumanReadableDuration(
duration: number,
intervalFormat?: string,
numberFormat?: string
): string {
const dayJsDuration = dayjs.duration(duration, 's'); const dayJsDuration = dayjs.duration(duration, 's');
const hours = Math.floor(dayJsDuration.asHours()); const hours = Math.floor(dayJsDuration.asHours());
const minutes = dayJsDuration.minutes(); const minutes = dayJsDuration.minutes();
return `${hours}h ${minutes.toString().padStart(2, '0')}min`; const seconds = dayJsDuration.seconds();
switch (intervalFormat) {
case 'decimal':
return formatNumber(dayJsDuration.asHours(), numberFormat) + ' h';
case 'hours-minutes':
return `${hours}h ${minutes.toString().padStart(2, '0')}min`;
case 'hours-minutes-colon-separated':
return `${hours}:${minutes.toString().padStart(2, '0')}`;
case 'hours-minutes-seconds-colon-separated':
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
default:
return `${hours}h ${minutes.toString().padStart(2, '0')}min`;
}
} }
export function formatDuration(duration: number): string { export function formatDuration(duration: number): string {
@@ -65,9 +110,11 @@ export function calculateDifference(start: string, end: string | null) {
/** /**
* Returns a formatted time. * Returns a formatted time.
* @param date - A UTC date time string. * @param date - A UTC date time string.
* @param timeFormat - The time format to use ('12-hours' or '24-hours')
*/ */
export function formatTime(date: string) { export function formatTime(date: string, timeFormat: TimeFormat = '24-hours') {
return dayjs.utc(date).tz(getUserTimezone()).format('HH:mm'); const format = timeFormat === '12-hours' ? 'hh:mm A' : 'HH:mm';
return dayjs.utc(date).tz(getUserTimezone()).format(format);
} }
export function getLocalizedDayJs(timestamp?: string | null) { export function getLocalizedDayJs(timestamp?: string | null) {
@@ -82,21 +129,21 @@ export function getLocalizedDateFromTimestamp(timestamp: string) {
* Returns a formatted date. * Returns a formatted date.
* @param date - date in the format of 'YYYY-MM-DD' * @param date - date in the format of 'YYYY-MM-DD'
*/ */
export function formatDate(date: string): string { export function formatDate(date: string, format: DateFormat = 'point-separated-d-m-yyyy'): string {
if (date?.includes('+')) { if (date?.includes('+')) {
console.warn( console.warn(
'Date contains timezone information, use formatDateLocalized instead' 'Date contains timezone information, use formatDateLocalized instead'
); );
} }
return getDayJsInstance()(date).format('DD.MM.YYYY'); return getDayJsInstance()(date).format(dateFormatMap[format]);
} }
/* /*
* Returns a formatted date. * Returns a formatted date.
* @param date - date in the format of 'YYYY-MM-DD' * @param date - date in the format of 'YYYY-MM-DD'
*/ */
export function formatDateLocalized(date: string): string { export function formatDateLocalized(date: string, format: DateFormat = 'point-separated-d-m-yyyy'): string {
return getLocalizedDayJs(date).format('DD.MM.YYYY'); return getLocalizedDayJs(date).format(dateFormatMap[format]);
} }
export function formatDateTimeLocalized(date: string): string { export function formatDateTimeLocalized(date: string): string {
@@ -124,10 +171,51 @@ export function formatWeekday(date: string) {
return dayjs(date).format('dddd'); return dayjs(date).format('dddd');
} }
export function formatStartEnd(start: string, end: string | null) { export function formatStartEnd(start: string, end: string | null, timeFormat: TimeFormat = '24-hours') {
if (end) { if (end) {
return `${formatTime(start)} - ${formatTime(end)}`; return `${formatTime(start, timeFormat)} - ${formatTime(end, timeFormat)}`;
} else { } else {
return `${formatTime(start)} - ...`; return `${formatTime(start, timeFormat)} - ...`;
} }
} }
export function parseTimeInput(
input: string,
defaultUnit: TimeInputUnit = 'minutes'
): {
seconds: number | null;
isHHMM: boolean;
} {
// Check if input is a decimal number (hours)
const decimalRegex = /^-?\d+[.,]\d+$/;
if (decimalRegex.test(input)) {
const hours = parseFloat(input.replace(',', '.'));
return { seconds: Math.round(hours * 3600), isHHMM: false };
}
// Check if input is just a number (minutes or hours based on defaultUnit)
if (/^-?\d+$/.test(input)) {
const value = parseInt(input);
const seconds = defaultUnit === 'minutes' ? value * 60 : value * 3600;
return { seconds, isHHMM: false };
}
// Check if input is in HH:MM format
const HHMMtimeRegex = /^([0-9]{1,2}):([0-5]?[0-9])$/;
if (HHMMtimeRegex.test(input)) {
const match = input.match(HHMMtimeRegex);
if (match) {
const hours = parseInt(match[1]);
const minutes = parseInt(match[2]);
return { seconds: (hours * 60 + minutes) * 60, isHHMM: true };
}
}
// Try to parse natural language like "1h 30m"
const parsedDuration = parse(input, 's');
if (parsedDuration && parsedDuration > 0) {
return { seconds: parsedDuration, isHHMM: false };
}
return { seconds: null, isHHMM: false };
}

View File

@@ -14,6 +14,7 @@ import {
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useNotificationsStore } from '@/utils/notification'; import { useNotificationsStore } from '@/utils/notification';
import type { UpdateMultipleTimeEntriesChangeset } from '@/packages/api/src'; import type { UpdateMultipleTimeEntriesChangeset } from '@/packages/api/src';
import { useQueryClient } from "@tanstack/vue-query";
export const useTimeEntriesStore = defineStore('timeEntries', () => { export const useTimeEntriesStore = defineStore('timeEntries', () => {
const timeEntries = ref<TimeEntry[]>(reactive([])); const timeEntries = ref<TimeEntry[]>(reactive([]));
@@ -21,6 +22,8 @@ export const useTimeEntriesStore = defineStore('timeEntries', () => {
const allTimeEntriesLoaded = ref(false); const allTimeEntriesLoaded = ref(false);
const { handleApiRequestNotifications } = useNotificationsStore(); const { handleApiRequestNotifications } = useNotificationsStore();
const queryClient = useQueryClient();
async function patchTimeEntries( async function patchTimeEntries(
queryParams: TimeEntriesQueryParams = { queryParams: TimeEntriesQueryParams = {
only_full_dates: 'true', only_full_dates: 'true',
@@ -157,6 +160,7 @@ export const useTimeEntriesStore = defineStore('timeEntries', () => {
timeEntries.value = timeEntries.value.map((entry) => timeEntries.value = timeEntries.value.map((entry) =>
entry.id === timeEntry.id ? response.data : entry entry.id === timeEntry.id ? response.data : entry
); );
queryClient.invalidateQueries({queryKey: ['timeEntry']});
} }
} }

View File

@@ -1,12 +1,12 @@
@use('Brick\Math\BigDecimal') @use('Brick\Math\BigDecimal')
@use('Brick\Money\Money') @use('Brick\Money\Money')
@use('PhpOffice\PhpSpreadsheet\Cell\DataType') @use('Illuminate\Support\Carbon')
@use('Carbon\CarbonInterval') @use('Carbon\CarbonInterval')
@inject('colorService', 'App\Service\ColorService') @inject('colorService', 'App\Service\ColorService')
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8"/>
<title>Report</title> <title>Report</title>
<style> <style>
html, body, div, span, applet, object, iframe, html, body, div, span, applet, object, iframe,
@@ -130,7 +130,7 @@
@if($debug) @if($debug)
<link rel="preconnect" href="https://fonts.bunny.net"> <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 @endif
</head> </head>
@@ -356,7 +356,12 @@
animation: false, animation: false,
tooltip: {}, tooltip: {},
xAxis: { 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: { axisLabel: {
fontSize: 10, fontSize: 10,
fontWeight: 400, fontWeight: 400,
@@ -381,26 +386,16 @@
axisLabel: { axisLabel: {
show: false, show: false,
inside: true, 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: [ series: [
{ {
name: "time", name: "time",
type: "bar", 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: { itemStyle: {
borderColor: "#7dd3fc", borderColor: "#7dd3fc",
color: "#7dd3fc" color: "#7dd3fc"
@@ -413,22 +408,8 @@
@endif @endif
fontSize: 10, fontSize: 10,
position: "top", position: "top",
formatter: function(params) { formatter: function (params) {
let value = params.value; return params.name;
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;
} }
} }
} }

Some files were not shown because too many files have changed in this diff Show More