Compare commits

..

27 Commits

Author SHA1 Message Date
Gregor Vostrak
d80896e0b8 respect organization currency setting in shared report 2025-05-06 12:37:38 +02:00
Gregor Vostrak
b796d232f5 add reporting tests for detailed, project filter, billable filter, tag filter 2025-05-05 21:30:18 +02:00
Gregor Vostrak
26c50867b3 fix layout shift in shared reporting view 2025-05-01 12:35:51 +02:00
Constantin Graf
b8110e222a Fixed descriptions and billable in shared reports 2025-04-30 13:36:21 +02:00
Gregor Vostrak
7673b365ca fix light/dark theme not currectly initializing on shared report, unify logic 2025-04-30 13:32:25 +02:00
Gregor Vostrak
da5fc3f113 only show invoicing tab when module is activated 2025-04-30 12:06:48 +02:00
Gregor Vostrak
8c66068663 update openapi api client 2025-04-29 16:38:34 +02:00
Constantin Graf
dd0cc0d60b Add more validation for clockify importer 2025-04-29 16:38:08 +02:00
Gregor Vostrak
3a482c1e6a fix reporting not updating and client ui cue #458 2025-04-28 13:34:08 +02:00
Constantin Graf
ef9f353047 Fixed data type of project and task spend time 2025-04-25 22:32:37 +02:00
Constantin Graf
f1a1d2a266 Project name is now unique per client and organization 2025-04-25 17:55:29 +02:00
Constantin Graf
f5efbad703 Api docs for date time format 2025-04-25 17:55:29 +02:00
Constantin Graf
17242188c2 Updated composer dependencies 2025-04-25 17:55:29 +02:00
dependabot[bot]
0a376b1caa Bump codecov/codecov-action from 5.4.0 to 5.4.2
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.4.0 to 5.4.2.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v5.4.0...v5.4.2)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: 5.4.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-25 11:54:43 +02:00
Gregor Vostrak
10a8310e37 fix stage name in private build action 2025-04-23 15:54:33 +02:00
Gregor Vostrak
89131b9e77 prevent billable rate change modals from immediately sumbitting when pressing enter on the previous form 2025-04-23 14:33:32 +02:00
Gregor Vostrak
c17c5dc6c0 fix escape handling in tagdropdown and timetrackerprojecttaskdropdown after changing to radix dropdowns 2025-04-23 14:33:32 +02:00
Constantin Graf
3444281703 Add composer dependency “league/iso3166” 2025-04-23 14:33:32 +02:00
Gregor Vostrak
84e2365a6d add invoicing extension to private build action 2025-04-23 14:33:32 +02:00
Gregor Vostrak
92ac9948a0 add accordion component and countries api route 2025-04-23 14:33:32 +02:00
Gregor Vostrak
8da358dbe6 fix timeentry checkboxes 2025-04-23 14:33:32 +02:00
Gregor Vostrak
b7b9092e64 update api client, and report empty state improvement 2025-04-23 14:33:32 +02:00
Gregor Vostrak
15ac3e9a43 fix tests, add autofocus disable option for dropdown 2025-04-23 14:33:32 +02:00
Constantin Graf
d03dd60864 Add composer package korridor/laravel-has-many-sync 2025-04-23 14:33:32 +02:00
Constantin Graf
827e0fe377 Fixes for invoice feature 2025-04-23 14:33:32 +02:00
Gregor Vostrak
e78a551098 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-23 14:33:32 +02:00
Constantin Graf
ae00fdb0e9 Add localization settings 2025-04-23 14:33:32 +02:00
49 changed files with 1732 additions and 894 deletions

View File

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

View File

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

View File

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

View File

@@ -20,7 +20,7 @@ use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Artisan;
use Novadaemon\FilamentPrettyJson\PrettyJson;
use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField;
/**
* @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:
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);
}

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ use App\Rules\ColorRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
@@ -27,6 +28,7 @@ class ProjectStoreRequest extends FormRequest
public function rules(): array
{
return [
// Name of the project, the name needs to be unique per client and organization
'name' => [
'required',
'string',
@@ -34,7 +36,13 @@ class ProjectStoreRequest extends FormRequest
'max:255',
UniqueEloquent::make(Project::class, 'name', function (Builder $builder): 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'),
],
'color' => [
@@ -55,6 +63,7 @@ class ProjectStoreRequest extends FormRequest
],
// ID of the client
'client_id' => [
'present',
'nullable',
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */

View File

@@ -11,6 +11,7 @@ use App\Rules\ColorRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
@@ -34,7 +35,13 @@ class ProjectUpdateRequest extends FormRequest
'max:255',
UniqueEloquent::make(Project::class, 'name', function (Builder $builder): 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'),
],
'color' => [
@@ -54,6 +61,7 @@ class ProjectUpdateRequest extends FormRequest
'boolean',
],
'client_id' => [
'present',
'nullable',
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */

View File

@@ -40,7 +40,7 @@ class ReportStoreRequest extends FormRequest
'required',
'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' => [
'nullable',
'date_format:Y-m-d\TH:i:s\Z',

View File

@@ -59,12 +59,12 @@ class TimeEntryStoreRequest extends FormRequest
->where('project_id', $this->input('project_id'));
})->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' => [
'required',
'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' => [
'nullable',
'date_format:Y-m-d\TH:i:s\Z',

View File

@@ -59,11 +59,11 @@ class TimeEntryUpdateRequest extends FormRequest
->where('project_id', $this->input('project_id'));
})->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' => [
'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' => [
'nullable',
'date_format:Y-m-d\TH:i:s\Z',

View File

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

View File

@@ -37,9 +37,9 @@ class ClockifyProjectsImporter extends DefaultImporter
if ($record['Project'] !== '') {
$projectId = $this->projectImportHelper->getKey([
'name' => $record['Project'],
'client_id' => $clientId,
'organization_id' => $this->organization->id,
], [
'client_id' => $clientId,
'color' => $this->colorService->getRandomColor(),
'is_billable' => $record['Billability'] === 'Yes',
'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'] !== '') {
$projectId = $this->projectImportHelper->getKey([
'name' => $record['Project'],
'client_id' => $clientId,
'organization_id' => $this->organization->id,
], [
'client_id' => $clientId,
'color' => $this->colorService->getRandomColor(),
'is_billable' => false,
]);
@@ -124,34 +124,59 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
$timeEntry->is_imported = true;
// Start
$start = null;
try {
if (preg_match('/^[0-9]{1,2}:[0-9]{1,2} (AM|PM)$/', $record['Start Time']) === 1) {
$start = Carbon::createFromFormat('m/d/Y h:i A', $record['Start Date'].' '.$record['Start Time'], $timezone);
} else {
$start = Carbon::createFromFormat('m/d/Y H:i:s A', $record['Start Date'].' '.$record['Start Time'], $timezone);
$startDateStr = $record['Start Date'];
$startTimeStr = $record['Start Time'];
$startStr = $startDateStr.' '.$startTimeStr;
$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) {
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) {
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();
// End
$end = null;
try {
if (preg_match('/^[0-9]{1,2}:[0-9]{1,2} (AM|PM)$/', $record['End Time']) === 1) {
$end = Carbon::createFromFormat('m/d/Y h:i A', $record['End Date'].' '.$record['End Time'], $timezone);
} else {
$end = Carbon::createFromFormat('m/d/Y H:i:s A', $record['End Date'].' '.$record['End Time'], $timezone);
$endDateStr = $record['End Date'];
$endTimeStr = $record['End Time'];
$endStr = $endDateStr.' '.$endTimeStr;
$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) {
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) {
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->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
$timeEntry,
$projectMember,

View File

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

View File

@@ -55,12 +55,12 @@ class GenericProjectsImporter extends DefaultImporter
}
$this->projectImportHelper->getKey([
'name' => $record['name'],
'client_id' => $clientId,
'organization_id' => $this->organization->id,
], [
'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,
'is_public' => isset($record['is_public']) && $record['is_public'] === 'true',
'client_id' => $clientId,
'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,
'archived_at' => $archivedAt,

View File

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

View File

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

View File

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

View File

@@ -176,12 +176,12 @@ class SolidtimeImporter extends DefaultImporter
$this->projectImportHelper->getKey([
'name' => $project['name'],
'client_id' => $clientId,
'organization_id' => $this->organization->getKey(),
], [
'color' => $project['color'],
'billable_rate' => $project['billable_rate'] === '' ? null : (int) $project['billable_rate'],
'is_public' => $project['is_public'] === 'true',
'client_id' => $clientId,
'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,
], $project['id']);

View File

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

View File

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

View File

@@ -280,6 +280,20 @@ class TimeEntryAggregationService
'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;

1671
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,5 +1,186 @@
// TODO: Test filter
import { expect, Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
// TODO: Test date range
// TODO: Test grouping and sub-grouping
async function goToTimeOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
}
async function goToReporting(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/reporting');
}
async function goToReportingDetailed(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/reporting/detailed');
}
async function createTimeEntryWithProject(page: Page, projectName: string, duration: string) {
// First create the project through the Projects page
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(projectName);
await page.getByRole('dialog').getByRole('button', { name: 'Create Project' }).click();
// Wait for the project to be created and visible in the list
await page.getByText(projectName).waitFor({ state: 'visible' });
// Then create the time entry
await goToTimeOverview(page);
await page.getByRole('button', { name: 'Manual time entry' }).click();
// Fill in the time entry details
await page.getByTestId('time_entry_description').fill(`Time entry for ${projectName}`);
await page.getByRole('button', { name: 'No Project' }).click();
await page.getByText(projectName).click();
// Set duration
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
// Submit the time entry
await Promise.all([
page.getByRole('button', { name: 'Create Time Entry' }).click(),
page.waitForLoadState('networkidle')
]);
}
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();
});
// TODO: test that date range filtering works in reporting

View File

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

View File

@@ -202,7 +202,7 @@ return [
'currency' => 'The :attribute field must be a valid currency code (ISO 4217).',
'organization' => 'The :attribute does not exist.',
'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.',
'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.',

View File

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

View File

@@ -4,7 +4,6 @@ import { formatCents } from '@/packages/ui/src/utils/money';
import GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue';
import { ref } from 'vue';
import { twMerge } from 'tailwind-merge';
import { getOrganizationCurrencyString } from '@/utils/money';
type AggregatedGroupedData = GroupedData & {
grouped_data?: GroupedData[] | null;
@@ -19,6 +18,7 @@ type GroupedData = {
const props = defineProps<{
entry: AggregatedGroupedData;
indent?: boolean;
currency: string;
}>();
const expanded = ref(false);
@@ -48,7 +48,7 @@ const expanded = ref(false);
{{ formatHumanReadableDuration(entry.seconds) }}
</div>
<div class="justify-end pr-6 flex items-center">
{{entry.cost ? formatCents(entry.cost, getOrganizationCurrencyString()) : '--' }}
{{entry.cost ? formatCents(entry.cost, props.currency) : '--' }}
</div>
</div>
<div
@@ -58,6 +58,7 @@ const expanded = ref(false);
<ReportingRow
v-for="subEntry in entry.grouped_data"
:key="subEntry.description ?? 'none'"
:currency="props.currency"
indent
:entry="subEntry"></ReportingRow>
</div>

View File

@@ -20,24 +20,24 @@ import {
import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue';
import UserSettingsIcon from '@/Components/UserSettingsIcon.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 { initializeStores, refreshStores } from '@/utils/init';
import {
canManageBilling,
canUpdateOrganization,
canViewClients,
canViewClients, canViewInvoices,
canViewMembers,
canViewProjects, canViewReport,
canViewTags,
} from '@/utils/permissions';
import { isBillingActivated } from '@/utils/billing';
import { isBillingActivated, isInvoicingActivated } from '@/utils/billing';
import type { User } from '@/types/models';
import { ArrowsRightLeftIcon } from '@heroicons/vue/16/solid';
import { fetchToken, isTokenValid } from '@/utils/session';
import UpdateSidebarNotification from '@/Components/UpdateSidebarNotification.vue';
import BillingBanner from '@/Components/Billing/BillingBanner.vue';
import { theme } from "@/utils/theme";
import { useTheme } from "@/utils/theme";
defineProps({
title: String,
@@ -47,12 +47,7 @@ const showSidebarMenu = ref(false);
const isUnloading = ref(false);
onMounted(async () => {
document.documentElement.classList.add(theme.value);
watch(theme, (newTheme, oldTheme) => {
document.documentElement.classList.remove(oldTheme);
document.documentElement.classList.add(newTheme);
});
useTheme()
// make sure that the initial requests are only loaded once, this can be removed once we move away from inertia
if (window.initialDataLoaded !== true) {
window.initialDataLoaded = true;
@@ -188,6 +183,7 @@ const page = usePage<{
:current="route().current('tags')"
:href="route('tags')"></NavigationSidebarItem>
<NavigationSidebarItem
v-if="isInvoicingActivated() && canViewInvoices()"
title="Invoices"
:icon="DocumentTextIcon"
:current="route().current('invoices')"
@@ -272,8 +268,6 @@ const page = usePage<{
v-if="$slots.header"
class="bg-default-background border-b border-default-background-separator shadow">
<div class="pt-8 pb-3">
<MainContainer>
<slot name="header" />
</MainContainer>

View File

@@ -339,6 +339,8 @@ const tableData = computed(() => {
@submit="updateReporting">
<template #trigger>
<ReportingFilterBadge
:count="selectedClients.length"
:active="selectedClients.length > 0"
title="Clients"
:icon="FolderIcon"></ReportingFilterBadge>
</template>
@@ -444,6 +446,7 @@ const tableData = computed(() => {
<ReportingRow
v-for="entry in tableData"
:key="entry.description ?? 'none'"
:currency="getOrganizationCurrencyString()"
:entry="entry"
:type="
aggregatedTableTimeEntries.grouped_type

View File

@@ -308,6 +308,8 @@ async function downloadExport(format: ExportFormat) {
@submit="updateFilteredTimeEntries">
<template #trigger>
<ReportingFilterBadge
:count="selectedClients.length"
:active="selectedClients.length > 0"
title="Clients"
:icon="FolderIcon"></ReportingFilterBadge>
</template>

View File

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

View File

@@ -5,7 +5,6 @@ import { ChartBarIcon } from '@heroicons/vue/20/solid';
import ReportingChart from '@/Components/Common/Reporting/ReportingChart.vue';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue';
import { formatCents } from '@/packages/ui/src/utils/money';
import { computed, onMounted, ref } from 'vue';
@@ -14,6 +13,7 @@ import { api } from '@/packages/api/src';
import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';
import { useReportingStore } from '@/utils/useReporting';
import { Head } from '@inertiajs/vue3';
import { useTheme } from "@/utils/theme";
const sharedSecret = ref<string | null>(null);
@@ -40,6 +40,13 @@ onMounted(() => {
}
});
const reportCurrency = computed(() => {
if (sharedReportResponseData.value) {
return sharedReportResponseData.value?.currency;
}
return 'EUR';
});
const aggregatedTableTimeEntries = computed(() => {
if (sharedReportResponseData.value) {
return sharedReportResponseData.value?.data;
@@ -136,6 +143,10 @@ function getGroupLabel(key: string) {
return option.value === key;
})?.label;
}
onMounted(async () => {
useTheme();
})
</script>
<template>
@@ -188,6 +199,7 @@ function getGroupLabel(key: string) {
<ReportingRow
v-for="entry in tableData"
:key="entry.description ?? 'none'"
:currency="reportCurrency"
:entry="entry"
:type="
aggregatedTableTimeEntries.grouped_type
@@ -201,7 +213,7 @@ function getGroupLabel(key: string) {
class="justify-end flex items-center font-medium">
{{
formatHumanReadableDuration(
aggregatedTableTimeEntries.seconds
aggregatedTableTimeEntries.seconds,
)
}}
</div>
@@ -210,7 +222,7 @@ function getGroupLabel(key: string) {
{{
formatCents(
aggregatedTableTimeEntries.cost,
getOrganizationCurrencyString()
reportCurrency,
)
}}
</div>

View File

@@ -66,6 +66,7 @@ const InvoiceResource = z
buyer_name: z.string(),
status: z.string(),
date: z.string(),
due_at: z.string(),
created_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(),
footer: 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
.array(
z
@@ -127,7 +130,7 @@ const InvoiceEntryResource = z
name: z.string(),
description: z.union([z.string(), z.null()]),
unit_price: z.number().int(),
quantity: z.number().int(),
quantity: z.string(),
order_index: z.number().int(),
created_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_phone: z.string(),
buyer_email: z.string(),
paid_at: z.string(),
paid_at: z.union([z.string(), z.null()]),
due_at: z.string(),
discount_type: z.string(),
discount_amount: z.string(),
@@ -168,6 +171,8 @@ const DetailedInvoiceResource = z
date: z.string(),
footer: z.string(),
notes: z.string(),
payment_terms: z.string(),
is_eu_reverse_charge: z.string(),
billing_period_start: z.string(),
billing_period_end: z.string(),
created_at: z.union([z.string(), z.null()]),
@@ -211,6 +216,8 @@ const InvoiceUpdateRequest = z
discount_type: InvoiceDiscountType,
footer: 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(
z
.object({
@@ -225,6 +232,9 @@ const InvoiceUpdateRequest = z
})
.partial()
.passthrough();
const InvoiceDownloadRequest = z
.object({ with_e_invoice: z.boolean() })
.passthrough();
const InvoiceSettingResource = z
.object({
seller_name: z.union([z.string(), z.null()]),
@@ -462,9 +472,9 @@ const ReportStoreRequest = z
task_ids: z
.union([z.array(z.string().uuid()), z.null()])
.optional(),
group: TimeEntryAggregationType.optional(),
sub_group: TimeEntryAggregationType.optional(),
history_group: TimeEntryAggregationTypeInterval.optional(),
group: TimeEntryAggregationType,
sub_group: TimeEntryAggregationType,
history_group: TimeEntryAggregationTypeInterval,
week_start: Weekday.optional(),
timezone: z.union([z.string(), z.null()]).optional(),
})
@@ -752,6 +762,7 @@ export const schemas = {
DetailedInvoiceResource,
InvoiceStatus,
InvoiceUpdateRequest,
InvoiceDownloadRequest,
InvoiceSettingResource,
InvoiceSettingUpdateRequest,
MemberResource,
@@ -1380,7 +1391,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.null(),
response: z.void(),
errors: [
{
status: 400,
@@ -1665,7 +1676,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.null(),
response: z.void(),
errors: [
{
status: 400,
@@ -1722,7 +1733,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.null(),
response: z.void(),
errors: [
{
status: 401,
@@ -1758,7 +1769,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.null(),
response: z.void(),
errors: [
{
status: 401,
@@ -2050,7 +2061,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.null(),
response: z.void(),
errors: [
{
status: 401,
@@ -2075,6 +2086,11 @@ const endpoints = makeApi([
alias: 'downloadInvoice',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: z.object({ with_e_invoice: z.boolean() }).passthrough(),
},
{
name: 'organization',
type: 'Path',
@@ -2103,6 +2119,16 @@ const endpoints = makeApi([
description: `Not found`,
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(),
},
],
response: z.null(),
response: z.void(),
errors: [
{
status: 400,
@@ -2358,7 +2384,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.null(),
response: z.void(),
errors: [
{
status: 400,
@@ -2405,7 +2431,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.null(),
response: z.void(),
errors: [
{
status: 400,
@@ -2452,7 +2478,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.null(),
response: z.void(),
errors: [
{
status: 400,
@@ -2550,7 +2576,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.null(),
response: z.void(),
errors: [
{
status: 401,
@@ -2802,7 +2828,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.null(),
response: z.void(),
errors: [
{
status: 400,
@@ -3175,7 +3201,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.null(),
response: z.void(),
errors: [
{
status: 401,
@@ -3343,7 +3369,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.null(),
response: z.void(),
errors: [
{
status: 400,
@@ -3570,7 +3596,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.null(),
response: z.void(),
errors: [
{
status: 400,
@@ -3950,7 +3976,7 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
schema: z.string(),
},
],
response: z.null(),
response: z.void(),
errors: [
{
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(),
},
],
response: z.null(),
response: z.void(),
errors: [
{
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(),
},
],
response: z.null(),
response: z.void(),
errors: [
{
status: 400,
@@ -4672,6 +4698,11 @@ Please note that the access token is only shown in this response and cannot be r
description: `Unauthenticated`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
import { watch } from "vue";
const props = withDefaults(
defineProps<{
@@ -36,6 +37,12 @@ function onOpenChange(value: boolean) {
}
}
watch(open, (value) => {
if (value === false) {
emit('submit');
}
});
</script>
<template>

View File

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

View File

@@ -63,7 +63,7 @@ function selectInput(event: Event) {
<template>
<input
v-model="currentTime"
data-testid="time_entry_duration_input"
name="Duration"
class="text-text-primary w-[90px] px-2 py-1.5 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-semibold focus-visible:bg-tertiary focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring"
@focus="selectInput"
@keydown.tab="open = false"

View File

@@ -9,6 +9,14 @@ export function isBillingActivated() {
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() {
const page = usePage<{
billing: {

View File

@@ -122,3 +122,7 @@ export function canDeleteReport() {
export function canViewAllTimeEntries() {
return currentUserHasPermission('time-entries:view:all');
}
export function canViewInvoices() {
return currentUserHasPermission('invoices:view');
}

View File

@@ -22,4 +22,12 @@ const theme = computed(() => {
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 };

View File

@@ -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"
1 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
2 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

View File

@@ -277,6 +277,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
'name' => $projectFake->name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
]);
@@ -299,6 +300,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
'name' => $projectFake->name,
'color' => $projectFake->color,
'is_billable' => $projectFake->is_billable,
'client_id' => null,
'billable_rate' => $billableRate,
]);
@@ -309,6 +311,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
'color' => $projectFake->color,
'organization_id' => $projectFake->organization_id,
'is_billable' => $projectFake->is_billable,
'client_id' => null,
'billable_rate' => $billableRate,
]);
}
@@ -328,6 +331,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
'name' => $projectFake->name,
'color' => $projectFake->color,
'is_billable' => $projectFake->is_billable,
'client_id' => null,
'billable_rate' => $billableRate,
]);
@@ -351,6 +355,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
'name' => $projectFake->name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
]);
@@ -360,6 +365,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
'name' => $projectFake->name,
'color' => $projectFake->color,
'organization_id' => $projectFake->organization_id,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
]);
}
@@ -378,6 +384,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
'name' => $projectFake->name,
'color' => $projectFake->color,
'is_billable' => $projectFake->is_billable,
'client_id' => null,
'estimated_time' => 10000,
]);
@@ -394,6 +401,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
'color' => $projectFake->color,
'organization_id' => $projectFake->organization_id,
'is_billable' => $projectFake->is_billable,
'client_id' => null,
'estimated_time' => null,
]);
}
@@ -413,6 +421,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
'name' => $projectFake->name,
'color' => $projectFake->color,
'is_billable' => $projectFake->is_billable,
'client_id' => null,
'estimated_time' => 10000,
]);
@@ -429,11 +438,47 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
'color' => $projectFake->color,
'organization_id' => $projectFake->organization_id,
'is_billable' => $projectFake->is_billable,
'client_id' => null,
'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
$data = $this->createUserWithPermission([
@@ -450,13 +495,43 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
'name' => $name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
]);
// Assert
$response->assertStatus(422);
$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()]), [
'name' => $name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
]);
@@ -534,6 +610,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
'name' => $projectFake->name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => true,
'billable_rate' => 10001,
]);
@@ -565,6 +642,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => $projectFake->name,
'color' => $projectFake->color,
'client_id' => null,
'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()]), [
'name' => $projectFake->name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
]);
@@ -592,7 +671,43 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$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
$data = $this->createUserWithPermission([
@@ -610,13 +725,44 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => $name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
]);
// Assert
$response->assertStatus(422);
$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()]), [
'name' => $name,
'color' => $projectFake->color,
'client_id' => null,
'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()]), [
'name' => $projectFake->name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
'estimated_time' => 10000,
]);
@@ -815,6 +963,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => $projectFake->name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
'estimated_time' => 10000,
]);
@@ -848,6 +997,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => $projectFake->name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
'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()]), [
'name' => $projectFake->name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
'billable_rate' => 10003,
]);
@@ -907,6 +1058,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => $projectFake->name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
'is_archived' => true,
]);
@@ -935,6 +1087,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => $projectFake->name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
'is_archived' => false,
]);

View File

@@ -16,11 +16,13 @@ class ForceHttpsMiddlewareTest extends MiddlewareTestAbstract
{
private function createTestRoute(): string
{
return Route::get('/test-route', function () {
$uri = Route::get('/test-route', function () {
return [
'is_secure' => request()->secure(),
];
})->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

View File

@@ -185,4 +185,19 @@ class ProjectModelTest extends ModelTestAbstract
// Assert
$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);
}
}

View File

@@ -114,4 +114,19 @@ class TaskModelTest extends ModelTestAbstract
// Assert
$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);
}
}

View File

@@ -94,4 +94,25 @@ class ClockifyTimeEntriesImporterTest extends ImporterTestAbstract
$this->assertSame(0, $report->projectsCreated);
$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();
}
}

View File

@@ -498,4 +498,198 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
],
], $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);
}
}