mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-16 20:22:15 +01:00
Compare commits
24 Commits
feature/nu
...
v0.7.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8110e222a | ||
|
|
7673b365ca | ||
|
|
da5fc3f113 | ||
|
|
8c66068663 | ||
|
|
dd0cc0d60b | ||
|
|
3a482c1e6a | ||
|
|
ef9f353047 | ||
|
|
f1a1d2a266 | ||
|
|
f5efbad703 | ||
|
|
17242188c2 | ||
|
|
0a376b1caa | ||
|
|
10a8310e37 | ||
|
|
89131b9e77 | ||
|
|
c17c5dc6c0 | ||
|
|
3444281703 | ||
|
|
84e2365a6d | ||
|
|
92ac9948a0 | ||
|
|
8da358dbe6 | ||
|
|
b7b9092e64 | ||
|
|
15ac3e9a43 | ||
|
|
d03dd60864 | ||
|
|
827e0fe377 | ||
|
|
e78a551098 | ||
|
|
ae00fdb0e9 |
2
.github/workflows/build-private.yml
vendored
2
.github/workflows/build-private.yml
vendored
@@ -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 services extension"
|
- name: "Checkout invoicing extension"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
repository: solidtime-io/extension-invoicing
|
repository: solidtime-io/extension-invoicing
|
||||||
|
|||||||
2
.github/workflows/phpunit.yml
vendored
2
.github/workflows/phpunit.yml
vendored
@@ -63,7 +63,7 @@ jobs:
|
|||||||
run: php artisan test --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.0
|
uses: codecov/codecov-action@v5.4.2
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
slug: solidtime-io/solidtime
|
slug: solidtime-io/solidtime
|
||||||
|
|||||||
@@ -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\PrettyJson;
|
use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField;
|
||||||
|
|
||||||
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(),
|
||||||
PrettyJson::make('old_values'),
|
PrettyJsonField::make('old_values'),
|
||||||
PrettyJson::make('new_values'),
|
PrettyJsonField::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')
|
||||||
|
|||||||
@@ -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\PrettyJson;
|
use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @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%;']),
|
||||||
PrettyJson::make('payload')->disabled()->columnSpan(4),
|
PrettyJsonField::make('payload')->disabled()->columnSpan(4),
|
||||||
])->columns(4);
|
])->columns(4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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\PrettyJson;
|
use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField;
|
||||||
|
|
||||||
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(),
|
||||||
PrettyJson::make('properties')
|
PrettyJsonField::make('properties')
|
||||||
->formatStateUsing(function (ReportPropertiesDto $state, Report $record): string {
|
->formatStateUsing(function (ReportPropertiesDto $state, Report $record): string {
|
||||||
return $record->getRawOriginal('properties');
|
return $record->getRawOriginal('properties');
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ 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);
|
||||||
@@ -48,6 +49,7 @@ 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),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use App\Rules\ColorRule;
|
|||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ class ProjectStoreRequest extends FormRequest
|
|||||||
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',
|
||||||
@@ -34,7 +36,13 @@ class ProjectStoreRequest extends FormRequest
|
|||||||
'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 */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
$clientId = $this->input('client_id');
|
||||||
|
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,6 +63,7 @@ class ProjectStoreRequest extends FormRequest
|
|||||||
],
|
],
|
||||||
// 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 */
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use App\Rules\ColorRule;
|
|||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||||
|
|
||||||
@@ -34,7 +35,13 @@ class ProjectUpdateRequest extends FormRequest
|
|||||||
'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 */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
$clientId = $this->input('client_id');
|
||||||
|
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' => [
|
||||||
@@ -54,6 +61,7 @@ class ProjectUpdateRequest extends FormRequest
|
|||||||
'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 */
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class ReportStoreRequest extends FormRequest
|
|||||||
'required',
|
'required',
|
||||||
'boolean',
|
'boolean',
|
||||||
],
|
],
|
||||||
// After this date the report will be automatically set to private (is_public=false) (ISO 8601 format, UTC timezone)
|
// 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")
|
||||||
'public_until' => [
|
'public_until' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'date_format:Y-m-d\TH:i:s\Z',
|
'date_format:Y-m-d\TH:i:s\Z',
|
||||||
|
|||||||
@@ -59,12 +59,12 @@ class TimeEntryStoreRequest extends FormRequest
|
|||||||
->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 (ISO 8601 format, UTC timezone)
|
// Start of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z")
|
||||||
'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 (ISO 8601 format, UTC timezone)
|
// End of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z")
|
||||||
'end' => [
|
'end' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'date_format:Y-m-d\TH:i:s\Z',
|
'date_format:Y-m-d\TH:i:s\Z',
|
||||||
|
|||||||
@@ -59,11 +59,11 @@ class TimeEntryUpdateRequest extends FormRequest
|
|||||||
->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 (ISO 8601 format, UTC timezone)
|
// Start of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z")
|
||||||
'start' => [
|
'start' => [
|
||||||
'date_format:Y-m-d\TH:i:s\Z',
|
'date_format:Y-m-d\TH:i:s\Z',
|
||||||
],
|
],
|
||||||
// End of time entry (ISO 8601 format, UTC timezone)
|
// End of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z")
|
||||||
'end' => [
|
'end' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'date_format:Y-m-d\TH:i:s\Z',
|
'date_format:Y-m-d\TH:i:s\Z',
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ 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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,34 +124,59 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
|||||||
$timeEntry->is_imported = true;
|
$timeEntry->is_imported = true;
|
||||||
|
|
||||||
// Start
|
// Start
|
||||||
|
$start = null;
|
||||||
try {
|
try {
|
||||||
if (preg_match('/^[0-9]{1,2}:[0-9]{1,2} (AM|PM)$/', $record['Start Time']) === 1) {
|
$startDateStr = $record['Start Date'];
|
||||||
$start = Carbon::createFromFormat('m/d/Y h:i A', $record['Start Date'].' '.$record['Start Time'], $timezone);
|
$startTimeStr = $record['Start Time'];
|
||||||
} else {
|
$startStr = $startDateStr.' '.$startTimeStr;
|
||||||
$start = Carbon::createFromFormat('m/d/Y H:i:s A', $record['Start Date'].' '.$record['Start Time'], $timezone);
|
$matches = [];
|
||||||
|
$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 ("'.$record['Start Date'].'") or time ("'.$record['Start Time'].'") are invalid');
|
throw new ImportException('Start date ("'.$startDateStr.'") or time ("'.$startTimeStr.'") are invalid');
|
||||||
}
|
}
|
||||||
if ($start === null) {
|
if ($start === null) {
|
||||||
throw new ImportException('Start date ("'.$record['Start Date'].'") or time ("'.$record['Start Time'].'") are invalid');
|
throw new ImportException('Start date ("'.$startDateStr.'") or time ("'.$startTimeStr.'") are invalid');
|
||||||
}
|
}
|
||||||
$timeEntry->start = $start->utc();
|
$timeEntry->start = $start->utc();
|
||||||
|
|
||||||
// End
|
// End
|
||||||
|
$end = null;
|
||||||
try {
|
try {
|
||||||
if (preg_match('/^[0-9]{1,2}:[0-9]{1,2} (AM|PM)$/', $record['End Time']) === 1) {
|
$endDateStr = $record['End Date'];
|
||||||
$end = Carbon::createFromFormat('m/d/Y h:i A', $record['End Date'].' '.$record['End Time'], $timezone);
|
$endTimeStr = $record['End Time'];
|
||||||
} else {
|
$endStr = $endDateStr.' '.$endTimeStr;
|
||||||
$end = Carbon::createFromFormat('m/d/Y H:i:s A', $record['End Date'].' '.$record['End Time'], $timezone);
|
$matches = [];
|
||||||
|
$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 ("'.$record['End Date'].'") or time ("'.$record['End Time'].'") are invalid');
|
throw new ImportException('End date ("'.$endDateStr.'") or time ("'.$endTimeStr.'") are invalid');
|
||||||
}
|
}
|
||||||
if ($end === null) {
|
if ($end === null) {
|
||||||
throw new ImportException('End date ("'.$record['End Date'].'") or time ("'.$record['End Time'].'") are invalid');
|
throw new ImportException('End date ("'.$endDateStr.'") or time ("'.$endTimeStr.'") 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,
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ abstract class DefaultImporter implements ImporterContract
|
|||||||
'in:placeholder',
|
'in:placeholder',
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
$this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], true, function (Builder $builder) {
|
$this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'client_id', '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,6 +114,11 @@ 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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -280,6 +280,20 @@ 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;
|
||||||
|
|||||||
1671
composer.lock
generated
1671
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
|||||||
|
<?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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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 already exists in the organization.',
|
'project_name_already_exists' => 'A project with the same name and client 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.',
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, watch } from "vue";
|
import { onMounted } from "vue";
|
||||||
import { theme } from "@/utils/theme.js";
|
import { useTheme } from "@/utils/theme.js";
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
document.documentElement.classList.add(theme.value);
|
useTheme()
|
||||||
watch(theme, (newTheme, oldTheme) => {
|
|
||||||
document.documentElement.classList.remove(oldTheme);
|
|
||||||
document.documentElement.classList.add(newTheme);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -20,24 +20,24 @@ import {
|
|||||||
import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue';
|
import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue';
|
||||||
import UserSettingsIcon from '@/Components/UserSettingsIcon.vue';
|
import UserSettingsIcon from '@/Components/UserSettingsIcon.vue';
|
||||||
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
||||||
import { onMounted, ref, watch } from "vue";
|
import { onMounted, ref } from "vue";
|
||||||
import NotificationContainer from '@/Components/NotificationContainer.vue';
|
import NotificationContainer from '@/Components/NotificationContainer.vue';
|
||||||
import { initializeStores, refreshStores } from '@/utils/init';
|
import { initializeStores, refreshStores } from '@/utils/init';
|
||||||
import {
|
import {
|
||||||
canManageBilling,
|
canManageBilling,
|
||||||
canUpdateOrganization,
|
canUpdateOrganization,
|
||||||
canViewClients,
|
canViewClients, canViewInvoices,
|
||||||
canViewMembers,
|
canViewMembers,
|
||||||
canViewProjects, canViewReport,
|
canViewProjects, canViewReport,
|
||||||
canViewTags,
|
canViewTags,
|
||||||
} from '@/utils/permissions';
|
} from '@/utils/permissions';
|
||||||
import { isBillingActivated } from '@/utils/billing';
|
import { isBillingActivated, isInvoicingActivated } from '@/utils/billing';
|
||||||
import type { User } from '@/types/models';
|
import type { User } from '@/types/models';
|
||||||
import { ArrowsRightLeftIcon } from '@heroicons/vue/16/solid';
|
import { ArrowsRightLeftIcon } from '@heroicons/vue/16/solid';
|
||||||
import { fetchToken, isTokenValid } from '@/utils/session';
|
import { fetchToken, isTokenValid } from '@/utils/session';
|
||||||
import UpdateSidebarNotification from '@/Components/UpdateSidebarNotification.vue';
|
import UpdateSidebarNotification from '@/Components/UpdateSidebarNotification.vue';
|
||||||
import BillingBanner from '@/Components/Billing/BillingBanner.vue';
|
import BillingBanner from '@/Components/Billing/BillingBanner.vue';
|
||||||
import { theme } from "@/utils/theme";
|
import { useTheme } from "@/utils/theme";
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
title: String,
|
title: String,
|
||||||
@@ -47,12 +47,7 @@ const showSidebarMenu = ref(false);
|
|||||||
const isUnloading = ref(false);
|
const isUnloading = ref(false);
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|
||||||
document.documentElement.classList.add(theme.value);
|
useTheme()
|
||||||
watch(theme, (newTheme, oldTheme) => {
|
|
||||||
document.documentElement.classList.remove(oldTheme);
|
|
||||||
document.documentElement.classList.add(newTheme);
|
|
||||||
});
|
|
||||||
|
|
||||||
// make sure that the initial requests are only loaded once, this can be removed once we move away from inertia
|
// make sure that the initial requests are only loaded once, this can be removed once we move away from inertia
|
||||||
if (window.initialDataLoaded !== true) {
|
if (window.initialDataLoaded !== true) {
|
||||||
window.initialDataLoaded = true;
|
window.initialDataLoaded = true;
|
||||||
@@ -188,6 +183,7 @@ const page = usePage<{
|
|||||||
:current="route().current('tags')"
|
:current="route().current('tags')"
|
||||||
:href="route('tags')"></NavigationSidebarItem>
|
:href="route('tags')"></NavigationSidebarItem>
|
||||||
<NavigationSidebarItem
|
<NavigationSidebarItem
|
||||||
|
v-if="isInvoicingActivated() && canViewInvoices()"
|
||||||
title="Invoices"
|
title="Invoices"
|
||||||
:icon="DocumentTextIcon"
|
:icon="DocumentTextIcon"
|
||||||
:current="route().current('invoices')"
|
:current="route().current('invoices')"
|
||||||
@@ -272,8 +268,6 @@ const page = usePage<{
|
|||||||
v-if="$slots.header"
|
v-if="$slots.header"
|
||||||
class="bg-default-background border-b border-default-background-separator shadow">
|
class="bg-default-background border-b border-default-background-separator shadow">
|
||||||
<div class="pt-8 pb-3">
|
<div class="pt-8 pb-3">
|
||||||
|
|
||||||
|
|
||||||
<MainContainer>
|
<MainContainer>
|
||||||
<slot name="header" />
|
<slot name="header" />
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
|
|||||||
@@ -339,6 +339,8 @@ const tableData = computed(() => {
|
|||||||
@submit="updateReporting">
|
@submit="updateReporting">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<ReportingFilterBadge
|
<ReportingFilterBadge
|
||||||
|
:count="selectedClients.length"
|
||||||
|
:active="selectedClients.length > 0"
|
||||||
title="Clients"
|
title="Clients"
|
||||||
:icon="FolderIcon"></ReportingFilterBadge>
|
:icon="FolderIcon"></ReportingFilterBadge>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -308,6 +308,8 @@ async function downloadExport(format: ExportFormat) {
|
|||||||
@submit="updateFilteredTimeEntries">
|
@submit="updateFilteredTimeEntries">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<ReportingFilterBadge
|
<ReportingFilterBadge
|
||||||
|
:count="selectedClients.length"
|
||||||
|
:active="selectedClients.length > 0"
|
||||||
title="Clients"
|
title="Clients"
|
||||||
:icon="FolderIcon"></ReportingFilterBadge>
|
:icon="FolderIcon"></ReportingFilterBadge>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { api } from '@/packages/api/src';
|
|||||||
import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';
|
import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';
|
||||||
import { useReportingStore } from '@/utils/useReporting';
|
import { useReportingStore } from '@/utils/useReporting';
|
||||||
import { Head } from '@inertiajs/vue3';
|
import { Head } from '@inertiajs/vue3';
|
||||||
|
import { useTheme } from "@/utils/theme";
|
||||||
|
|
||||||
const sharedSecret = ref<string | null>(null);
|
const sharedSecret = ref<string | null>(null);
|
||||||
|
|
||||||
@@ -136,6 +137,10 @@ function getGroupLabel(key: string) {
|
|||||||
return option.value === key;
|
return option.value === key;
|
||||||
})?.label;
|
})?.label;
|
||||||
}
|
}
|
||||||
|
onMounted(async () => {
|
||||||
|
useTheme();
|
||||||
|
})
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ const InvoiceResource = z
|
|||||||
buyer_name: z.string(),
|
buyer_name: z.string(),
|
||||||
status: z.string(),
|
status: z.string(),
|
||||||
date: z.string(),
|
date: z.string(),
|
||||||
|
due_at: z.string(),
|
||||||
created_at: z.union([z.string(), z.null()]),
|
created_at: z.union([z.string(), z.null()]),
|
||||||
updated_at: z.union([z.string(), z.null()]),
|
updated_at: z.union([z.string(), z.null()]),
|
||||||
})
|
})
|
||||||
@@ -106,6 +107,8 @@ const InvoiceStoreRequest = z
|
|||||||
discount_type: InvoiceDiscountType.optional(),
|
discount_type: InvoiceDiscountType.optional(),
|
||||||
footer: z.union([z.string(), z.null()]).optional(),
|
footer: z.union([z.string(), z.null()]).optional(),
|
||||||
notes: z.union([z.string(), z.null()]).optional(),
|
notes: z.union([z.string(), z.null()]).optional(),
|
||||||
|
payment_terms: z.union([z.string(), z.null()]).optional(),
|
||||||
|
is_eu_reverse_charge: z.boolean().optional(),
|
||||||
entries: z
|
entries: z
|
||||||
.array(
|
.array(
|
||||||
z
|
z
|
||||||
@@ -127,7 +130,7 @@ const InvoiceEntryResource = z
|
|||||||
name: z.string(),
|
name: z.string(),
|
||||||
description: z.union([z.string(), z.null()]),
|
description: z.union([z.string(), z.null()]),
|
||||||
unit_price: z.number().int(),
|
unit_price: z.number().int(),
|
||||||
quantity: z.number().int(),
|
quantity: z.string(),
|
||||||
order_index: z.number().int(),
|
order_index: z.number().int(),
|
||||||
created_at: z.union([z.string(), z.null()]),
|
created_at: z.union([z.string(), z.null()]),
|
||||||
updated_at: z.union([z.string(), z.null()]),
|
updated_at: z.union([z.string(), z.null()]),
|
||||||
@@ -158,7 +161,7 @@ const DetailedInvoiceResource = z
|
|||||||
buyer_address_country: z.string(),
|
buyer_address_country: z.string(),
|
||||||
buyer_phone: z.string(),
|
buyer_phone: z.string(),
|
||||||
buyer_email: z.string(),
|
buyer_email: z.string(),
|
||||||
paid_at: z.string(),
|
paid_at: z.union([z.string(), z.null()]),
|
||||||
due_at: z.string(),
|
due_at: z.string(),
|
||||||
discount_type: z.string(),
|
discount_type: z.string(),
|
||||||
discount_amount: z.string(),
|
discount_amount: z.string(),
|
||||||
@@ -168,6 +171,8 @@ const DetailedInvoiceResource = z
|
|||||||
date: z.string(),
|
date: z.string(),
|
||||||
footer: z.string(),
|
footer: z.string(),
|
||||||
notes: z.string(),
|
notes: z.string(),
|
||||||
|
payment_terms: z.string(),
|
||||||
|
is_eu_reverse_charge: z.string(),
|
||||||
billing_period_start: z.string(),
|
billing_period_start: z.string(),
|
||||||
billing_period_end: z.string(),
|
billing_period_end: z.string(),
|
||||||
created_at: z.union([z.string(), z.null()]),
|
created_at: z.union([z.string(), z.null()]),
|
||||||
@@ -211,6 +216,8 @@ const InvoiceUpdateRequest = z
|
|||||||
discount_type: InvoiceDiscountType,
|
discount_type: InvoiceDiscountType,
|
||||||
footer: z.union([z.string(), z.null()]),
|
footer: z.union([z.string(), z.null()]),
|
||||||
notes: z.union([z.string(), z.null()]),
|
notes: z.union([z.string(), z.null()]),
|
||||||
|
payment_terms: z.union([z.string(), z.null()]),
|
||||||
|
is_eu_reverse_charge: z.boolean(),
|
||||||
entries: z.array(
|
entries: z.array(
|
||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
@@ -225,6 +232,9 @@ const InvoiceUpdateRequest = z
|
|||||||
})
|
})
|
||||||
.partial()
|
.partial()
|
||||||
.passthrough();
|
.passthrough();
|
||||||
|
const InvoiceDownloadRequest = z
|
||||||
|
.object({ with_e_invoice: z.boolean() })
|
||||||
|
.passthrough();
|
||||||
const InvoiceSettingResource = z
|
const InvoiceSettingResource = z
|
||||||
.object({
|
.object({
|
||||||
seller_name: z.union([z.string(), z.null()]),
|
seller_name: z.union([z.string(), z.null()]),
|
||||||
@@ -462,9 +472,9 @@ const ReportStoreRequest = z
|
|||||||
task_ids: z
|
task_ids: z
|
||||||
.union([z.array(z.string().uuid()), z.null()])
|
.union([z.array(z.string().uuid()), z.null()])
|
||||||
.optional(),
|
.optional(),
|
||||||
group: TimeEntryAggregationType.optional(),
|
group: TimeEntryAggregationType,
|
||||||
sub_group: TimeEntryAggregationType.optional(),
|
sub_group: TimeEntryAggregationType,
|
||||||
history_group: TimeEntryAggregationTypeInterval.optional(),
|
history_group: TimeEntryAggregationTypeInterval,
|
||||||
week_start: Weekday.optional(),
|
week_start: Weekday.optional(),
|
||||||
timezone: z.union([z.string(), z.null()]).optional(),
|
timezone: z.union([z.string(), z.null()]).optional(),
|
||||||
})
|
})
|
||||||
@@ -752,6 +762,7 @@ export const schemas = {
|
|||||||
DetailedInvoiceResource,
|
DetailedInvoiceResource,
|
||||||
InvoiceStatus,
|
InvoiceStatus,
|
||||||
InvoiceUpdateRequest,
|
InvoiceUpdateRequest,
|
||||||
|
InvoiceDownloadRequest,
|
||||||
InvoiceSettingResource,
|
InvoiceSettingResource,
|
||||||
InvoiceSettingUpdateRequest,
|
InvoiceSettingUpdateRequest,
|
||||||
MemberResource,
|
MemberResource,
|
||||||
@@ -1380,7 +1391,7 @@ const endpoints = makeApi([
|
|||||||
schema: z.string(),
|
schema: z.string(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
response: z.null(),
|
response: z.void(),
|
||||||
errors: [
|
errors: [
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
@@ -1665,7 +1676,7 @@ const endpoints = makeApi([
|
|||||||
schema: z.string(),
|
schema: z.string(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
response: z.null(),
|
response: z.void(),
|
||||||
errors: [
|
errors: [
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
@@ -1722,7 +1733,7 @@ const endpoints = makeApi([
|
|||||||
schema: z.string(),
|
schema: z.string(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
response: z.null(),
|
response: z.void(),
|
||||||
errors: [
|
errors: [
|
||||||
{
|
{
|
||||||
status: 401,
|
status: 401,
|
||||||
@@ -1758,7 +1769,7 @@ const endpoints = makeApi([
|
|||||||
schema: z.string(),
|
schema: z.string(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
response: z.null(),
|
response: z.void(),
|
||||||
errors: [
|
errors: [
|
||||||
{
|
{
|
||||||
status: 401,
|
status: 401,
|
||||||
@@ -2050,7 +2061,7 @@ const endpoints = makeApi([
|
|||||||
schema: z.string(),
|
schema: z.string(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
response: z.null(),
|
response: z.void(),
|
||||||
errors: [
|
errors: [
|
||||||
{
|
{
|
||||||
status: 401,
|
status: 401,
|
||||||
@@ -2075,6 +2086,11 @@ const endpoints = makeApi([
|
|||||||
alias: 'downloadInvoice',
|
alias: 'downloadInvoice',
|
||||||
requestFormat: 'json',
|
requestFormat: 'json',
|
||||||
parameters: [
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'body',
|
||||||
|
type: 'Body',
|
||||||
|
schema: z.object({ with_e_invoice: z.boolean() }).passthrough(),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'organization',
|
name: 'organization',
|
||||||
type: 'Path',
|
type: 'Path',
|
||||||
@@ -2103,6 +2119,16 @@ const endpoints = makeApi([
|
|||||||
description: `Not found`,
|
description: `Not found`,
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
status: 422,
|
||||||
|
description: `Validation error`,
|
||||||
|
schema: z
|
||||||
|
.object({
|
||||||
|
message: z.string(),
|
||||||
|
errors: z.record(z.array(z.string())),
|
||||||
|
})
|
||||||
|
.passthrough(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2166,7 +2192,7 @@ const endpoints = makeApi([
|
|||||||
schema: z.string(),
|
schema: z.string(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
response: z.null(),
|
response: z.void(),
|
||||||
errors: [
|
errors: [
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
@@ -2358,7 +2384,7 @@ const endpoints = makeApi([
|
|||||||
schema: z.string(),
|
schema: z.string(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
response: z.null(),
|
response: z.void(),
|
||||||
errors: [
|
errors: [
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
@@ -2405,7 +2431,7 @@ const endpoints = makeApi([
|
|||||||
schema: z.string(),
|
schema: z.string(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
response: z.null(),
|
response: z.void(),
|
||||||
errors: [
|
errors: [
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
@@ -2452,7 +2478,7 @@ const endpoints = makeApi([
|
|||||||
schema: z.string(),
|
schema: z.string(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
response: z.null(),
|
response: z.void(),
|
||||||
errors: [
|
errors: [
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
@@ -2550,7 +2576,7 @@ const endpoints = makeApi([
|
|||||||
schema: z.string(),
|
schema: z.string(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
response: z.null(),
|
response: z.void(),
|
||||||
errors: [
|
errors: [
|
||||||
{
|
{
|
||||||
status: 401,
|
status: 401,
|
||||||
@@ -2802,7 +2828,7 @@ const endpoints = makeApi([
|
|||||||
schema: z.string(),
|
schema: z.string(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
response: z.null(),
|
response: z.void(),
|
||||||
errors: [
|
errors: [
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
@@ -3175,7 +3201,7 @@ const endpoints = makeApi([
|
|||||||
schema: z.string(),
|
schema: z.string(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
response: z.null(),
|
response: z.void(),
|
||||||
errors: [
|
errors: [
|
||||||
{
|
{
|
||||||
status: 401,
|
status: 401,
|
||||||
@@ -3343,7 +3369,7 @@ const endpoints = makeApi([
|
|||||||
schema: z.string(),
|
schema: z.string(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
response: z.null(),
|
response: z.void(),
|
||||||
errors: [
|
errors: [
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
@@ -3570,7 +3596,7 @@ const endpoints = makeApi([
|
|||||||
schema: z.string(),
|
schema: z.string(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
response: z.null(),
|
response: z.void(),
|
||||||
errors: [
|
errors: [
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
@@ -3950,7 +3976,7 @@ Users with the permission `time-entries:view:own` can only use this en
|
|||||||
schema: z.string(),
|
schema: z.string(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
response: z.null(),
|
response: z.void(),
|
||||||
errors: [
|
errors: [
|
||||||
{
|
{
|
||||||
status: 401,
|
status: 401,
|
||||||
@@ -4565,7 +4591,7 @@ Please note that the access token is only shown in this response and cannot be r
|
|||||||
schema: z.string(),
|
schema: z.string(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
response: z.null(),
|
response: z.void(),
|
||||||
errors: [
|
errors: [
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
@@ -4607,7 +4633,7 @@ Please note that the access token is only shown in this response and cannot be r
|
|||||||
schema: z.string(),
|
schema: z.string(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
response: z.null(),
|
response: z.void(),
|
||||||
errors: [
|
errors: [
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
@@ -4672,6 +4698,11 @@ Please note that the access token is only shown in this response and cannot be r
|
|||||||
description: `Unauthenticated`,
|
description: `Unauthenticated`,
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
description: `Authorization error`,
|
||||||
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
status: 404,
|
status: 404,
|
||||||
description: `Not found`,
|
description: `Not found`,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
|
||||||
|
import { watch } from "vue";
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -36,6 +37,12 @@ function onOpenChange(value: boolean) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(open, (value) => {
|
||||||
|
if (value === false) {
|
||||||
|
emit('submit');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ export function isBillingActivated() {
|
|||||||
return page.props.has_billing_extension;
|
return page.props.has_billing_extension;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isInvoicingActivated() {
|
||||||
|
const page = usePage<{
|
||||||
|
has_invoicing_extension: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
return page.props.has_invoicing_extension;
|
||||||
|
}
|
||||||
|
|
||||||
export function isInTrial() {
|
export function isInTrial() {
|
||||||
const page = usePage<{
|
const page = usePage<{
|
||||||
billing: {
|
billing: {
|
||||||
|
|||||||
@@ -122,3 +122,7 @@ export function canDeleteReport() {
|
|||||||
export function canViewAllTimeEntries() {
|
export function canViewAllTimeEntries() {
|
||||||
return currentUserHasPermission('time-entries:view:all');
|
return currentUserHasPermission('time-entries:view:all');
|
||||||
}
|
}
|
||||||
|
export function canViewInvoices() {
|
||||||
|
return currentUserHasPermission('invoices:view');
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,4 +22,12 @@ const theme = computed(() => {
|
|||||||
return themeSetting.value
|
return themeSetting.value
|
||||||
});
|
});
|
||||||
|
|
||||||
export { type themeOption, themeSetting, theme };
|
function useTheme() {
|
||||||
|
document.documentElement.classList.add(theme.value);
|
||||||
|
watch(theme, (newTheme, oldTheme) => {
|
||||||
|
document.documentElement.classList.remove(oldTheme);
|
||||||
|
document.documentElement.classList.add(newTheme);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { type themeOption, themeSetting, theme, useTheme };
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"Project","Client","Description","Task","User","Group","Email","Tags","Type","Billable","Invoiced","Invoice ID","Start Date","Start Time","End Date","End Time","Duration (h)","Duration (decimal)","Billable Rate (EUR)","Billable Amount (EUR)","Date of creation"
|
||||||
|
"Real World Project","Real World Client","\\ 🔥 Special characters ''''''`!@#$%^&*()_+\-=\[\]{};':''\\|,.''<>\/?~ \\\","A giant task","Peter Tester","Group1, Group2","peter.test@email.test","","Regular","Yes","Yes","Invoice100","13/15/2024","11:00:00 AM","10/15/2024","11:30:00 AM","00:30:00","0.50","1000.00","500.00","10/15/2024"
|
||||||
|
@@ -277,6 +277,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
|
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
|
||||||
'name' => $projectFake->name,
|
'name' => $projectFake->name,
|
||||||
'color' => $projectFake->color,
|
'color' => $projectFake->color,
|
||||||
|
'client_id' => null,
|
||||||
'is_billable' => $projectFake->is_billable,
|
'is_billable' => $projectFake->is_billable,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -299,6 +300,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'name' => $projectFake->name,
|
'name' => $projectFake->name,
|
||||||
'color' => $projectFake->color,
|
'color' => $projectFake->color,
|
||||||
'is_billable' => $projectFake->is_billable,
|
'is_billable' => $projectFake->is_billable,
|
||||||
|
'client_id' => null,
|
||||||
'billable_rate' => $billableRate,
|
'billable_rate' => $billableRate,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -309,6 +311,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'color' => $projectFake->color,
|
'color' => $projectFake->color,
|
||||||
'organization_id' => $projectFake->organization_id,
|
'organization_id' => $projectFake->organization_id,
|
||||||
'is_billable' => $projectFake->is_billable,
|
'is_billable' => $projectFake->is_billable,
|
||||||
|
'client_id' => null,
|
||||||
'billable_rate' => $billableRate,
|
'billable_rate' => $billableRate,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -328,6 +331,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'name' => $projectFake->name,
|
'name' => $projectFake->name,
|
||||||
'color' => $projectFake->color,
|
'color' => $projectFake->color,
|
||||||
'is_billable' => $projectFake->is_billable,
|
'is_billable' => $projectFake->is_billable,
|
||||||
|
'client_id' => null,
|
||||||
'billable_rate' => $billableRate,
|
'billable_rate' => $billableRate,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -351,6 +355,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
|
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
|
||||||
'name' => $projectFake->name,
|
'name' => $projectFake->name,
|
||||||
'color' => $projectFake->color,
|
'color' => $projectFake->color,
|
||||||
|
'client_id' => null,
|
||||||
'is_billable' => $projectFake->is_billable,
|
'is_billable' => $projectFake->is_billable,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -360,6 +365,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'name' => $projectFake->name,
|
'name' => $projectFake->name,
|
||||||
'color' => $projectFake->color,
|
'color' => $projectFake->color,
|
||||||
'organization_id' => $projectFake->organization_id,
|
'organization_id' => $projectFake->organization_id,
|
||||||
|
'client_id' => null,
|
||||||
'is_billable' => $projectFake->is_billable,
|
'is_billable' => $projectFake->is_billable,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -378,6 +384,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'name' => $projectFake->name,
|
'name' => $projectFake->name,
|
||||||
'color' => $projectFake->color,
|
'color' => $projectFake->color,
|
||||||
'is_billable' => $projectFake->is_billable,
|
'is_billable' => $projectFake->is_billable,
|
||||||
|
'client_id' => null,
|
||||||
'estimated_time' => 10000,
|
'estimated_time' => 10000,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -394,6 +401,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'color' => $projectFake->color,
|
'color' => $projectFake->color,
|
||||||
'organization_id' => $projectFake->organization_id,
|
'organization_id' => $projectFake->organization_id,
|
||||||
'is_billable' => $projectFake->is_billable,
|
'is_billable' => $projectFake->is_billable,
|
||||||
|
'client_id' => null,
|
||||||
'estimated_time' => null,
|
'estimated_time' => null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -413,6 +421,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'name' => $projectFake->name,
|
'name' => $projectFake->name,
|
||||||
'color' => $projectFake->color,
|
'color' => $projectFake->color,
|
||||||
'is_billable' => $projectFake->is_billable,
|
'is_billable' => $projectFake->is_billable,
|
||||||
|
'client_id' => null,
|
||||||
'estimated_time' => 10000,
|
'estimated_time' => 10000,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -429,11 +438,47 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'color' => $projectFake->color,
|
'color' => $projectFake->color,
|
||||||
'organization_id' => $projectFake->organization_id,
|
'organization_id' => $projectFake->organization_id,
|
||||||
'is_billable' => $projectFake->is_billable,
|
'is_billable' => $projectFake->is_billable,
|
||||||
|
'client_id' => null,
|
||||||
'estimated_time' => 10000,
|
'estimated_time' => 10000,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_store_endpoint_fails_if_name_is_already_used_in_organization(): void
|
public function test_store_endpoint_can_create_project_if_project_name_already_exists_in_organization_but_with_different_client(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'projects:create',
|
||||||
|
]);
|
||||||
|
$name = 'Project Name';
|
||||||
|
$clientA = Client::factory()->forOrganization($data->organization)->create();
|
||||||
|
$clientB = Client::factory()->forOrganization($data->organization)->create();
|
||||||
|
$projectA = Project::factory()->forOrganization($data->organization)->forClient($clientA)->create([
|
||||||
|
'name' => $name,
|
||||||
|
]);
|
||||||
|
$projectFake = Project::factory()->forOrganization($data->organization)->make();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
|
||||||
|
'name' => $name,
|
||||||
|
'color' => $projectFake->color,
|
||||||
|
'client_id' => $clientB->getKey(),
|
||||||
|
'is_billable' => $projectFake->is_billable,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(201);
|
||||||
|
$this->assertDatabaseHas(Project::class, [
|
||||||
|
'name' => $name,
|
||||||
|
'client_id' => $clientB->getKey(),
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas(Project::class, [
|
||||||
|
'name' => $name,
|
||||||
|
'client_id' => $clientA->getKey(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_store_endpoint_fails_without_client_if_name_is_already_used_for_project_without_client_in_organization(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
$data = $this->createUserWithPermission([
|
$data = $this->createUserWithPermission([
|
||||||
@@ -450,13 +495,43 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
|
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
|
||||||
'name' => $name,
|
'name' => $name,
|
||||||
'color' => $projectFake->color,
|
'color' => $projectFake->color,
|
||||||
|
'client_id' => null,
|
||||||
'is_billable' => $projectFake->is_billable,
|
'is_billable' => $projectFake->is_billable,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertStatus(422);
|
$response->assertStatus(422);
|
||||||
$response->assertJsonValidationErrors([
|
$response->assertJsonValidationErrors([
|
||||||
'name' => 'A project with the same name already exists in the organization.',
|
'name' => 'A project with the same name and client already exists in the organization.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_store_endpoint_fails_with_client_if_name_is_already_used_for_the_same_client(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'projects:create',
|
||||||
|
]);
|
||||||
|
$name = 'Project Name';
|
||||||
|
$client = Client::factory()->forOrganization($data->organization)->create();
|
||||||
|
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create([
|
||||||
|
'name' => $name,
|
||||||
|
]);
|
||||||
|
$projectFake = Project::factory()->forOrganization($data->organization)->make();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
|
||||||
|
'name' => $name,
|
||||||
|
'color' => $projectFake->color,
|
||||||
|
'client_id' => $client->getKey(),
|
||||||
|
'is_billable' => $projectFake->is_billable,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(422);
|
||||||
|
$response->assertJsonValidationErrors([
|
||||||
|
'name' => 'A project with the same name and client already exists in the organization.',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,6 +553,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
|
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
|
||||||
'name' => $name,
|
'name' => $name,
|
||||||
'color' => $projectFake->color,
|
'color' => $projectFake->color,
|
||||||
|
'client_id' => null,
|
||||||
'is_billable' => $projectFake->is_billable,
|
'is_billable' => $projectFake->is_billable,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -534,6 +610,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
|
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
|
||||||
'name' => $projectFake->name,
|
'name' => $projectFake->name,
|
||||||
'color' => $projectFake->color,
|
'color' => $projectFake->color,
|
||||||
|
'client_id' => null,
|
||||||
'is_billable' => true,
|
'is_billable' => true,
|
||||||
'billable_rate' => 10001,
|
'billable_rate' => 10001,
|
||||||
]);
|
]);
|
||||||
@@ -565,6 +642,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
||||||
'name' => $projectFake->name,
|
'name' => $projectFake->name,
|
||||||
'color' => $projectFake->color,
|
'color' => $projectFake->color,
|
||||||
|
'client_id' => null,
|
||||||
'is_billable' => $projectFake->is_billable,
|
'is_billable' => $projectFake->is_billable,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -585,6 +663,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
||||||
'name' => $projectFake->name,
|
'name' => $projectFake->name,
|
||||||
'color' => $projectFake->color,
|
'color' => $projectFake->color,
|
||||||
|
'client_id' => null,
|
||||||
'is_billable' => $projectFake->is_billable,
|
'is_billable' => $projectFake->is_billable,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -592,7 +671,43 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response->assertForbidden();
|
$response->assertForbidden();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_update_endpoint_fails_if_name_is_already_used_in_organization(): void
|
public function test_update_endpoint_can_update_project_if_project_name_already_exists_in_organization_but_with_different_client(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'projects:update',
|
||||||
|
]);
|
||||||
|
$name = 'Project Name';
|
||||||
|
$clientA = Client::factory()->forOrganization($data->organization)->create();
|
||||||
|
$clientB = Client::factory()->forOrganization($data->organization)->create();
|
||||||
|
$projectWithTheName = Project::factory()->forOrganization($data->organization)->forClient($clientA)->create([
|
||||||
|
'name' => $name,
|
||||||
|
]);
|
||||||
|
$project = Project::factory()->forOrganization($data->organization)->create();
|
||||||
|
$projectFake = Project::factory()->forOrganization($data->organization)->create();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
||||||
|
'name' => $name,
|
||||||
|
'color' => $projectFake->color,
|
||||||
|
'client_id' => $clientB->getKey(),
|
||||||
|
'is_billable' => $projectFake->is_billable,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$this->assertDatabaseHas(Project::class, [
|
||||||
|
'name' => $name,
|
||||||
|
'client_id' => $clientA->getKey(),
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas(Project::class, [
|
||||||
|
'name' => $name,
|
||||||
|
'client_id' => $clientB->getKey(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_update_endpoint_fails_without_client_if_name_is_already_used_for_project_without_client_in_organization(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
$data = $this->createUserWithPermission([
|
$data = $this->createUserWithPermission([
|
||||||
@@ -610,13 +725,44 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
||||||
'name' => $name,
|
'name' => $name,
|
||||||
'color' => $projectFake->color,
|
'color' => $projectFake->color,
|
||||||
|
'client_id' => null,
|
||||||
'is_billable' => $projectFake->is_billable,
|
'is_billable' => $projectFake->is_billable,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertStatus(422);
|
$response->assertStatus(422);
|
||||||
$response->assertJsonValidationErrors([
|
$response->assertJsonValidationErrors([
|
||||||
'name' => 'A project with the same name already exists in the organization.',
|
'name' => 'A project with the same name and client already exists in the organization.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_update_endpoint_fails_with_client_if_name_is_already_used_for_the_same_client(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'projects:update',
|
||||||
|
]);
|
||||||
|
$name = 'Project Name';
|
||||||
|
$client = Client::factory()->forOrganization($data->organization)->create();
|
||||||
|
$projectWithTheName = Project::factory()->forOrganization($data->organization)->forClient($client)->create([
|
||||||
|
'name' => $name,
|
||||||
|
]);
|
||||||
|
$project = Project::factory()->forOrganization($data->organization)->create();
|
||||||
|
$projectFake = Project::factory()->forOrganization($data->organization)->create();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
||||||
|
'name' => $name,
|
||||||
|
'color' => $projectFake->color,
|
||||||
|
'client_id' => $client->getKey(),
|
||||||
|
'is_billable' => $projectFake->is_billable,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(422);
|
||||||
|
$response->assertJsonValidationErrors([
|
||||||
|
'name' => 'A project with the same name and client already exists in the organization.',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -720,6 +866,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
||||||
'name' => $name,
|
'name' => $name,
|
||||||
'color' => $projectFake->color,
|
'color' => $projectFake->color,
|
||||||
|
'client_id' => null,
|
||||||
'is_billable' => $projectFake->is_billable,
|
'is_billable' => $projectFake->is_billable,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -782,6 +929,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
||||||
'name' => $projectFake->name,
|
'name' => $projectFake->name,
|
||||||
'color' => $projectFake->color,
|
'color' => $projectFake->color,
|
||||||
|
'client_id' => null,
|
||||||
'is_billable' => $projectFake->is_billable,
|
'is_billable' => $projectFake->is_billable,
|
||||||
'estimated_time' => 10000,
|
'estimated_time' => 10000,
|
||||||
]);
|
]);
|
||||||
@@ -815,6 +963,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
||||||
'name' => $projectFake->name,
|
'name' => $projectFake->name,
|
||||||
'color' => $projectFake->color,
|
'color' => $projectFake->color,
|
||||||
|
'client_id' => null,
|
||||||
'is_billable' => $projectFake->is_billable,
|
'is_billable' => $projectFake->is_billable,
|
||||||
'estimated_time' => 10000,
|
'estimated_time' => 10000,
|
||||||
]);
|
]);
|
||||||
@@ -848,6 +997,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
||||||
'name' => $projectFake->name,
|
'name' => $projectFake->name,
|
||||||
'color' => $projectFake->color,
|
'color' => $projectFake->color,
|
||||||
|
'client_id' => null,
|
||||||
'is_billable' => $projectFake->is_billable,
|
'is_billable' => $projectFake->is_billable,
|
||||||
'billable_rate' => $project->billable_rate,
|
'billable_rate' => $project->billable_rate,
|
||||||
]);
|
]);
|
||||||
@@ -880,6 +1030,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
||||||
'name' => $projectFake->name,
|
'name' => $projectFake->name,
|
||||||
'color' => $projectFake->color,
|
'color' => $projectFake->color,
|
||||||
|
'client_id' => null,
|
||||||
'is_billable' => $projectFake->is_billable,
|
'is_billable' => $projectFake->is_billable,
|
||||||
'billable_rate' => 10003,
|
'billable_rate' => 10003,
|
||||||
]);
|
]);
|
||||||
@@ -907,6 +1058,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
||||||
'name' => $projectFake->name,
|
'name' => $projectFake->name,
|
||||||
'color' => $projectFake->color,
|
'color' => $projectFake->color,
|
||||||
|
'client_id' => null,
|
||||||
'is_billable' => $projectFake->is_billable,
|
'is_billable' => $projectFake->is_billable,
|
||||||
'is_archived' => true,
|
'is_archived' => true,
|
||||||
]);
|
]);
|
||||||
@@ -935,6 +1087,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
||||||
'name' => $projectFake->name,
|
'name' => $projectFake->name,
|
||||||
'color' => $projectFake->color,
|
'color' => $projectFake->color,
|
||||||
|
'client_id' => null,
|
||||||
'is_billable' => $projectFake->is_billable,
|
'is_billable' => $projectFake->is_billable,
|
||||||
'is_archived' => false,
|
'is_archived' => false,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -16,11 +16,13 @@ class ForceHttpsMiddlewareTest extends MiddlewareTestAbstract
|
|||||||
{
|
{
|
||||||
private function createTestRoute(): string
|
private function createTestRoute(): string
|
||||||
{
|
{
|
||||||
return Route::get('/test-route', function () {
|
$uri = Route::get('/test-route', function () {
|
||||||
return [
|
return [
|
||||||
'is_secure' => request()->secure(),
|
'is_secure' => request()->secure(),
|
||||||
];
|
];
|
||||||
})->middleware(ForceHttps::class)->uri;
|
})->middleware(ForceHttps::class)->uri;
|
||||||
|
|
||||||
|
return url($uri, [], false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_if_config_app_force_https_is_true_then_the_request_will_be_modified_to_make_the_app_think_it_was_a_https_request(): void
|
public function test_if_config_app_force_https_is_true_then_the_request_will_be_modified_to_make_the_app_think_it_was_a_https_request(): void
|
||||||
|
|||||||
@@ -185,4 +185,19 @@ class ProjectModelTest extends ModelTestAbstract
|
|||||||
// Assert
|
// Assert
|
||||||
$this->assertFalse($isArchived);
|
$this->assertFalse($isArchived);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_project_can_store_big_amounts_of_spent_time(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
$spentTime = 100 * 365 * 24 * 60 * 60; // 100 years in seconds
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$project->spent_time = $spentTime;
|
||||||
|
$project->save();
|
||||||
|
$project->refresh();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertSame($spentTime, $project->spent_time);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,4 +114,19 @@ class TaskModelTest extends ModelTestAbstract
|
|||||||
// Assert
|
// Assert
|
||||||
$this->assertFalse($task->is_done);
|
$this->assertFalse($task->is_done);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_task_can_store_big_amounts_of_spent_time(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$task = Task::factory()->create();
|
||||||
|
$spentTime = 100 * 365 * 24 * 60 * 60; // 100 years in seconds
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$task->spent_time = $spentTime;
|
||||||
|
$task->save();
|
||||||
|
$task->refresh();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertSame($spentTime, $task->spent_time);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,4 +94,25 @@ class ClockifyTimeEntriesImporterTest extends ImporterTestAbstract
|
|||||||
$this->assertSame(0, $report->projectsCreated);
|
$this->assertSame(0, $report->projectsCreated);
|
||||||
$this->assertSame(0, $report->clientsCreated);
|
$this->assertSame(0, $report->clientsCreated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_import_fails_if_month_in_date_is_bigger_than_12(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$organization = Organization::factory()->create();
|
||||||
|
$timezone = 'Europe/Vienna';
|
||||||
|
$importer = new ClockifyTimeEntriesImporter;
|
||||||
|
$importer->init($organization);
|
||||||
|
$data = Storage::disk('testfiles')->get('clockify_time_entries_import_test_3.csv');
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try {
|
||||||
|
$importer->importData($data, $timezone);
|
||||||
|
} catch (ImportException $e) {
|
||||||
|
// Assert
|
||||||
|
$this->assertSame('Start date ("13/15/2024") is invalid, please select the correct date format before exporting from Clockify', $e->getMessage());
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->fail();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -498,4 +498,198 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
|||||||
],
|
],
|
||||||
], $result);
|
], $result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_aggregated_time_entries_with_descriptions_by_description_and_billable(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
TimeEntry::factory()->startWithDuration(now(), 10)->create([
|
||||||
|
'description' => 'TEST 1',
|
||||||
|
'billable' => true,
|
||||||
|
]);
|
||||||
|
TimeEntry::factory()->startWithDuration(now(), 10)->create([
|
||||||
|
'description' => '',
|
||||||
|
'billable' => false,
|
||||||
|
]);
|
||||||
|
TimeEntry::factory()->startWithDuration(now(), 10)->create([
|
||||||
|
'description' => 'TEST 1',
|
||||||
|
'billable' => false,
|
||||||
|
]);
|
||||||
|
TimeEntry::factory()->startWithDuration(now(), 10)->create([
|
||||||
|
'description' => '',
|
||||||
|
'billable' => false,
|
||||||
|
]);
|
||||||
|
$query = TimeEntry::query();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$result = $this->service->getAggregatedTimeEntriesWithDescriptions(
|
||||||
|
$query,
|
||||||
|
TimeEntryAggregationType::Description,
|
||||||
|
TimeEntryAggregationType::Billable,
|
||||||
|
'Europe/Vienna',
|
||||||
|
Weekday::Monday,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertSame([
|
||||||
|
'seconds' => 40,
|
||||||
|
'cost' => 0,
|
||||||
|
'grouped_type' => 'description',
|
||||||
|
'grouped_data' => [
|
||||||
|
[
|
||||||
|
'key' => null,
|
||||||
|
'seconds' => 20,
|
||||||
|
'cost' => 0,
|
||||||
|
'grouped_type' => 'billable',
|
||||||
|
'grouped_data' => [
|
||||||
|
[
|
||||||
|
'key' => '0',
|
||||||
|
'seconds' => 20,
|
||||||
|
'cost' => 0,
|
||||||
|
'grouped_type' => null,
|
||||||
|
'grouped_data' => null,
|
||||||
|
'description' => 'Non-billable',
|
||||||
|
'color' => null,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'description' => null,
|
||||||
|
'color' => null,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'TEST 1',
|
||||||
|
'seconds' => 20,
|
||||||
|
'cost' => 0,
|
||||||
|
'grouped_type' => 'billable',
|
||||||
|
'grouped_data' => [
|
||||||
|
[
|
||||||
|
'key' => '0',
|
||||||
|
'seconds' => 10,
|
||||||
|
'cost' => 0,
|
||||||
|
'grouped_type' => null,
|
||||||
|
'grouped_data' => null,
|
||||||
|
'description' => 'Non-billable',
|
||||||
|
'color' => null,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => '1',
|
||||||
|
'seconds' => 10,
|
||||||
|
'cost' => 0,
|
||||||
|
'grouped_type' => null,
|
||||||
|
'grouped_data' => null,
|
||||||
|
'description' => 'Billable',
|
||||||
|
'color' => null,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'description' => 'TEST 1',
|
||||||
|
'color' => null,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
], $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_aggregated_time_entries_with_descriptions_by_client_and_project(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$client1 = Client::factory()->create();
|
||||||
|
$client2 = Client::factory()->create();
|
||||||
|
$project1 = Project::factory()->forClient($client1)->create();
|
||||||
|
$project2 = Project::factory()->forClient($client2)->create();
|
||||||
|
$project3 = Project::factory()->create();
|
||||||
|
TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project1)->create();
|
||||||
|
TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project2)->create();
|
||||||
|
TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project3)->create();
|
||||||
|
TimeEntry::factory()->startWithDuration(now(), 10)->create();
|
||||||
|
$query = TimeEntry::query();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$result = $this->service->getAggregatedTimeEntriesWithDescriptions(
|
||||||
|
$query,
|
||||||
|
TimeEntryAggregationType::Client,
|
||||||
|
TimeEntryAggregationType::Project,
|
||||||
|
'Europe/Vienna',
|
||||||
|
Weekday::Monday,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertEqualsCanonicalizing([
|
||||||
|
'seconds' => 40,
|
||||||
|
'cost' => 0,
|
||||||
|
'grouped_type' => 'client',
|
||||||
|
'grouped_data' => [
|
||||||
|
[
|
||||||
|
'key' => null,
|
||||||
|
'seconds' => 20,
|
||||||
|
'cost' => 0,
|
||||||
|
'grouped_type' => 'project',
|
||||||
|
'grouped_data' => [
|
||||||
|
[
|
||||||
|
'key' => null,
|
||||||
|
'seconds' => 10,
|
||||||
|
'cost' => 0,
|
||||||
|
'grouped_type' => null,
|
||||||
|
'grouped_data' => null,
|
||||||
|
'description' => null,
|
||||||
|
'color' => null,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => $project3->getKey(),
|
||||||
|
'seconds' => 10,
|
||||||
|
'cost' => 0,
|
||||||
|
'grouped_type' => null,
|
||||||
|
'grouped_data' => null,
|
||||||
|
'description' => $project3->name,
|
||||||
|
'color' => $project3->color,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'description' => null,
|
||||||
|
'color' => null,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => $client1->getKey(),
|
||||||
|
'seconds' => 10,
|
||||||
|
'cost' => 0,
|
||||||
|
'grouped_type' => 'project',
|
||||||
|
'grouped_data' => [
|
||||||
|
[
|
||||||
|
'key' => $project1->getKey(),
|
||||||
|
'seconds' => 10,
|
||||||
|
'cost' => 0,
|
||||||
|
'grouped_type' => null,
|
||||||
|
'grouped_data' => null,
|
||||||
|
'description' => $project1->name,
|
||||||
|
'color' => $project1->color,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'description' => $client1->name,
|
||||||
|
'color' => null,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => $client2->getKey(),
|
||||||
|
'seconds' => 10,
|
||||||
|
'cost' => 0,
|
||||||
|
'grouped_type' => 'project',
|
||||||
|
'grouped_data' => [
|
||||||
|
[
|
||||||
|
'key' => $project2->getKey(),
|
||||||
|
'seconds' => 10,
|
||||||
|
'cost' => 0,
|
||||||
|
'grouped_type' => null,
|
||||||
|
'grouped_data' => null,
|
||||||
|
'description' => $project2->name,
|
||||||
|
'color' => $project2->color,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'description' => $client2->name,
|
||||||
|
'color' => null,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
], $result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user