Compare commits

..

12 Commits

Author SHA1 Message Date
Gregor Vostrak
7b82bf660b prevent billable rate change modals from immediately sumbitting when pressing enter on the previous form 2025-04-23 14:24:36 +02:00
Gregor Vostrak
a0a8a7f772 fix escape handling in tagdropdown and timetrackerprojecttaskdropdown after changing to radix dropdowns 2025-04-23 14:11:15 +02:00
Constantin Graf
5c63a94857 Add composer dependency “league/iso3166” 2025-04-23 12:37:20 +02:00
Gregor Vostrak
e377e58c98 add invoicing extension to private build action 2025-04-22 20:29:30 +02:00
Gregor Vostrak
08e0118181 add accordion component and countries api route 2025-04-22 17:32:32 +02:00
Gregor Vostrak
730604987f fix timeentry checkboxes 2025-04-22 17:11:41 +02:00
Gregor Vostrak
80523cba3a update api client, and report empty state improvement 2025-04-16 17:18:51 +02:00
Gregor Vostrak
af374c9c4d fix tests, add autofocus disable option for dropdown 2025-04-15 15:18:02 +02:00
Constantin Graf
48be348c4c Add composer package korridor/laravel-has-many-sync 2025-04-14 16:03:12 +02:00
Constantin Graf
7e2d1ccc3d Fixes for invoice feature 2025-04-13 23:37:57 +02:00
Gregor Vostrak
132b6cbe8f refactor to shadcn components, dynamically load extension frontend
add jetstream permissions, add dynamic inertia module loading, add shadcn components, change modals and dropdowns to shadcn dismissable layer,
2025-04-13 23:06:58 +02:00
Constantin Graf
4605aa75ff Add localization settings 2025-04-13 16:26:31 +02:00
166 changed files with 2335 additions and 5256 deletions

View File

@@ -107,7 +107,7 @@ jobs:
- name: "Install npm dependencies in services extension" - name: "Install npm dependencies in services extension"
run: cd extensions/Services && npm ci run: cd extensions/Services && npm ci
- name: "Checkout invoicing extension" - name: "Checkout services extension"
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
repository: solidtime-io/extension-invoicing repository: solidtime-io/extension-invoicing

View File

@@ -60,10 +60,10 @@ jobs:
php artisan passport:keys php artisan passport:keys
- name: "Run PHPUnit" - name: "Run PHPUnit"
run: php artisan test --parallel --stop-on-failure --coverage-text --coverage-clover=coverage.xml run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
- name: "Upload coverage reports to Codecov" - name: "Upload coverage reports to Codecov"
uses: codecov/codecov-action@v5.4.2 uses: codecov/codecov-action@v5.4.0
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
slug: solidtime-io/solidtime slug: solidtime-io/solidtime

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

@@ -13,7 +13,7 @@ use Filament\Tables;
use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField; use Novadaemon\FilamentPrettyJson\PrettyJson;
class AuditResource extends Resource class AuditResource extends Resource
{ {
@@ -38,8 +38,8 @@ class AuditResource extends Resource
->maxLength(255), ->maxLength(255),
Forms\Components\TextInput::make('auditable_id') Forms\Components\TextInput::make('auditable_id')
->required(), ->required(),
PrettyJsonField::make('old_values'), PrettyJson::make('old_values'),
PrettyJsonField::make('new_values'), PrettyJson::make('new_values'),
Forms\Components\Textarea::make('url'), Forms\Components\Textarea::make('url'),
Forms\Components\TextInput::make('ip_address'), Forms\Components\TextInput::make('ip_address'),
Forms\Components\TextInput::make('user_agent') Forms\Components\TextInput::make('user_agent')

View File

@@ -20,7 +20,7 @@ use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField; use Novadaemon\FilamentPrettyJson\PrettyJson;
/** /**
* @source https://gitlab.com/amvisor/filament-failed-jobs * @source https://gitlab.com/amvisor/filament-failed-jobs
@@ -50,7 +50,7 @@ class FailedJobResource extends Resource
// make text a little bit smaller because often a complete Stack Trace is shown: // make text a little bit smaller because often a complete Stack Trace is shown:
TextArea::make('exception')->disabled()->columnSpan(4)->extraInputAttributes(['style' => 'font-size: 80%;']), TextArea::make('exception')->disabled()->columnSpan(4)->extraInputAttributes(['style' => 'font-size: 80%;']),
PrettyJsonField::make('payload')->disabled()->columnSpan(4), PrettyJson::make('payload')->disabled()->columnSpan(4),
])->columns(4); ])->columns(4);
} }

View File

@@ -18,7 +18,7 @@ use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ToggleColumn; use Filament\Tables\Columns\ToggleColumn;
use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField; use Novadaemon\FilamentPrettyJson\PrettyJson;
class ReportResource extends Resource class ReportResource extends Resource
{ {
@@ -58,7 +58,7 @@ class ReportResource extends Resource
Forms\Components\TextInput::make('share_secret') Forms\Components\TextInput::make('share_secret')
->label('Share Secret') ->label('Share Secret')
->nullable(), ->nullable(),
PrettyJsonField::make('properties') PrettyJson::make('properties')
->formatStateUsing(function (ReportPropertiesDto $state, Report $record): string { ->formatStateUsing(function (ReportPropertiesDto $state, Report $record): string {
return $record->getRawOriginal('properties'); return $record->getRawOriginal('properties');
}) })

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

@@ -40,7 +40,6 @@ class HandleInertiaRequests extends Middleware
public function share(Request $request): array public function share(Request $request): array
{ {
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing'); $hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
$hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing');
/** @var BillingContract $billing */ /** @var BillingContract $billing */
$billing = app(BillingContract::class); $billing = app(BillingContract::class);
@@ -49,7 +48,6 @@ class HandleInertiaRequests extends Middleware
return array_merge(parent::share($request), [ return array_merge(parent::share($request), [
'has_billing_extension' => $hasBilling, 'has_billing_extension' => $hasBilling,
'has_invoicing_extension' => $hasInvoicing,
'billing' => $billing !== null && $currentOrganization !== null ? [ 'billing' => $billing !== null && $currentOrganization !== null ? [
'has_subscription' => $billing->hasSubscription($currentOrganization), 'has_subscription' => $billing->hasSubscription($currentOrganization),
'has_trial' => $billing->hasTrial($currentOrganization), 'has_trial' => $billing->hasTrial($currentOrganization),

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,21 +4,20 @@ 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\Support\Str; 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 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.
@@ -28,7 +27,6 @@ class ProjectStoreRequest extends BaseFormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
// Name of the project, the name needs to be unique per client and organization
'name' => [ 'name' => [
'required', 'required',
'string', 'string',
@@ -36,13 +34,7 @@ class ProjectStoreRequest extends BaseFormRequest
'max:255', 'max:255',
UniqueEloquent::make(Project::class, 'name', function (Builder $builder): Builder { UniqueEloquent::make(Project::class, 'name', function (Builder $builder): Builder {
/** @var Builder<Project> $builder */ /** @var Builder<Project> $builder */
$clientId = $this->input('client_id'); return $builder->whereBelongsTo($this->organization, 'organization');
if (! is_string($clientId) || ! Str::isUuid($clientId)) {
$clientId = null;
}
return $builder->whereBelongsTo($this->organization, 'organization')
->where('client_id', $clientId);
})->withCustomTranslation('validation.project_name_already_exists'), })->withCustomTranslation('validation.project_name_already_exists'),
], ],
'color' => [ 'color' => [
@@ -55,15 +47,14 @@ 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',
'nullable', 'nullable',
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder { ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */ /** @var Builder<Client> $builder */

View File

@@ -4,14 +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\Support\Str; 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;
@@ -19,7 +18,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.
@@ -35,13 +34,7 @@ class ProjectUpdateRequest extends BaseFormRequest
'max:255', 'max:255',
UniqueEloquent::make(Project::class, 'name', function (Builder $builder): Builder { UniqueEloquent::make(Project::class, 'name', function (Builder $builder): Builder {
/** @var Builder<Project> $builder */ /** @var Builder<Project> $builder */
$clientId = $this->input('client_id'); return $builder->whereBelongsTo($this->organization, 'organization');
if (! is_string($clientId) || ! Str::isUuid($clientId)) {
$clientId = null;
}
return $builder->whereBelongsTo($this->organization, 'organization')
->where('client_id', $clientId);
})->ignore($this->project?->getKey())->withCustomTranslation('validation.project_name_already_exists'), })->ignore($this->project?->getKey())->withCustomTranslation('validation.project_name_already_exists'),
], ],
'color' => [ 'color' => [
@@ -61,18 +54,18 @@ class ProjectUpdateRequest extends BaseFormRequest
'boolean', 'boolean',
], ],
'client_id' => [ 'client_id' => [
'present',
'nullable', 'nullable',
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder { ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */ /** @var Builder<Client> $builder */
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.
@@ -40,7 +40,7 @@ class ReportStoreRequest extends BaseFormRequest
'required', 'required',
'boolean', 'boolean',
], ],
// After this date the report will be automatically set to private (is_public=false) (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z") // After this date the report will be automatically set to private (is_public=false) (ISO 8601 format, UTC timezone)
'public_until' => [ 'public_until' => [
'nullable', 'nullable',
'date_format:Y-m-d\TH:i:s\Z', 'date_format:Y-m-d\TH:i:s\Z',

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.
@@ -59,12 +59,12 @@ class TimeEntryStoreRequest extends BaseFormRequest
->where('project_id', $this->input('project_id')); ->where('project_id', $this->input('project_id'));
})->uuid()->withMessage(__('validation.task_belongs_to_project')), })->uuid()->withMessage(__('validation.task_belongs_to_project')),
], ],
// Start of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z") // Start of time entry (ISO 8601 format, UTC timezone)
'start' => [ 'start' => [
'required', 'required',
'date_format:Y-m-d\TH:i:s\Z', 'date_format:Y-m-d\TH:i:s\Z',
], ],
// End of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z") // End of time entry (ISO 8601 format, UTC timezone)
'end' => [ 'end' => [
'nullable', 'nullable',
'date_format:Y-m-d\TH:i:s\Z', 'date_format:Y-m-d\TH:i:s\Z',

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.
@@ -59,11 +59,11 @@ class TimeEntryUpdateRequest extends BaseFormRequest
->where('project_id', $this->input('project_id')); ->where('project_id', $this->input('project_id'));
})->uuid()->withMessage(__('validation.task_belongs_to_project')), })->uuid()->withMessage(__('validation.task_belongs_to_project')),
], ],
// Start of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z") // Start of time entry (ISO 8601 format, UTC timezone)
'start' => [ 'start' => [
'date_format:Y-m-d\TH:i:s\Z', 'date_format:Y-m-d\TH:i:s\Z',
], ],
// End of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z") // End of time entry (ISO 8601 format, UTC timezone)
'end' => [ 'end' => [
'nullable', 'nullable',
'date_format:Y-m-d\TH:i:s\Z', 'date_format:Y-m-d\TH:i:s\Z',

View File

@@ -12,10 +12,6 @@ abstract class BaseResource extends JsonResource
protected function formatDateTime(?Carbon $carbon): ?string protected function formatDateTime(?Carbon $carbon): ?string
{ {
return $carbon?->toIso8601ZuluString(); return $carbon?->toIso8601ZuluString();
}
protected function formatDate(?Carbon $carbon): ?string
{
return $carbon?->format('Y-m-d');
} }
} }

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

@@ -37,9 +37,9 @@ class ClockifyProjectsImporter extends DefaultImporter
if ($record['Project'] !== '') { if ($record['Project'] !== '') {
$projectId = $this->projectImportHelper->getKey([ $projectId = $this->projectImportHelper->getKey([
'name' => $record['Project'], 'name' => $record['Project'],
'client_id' => $clientId,
'organization_id' => $this->organization->id, 'organization_id' => $this->organization->id,
], [ ], [
'client_id' => $clientId,
'color' => $this->colorService->getRandomColor(), 'color' => $this->colorService->getRandomColor(),
'is_billable' => $record['Billability'] === 'Yes', 'is_billable' => $record['Billability'] === 'Yes',
'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null, 'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null,

View File

@@ -83,9 +83,9 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
if ($record['Project'] !== '') { if ($record['Project'] !== '') {
$projectId = $this->projectImportHelper->getKey([ $projectId = $this->projectImportHelper->getKey([
'name' => $record['Project'], 'name' => $record['Project'],
'client_id' => $clientId,
'organization_id' => $this->organization->id, 'organization_id' => $this->organization->id,
], [ ], [
'client_id' => $clientId,
'color' => $this->colorService->getRandomColor(), 'color' => $this->colorService->getRandomColor(),
'is_billable' => false, 'is_billable' => false,
]); ]);
@@ -124,59 +124,34 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
$timeEntry->is_imported = true; $timeEntry->is_imported = true;
// Start // Start
$start = null;
try { try {
$startDateStr = $record['Start Date']; if (preg_match('/^[0-9]{1,2}:[0-9]{1,2} (AM|PM)$/', $record['Start Time']) === 1) {
$startTimeStr = $record['Start Time']; $start = Carbon::createFromFormat('m/d/Y h:i A', $record['Start Date'].' '.$record['Start Time'], $timezone);
$startStr = $startDateStr.' '.$startTimeStr; } else {
$matches = []; $start = Carbon::createFromFormat('m/d/Y H:i:s A', $record['Start Date'].' '.$record['Start Time'], $timezone);
$checkResult = preg_match('/^([0-9]{1,2})\/([0-9]{1,2})\/([0-9]{4}) ([0-9]{1,2}):([0-9]{1,2})(:[0-9]{1,2})? (AM|PM)$/', $startStr, $matches);
if ($checkResult === 1) {
if ((int) $matches[1] > 12) {
throw new ImportException('Start date ("'.$startDateStr.'") is invalid, please select the correct date format before exporting from Clockify');
}
if ($matches[6] === '') {
$start = Carbon::createFromFormat('m/d/Y h:i A', $startStr, $timezone);
} else {
$start = Carbon::createFromFormat('m/d/Y H:i:s A', $startStr, $timezone);
}
} }
} catch (InvalidFormatException) { } catch (InvalidFormatException) {
throw new ImportException('Start date ("'.$startDateStr.'") or time ("'.$startTimeStr.'") are invalid'); throw new ImportException('Start date ("'.$record['Start Date'].'") or time ("'.$record['Start Time'].'") are invalid');
} }
if ($start === null) { if ($start === null) {
throw new ImportException('Start date ("'.$startDateStr.'") or time ("'.$startTimeStr.'") are invalid'); throw new ImportException('Start date ("'.$record['Start Date'].'") or time ("'.$record['Start Time'].'") are invalid');
} }
$timeEntry->start = $start->utc(); $timeEntry->start = $start->utc();
// End // End
$end = null;
try { try {
$endDateStr = $record['End Date']; if (preg_match('/^[0-9]{1,2}:[0-9]{1,2} (AM|PM)$/', $record['End Time']) === 1) {
$endTimeStr = $record['End Time']; $end = Carbon::createFromFormat('m/d/Y h:i A', $record['End Date'].' '.$record['End Time'], $timezone);
$endStr = $endDateStr.' '.$endTimeStr; } else {
$matches = []; $end = Carbon::createFromFormat('m/d/Y H:i:s A', $record['End Date'].' '.$record['End Time'], $timezone);
$checkResult = preg_match('/^([0-9]{1,2})\/([0-9]{1,2})\/([0-9]{4}) ([0-9]{1,2}):([0-9]{1,2})(:[0-9]{1,2})? (AM|PM)$/', $endStr, $matches);
if ($checkResult === 1) {
if ((int) $matches[1] > 12) {
throw new ImportException('Start date ("'.$endDateStr.'") is invalid, please select the correct date format before exporting from Clockify');
}
if ($matches[6] === '') {
$end = Carbon::createFromFormat('m/d/Y h:i A', $endStr, $timezone);
} else {
$end = Carbon::createFromFormat('m/d/Y H:i:s A', $endStr, $timezone);
}
} }
} catch (InvalidFormatException) { } catch (InvalidFormatException) {
throw new ImportException('End date ("'.$endDateStr.'") or time ("'.$endTimeStr.'") are invalid'); throw new ImportException('End date ("'.$record['End Date'].'") or time ("'.$record['End Time'].'") are invalid');
} }
if ($end === null) { if ($end === null) {
throw new ImportException('End date ("'.$endDateStr.'") or time ("'.$endTimeStr.'") are invalid'); throw new ImportException('End date ("'.$record['End Date'].'") or time ("'.$record['End Time'].'") are invalid');
} }
$timeEntry->end = $end->utc(); $timeEntry->end = $end->utc();
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations( $timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
$timeEntry, $timeEntry,
$projectMember, $projectMember,

View File

@@ -97,7 +97,7 @@ abstract class DefaultImporter implements ImporterContract
'in:placeholder', 'in:placeholder',
], ],
]); ]);
$this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'client_id', 'organization_id'], true, function (Builder $builder) { $this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], true, function (Builder $builder) {
/** @var Builder<Project> $builder */ /** @var Builder<Project> $builder */
return $builder->where('organization_id', $this->organization->id); return $builder->where('organization_id', $this->organization->id);
}, validate: [ }, validate: [
@@ -114,11 +114,6 @@ abstract class DefaultImporter implements ImporterContract
'integer', 'integer',
'max:2147483647', 'max:2147483647',
], ],
'client_id' => [
'nullable',
'string',
'uuid',
],
], beforeSave: function (Project $project): void { ], beforeSave: function (Project $project): void {
if ($project->billable_rate === 0) { if ($project->billable_rate === 0) {
$project->billable_rate = null; $project->billable_rate = null;

View File

@@ -55,12 +55,12 @@ class GenericProjectsImporter extends DefaultImporter
} }
$this->projectImportHelper->getKey([ $this->projectImportHelper->getKey([
'name' => $record['name'], 'name' => $record['name'],
'client_id' => $clientId,
'organization_id' => $this->organization->id, 'organization_id' => $this->organization->id,
], [ ], [
'color' => isset($record['color']) && $record['color'] !== '' ? $record['color'] : app(ColorService::class)->getRandomColor(), 'color' => isset($record['color']) && $record['color'] !== '' ? $record['color'] : app(ColorService::class)->getRandomColor(),
'billable_rate' => isset($record['billable_rate']) && $record['billable_rate'] !== '' ? (int) $record['billable_rate'] : null, 'billable_rate' => isset($record['billable_rate']) && $record['billable_rate'] !== '' ? (int) $record['billable_rate'] : null,
'is_public' => isset($record['is_public']) && $record['is_public'] === 'true', 'is_public' => isset($record['is_public']) && $record['is_public'] === 'true',
'client_id' => $clientId,
'is_billable' => isset($record['billable_default']) && $record['billable_default'] === 'true', 'is_billable' => isset($record['billable_default']) && $record['billable_default'] === 'true',
'estimated_time' => isset($record['estimated_time']) && $record['estimated_time'] !== '' && is_numeric($record['estimated_time']) && ((int) $record['estimated_time'] !== 0) ? (int) $record['estimated_time'] : null, 'estimated_time' => isset($record['estimated_time']) && $record['estimated_time'] !== '' && is_numeric($record['estimated_time']) && ((int) $record['estimated_time'] !== 0) ? (int) $record['estimated_time'] : null,
'archived_at' => $archivedAt, 'archived_at' => $archivedAt,

View File

@@ -99,9 +99,9 @@ class GenericTimeEntriesImporter extends DefaultImporter
if ($record['project'] !== '') { if ($record['project'] !== '') {
$projectId = $this->projectImportHelper->getKey([ $projectId = $this->projectImportHelper->getKey([
'name' => $record['project'], 'name' => $record['project'],
'client_id' => $clientId,
'organization_id' => $this->organization->id, 'organization_id' => $this->organization->id,
], [ ], [
'client_id' => $clientId,
'is_billable' => false, 'is_billable' => false,
'color' => $this->colorService->getRandomColor(), 'color' => $this->colorService->getRandomColor(),
]); ]);

View File

@@ -60,10 +60,10 @@ class HarvestProjectsImporter extends DefaultImporter
$billableHours = $billableHoursField !== '' && is_numeric($billableHoursField) ? (int) ((float) $billableHoursField) : null; $billableHours = $billableHoursField !== '' && is_numeric($billableHoursField) ? (int) ((float) $billableHoursField) : null;
$this->projectImportHelper->getKey([ $this->projectImportHelper->getKey([
'name' => $record['Project'], 'name' => $record['Project'],
'client_id' => $clientId,
'organization_id' => $this->organization->id, 'organization_id' => $this->organization->id,
], [ ], [
'color' => $this->colorService->getRandomColor(), 'color' => $this->colorService->getRandomColor(),
'client_id' => $clientId,
'estimated_time' => $estimatedTime, 'estimated_time' => $estimatedTime,
'is_billable' => $billableHours > 0, 'is_billable' => $billableHours > 0,
]); ]);

View File

@@ -78,9 +78,9 @@ class HarvestTimeEntriesImporter extends DefaultImporter
if ($record['Project'] !== '') { if ($record['Project'] !== '') {
$projectId = $this->projectImportHelper->getKey([ $projectId = $this->projectImportHelper->getKey([
'name' => $record['Project'], 'name' => $record['Project'],
'client_id' => $clientId,
'organization_id' => $this->organization->id, 'organization_id' => $this->organization->id,
], [ ], [
'client_id' => $clientId,
'color' => $this->colorService->getRandomColor(), 'color' => $this->colorService->getRandomColor(),
'is_billable' => true, 'is_billable' => true,
]); ]);

View File

@@ -176,12 +176,12 @@ class SolidtimeImporter extends DefaultImporter
$this->projectImportHelper->getKey([ $this->projectImportHelper->getKey([
'name' => $project['name'], 'name' => $project['name'],
'client_id' => $clientId,
'organization_id' => $this->organization->getKey(), 'organization_id' => $this->organization->getKey(),
], [ ], [
'color' => $project['color'], 'color' => $project['color'],
'billable_rate' => $project['billable_rate'] === '' ? null : (int) $project['billable_rate'], 'billable_rate' => $project['billable_rate'] === '' ? null : (int) $project['billable_rate'],
'is_public' => $project['is_public'] === 'true', 'is_public' => $project['is_public'] === 'true',
'client_id' => $clientId,
'is_billable' => $project['is_billable'] === 'true', 'is_billable' => $project['is_billable'] === 'true',
'archived_at' => $project['archived_at'] !== '' ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $project['archived_at'], 'UTC') : null, 'archived_at' => $project['archived_at'] !== '' ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $project['archived_at'], 'UTC') : null,
], $project['id']); ], $project['id']);

View File

@@ -137,9 +137,9 @@ class TogglDataImporter extends DefaultImporter
$projectId = $this->projectImportHelper->getKey([ $projectId = $this->projectImportHelper->getKey([
'name' => $project->name, 'name' => $project->name,
'client_id' => $clientId,
'organization_id' => $this->organization->getKey(), 'organization_id' => $this->organization->getKey(),
], [ ], [
'client_id' => $clientId,
'color' => $project->color, 'color' => $project->color,
'is_billable' => $project->billable, 'is_billable' => $project->billable,
'is_public' => ! $project->is_private, 'is_public' => ! $project->is_private,

View File

@@ -83,9 +83,9 @@ class TogglTimeEntriesImporter extends DefaultImporter
if ($record['Project'] !== '') { if ($record['Project'] !== '') {
$projectId = $this->projectImportHelper->getKey([ $projectId = $this->projectImportHelper->getKey([
'name' => $record['Project'], 'name' => $record['Project'],
'client_id' => $clientId,
'organization_id' => $this->organization->id, 'organization_id' => $this->organization->id,
], [ ], [
'client_id' => $clientId,
'is_billable' => false, 'is_billable' => false,
'color' => $this->colorService->getRandomColor(), 'color' => $this->colorService->getRandomColor(),
]); ]);

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

@@ -280,20 +280,6 @@ class TimeEntryAggregationService
'color' => null, 'color' => null,
]; ];
} }
} elseif ($type === TimeEntryAggregationType::Description) {
foreach ($keys as $key) {
$descriptorMap[$key] = [
'description' => $key,
'color' => null,
];
}
} elseif ($type === TimeEntryAggregationType::Billable) {
foreach ($keys as $key) {
$descriptorMap[$key] = [
'description' => $key === '0' ? 'Non-billable' : 'Billable',
'color' => null,
];
}
} }
return $descriptorMap; return $descriptorMap;

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();
}
} }
/** /**

1671
composer.lock generated

File diff suppressed because it is too large Load Diff

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,36 +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
{
Schema::table('projects', function (Blueprint $table): void {
$table->bigInteger('spent_time')->unsigned()->default(0)->change();
});
Schema::table('tasks', function (Blueprint $table): void {
$table->bigInteger('spent_time')->unsigned()->default(0)->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('projects', function (Blueprint $table): void {
$table->integer('spent_time')->unsigned()->default(0)->change();
});
Schema::table('tasks', function (Blueprint $table): void {
$table->integer('spent_time')->unsigned()->default(0)->change();
});
}
};

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

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

View File

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

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

@@ -202,7 +202,7 @@ return [
'currency' => 'The :attribute field must be a valid currency code (ISO 4217).', 'currency' => 'The :attribute field must be a valid currency code (ISO 4217).',
'organization' => 'The :attribute does not exist.', 'organization' => 'The :attribute does not exist.',
'task_belongs_to_project' => 'The :attribute is not part of the given project.', 'task_belongs_to_project' => 'The :attribute is not part of the given project.',
'project_name_already_exists' => 'A project with the same name and client already exists in the organization.', 'project_name_already_exists' => 'A project with the same name already exists in the organization.',
'tag_name_already_exists' => 'A tag with the same name already exists in the organization.', 'tag_name_already_exists' => 'A tag with the same name already exists in the organization.',
'client_name_already_exists' => 'A client with the same name already exists in the organization.', 'client_name_already_exists' => 'A client with the same name already exists in the organization.',
'task_name_already_exists' => 'A task with the same name already exists in the project.', 'task_name_already_exists' => 'A task with the same name already exists in the project.',

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

@@ -1,9 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from "vue"; import { onMounted, watch } from "vue";
import { useTheme } from "@/utils/theme.js"; import { theme } from "@/utils/theme.js";
onMounted(async () => { onMounted(async () => {
useTheme() document.documentElement.classList.add(theme.value);
watch(theme, (newTheme, oldTheme) => {
document.documentElement.classList.remove(oldTheme);
document.documentElement.classList.add(newTheme);
});
}); });
</script> </script>

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>

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