Compare commits

..

1 Commits

Author SHA1 Message Date
Gregor Vostrak
692f7a725d add reporting tests for detailed, project filter, billable filter, tag filter 2025-05-05 17:23:01 +02:00
142 changed files with 1468 additions and 3599 deletions

View File

@@ -10,8 +10,6 @@ on:
- '.github/workflows/build-private.yml' - '.github/workflows/build-private.yml'
- 'docker/prod/**' - 'docker/prod/**'
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
name: Build - Private name: Build - Private
jobs: jobs:
@@ -19,7 +17,6 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20 timeout-minutes: 20
steps: steps:
- name: "Check out code" - name: "Check out code"
uses: actions/checkout@v4 uses: actions/checkout@v4

View File

@@ -11,12 +11,6 @@ on:
- 'docker/prod/**' - 'docker/prod/**'
workflow_dispatch: workflow_dispatch:
permissions:
packages: write
contents: read
attestations: write
id-token: write
env: env:
DOCKERHUB_REPO: solidtime/solidtime DOCKERHUB_REPO: solidtime/solidtime
GHCR_REPO: ghcr.io/solidtime-io/solidtime GHCR_REPO: ghcr.io/solidtime-io/solidtime
@@ -32,6 +26,11 @@ jobs:
- runs-on: "ubuntu-24.04" - runs-on: "ubuntu-24.04"
platform: "linux/amd64" platform: "linux/amd64"
runs-on: ${{ matrix.runs-on }} runs-on: ${{ matrix.runs-on }}
permissions:
packages: write
contents: read
attestations: write
id-token: write
timeout-minutes: 90 timeout-minutes: 90
steps: steps:
@@ -164,6 +163,11 @@ jobs:
merge: merge:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
packages: write
contents: read
attestations: write
id-token: write
timeout-minutes: 90 timeout-minutes: 90
needs: needs:
- build - build

View File

@@ -3,9 +3,6 @@ on:
push: push:
branches: branches:
- main - main
permissions:
contents: read
jobs: jobs:
api_docs: api_docs:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -1,8 +1,6 @@
name: NPM Build name: NPM Build
on: [push] on: [push]
permissions:
contents: read
jobs: jobs:
build: build:

View File

@@ -1,8 +1,6 @@
name: NPM Lint name: NPM Lint
on: [push] on: [push]
permissions:
contents: read
jobs: jobs:
build: build:

View File

@@ -1,8 +1,6 @@
name: Publish API package to NPM name: Publish API package to NPM
on: on:
workflow_dispatch workflow_dispatch
permissions:
contents: read
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -1,8 +1,6 @@
name: Publish UI package to NPM name: Publish UI package to NPM
on: on:
workflow_dispatch workflow_dispatch
permissions:
contents: read
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -1,8 +1,7 @@
name: NPM Typecheck name: NPM Typecheck
on: [push] on: [push]
permissions:
contents: read
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -1,7 +1,5 @@
name: Static code analysis (PHPStan) name: Static code analysis (PHPStan)
on: push on: push
permissions:
contents: read
jobs: jobs:
phpstan: phpstan:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -1,7 +1,5 @@
name: PHPUnit Tests name: PHPUnit Tests
on: push on: push
permissions:
contents: read
jobs: jobs:
phpunit: phpunit:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -1,7 +1,5 @@
name: PHP Linting name: PHP Linting
on: push on: push
permissions:
contents: read
jobs: jobs:
pint: pint:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -1,7 +1,5 @@
name: Playwright Tests name: Playwright Tests
on: [push] on: [push]
permissions:
contents: read
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -1,123 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\SelfHost;
use Illuminate\Console\Command;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class SelfHostDatabaseConsistency extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'self-host:database-consistency';
/**
* The console command description.
*
* @var string
*/
protected $description = '';
/**
* Execute the console command.
*/
public function handle(): int
{
$hadAProblem = false;
// Task need to be part of project in time entries
$problems = DB::table('time_entries')
->select(['time_entries.id as id'])
->join('tasks', 'time_entries.task_id', '=', 'tasks.id')
->where('tasks.project_id', '!=', DB::raw('time_entries.project_id'))
->get();
$this->logProblems($problems, 'Time entries have a task that does not belong to the project of the time entry', $hadAProblem);
// Client id is the client id of the project
$problems = DB::table('time_entries')
->select(['time_entries.id as id'])
->join('projects', 'time_entries.project_id', '=', 'projects.id')
->where(DB::raw('coalesce(projects.client_id::varchar, \'\')'), '!=', DB::raw('coalesce(time_entries.client_id::varchar, \'\')'))
->get();
$this->logProblems($problems, 'Time entries have a client that does not match the client of the project', $hadAProblem);
// Client id can only be not null if the project id is not null
$problems = DB::table('time_entries')
->select(['time_entries.id as id'])
->whereNotNull('client_id')
->whereNull('project_id')
->get();
$this->logProblems($problems, 'Time entries have a client but no project', $hadAProblem);
// Every user needs to be a member of at least one organization
$problems = DB::table('users')
->select(['users.id as id'])
->leftJoin('members', 'users.id', '=', 'members.user_id')
->whereNull('members.id')
->get();
$this->logProblems($problems, 'Users are not member of any organization', $hadAProblem);
// Every organization needs at least an owner
$problems = DB::table('organizations')
->select(['organizations.id as id'])
->leftJoin('members', function (JoinClause $join): void {
$join->on('organizations.id', '=', 'members.organization_id')
->where('members.role', '=', 'owner');
})
->whereNull('members.id')
->get();
$this->logProblems($problems, 'Organizations without an owner', $hadAProblem);
// Every member can only have one running time entry
$problems = DB::table('time_entries')
->select(['user_id as id'])
->whereNull('end')
->groupBy('user_id')
->havingRaw('count(*) > 1')
->get(['user_id', DB::raw('count(*) as count')]);
$this->logProblems($problems, 'Users with more than one running time entry', $hadAProblem);
// Users have a current organization that they are not a member of
$problems = DB::table('users')
->select(['users.id as id'])
->whereNotNull('current_team_id')
->whereNotIn('current_team_id', function (Builder $query): void {
$query->select('organization_id')
->from('members')
->whereColumn('members.user_id', 'users.id');
})->get();
$this->logProblems($problems, 'Users have a current organization that they are not a member of', $hadAProblem);
return $hadAProblem ? self::FAILURE : self::SUCCESS;
}
/**
* @param Collection<int, \stdClass> $problems
*/
private function logProblems(Collection $problems, string $message, bool &$hadAProblem): void
{
$message = 'Consistency problem: '.$message;
if ($problems->isNotEmpty()) {
$ids = $problems->pluck('id');
$hadAProblem = true;
Log::error($message, [
'ids' => $ids,
]);
$error = $message;
foreach ($ids as $id) {
$error .= "\n - ".$id;
}
$this->error($error);
}
}
}

View File

@@ -25,10 +25,6 @@ class Kernel extends ConsoleKernel
$schedule->command('self-host:telemetry') $schedule->command('self-host:telemetry')
->when(fn (): bool => config('scheduling.tasks.self_hosting_telemetry')) ->when(fn (): bool => config('scheduling.tasks.self_hosting_telemetry'))
->twiceDaily(); ->twiceDaily();
$schedule->command('self-host:database-consistency')
->when(fn (): bool => config('scheduling.tasks.self_hosting_database_consistency'))
->twiceDaily();
} }
/** /**

View File

@@ -10,26 +10,26 @@ enum DateFormat: string
{ {
use LaravelEnumHelper; use LaravelEnumHelper;
case PointSeparatedDMYYYY = 'point-separated-d-m-yyyy'; case PointSeperatedDMYYYY = 'point-seperated-d-m-yyyy';
case SlashSeparatedMMDDYYYY = 'slash-separated-mm-dd-yyyy'; case SlashSeperatedMMDDYYYY = 'slash-seperated-mm-dd-yyyy';
case SlashSeparatedDDMMYYYY = 'slash-separated-dd-mm-yyyy'; case SlashSeperatedDDMMYYYY = 'slash-seperated-dd-mm-yyyy';
case HyphenSeparatedDDMMYYY = 'hyphen-separated-dd-mm-yyyy'; case HyphenSeperatedDDMMYYY = 'hyphen-seperated-dd-mm-yyyy';
case HyphenSeparatedMMDDDYYYY = 'hyphen-separated-mm-dd-yyyy'; case HyphenSeperatedMMDDDYYYY = 'hyphen-seperated-mm-dd-yyyy';
case HyphenSeparatedYYYYMMDD = 'hyphen-separated-yyyy-mm-dd'; case HyphenSeperatedYYYYMMDD = 'hyphen-seperated-yyyy-mm-dd';
public function toCarbonFormat(): string public function toCarbonFormat(): string
{ {
return match ($this->value) { return match ($this->value) {
self::PointSeparatedDMYYYY->value => 'j.n.Y', self::PointSeperatedDMYYYY->value => 'j.n.Y',
self::SlashSeparatedMMDDYYYY->value => 'm/d/Y', self::SlashSeperatedMMDDYYYY->value => 'm/d/Y',
self::SlashSeparatedDDMMYYYY->value => 'd/m/Y', self::SlashSeperatedDDMMYYYY->value => 'd/m/Y',
self::HyphenSeparatedDDMMYYY->value => 'd-m-Y', self::HyphenSeperatedDDMMYYY->value => 'd-m-Y',
self::HyphenSeparatedMMDDDYYYY->value => 'm-d-Y', self::HyphenSeperatedMMDDDYYYY->value => 'm-d-Y',
self::HyphenSeparatedYYYYMMDD->value => 'Y-m-d', self::HyphenSeperatedYYYYMMDD->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 HoursMinutesColonSeparated = 'hours-minutes-colon-separated'; case HoursMinutesColonSeperated = 'hours-minutes-colon-seperated';
case HoursMinutesSecondsColonSeparated = 'hours-minutes-seconds-colon-separated'; case HoursMinutesSecondsColonSeperated = 'hours-minutes-seconds-colon-seperated';
/** /**
* @return array<string, string> * @return array<string, string>

View File

@@ -1,14 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Events;
use Illuminate\Foundation\Events\Dispatchable;
class DatabaseSeederAfterSeed
{
use Dispatchable;
public function __construct() {}
}

View File

@@ -1,14 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Events;
use Illuminate\Foundation\Events\Dispatchable;
class DatabaseSeederBeforeDelete
{
use Dispatchable;
public function __construct() {}
}

View File

@@ -23,7 +23,6 @@ use Filament\Tables;
use Filament\Tables\Filters\TernaryFilter; use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
@@ -208,14 +207,6 @@ class UserResource extends Resource
}), }),
]) ])
->bulkActions([ ->bulkActions([
Tables\Actions\BulkAction::make('Resend verification email')
->icon('heroicon-o-paper-airplane')
->action(function (Collection $records): void {
foreach ($records as $user) {
/** @var User $user */
$user->sendEmailVerificationNotification();
}
}),
]); ]);
} }

View File

@@ -1,37 +0,0 @@
<?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

@@ -226,7 +226,6 @@ class TimeEntryController extends Controller
'start' => $request->getStart()->timezone($timezone), 'start' => $request->getStart()->timezone($timezone),
'end' => $request->getEnd()->timezone($timezone), 'end' => $request->getEnd()->timezone($timezone),
'localization' => $localizationService, 'localization' => $localizationService,
'showBillableRate' => $showBillableRate,
]); ]);
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-index/pdf-footer.blade.php')); $footerViewFile = file_get_contents(resource_path('views/reports/time-entry-index/pdf-footer.blade.php'));
if ($footerViewFile === false) { if ($footerViewFile === false) {
@@ -429,7 +428,6 @@ class TimeEntryController extends Controller
'end' => $request->getEnd()->timezone($timezone), 'end' => $request->getEnd()->timezone($timezone),
'debug' => $debug, 'debug' => $debug,
'localization' => $localizationService, 'localization' => $localizationService,
'showBillableRate' => $showBillableRate,
]); ]);
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate/pdf-footer.blade.php')); $footerViewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate/pdf-footer.blade.php'));
if ($footerViewFile === false) { if ($footerViewFile === false) {
@@ -458,7 +456,7 @@ class TimeEntryController extends Controller
->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename); ->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename);
} else { } else {
Excel::store( Excel::store(
new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup, $showBillableRate), new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup),
$path, $path,
config('filesystems.private'), config('filesystems.private'),
$format->getExportPackageType(), $format->getExportPackageType(),

View File

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

View File

@@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1;
use Illuminate\Foundation\Http\FormRequest;
class BaseFormRequest extends FormRequest
{
/**
* @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 BaseFormRequest class ClientIndexRequest extends FormRequest
{ {
/** /**
* 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 BaseFormRequest class ClientStoreRequest extends FormRequest
{ {
/** /**
* 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 BaseFormRequest class ClientUpdateRequest extends FormRequest
{ {
/** /**
* 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 BaseFormRequest class ImportRequest extends FormRequest
{ {
/** /**
* 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 BaseFormRequest class InvitationIndexRequest extends FormRequest
{ {
/** /**
* 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 BaseFormRequest class InvitationStoreRequest extends FormRequest
{ {
/** /**
* 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 BaseFormRequest class MemberIndexRequest extends FormRequest
{ {
/** /**
* 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 BaseFormRequest class MemberMergeIntoRequest extends FormRequest
{ {
/** /**
* 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 BaseFormRequest class MemberUpdateRequest extends FormRequest
{ {
/** /**
* 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 BaseFormRequest
'string', 'string',
Rule::enum(Role::class), Rule::enum(Role::class),
], ],
'billable_rate' => array_merge( 'billable_rate' => [
[ 'nullable',
'nullable', 'integer',
], 'min:0',
$this->moneyRules() 'max:2147483647',
), ],
]; ];
} }

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 BaseFormRequest class OrganizationUpdateRequest extends FormRequest
{ {
/** /**
* 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 BaseFormRequest
'string', 'string',
'max:255', 'max:255',
], ],
'billable_rate' => array_merge( 'billable_rate' => [
[ 'nullable',
'nullable', 'integer',
], 'min:0',
$this->moneyRules() 'max:2147483647',
), ],
'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 BaseFormRequest class ProjectIndexRequest extends FormRequest
{ {
/** /**
* 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 BaseFormRequest class ProjectStoreRequest extends FormRequest
{ {
/** /**
* 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 BaseFormRequest
'required', 'required',
'boolean', 'boolean',
], ],
'billable_rate' => array_merge( 'billable_rate' => [
[ 'nullable',
'nullable', 'integer',
], 'min:0',
$this->moneyRules() 'max:2147483647',
), ],
// 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 BaseFormRequest class ProjectUpdateRequest extends FormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * Get the validation rules that apply to the request.
@@ -68,11 +68,12 @@ class ProjectUpdateRequest extends BaseFormRequest
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), })->uuid(),
], ],
'billable_rate' => array_merge([ 'billable_rate' => [
'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 BaseFormRequest class ProjectMemberStoreRequest extends FormRequest
{ {
/** /**
* 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 BaseFormRequest
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), })->uuid(),
], ],
'billable_rate' => array_merge( 'billable_rate' => [
[ 'nullable',
'nullable', 'integer',
], 'min:0',
$this->moneyRules() 'max:2147483647',
), ],
]; ];
} }

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 BaseFormRequest class ProjectMemberUpdateRequest extends FormRequest
{ {
/** /**
* 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 BaseFormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
'billable_rate' => array_merge( 'billable_rate' => [
[ 'nullable',
'nullable', 'integer',
], 'min:0',
$this->moneyRules() 'max:2147483647',
), ],
]; ];
} }

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 BaseFormRequest class ReportStoreRequest extends FormRequest
{ {
/** /**
* 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 BaseFormRequest class ReportUpdateRequest extends FormRequest
{ {
/** /**
* 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 BaseFormRequest class TagStoreRequest extends FormRequest
{ {
/** /**
* 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 BaseFormRequest class TagUpdateRequest extends FormRequest
{ {
/** /**
* 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 BaseFormRequest class TaskIndexRequest extends FormRequest
{ {
/** /**
* 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 BaseFormRequest class TaskStoreRequest extends FormRequest
{ {
/** /**
* 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 BaseFormRequest class TaskUpdateRequest extends FormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * Get the validation rules that apply to the request.

View File

@@ -7,7 +7,6 @@ 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;
@@ -17,6 +16,7 @@ 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 BaseFormRequest class TimeEntryAggregateExportRequest extends FormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * Get the validation rules that apply to the request.

View File

@@ -5,7 +5,6 @@ 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;
@@ -15,6 +14,7 @@ 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 BaseFormRequest class TimeEntryAggregateRequest extends FormRequest
{ {
/** /**
* 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 BaseFormRequest class TimeEntryDestroyMultipleRequest extends FormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * Get the validation rules that apply to the request.

View File

@@ -4,7 +4,6 @@ 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;
@@ -13,12 +12,13 @@ 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 BaseFormRequest class TimeEntryIndexRequest extends FormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * Get the validation rules that apply to the request.

View File

@@ -4,7 +4,6 @@ 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;
@@ -12,12 +11,13 @@ 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 BaseFormRequest class TimeEntryStoreRequest extends FormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * Get the validation rules that apply to the request.

View File

@@ -4,7 +4,6 @@ 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;
@@ -12,12 +11,13 @@ 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 BaseFormRequest class TimeEntryUpdateMultipleRequest extends FormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * Get the validation rules that apply to the request.

View File

@@ -4,7 +4,6 @@ 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;
@@ -12,12 +11,13 @@ 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 BaseFormRequest class TimeEntryUpdateRequest extends FormRequest
{ {
/** /**
* Get the validation rules that apply to the request. * Get the validation rules that apply to the request.

View File

@@ -4,14 +4,8 @@ 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;
/** /**
@@ -40,8 +34,6 @@ 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,
@@ -55,17 +47,15 @@ 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 $currency_symbol Currency symbol */ /** @var string $number_format Number format */
'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 CurrencyFormat $currency_format Currency format */ /** @var string $currency_format Currency format */
'currency_format' => $this->resource->currency_format->value, 'currency_format' => $this->resource->currency_format->value,
/** @var DateFormat $date_format Date format */ /** @var string $date_format Date format */
'date_format' => $this->resource->date_format->value, 'date_format' => $this->resource->date_format->value,
/** @var IntervalFormat $interval_format Interval format */ /** @var string $interval_format Interval format */
'interval_format' => $this->resource->interval_format->value, 'interval_format' => $this->resource->interval_format->value,
/** @var TimeFormat $time_format Time format */ /** @var string $time_format Time format */
'time_format' => $this->resource->time_format->value, 'time_format' => $this->resource->time_format->value,
]; ];
} }

View File

@@ -4,14 +4,8 @@ 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;
/** /**
@@ -70,8 +64,6 @@ 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,
@@ -81,18 +73,6 @@ 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

@@ -100,18 +100,12 @@ class DeletionService
// Make sure all users have at least one organization and delete placeholders // Make sure all users have at least one organization and delete placeholders
foreach ($users as $user) { foreach ($users as $user) {
/** @var User $user */
if ($ignoreUser !== null && $user->is($ignoreUser)) { if ($ignoreUser !== null && $user->is($ignoreUser)) {
continue; continue;
} }
if ($user->is_placeholder) { if ($user->is_placeholder) {
$user->delete(); $user->delete();
} else { } else {
if ($user->current_team_id === $organization->getKey()) {
$user->currentOrganization()->disassociate();
$user->save();
}
$this->userService->makeSureUserHasAtLeastOneOrganization($user); $this->userService->makeSureUserHasAtLeastOneOrganization($user);
$this->userService->makeSureUserHasCurrentOrganization($user); $this->userService->makeSureUserHasCurrentOrganization($user);
} }

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).' h'; return $this->formatNumber($interval->totalHours);
} 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::HoursMinutesColonSeparated) { } elseif ($this->intervalFormat === IntervalFormat::HoursMinutesColonSeperated) {
$interval->cascade(); $interval->cascade();
return ((int) floor($interval->totalHours)).':'.$interval->format('%I'); return ((int) floor($interval->totalHours)).':'.$interval->format('%I');
} elseif ($this->intervalFormat === IntervalFormat::HoursMinutesSecondsColonSeparated) { } elseif ($this->intervalFormat === IntervalFormat::HoursMinutesSecondsColonSeperated) {
$interval->cascade(); $interval->cascade();
return ((int) floor($interval->totalHours)).':'.$interval->format('%I:%S'); return ((int) floor($interval->totalHours)).':'.$interval->format('%I:%S');

View File

@@ -164,11 +164,6 @@ class MemberService
public function makeMemberToPlaceholder(Member $member, bool $makeSureUserHasAtLeastOneOrganization = true): void public function makeMemberToPlaceholder(Member $member, bool $makeSureUserHasAtLeastOneOrganization = true): void
{ {
$user = $member->user; $user = $member->user;
if ($user->current_team_id === $member->organization_id) {
$user->currentTeam()->disassociate();
$user->save();
}
$placeholderUser = $user->replicate(); $placeholderUser = $user->replicate();
$placeholderUser->is_placeholder = true; $placeholderUser->is_placeholder = true;
$placeholderUser->save(); $placeholderUser->save();
@@ -180,7 +175,6 @@ class MemberService
$this->userService->assignOrganizationEntitiesToDifferentUser($member->organization, $user, $placeholderUser); $this->userService->assignOrganizationEntitiesToDifferentUser($member->organization, $user, $placeholderUser);
if ($makeSureUserHasAtLeastOneOrganization) { if ($makeSureUserHasAtLeastOneOrganization) {
$this->userService->makeSureUserHasAtLeastOneOrganization($user); $this->userService->makeSureUserHasAtLeastOneOrganization($user);
$this->userService->makeSureUserHasCurrentOrganization($user);
} }
} }
} }

View File

@@ -46,8 +46,6 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
private TimeEntryAggregationType $subGroup; private TimeEntryAggregationType $subGroup;
private bool $showBillableRate;
/** /**
* @param array{ * @param array{
* grouped_type: string|null, * grouped_type: string|null,
@@ -68,14 +66,13 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
* cost: int|null * cost: int|null
* } $data * } $data
*/ */
public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup, bool $showBillableRate) public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup)
{ {
$this->data = $data; $this->data = $data;
$this->exportFormat = $exportFormat; $this->exportFormat = $exportFormat;
$this->currency = $currency; $this->currency = $currency;
$this->group = $group; $this->group = $group;
$this->subGroup = $subGroup; $this->subGroup = $subGroup;
$this->showBillableRate = $showBillableRate;
} }
public function view(): View public function view(): View
@@ -86,7 +83,6 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
'group' => $this->group, 'group' => $this->group,
'subGroup' => $this->subGroup, 'subGroup' => $this->subGroup,
'exportFormat' => $this->exportFormat, 'exportFormat' => $this->exportFormat,
'showBillableRate' => $this->showBillableRate,
]); ]);
} }

View File

@@ -114,15 +114,13 @@ class UserService
public function makeSureUserHasCurrentOrganization(User $user): void public function makeSureUserHasCurrentOrganization(User $user): void
{ {
if ($user->current_team_id !== null) { if ($user->currentOrganization !== null) {
return; return;
} }
$organization = $user->organizations()->first(); $organization = $user->organizations()->first();
if ($organization !== null) { $user->currentOrganization()->associate($organization);
$user->currentOrganization()->associate($organization); $user->save();
$user->save();
}
} }
/** /**

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::HyphenSeparatedYYYYMMDD->value), 'default_date_format' => env('LOCALIZATION_DEFAULT_DATE_FORMAT', DateFormat::HyphenSeperatedYYYYMMDD->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

@@ -8,6 +8,5 @@ return [
'time_entry_send_still_running_mails' => (bool) env('SCHEDULING_TASK_TIME_ENTRY_SEND_STILL_RUNNING_MAILS', true), 'time_entry_send_still_running_mails' => (bool) env('SCHEDULING_TASK_TIME_ENTRY_SEND_STILL_RUNNING_MAILS', true),
'self_hosting_check_for_update' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_CHECK_FOR_UPDATE', true), 'self_hosting_check_for_update' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_CHECK_FOR_UPDATE', true),
'self_hosting_telemetry' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_TELEMETRY', true), 'self_hosting_telemetry' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_TELEMETRY', true),
'self_hosting_database_consistency' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_DATABASE_CONSISTENCY', false),
], ],
]; ];

View File

@@ -1,44 +0,0 @@
<?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

@@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
DB::statement('
update users
set current_team_id = null
where id in (
select users.id from users
left join organizations on users.current_team_id = organizations.id
where users.current_team_id is not null and organizations.id is null
)
');
Schema::table('users', function (Blueprint $table): void {
$table->foreign('current_team_id', 'organizations_current_organization_id_foreign')
->references('id')
->on('organizations')
->onDelete('restrict')
->onUpdate('cascade');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->dropForeign('organizations_current_organization_id_foreign');
});
}
};

View File

@@ -5,8 +5,6 @@ declare(strict_types=1);
namespace Database\Seeders; namespace Database\Seeders;
use App\Enums\Role; use App\Enums\Role;
use App\Events\DatabaseSeederAfterSeed;
use App\Events\DatabaseSeederBeforeDelete;
use App\Models\Audit; use App\Models\Audit;
use App\Models\Client; use App\Models\Client;
use App\Models\Member; use App\Models\Member;
@@ -186,13 +184,10 @@ class DatabaseSeeder extends Seeder
'email' => 'admin@example.com', 'email' => 'admin@example.com',
]); ]);
DatabaseSeederAfterSeed::dispatch();
} }
private function deleteAll(): void private function deleteAll(): void
{ {
DatabaseSeederBeforeDelete::dispatch();
// Laravel Passport tables // Laravel Passport tables
DB::table((new RefreshToken)->getTable())->delete(); DB::table((new RefreshToken)->getTable())->delete();
DB::table((new Token)->getTable())->delete(); DB::table((new Token)->getTable())->delete();
@@ -218,9 +213,6 @@ class DatabaseSeeder extends Seeder
DB::table((new Client)->getTable())->delete(); DB::table((new Client)->getTable())->delete();
DB::table((new Member)->getTable())->delete(); DB::table((new Member)->getTable())->delete();
DB::table((new OrganizationInvitation)->getTable())->delete(); DB::table((new OrganizationInvitation)->getTable())->delete();
DB::table((new User)->getTable())->update([
'current_team_id' => null,
]);
DB::table((new Organization)->getTable())->delete(); DB::table((new Organization)->getTable())->delete();
DB::table((new User)->getTable())->delete(); DB::table((new User)->getTable())->delete();
} }

View File

@@ -7,29 +7,6 @@ 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');
@@ -50,11 +27,9 @@ 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('form') .locator('button')
.filter({ hasText: 'Organization Billable' }) .filter({ hasText: /^Save$/ })
.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' })
@@ -76,173 +51,4 @@ test('test that organization billable rate can be updated with all existing time
]); ]);
}); });
test('test that organization format settings can be updated', async ({ // TODO: Add Test for import
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,9 +1,7 @@
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 { formatCentsWithOrganizationDefaults } from './utils/money'; import { formatCents } from '../resources/js/packages/ui/src/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');
@@ -63,6 +61,6 @@ test('test that updating project member billable rate works for existing time en
page page
.getByRole('row') .getByRole('row')
.first() .first()
.getByText(formatCentsWithOrganizationDefaults(newBillableRate * 100)) .getByText(formatCents(newBillableRate * 100, 'EUR'))
).toBeVisible(); ).toBeVisible();
}); });

View File

@@ -1,8 +1,7 @@
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 { formatCentsWithOrganizationDefaults } from './utils/money'; import { formatCents } from '../resources/js/packages/ui/src/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');
@@ -132,7 +131,7 @@ test('test that updating billable rate works with existing time entries', async
page page
.getByRole('row') .getByRole('row')
.first() .first()
.getByText(formatCentsWithOrganizationDefaults(newBillableRate * 100)) .getByText(formatCents(newBillableRate * 100, 'EUR'))
).toBeVisible(); ).toBeVisible();
}); });

View File

@@ -43,7 +43,7 @@ async function createTimeEntryWithProject(page: Page, projectName: string, durat
// Submit the time entry // Submit the time entry
await Promise.all([ await Promise.all([
page.getByRole('button', { name: 'Create Time Entry' }).click(), page.getByRole('button', { name: 'Create Time Entry' }).click(),
page.waitForResponse(response => response.url().includes('/time-entries') && response.status() === 201) page.waitForLoadState('networkidle')
]); ]);
} }
@@ -183,28 +183,4 @@ test('test that detailed view shows time entries correctly', async ({ page }) =>
await expect(page.getByText('Time entry for ' + projectName, { exact: true })).toBeVisible(); 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 // TODO: test that date range filtering works in reporting

View File

@@ -1,17 +0,0 @@
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::PointSeparatedDMYYYY->value => 'D.M.YYYY', DateFormat::PointSeperatedDMYYYY->value => 'D.M.YYYY',
DateFormat::SlashSeparatedMMDDYYYY->value => 'MM/DD/YYYY', DateFormat::SlashSeperatedMMDDYYYY->value => 'MM/DD/YYYY',
DateFormat::SlashSeparatedDDMMYYYY->value => 'DD/MM/YYYY', DateFormat::SlashSeperatedDDMMYYYY->value => 'DD/MM/YYYY',
DateFormat::HyphenSeparatedDDMMYYY->value => 'DD-MM-YYYY', DateFormat::HyphenSeperatedDDMMYYY->value => 'DD-MM-YYYY',
DateFormat::HyphenSeparatedMMDDDYYYY->value => 'MM-DD-YYYY', DateFormat::HyphenSeperatedMMDDDYYYY->value => 'MM-DD-YYYY',
DateFormat::HyphenSeparatedYYYYMMDD->value => 'YYYY-MM-DD', DateFormat::HyphenSeperatedYYYYMMDD->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::HoursMinutesColonSeparated->value => '12:03', IntervalFormat::HoursMinutesColonSeperated->value => '12:03',
IntervalFormat::HoursMinutesSecondsColonSeparated->value => '12:03:45', IntervalFormat::HoursMinutesSecondsColonSeperated->value => '12:03:45',
], ],
'currency_format' => [ 'currency_format' => [

View File

@@ -9,8 +9,7 @@ return [
'2. In the same preferences page change the language of Clockfiy to English.<br>'. '2. In the same preferences page change the language of Clockfiy to English.<br>'.
'3. Go to REPORTS -> TIME -> Detailed in the navigation on the left. <br>'. '3. Go to REPORTS -> TIME -> Detailed in the navigation on the left. <br>'.
'4. Now select the date range that you want to export in the right top. '. '4. Now select the date range that you want to export in the right top. '.
'In the free Clockify plan it\'s currently not possible to select more than one year. '. 'It is currently not possible to select more than one year. You can export each year separately and import them one after another .'.
'You can export each year separately and import them one after another.'.
'<br> 4. Now click Export -> Save as CSV. The Export dropdown is in the header of the export table left of the printer symbol. '. '<br> 4. Now click Export -> Save as CSV. The Export dropdown is in the header of the export table left of the printer symbol. '.
'<br><br>Before you import make sure that the Timezone settings in Clockify are the same as in solidtime.', '<br><br>Before you import make sure that the Timezone settings in Clockify are the same as in solidtime.',
], ],

View File

@@ -43,9 +43,6 @@
--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: rgba(var(--color-accent-300), 0.2);
--color-accent-foreground: rgb(var(--color-accent-100));
} }
:root.light { :root.light {
@@ -89,9 +86,6 @@
--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,12 +26,10 @@ const createClient = ref(false);
<ClientTableHeading></ClientTableHeading> <ClientTableHeading></ClientTableHeading>
<div <div
v-if="clients.length === 0" v-if="clients.length === 0"
class="col-span-3 py-24 text-center"> class="col-span-2 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"> <h3 class="text-text-primary font-semibold">No clients found</h3>
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,14 +2,10 @@
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;
@@ -32,10 +28,7 @@ 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,6 +154,7 @@ 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,27 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Member, Organization } from '@/packages/api/src'; import type { Member } 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, type ComputedRef, inject, ref } from 'vue'; import {computed, ref} from 'vue';
import MemberEditModal from '@/Components/Common/Member/MemberEditModal.vue'; import MemberEditModal from '@/Components/Common/Member/MemberEditModal.vue';
import MemberMergeModal from '@/Components/Common/Member/MemberMergeModal.vue'; import { getOrganizationCurrencyString } from '@/utils/money';
import MemberMakePlaceholderModal from '@/Components/Common/Member/MemberMakePlaceholderModal.vue'; import { formatCents } from '@/packages/ui/src/utils/money';
import { capitalizeFirstLetter } from '../../../utils/format'; import MemberMergeModal from "@/Components/Common/Member/MemberMergeModal.vue";
import { formatCents } from '../../../packages/ui/src/utils/money'; import MemberMakePlaceholderModal from "@/Components/Common/Member/MemberMakePlaceholderModal.vue";
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);
@@ -36,12 +35,15 @@ async function invitePlaceholder(id: string) {
if (organizationId) { if (organizationId) {
await handleApiRequestNotifications( await handleApiRequestNotifications(
() => () =>
api.invitePlaceholder(undefined, { api.invitePlaceholder(
params: { undefined,
organization: organizationId, {
member: id, params: {
}, organization: organizationId,
}), member: id,
},
}
),
'Member invited successfully', 'Member invited successfully',
'Error inviting member' 'Error inviting member'
); );
@@ -50,7 +52,8 @@ 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>
@@ -72,10 +75,7 @@ const userHasValidMailAddress = computed(() => {
member.billable_rate member.billable_rate
? formatCents( ? formatCents(
member.billable_rate, member.billable_rate,
organization?.currency, getOrganizationCurrencyString()
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
) )
: '--' : '--'
}} }}
@@ -101,26 +101,21 @@ const userHasValidMailAddress = computed(() => {
" "
size="small" size="small"
@click="invitePlaceholder(member.id)" @click="invitePlaceholder(member.id)"
>Invite >Invite</SecondaryButton
</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=" @make-placeholder="showMakeMemberPlaceholderModal = true"
showMakeMemberPlaceholderModal = true ></MemberMoreOptionsDropdown>
"></MemberMoreOptionsDropdown>
</div> </div>
<MemberEditModal <MemberEditModal
v-model:show="showEditMemberModal" v-model:show="showEditMemberModal"
:member="member"></MemberEditModal> :member="member"></MemberEditModal>
<MemberMergeModal <MemberMergeModal v-model:show="showMergeMemberModal" :member="member"></MemberMergeModal>
v-model:show="showMergeMemberModal" <MemberMakePlaceholderModal v-model:show="showMakeMemberPlaceholderModal" :member="member"></MemberMakePlaceholderModal>
:member="member"></MemberMergeModal>
<MemberMakePlaceholderModal
v-model:show="showMakeMemberPlaceholderModal"
:member="member"></MemberMakePlaceholderModal>
</TableRow> </TableRow>
</template> </template>

View File

@@ -2,14 +2,10 @@
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;
}>(); }>();
@@ -31,10 +27,7 @@ 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> <div class="lg:grid grid-cols-2 gap-12">
<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, inject, type ComputedRef } from 'vue'; import { computed, ref } 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,7 +15,6 @@ 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());
@@ -47,17 +46,12 @@ 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';
@@ -67,7 +61,6 @@ const billableRateInfo = computed(() => {
}); });
const showEditProjectModal = ref(false); const showEditProjectModal = ref(false);
</script> </script>
<template> <template>
@@ -86,12 +79,9 @@ 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"> <span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span>
{{ projectTasksCount }} Tasks
</span>
</div> </div>
<div <div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-secondary">
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">
@@ -101,13 +91,7 @@ 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,14 +2,10 @@
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;
@@ -32,10 +28,7 @@ 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, inject, type ComputedRef } from 'vue'; import { computed, ref } 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,14 +10,10 @@ 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,
@@ -55,10 +51,7 @@ 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

@@ -5,7 +5,6 @@ import { h, ref } from 'vue';
import type { CreateReportBodyProperties } from '@/packages/api/src'; import type { CreateReportBodyProperties } from '@/packages/api/src';
import { isAllowedToPerformPremiumAction } from '@/utils/billing'; import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import UpgradeModal from '@/Components/Common/UpgradeModal.vue'; import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
import { canCreateReports } from '@/utils/permissions';
defineProps<{ defineProps<{
reportProperties: CreateReportBodyProperties; reportProperties: CreateReportBodyProperties;
}>(); }>();
@@ -34,10 +33,7 @@ function onSaveReportClick() {
<strong>Sharable Reports</strong> is only available in solidtime <strong>Sharable Reports</strong> is only available in solidtime
Professional. Professional.
</UpgradeModal> </UpgradeModal>
<SecondaryButton <SecondaryButton :icon="SaveIcon" @click="onSaveReportClick"
v-if="canCreateReports()"
:icon="SaveIcon"
@click="onSaveReportClick"
>Save Report</SecondaryButton >Save Report</SecondaryButton
> >
</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 { computed, provide, inject, shallowRef, type ComputedRef } from 'vue'; import { computed, provide } 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, Organization } from '@/packages/api/src'; import type { AggregatedTimeEntries } from '@/packages/api/src';
import { useCssVar } from '@vueuse/core'; import { useCssVar } from '@vueuse/core';
use([ use([
@@ -30,8 +30,6 @@ 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<{
@@ -43,9 +41,7 @@ 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) => return props?.groupedData?.map((el) => formatDate(el.key ?? ''));
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 });
@@ -147,11 +143,7 @@ const option = computed(() => ({
type: 'bar', type: 'bar',
tooltip: { tooltip: {
valueFormatter: (value: number) => { valueFormatter: (value: number) => {
return formatHumanReadableDuration( return formatHumanReadableDuration(value);
value,
organization?.value?.interval_format,
organization?.value?.number_format
);
}, },
}, },
}, },
@@ -163,7 +155,6 @@ 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,10 +28,8 @@ const activeClass = computed(() => {
activeClass activeClass
) )
"> ">
<component <component :is="icon" class="-ml-0.5 h-4 w-4 text-text-quaternary"></component>
:is="icon" <span> {{ title }} </span>
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

@@ -1,508 +0,0 @@
<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,
member_id:
getCurrentRole() === 'employee'
? getCurrentMembershipId()
: 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, inject, type ComputedRef } from 'vue'; import { computed, provide } 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,8 +11,7 @@ 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,
@@ -25,8 +24,6 @@ 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;
@@ -74,11 +71,7 @@ const option = computed(() => ({
}, },
tooltip: { tooltip: {
valueFormatter: (value: number) => { valueFormatter: (value: number) => {
return formatHumanReadableDuration( return formatHumanReadableDuration(value);
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, inject, type ComputedRef } from 'vue'; import { ref } from 'vue';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import type { Organization } from '@/packages/api/src'; import { getOrganizationCurrencyString } from '@/utils/money';
type AggregatedGroupedData = GroupedData & { type AggregatedGroupedData = GroupedData & {
grouped_data?: GroupedData[] | null; grouped_data?: GroupedData[] | null;
@@ -19,12 +19,9 @@ 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>
@@ -48,22 +45,10 @@ const organization = inject<ComputedRef<Organization>>('organization');
</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 ? formatCents(entry.cost, getOrganizationCurrencyString()) : '--' }}
entry.cost,
props.currency,
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
) : '--' }}
</div> </div>
</div> </div>
<div <div
@@ -73,7 +58,6 @@ const organization = inject<ComputedRef<Organization>>('organization');
<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,19 +6,16 @@ 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, inject, type ComputedRef } from 'vue'; import { ref } 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);
} }
@@ -44,13 +41,7 @@ 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,10 +3,9 @@ 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 { formatDuration } from '@/packages/ui/src/utils/time'; import { formatHumanReadableDuration } 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;
@@ -15,9 +14,10 @@ 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 formatDuration(diff); // return dayjs(diff).utc().format('HH:mm:ss');
return formatHumanReadableDuration(diff);
} }
return formatDuration(0); return formatHumanReadableDuration(0);
}); });
const isRunningInDifferentOrganization = computed(() => { const isRunningInDifferentOrganization = computed(() => {
@@ -43,9 +43,7 @@ const isRunningInDifferentOrganization = computed(() => {
</div> </div>
</div> </div>
<div> <div>
<div class="text-text-secondary font-extrabold text-xs"> <div class="text-text-secondary font-extrabold text-xs">Current Timer</div>
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,45 +1,44 @@
<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, inject, type ComputedRef } from 'vue'; import { provide, computed } 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, type Organization } from '@/packages/api/src'; import { api } 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([
@@ -48,105 +47,96 @@ 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, { const backgroundColor = useCssVar('--color-card-background', null, { observe: true });
observe: true, const itemBackgroundColor = useCssVar('--color-bg-tertiary', null, { 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: {
top: 40,
bottom: 20,
left: 40,
right: 10,
cellSize: [40, 40],
dayLabel: {
firstDay: firstDayIndex.value,
},
splitLine: {
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: {
type: 'heatmap',
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 '';
}
}, },
show: false
}, },
}, calendar: {
backgroundColor: 'transparent', top: 40,
}; bottom: 20,
}); left: 40,
right: 10,
cellSize: [40, 40],
dayLabel: {
firstDay: firstDayIndex.value
},
splitLine: {
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: {
type: "heatmap",
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) +
": " +
formatHumanReadableDuration(value)
);
}
else {
return "";
}
}
}
},
backgroundColor: "transparent"
};
});
</script> </script>
<template> <template>

View File

@@ -1,19 +1,15 @@
<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>
@@ -29,13 +25,7 @@ defineProps<{
</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, inject, type ComputedRef } from 'vue'; import { provide } 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,7 +12,6 @@ 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,
@@ -34,8 +33,6 @@ 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,
@@ -72,7 +69,7 @@ const option = computed(() => ({
}, },
tooltip: { tooltip: {
valueFormatter: (value: number) => { valueFormatter: (value: number) => {
return formatHumanReadableDuration(value, organization?.value?.interval_format, organization?.value?.number_format); return formatHumanReadableDuration(value);
}, },
}, },
data: seriesData, data: seriesData,

View File

@@ -1,28 +1,23 @@
<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 { import { GridComponent, LegendComponent, TitleComponent, TooltipComponent } from "echarts/components";
GridComponent, import VChart, { THEME_KEY } from "vue-echarts";
LegendComponent, import { computed, provide } from "vue";
TitleComponent, import StatCard from "@/Components/Common/StatCard.vue";
TooltipComponent, import { ClockIcon } from "@heroicons/vue/20/solid";
} from 'echarts/components'; import CardTitle from "@/packages/ui/src/CardTitle.vue";
import VChart, { THEME_KEY } from 'vue-echarts'; import LinearGradient from "zrender/lib/graphic/LinearGradient";
import { computed, provide, inject, type ComputedRef } from 'vue'; import ProjectsChartCard from "@/Components/Dashboard/ProjectsChartCard.vue";
import StatCard from '@/Components/Common/StatCard.vue'; import { formatHumanReadableDuration } from "@/packages/ui/src/utils/time";
import { ClockIcon } from '@heroicons/vue/20/solid'; import { formatCents } from "@/packages/ui/src/utils/money";
import CardTitle from '@/packages/ui/src/CardTitle.vue'; import { getWeekStart } from "@/packages/ui/src/utils/settings";
import LinearGradient from 'zrender/lib/graphic/LinearGradient'; import { useCssVar } from "@vueuse/core";
import ProjectsChartCard from '@/Components/Dashboard/ProjectsChartCard.vue'; import { getOrganizationCurrencyString } from "@/utils/money";
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time'; import { useQuery } from "@tanstack/vue-query";
import { formatCents } from '@/packages/ui/src/utils/money'; import { getCurrentOrganizationId } from "@/utils/useUser";
import { getWeekStart } from '@/packages/ui/src/utils/settings'; import { api } from "@/packages/api/src";
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,
@@ -30,21 +25,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 = [];
@@ -58,76 +53,78 @@ 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 [];
@@ -140,104 +137,101 @@ 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, {
observe: true, const markLineColor = useCssVar("--color-border-secondary", null, { 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( return formatHumanReadableDuration(value);
value, }
organization?.value?.interval_format, }
organization?.value?.number_format }
); ]
},
},
},
],
}; };
}); });
</script> </script>
<template> <template>
@@ -250,45 +244,28 @@ const option = computed(() => {
:icon="ClockIcon"></CardTitle> :icon="ClockIcon"></CardTitle>
<v-chart <v-chart
v-if="weeklyHistory" v-if="weeklyHistory"
:autoresize="true" :autoresize="true" class="chart" :option="option" />
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( formatHumanReadableDuration(totalWeeklyTime) : '--'" />
totalWeeklyTime,
organization?.interval_format,
organization?.number_format
)
: '--'
" />
<StatCard <StatCard
title="Billable Time" title="Billable Time"
:value=" :value="
totalWeeklyBillableTime totalWeeklyBillableTime ?
? formatHumanReadableDuration( formatHumanReadableDuration(totalWeeklyBillableTime) : '--'
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])]:border-accent [&[data-today]:not([data-selected])]:border [&[data-today]:not([data-selected])]:text-accent-foreground', '[&[data-today]:not([data-selected])]:bg-accent [&[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,8 +9,7 @@ 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, inject, type ComputedRef } from 'vue'; import { computed } 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<{
@@ -28,8 +27,6 @@ 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>
@@ -44,7 +41,7 @@ const organization = inject<ComputedRef<Organization>>('organization');
]" ]"
> >
<CalendarIcon class="mr-2 h-4 w-4" /> <CalendarIcon class="mr-2 h-4 w-4" />
{{ model ? formatDateLocalized(model, organization?.date_format) : 'Pick a date' }} {{ model ? formatDateLocalized(model) : 'Pick a date' }}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent class="w-auto p-0"> <PopoverContent class="w-auto p-0">

View File

@@ -34,18 +34,11 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
v-bind="forwarded" v-bind="forwarded"
:class=" :class="
cn( cn(
'fixed top-0 left-0 z-50 w-screen h-screen flex items-start pt-6 md:pt-20 xl:pt-32 justify-center overflow-auto data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95', 'fixed left-1/2 top-1/3 bg-default-background z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 border shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
)"
>
<div
:class="cn(
'bg-default-background grid w-full max-w-lg border shadow-lg duration-200 sm:rounded-lg',
props.class, props.class,
)" )"
> >
<slot /> <slot />
</div>
</DialogContent> </DialogContent>
</DialogPortal> </DialogPortal>
</template> </template>

View File

@@ -2,61 +2,22 @@
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, inject, type ComputedRef } from 'vue' import { computed, type HTMLAttributes } from 'vue'
import type { Organization } from '@/packages/api/src'
const props = defineProps<NumberFieldRootProps & { const props = defineProps<NumberFieldRootProps & { class?: HTMLAttributes['class'] }>()
class?: HTMLAttributes['class']
formatOptions?: {
maximumFractionDigits?: number
minimumFractionDigits?: number
}
}>()
const emits = defineEmits<NumberFieldRootEmits>() const emits = defineEmits<NumberFieldRootEmits>()
const delegatedProps = computed(() => { const delegatedProps = computed(() => {
const { class: _, formatOptions: __, ...delegated } = props const { class: _, ...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 <NumberFieldRoot v-bind="forwarded" :class="cn('grid gap-1.5', props.class)">
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])]:border-accent [&[data-today]:not([data-selected])]:border [&[data-today]:not([data-selected])]:text-accent-foreground', '[&[data-today]:not([data-selected])]:bg-accent [&[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,22 +15,20 @@ 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 { computed, onMounted, provide, ref } from 'vue'; import { onMounted, 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, canViewClients, canViewInvoices,
canViewInvoices,
canViewMembers, canViewMembers,
canViewProjects, canViewProjects, canViewReport,
canViewReport,
canViewTags, canViewTags,
} from '@/utils/permissions'; } from '@/utils/permissions';
import { isBillingActivated, isInvoicingActivated } from '@/utils/billing'; import { isBillingActivated, isInvoicingActivated } from '@/utils/billing';
@@ -39,11 +37,7 @@ 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,
@@ -51,25 +45,9 @@ defineProps({
const showSidebarMenu = ref(false); const showSidebarMenu = ref(false);
const isUnloading = ref(false); const isUnloading = ref(false);
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 () => { onMounted(async () => {
useTheme();
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;
@@ -99,9 +77,7 @@ const page = usePage<{
</script> </script>
<template> <template>
<div <div v-bind="$attrs" class="flex flex-wrap bg-background text-text-secondary">
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]':
@@ -146,17 +122,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="
@@ -207,9 +183,7 @@ const page = usePage<{
:current="route().current('tags')" :current="route().current('tags')"
:href="route('tags')"></NavigationSidebarItem> :href="route('tags')"></NavigationSidebarItem>
<NavigationSidebarItem <NavigationSidebarItem
v-if=" v-if="isInvoicingActivated() && canViewInvoices()"
isInvoicingActivated() && canViewInvoices()
"
title="Invoices" title="Invoices"
:icon="DocumentTextIcon" :icon="DocumentTextIcon"
:current="route().current('invoices')" :current="route().current('invoices')"
@@ -302,12 +276,8 @@ const page = usePage<{
<!-- Page Content --> <!-- Page Content -->
<main class="pb-28 flex-1"> <main class="pb-28 flex-1">
<div <slot />
v-if="isOrganizationLoading"
class="flex items-center justify-center h-screen">
<LoadingSpinner />
</div>
<slot v-else />
</main> </main>
</div> </div>
</div> </div>

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