diff --git a/app/Enums/TimeEntryAggregationType.php b/app/Enums/TimeEntryAggregationType.php index a5056ac2..5a36d5b0 100644 --- a/app/Enums/TimeEntryAggregationType.php +++ b/app/Enums/TimeEntryAggregationType.php @@ -21,6 +21,7 @@ enum TimeEntryAggregationType: string case Billable = 'billable'; case Description = 'description'; case Tag = 'tag'; + case Type = 'type'; public static function fromInterval(TimeEntryAggregationTypeInterval $timeEntryAggregationTypeInterval): TimeEntryAggregationType { diff --git a/app/Enums/TimeEntryType.php b/app/Enums/TimeEntryType.php new file mode 100644 index 00000000..af2f764c --- /dev/null +++ b/app/Enums/TimeEntryType.php @@ -0,0 +1,15 @@ +getPreventOverlappingTimeEntries() !== null) { $organization->prevent_overlapping_time_entries = $request->getPreventOverlappingTimeEntries(); } + if ($request->getBreaksEnabled() !== null) { + $organization->breaks_enabled = $request->getBreaksEnabled(); + } $hasBillableRate = $request->has('billable_rate'); if ($hasBillableRate) { $oldBillableRate = $organization->billable_rate; diff --git a/app/Http/Controllers/Api/V1/Public/ReportController.php b/app/Http/Controllers/Api/V1/Public/ReportController.php index f6018a69..f5de7af2 100644 --- a/app/Http/Controllers/Api/V1/Public/ReportController.php +++ b/app/Http/Controllers/Api/V1/Public/ReportController.php @@ -57,6 +57,7 @@ class ReportController extends Controller $filter->addEnd($properties->end); $filter->addActive($properties->active); $filter->addBillable($properties->billable); + $filter->addType($properties->timeEntryType); $filter->addMemberIdsFilter($properties->memberIds?->toArray()); $filter->addProjectIdsFilter($properties->projectIds?->toArray()); $filter->addTagIdsFilter($properties->tagIds?->toArray(), $properties->tagMatchType); diff --git a/app/Http/Controllers/Api/V1/ReportController.php b/app/Http/Controllers/Api/V1/ReportController.php index 2756eae6..8d6052ae 100644 --- a/app/Http/Controllers/Api/V1/ReportController.php +++ b/app/Http/Controllers/Api/V1/ReportController.php @@ -112,6 +112,7 @@ class ReportController extends Controller $properties->timezone = $timezone; $properties->roundingType = $request->getPropertyRoundingType(); $properties->roundingMinutes = $request->getPropertyRoundingMinutes(); + $properties->timeEntryType = $request->getPropertyTimeEntryType(); $report->properties = $properties; if ($isPublic) { $report->share_secret = $reportService->generateSecret(); diff --git a/app/Http/Controllers/Api/V1/TimeEntryController.php b/app/Http/Controllers/Api/V1/TimeEntryController.php index cb1c37dd..31073f17 100644 --- a/app/Http/Controllers/Api/V1/TimeEntryController.php +++ b/app/Http/Controllers/Api/V1/TimeEntryController.php @@ -6,6 +6,7 @@ namespace App\Http\Controllers\Api\V1; use App\Enums\ExportFormat; use App\Enums\Role; +use App\Enums\TimeEntryType; use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException; use App\Exceptions\Api\OverlappingTimeEntryApiException; use App\Exceptions\Api\PdfRendererIsNotConfiguredException; @@ -208,6 +209,7 @@ class TimeEntryController extends Controller $filter->addTaskIdsFilter($request->input('task_ids')); $filter->addClientIdsFilter($request->input('client_ids')); $filter->addBillableFilter($request->input('billable')); + $filter->addTypeFilter($request->input('type')); return $filter->get(); } @@ -564,6 +566,7 @@ class TimeEntryController extends Controller $filter->addTaskIdsFilter($request->input('task_ids')); $filter->addClientIdsFilter($request->input('client_ids')); $filter->addBillableFilter($request->input('billable')); + $filter->addTypeFilter($request->input('type')); return $filter->get(); } @@ -746,6 +749,19 @@ class TimeEntryController extends Controller continue; } + // Changing time entries to Break entries is only allowed when breaks are enabled in the org settings + $resultingType = isset($changes['type']) ? TimeEntryType::from($changes['type']) : $timeEntry->type; + if ($resultingType === TimeEntryType::Break && $timeEntry->type !== TimeEntryType::Break && ! $organization->breaks_enabled) { + $error->push($id); + + continue; + } + // Break entries can not be billable, have tags or belong to a project/task (see TimeEntry::booted) + if ($resultingType === TimeEntryType::Break && ($project !== null || $task !== null || $request->boolean('changes.billable') || count($changes['tags'] ?? []) > 0)) { + $error->push($id); + + continue; + } $oldProject = $timeEntry->project; $oldTask = $timeEntry->task; diff --git a/app/Http/Requests/V1/Organization/OrganizationUpdateRequest.php b/app/Http/Requests/V1/Organization/OrganizationUpdateRequest.php index 759e586d..1316715c 100644 --- a/app/Http/Requests/V1/Organization/OrganizationUpdateRequest.php +++ b/app/Http/Requests/V1/Organization/OrganizationUpdateRequest.php @@ -51,6 +51,9 @@ class OrganizationUpdateRequest extends BaseFormRequest 'prevent_overlapping_time_entries' => [ 'boolean', ], + 'breaks_enabled' => [ + 'boolean', + ], 'number_format' => [ Rule::enum(NumberFormat::class), ], @@ -125,4 +128,9 @@ class OrganizationUpdateRequest extends BaseFormRequest { return $this->has('prevent_overlapping_time_entries') ? $this->boolean('prevent_overlapping_time_entries') : null; } + + public function getBreaksEnabled(): ?bool + { + return $this->has('breaks_enabled') ? $this->boolean('breaks_enabled') : null; + } } diff --git a/app/Http/Requests/V1/Report/ReportStoreRequest.php b/app/Http/Requests/V1/Report/ReportStoreRequest.php index 68747d5f..cb9aacf7 100644 --- a/app/Http/Requests/V1/Report/ReportStoreRequest.php +++ b/app/Http/Requests/V1/Report/ReportStoreRequest.php @@ -8,6 +8,7 @@ use App\Enums\TagMatchType; use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\TimeEntryRoundingType; +use App\Enums\TimeEntryType; use App\Enums\Weekday; use App\Http\Requests\V1\BaseFormRequest; use App\Models\Organization; @@ -177,6 +178,12 @@ class ReportStoreRequest extends BaseFormRequest 'numeric', 'integer', ], + // Filter by time entry type + 'properties.time_entry_type' => [ + 'nullable', + 'string', + Rule::enum(TimeEntryType::class), + ], ]; } @@ -240,6 +247,15 @@ class ReportStoreRequest extends BaseFormRequest return null; } + public function getPropertyTimeEntryType(): ?TimeEntryType + { + if (! $this->has('properties.time_entry_type') || $this->input('properties.time_entry_type') === null) { + return null; + } + + return TimeEntryType::from($this->input('properties.time_entry_type')); + } + public function getPropertyGroup(): TimeEntryAggregationType { return TimeEntryAggregationType::from($this->input('properties.group')); diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php index 519a2a10..5cac5414 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php @@ -9,6 +9,7 @@ use App\Enums\TagMatchType; use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\TimeEntryRoundingType; +use App\Enums\TimeEntryType; use App\Http\Requests\V1\BaseFormRequest; use App\Models\Client; use App\Models\Member; @@ -183,6 +184,11 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest 'string', 'in:true,false', ], + // Filter by time entry type + 'type' => [ + 'string', + Rule::enum(TimeEntryType::class), + ], 'fill_gaps_in_time_groups' => [ 'string', 'in:true,false', diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php index 4a223d90..1df2c321 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php @@ -7,6 +7,7 @@ namespace App\Http\Requests\V1\TimeEntry; use App\Enums\TagMatchType; use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryRoundingType; +use App\Enums\TimeEntryType; use App\Http\Requests\V1\BaseFormRequest; use App\Models\Client; use App\Models\Member; @@ -169,6 +170,11 @@ class TimeEntryAggregateRequest extends BaseFormRequest 'string', 'in:true,false', ], + // Filter by time entry type + 'type' => [ + 'string', + Rule::enum(TimeEntryType::class), + ], 'fill_gaps_in_time_groups' => [ 'string', 'in:true,false', diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php index 30246f3c..7189139b 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php @@ -7,6 +7,7 @@ namespace App\Http\Requests\V1\TimeEntry; use App\Enums\ExportFormat; use App\Enums\TagMatchType; use App\Enums\TimeEntryRoundingType; +use App\Enums\TimeEntryType; use App\Models\Client; use App\Models\Member; use App\Models\Organization; @@ -155,6 +156,11 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest 'string', 'in:true,false', ], + // Filter by time entry type + 'type' => [ + 'string', + Rule::enum(TimeEntryType::class), + ], // Limit the number of returned time entries (default: 150) 'limit' => [ 'integer', diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php index fd906a4b..8dde3011 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php @@ -6,6 +6,7 @@ namespace App\Http\Requests\V1\TimeEntry; use App\Enums\TagMatchType; use App\Enums\TimeEntryRoundingType; +use App\Enums\TimeEntryType; use App\Http\Requests\V1\BaseFormRequest; use App\Models\Client; use App\Models\Member; @@ -148,6 +149,11 @@ class TimeEntryIndexRequest extends BaseFormRequest 'string', 'in:true,false', ], + // Filter by time entry type + 'type' => [ + 'string', + Rule::enum(TimeEntryType::class), + ], // Limit the number of returned time entries (default: 150) 'limit' => [ 'integer', diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryStoreRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryStoreRequest.php index ec51f84e..19a534e1 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryStoreRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryStoreRequest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Http\Requests\V1\TimeEntry; +use App\Enums\TimeEntryType; use App\Http\Requests\V1\BaseFormRequest; use App\Models\Member; use App\Models\Organization; @@ -14,6 +15,7 @@ use App\Service\PermissionStore; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\Auth; +use Illuminate\Validation\Rule; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; /** @@ -24,7 +26,7 @@ class TimeEntryStoreRequest extends BaseFormRequest /** * Get the validation rules that apply to the request. * - * @return array> + * @return array> */ public function rules(): array { @@ -42,6 +44,7 @@ class TimeEntryStoreRequest extends BaseFormRequest 'nullable', 'string', 'required_with:task_id', + 'prohibited_if:type,break', ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ $builder = $builder->whereBelongsTo($this->organization, 'organization'); @@ -60,6 +63,7 @@ class TimeEntryStoreRequest extends BaseFormRequest 'task_id' => [ 'nullable', 'string', + 'prohibited_if:type,break', ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); @@ -85,6 +89,16 @@ class TimeEntryStoreRequest extends BaseFormRequest 'billable' => [ 'required', 'boolean', + 'declined_if:type,break', + ], + // Type of the time entry (work time or a break) + 'type' => [ + Rule::enum(TimeEntryType::class), + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryType::Break->value && ! $this->organization->breaks_enabled) { + $fail('Breaks are disabled for this organization.'); + } + }, ], // Description of time entry 'description' => [ @@ -96,6 +110,7 @@ class TimeEntryStoreRequest extends BaseFormRequest 'tags' => [ 'nullable', 'array', + 'prohibited_if:type,break', ], 'tags.*' => [ ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder { diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryUpdateMultipleRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryUpdateMultipleRequest.php index e7c4b600..24cf3f78 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryUpdateMultipleRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryUpdateMultipleRequest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Http\Requests\V1\TimeEntry; +use App\Enums\TimeEntryType; use App\Http\Requests\V1\BaseFormRequest; use App\Models\Member; use App\Models\Organization; @@ -14,6 +15,7 @@ use App\Service\PermissionStore; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\Auth; +use Illuminate\Validation\Rule; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; /** @@ -24,7 +26,7 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest /** * Get the validation rules that apply to the request. * - * @return array> + * @return array> */ public function rules(): array { @@ -54,6 +56,7 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest 'nullable', 'string', 'required_with:task_id', + 'prohibited_if:changes.type,break', ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ $builder = $builder->whereBelongsTo($this->organization, 'organization'); @@ -72,6 +75,7 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest 'changes.task_id' => [ 'nullable', 'string', + 'prohibited_if:changes.type,break', ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); @@ -84,7 +88,13 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest ], // Whether time entry is billable 'changes.billable' => [ + 'sometimes', 'boolean', + 'declined_if:changes.type,break', + ], + // Type of the time entry (work time or a break) + 'changes.type' => [ + Rule::enum(TimeEntryType::class), ], // Description of time entry 'changes.description' => [ @@ -96,6 +106,7 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest 'changes.tags' => [ 'nullable', 'array', + 'prohibited_if:changes.type,break', ], 'changes.tags.*' => [ 'string', diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryUpdateRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryUpdateRequest.php index d895d98f..428148dc 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryUpdateRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryUpdateRequest.php @@ -4,16 +4,21 @@ declare(strict_types=1); namespace App\Http\Requests\V1\TimeEntry; +use App\Enums\TimeEntryType; use App\Http\Requests\V1\BaseFormRequest; use App\Models\Member; use App\Models\Organization; use App\Models\Project; use App\Models\Tag; use App\Models\Task; +use App\Models\TimeEntry; use App\Service\PermissionStore; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\Auth; +use Illuminate\Validation\ConditionalRules; +use Illuminate\Validation\Rule; +use Illuminate\Validation\Rules\ProhibitedIf; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; /** @@ -24,10 +29,19 @@ class TimeEntryUpdateRequest extends BaseFormRequest /** * Get the validation rules that apply to the request. * - * @return array> + * @return array> */ public function rules(): array { + // Break restrictions need to apply based on the type the entry will have after the + // update, not only when the payload itself contains type=break. + $timeEntry = $this->route('timeEntry'); + $timeEntry = $timeEntry instanceof TimeEntry ? $timeEntry : null; + $resultingType = $this->has('type') + ? TimeEntryType::tryFrom((string) $this->input('type')) + : $timeEntry?->type; + $isBreak = $resultingType === TimeEntryType::Break; + return [ // ID of the organization member that the time entry should belong to 'member_id' => [ @@ -42,6 +56,7 @@ class TimeEntryUpdateRequest extends BaseFormRequest 'nullable', 'string', 'required_with:task_id', + Rule::prohibitedIf($isBreak), ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ $builder = $builder->whereBelongsTo($this->organization, 'organization'); @@ -60,6 +75,7 @@ class TimeEntryUpdateRequest extends BaseFormRequest 'task_id' => [ 'nullable', 'string', + Rule::prohibitedIf($isBreak), ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ return $builder->whereBelongsTo($this->organization, 'organization'); @@ -82,7 +98,22 @@ class TimeEntryUpdateRequest extends BaseFormRequest ], // Whether time entry is billable 'billable' => [ + 'sometimes', 'boolean', + Rule::when($isBreak, ['declined']), + ], + // Type of the time entry (work time or a break) + 'type' => [ + Rule::enum(TimeEntryType::class), + function (string $attribute, mixed $value, \Closure $fail) use ($timeEntry): void { + // While breaks are disabled, entries that already are breaks may stay + // breaks, but converting a work entry to a break is not allowed. + if ($value === TimeEntryType::Break->value + && ! $this->organization->breaks_enabled + && $timeEntry?->type !== TimeEntryType::Break) { + $fail('Breaks are disabled for this organization.'); + } + }, ], // Description of time entry 'description' => [ @@ -94,6 +125,7 @@ class TimeEntryUpdateRequest extends BaseFormRequest 'tags' => [ 'nullable', 'array', + Rule::prohibitedIf($isBreak), ], 'tags.*' => [ 'string', diff --git a/app/Http/Resources/V1/Organization/OrganizationResource.php b/app/Http/Resources/V1/Organization/OrganizationResource.php index 4faa9e2f..bb5075b6 100644 --- a/app/Http/Resources/V1/Organization/OrganizationResource.php +++ b/app/Http/Resources/V1/Organization/OrganizationResource.php @@ -57,6 +57,8 @@ class OrganizationResource extends BaseResource 'employees_can_manage_tasks' => $this->resource->employees_can_manage_tasks, /** @var bool $prevent_overlapping_time_entries Prevent creating overlapping time entries (only new entries) */ 'prevent_overlapping_time_entries' => $this->resource->prevent_overlapping_time_entries, + /** @var bool $breaks_enabled Whether members of the organization can track breaks */ + 'breaks_enabled' => $this->resource->breaks_enabled, /** @var string $currency Currency code (ISO 4217) */ 'currency' => $this->resource->currency, /** @var string $currency_symbol Currency symbol */ diff --git a/app/Http/Resources/V1/Report/DetailedReportResource.php b/app/Http/Resources/V1/Report/DetailedReportResource.php index b8e6e9d4..fd029e34 100644 --- a/app/Http/Resources/V1/Report/DetailedReportResource.php +++ b/app/Http/Resources/V1/Report/DetailedReportResource.php @@ -50,6 +50,8 @@ class DetailedReportResource extends BaseResource 'member_ids' => $this->resource->properties->memberIds?->toArray(), /** @var bool|null $billable Filter by billable status */ 'billable' => $this->resource->properties->billable, + /** @var string|null $time_entry_type Filter by time entry type */ + 'time_entry_type' => $this->resource->properties->timeEntryType?->value, /** @var array|null $client_ids Filter by client IDs, client IDs are OR combined */ 'client_ids' => $this->resource->properties->clientIds?->toArray(), /** @var array|null $project_ids Filter by project IDs, project IDs are OR combined */ diff --git a/app/Http/Resources/V1/TimeEntry/TimeEntryResource.php b/app/Http/Resources/V1/TimeEntry/TimeEntryResource.php index d56bfbe5..2702751b 100644 --- a/app/Http/Resources/V1/TimeEntry/TimeEntryResource.php +++ b/app/Http/Resources/V1/TimeEntry/TimeEntryResource.php @@ -47,6 +47,8 @@ class TimeEntryResource extends BaseResource 'tags' => $this->resource->tags ?? [], /** @var bool $billable Whether time entry is billable */ 'billable' => $this->resource->billable, + /** @var string $type Type of the time entry (`work` time or a `break`) */ + 'type' => $this->resource->type->value, ]; } } diff --git a/app/Models/Organization.php b/app/Models/Organization.php index c5250e90..747ceb56 100644 --- a/app/Models/Organization.php +++ b/app/Models/Organization.php @@ -34,6 +34,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract; * @property bool $employees_can_see_billable_rates * @property bool $employees_can_manage_tasks * @property bool $prevent_overlapping_time_entries + * @property bool $breaks_enabled * @property User $owner * @property Carbon|null $created_at * @property Carbon|null $updated_at @@ -70,6 +71,7 @@ class Organization extends Model implements AuditableContract 'employees_can_see_billable_rates' => 'boolean', 'employees_can_manage_tasks' => 'boolean', 'prevent_overlapping_time_entries' => 'boolean', + 'breaks_enabled' => 'boolean', 'number_format' => NumberFormat::class, 'currency_format' => CurrencyFormat::class, 'date_format' => DateFormat::class, diff --git a/app/Models/TimeEntry.php b/app/Models/TimeEntry.php index 6143111e..2928cca5 100644 --- a/app/Models/TimeEntry.php +++ b/app/Models/TimeEntry.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models; +use App\Enums\TimeEntryType; use App\Models\Concerns\CustomAuditable; use App\Models\Concerns\HasUuids; use App\Service\BillableRateService; @@ -28,6 +29,7 @@ use Staudenmeir\EloquentJsonRelations\Relations\BelongsToJson; * @property Carbon|null $end * @property int|null $billable_rate Billable rate per hour in cents * @property bool $billable + * @property TimeEntryType $type * @property array $tags * @property string $user_id * @property string $member_id @@ -71,12 +73,20 @@ class TimeEntry extends Model implements AuditableContract 'start' => 'datetime', 'end' => 'datetime', 'billable' => 'bool', + 'type' => TimeEntryType::class, 'tags' => 'array', 'billable_rate' => 'int', 'is_imported' => 'bool', 'still_active_email_sent_at' => 'datetime', ]; + /** + * @var array + */ + protected $attributes = [ + 'type' => 'work', + ]; + public const array SELECT_COLUMNS = [ 'id', 'description', @@ -84,6 +94,7 @@ class TimeEntry extends Model implements AuditableContract 'end', 'billable_rate', 'billable', + 'type', 'user_id', 'organization_id', 'project_id', @@ -117,6 +128,21 @@ class TimeEntry extends Model implements AuditableContract 'billable_rate', ]; + protected static function booted(): void + { + // Break entries can never be billable, have tags or belong to a project/task. + static::saving(function (TimeEntry $timeEntry): void { + if ($timeEntry->type === TimeEntryType::Break) { + $timeEntry->billable = false; + $timeEntry->billable_rate = null; + $timeEntry->project_id = null; + $timeEntry->task_id = null; + $timeEntry->client_id = null; + $timeEntry->tags = []; + } + }); + } + public function getBillableRateComputed(): ?int { return app(BillableRateService::class)->getBillableRateForTimeEntry($this); @@ -173,6 +199,16 @@ class TimeEntry extends Model implements AuditableContract $builder->whereJsonContains('tags', $tag->getKey()); } + /** + * Only work entries — breaks do not count toward tracked/billable time. + * + * @param Builder $builder + */ + public function scopeWorkTime(Builder $builder): void + { + $builder->where('type', '=', TimeEntryType::Work); + } + /** * @return BelongsTo */ diff --git a/app/Service/DashboardService.php b/app/Service/DashboardService.php index 250bc7f9..a95065a0 100644 --- a/app/Service/DashboardService.php +++ b/app/Service/DashboardService.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Service; +use App\Enums\TimeEntryType; use App\Enums\Weekday; use App\Models\Organization; use App\Models\Project; @@ -154,6 +155,7 @@ class DashboardService ->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate')) ->where('user_id', '=', $user->getKey()) ->where('organization_id', '=', $organization->getKey()) + ->workTime() ->groupBy(DB::raw('DATE('.$dateWithTimeZone.')')) ->orderBy('date'); @@ -195,6 +197,7 @@ class DashboardService ->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate')) ->where('user_id', '=', $user->getKey()) ->where('organization_id', '=', $organization->getKey()) + ->workTime() ->groupBy(DB::raw('DATE('.$dateWithTimeZone.')')) ->orderBy('date'); @@ -222,7 +225,8 @@ class DashboardService $query = TimeEntry::query() ->select(DB::raw('round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate')) ->where('user_id', '=', $user->getKey()) - ->where('organization_id', '=', $organization->getKey()); + ->where('organization_id', '=', $organization->getKey()) + ->workTime(); $query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone); /** @var Collection $resultDb */ @@ -290,6 +294,7 @@ class DashboardService ->select(DB::raw('project_id, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate')) ->where('user_id', '=', $user->getKey()) ->where('organization_id', '=', $organization->getKey()) + ->workTime() ->groupBy('project_id'); $query = $this->constrainDateByCurrentWeek($query, $timezone, $user->week_start); @@ -433,7 +438,8 @@ class DashboardService JOIN time_entries ON time_entries.start < time_ranges."end" AND coalesce(time_entries."end", :now::timestamp) > time_ranges.start WHERE time_entries.user_id = :user_id and - time_entries.organization_id = :organization_id + time_entries.organization_id = :organization_id and + time_entries.type = :work_type GROUP BY time_ranges.start ORDER BY time_ranges.start ', [ @@ -442,6 +448,7 @@ class DashboardService 'user_id' => $user->getKey(), 'organization_id' => $organization->getKey(), 'now' => Carbon::now()->toDateTimeString(), + 'work_type' => TimeEntryType::Work->value, ]))->pluck('aggregate', 'start'); $response = []; diff --git a/app/Service/Dto/ReportPropertiesDto.php b/app/Service/Dto/ReportPropertiesDto.php index 6a476342..bfb2583f 100644 --- a/app/Service/Dto/ReportPropertiesDto.php +++ b/app/Service/Dto/ReportPropertiesDto.php @@ -8,6 +8,7 @@ use App\Enums\TagMatchType; use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\TimeEntryRoundingType; +use App\Enums\TimeEntryType; use App\Enums\Weekday; use App\Service\TimeEntryFilter; use Illuminate\Contracts\Database\Eloquent\Castable; @@ -68,6 +69,8 @@ class ReportPropertiesDto implements Castable public ?int $roundingMinutes = null; + public ?TimeEntryType $timeEntryType = null; + /** * Get the caster class to use when casting from / to this cast target. * @@ -129,6 +132,8 @@ class ReportPropertiesDto implements Castable $dto->roundingType = isset($data->roundingType) ? TimeEntryRoundingType::from($data->roundingType) : null; // Note: roundingMinutes was added later so it is possible that the value is missing in persisted reports in the DB $dto->roundingMinutes = isset($data->roundingMinutes) ? (int) $data->roundingMinutes : null; + // Note: timeEntryType was added later so it is possible that the value is missing in persisted reports in the DB + $dto->timeEntryType = isset($data->timeEntryType) ? TimeEntryType::from($data->timeEntryType) : null; return $dto; } @@ -157,6 +162,7 @@ class ReportPropertiesDto implements Castable 'timezone' => $value->timezone, 'roundingType' => $value->roundingType?->value, 'roundingMinutes' => $value->roundingMinutes, + 'timeEntryType' => $value->timeEntryType?->value, ]; $jsonString = json_encode($data); diff --git a/app/Service/Export/ExportService.php b/app/Service/Export/ExportService.php index 9b5e21b2..22d85963 100644 --- a/app/Service/Export/ExportService.php +++ b/app/Service/Export/ExportService.php @@ -107,6 +107,7 @@ class ExportService 'end', 'billable_rate', 'billable', + 'type', 'member_id', 'user_id', 'organization_id', @@ -131,6 +132,7 @@ class ExportService $timeEntry->end?->toIso8601ZuluString() ?? '', $timeEntry->billable_rate ?? '', $timeEntry->billable ? 'true' : 'false', + $timeEntry->type->value, $timeEntry->member_id, $timeEntry->user_id, $timeEntry->organization_id, diff --git a/app/Service/Import/Importers/ClockifyTimeEntriesImporter.php b/app/Service/Import/Importers/ClockifyTimeEntriesImporter.php index 0492b87c..ef8b1ca9 100644 --- a/app/Service/Import/Importers/ClockifyTimeEntriesImporter.php +++ b/app/Service/Import/Importers/ClockifyTimeEntriesImporter.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Service\Import\Importers; use App\Enums\Role; +use App\Enums\TimeEntryType; use App\Jobs\RecalculateSpentTimeForProject; use App\Jobs\RecalculateSpentTimeForTask; use App\Models\TimeEntry; @@ -71,8 +72,12 @@ class ClockifyTimeEntriesImporter extends DefaultImporter 'role' => Role::Placeholder->value, ]); $member = $this->memberImportHelper->getModelById($memberId); + // Clockify allows a project/task/client/tags/billable on breaks, but those are + // meaningless for non-work time. Detect breaks up front and skip creating any of + // that so a break can't spawn an orphan project/tag or inflate the import counts. + $isBreak = isset($record['Type']) && strtolower($record['Type']) === 'break'; $clientId = null; - if (($record['Client'] ?? '') !== '') { + if (! $isBreak && ($record['Client'] ?? '') !== '') { $clientId = $this->clientImportHelper->getKey([ 'name' => $record['Client'], 'organization_id' => $this->organization->id, @@ -81,7 +86,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter $projectId = null; $project = null; $projectMember = null; - if ($record['Project'] !== '') { + if (! $isBreak && $record['Project'] !== '') { $projectId = $this->projectImportHelper->getKey([ 'name' => $record['Project'], 'client_id' => $clientId, @@ -97,7 +102,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter ]); } $taskId = null; - if ($taskKey !== null && $record[$taskKey] !== '') { + if (! $isBreak && $taskKey !== null && $record[$taskKey] !== '') { $taskId = $this->taskImportHelper->getKey([ 'name' => $record[$taskKey], 'project_id' => $projectId, @@ -123,7 +128,12 @@ class ClockifyTimeEntriesImporter extends DefaultImporter } $timeEntry->billable = $record['Billable'] === 'Yes'; } - $timeEntry->tags = $this->getTags($record['Tags']); + if ($isBreak) { + // Breaks can not be billable or belong to a project/task (already skipped above) + $timeEntry->type = TimeEntryType::Break; + $timeEntry->billable = false; + } + $timeEntry->tags = $isBreak ? [] : $this->getTags($record['Tags']); $timeEntry->is_imported = true; // Start diff --git a/app/Service/Import/Importers/SolidtimeImporter.php b/app/Service/Import/Importers/SolidtimeImporter.php index b416c688..9904e04b 100644 --- a/app/Service/Import/Importers/SolidtimeImporter.php +++ b/app/Service/Import/Importers/SolidtimeImporter.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Service\Import\Importers; use App\Enums\Role; +use App\Enums\TimeEntryType; use App\Jobs\RecalculateSpentTimeForProject; use App\Jobs\RecalculateSpentTimeForTask; use App\Models\TimeEntry; @@ -255,6 +256,14 @@ class SolidtimeImporter extends DefaultImporter throw new ImportException('Invalid billable value'); } $timeEntry->billable = $timeEntryRow['billable'] === 'true'; + // The type column does not exist in old exports + if (($timeEntryRow['type'] ?? '') !== '') { + $type = TimeEntryType::tryFrom($timeEntryRow['type']); + if ($type === null) { + throw new ImportException('Invalid type value'); + } + $timeEntry->type = $type; + } $timeEntry->tags = $this->getTags($timeEntryRow['tags']); $timeEntry->is_imported = true; diff --git a/app/Service/ReportExport/TimeEntriesDetailedCsvExport.php b/app/Service/ReportExport/TimeEntriesDetailedCsvExport.php index 8534d4c8..44c6c992 100644 --- a/app/Service/ReportExport/TimeEntriesDetailedCsvExport.php +++ b/app/Service/ReportExport/TimeEntriesDetailedCsvExport.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Service\ReportExport; +use App\Enums\TimeEntryType; use App\Models\TimeEntry; use App\Service\IntervalService; use Illuminate\Database\Eloquent\Builder; @@ -25,6 +26,7 @@ class TimeEntriesDetailedCsvExport extends CsvExport 'Duration', 'Duration (decimal)', 'Billable', + 'Break', 'Tags', ]; @@ -58,6 +60,7 @@ class TimeEntriesDetailedCsvExport extends CsvExport 'Duration' => $duration !== null ? $interval->format($model->getDuration()) : null, 'Duration (decimal)' => $duration?->totalHours, 'Billable' => $model->billable ? 'Yes' : 'No', + 'Break' => $model->type === TimeEntryType::Break ? 'Yes' : 'No', 'Tags' => $model->tagsRelation->pluck('name')->implode(', '), ]; } diff --git a/app/Service/ReportExport/TimeEntriesDetailedExport.php b/app/Service/ReportExport/TimeEntriesDetailedExport.php index e871a922..0437d648 100644 --- a/app/Service/ReportExport/TimeEntriesDetailedExport.php +++ b/app/Service/ReportExport/TimeEntriesDetailedExport.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Service\ReportExport; use App\Enums\ExportFormat; +use App\Enums\TimeEntryType; use App\Models\TimeEntry; use App\Service\LocalizationService; use Illuminate\Database\Eloquent\Builder; @@ -106,6 +107,7 @@ class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumn 'Duration', 'Duration (decimal)', 'Billable', + 'Break', 'Tags', ]; } @@ -130,6 +132,7 @@ class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumn $duration !== null ? $this->localizationService->formatInterval($duration) : null, $duration?->totalHours, $model->billable ? 'Yes' : 'No', + $model->type === TimeEntryType::Break ? 'Yes' : 'No', $model->tagsRelation->pluck('name')->implode(', '), ]; } elseif ($this->exportFormat === ExportFormat::ODS) { @@ -144,6 +147,7 @@ class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumn $duration !== null ? $this->localizationService->formatInterval($duration) : null, $duration?->totalHours, $model->billable ? 'Yes' : 'No', + $model->type === TimeEntryType::Break ? 'Yes' : 'No', $model->tagsRelation->pluck('name')->implode(', '), ]; } else { diff --git a/app/Service/TimeEntryAggregationService.php b/app/Service/TimeEntryAggregationService.php index 6cd38329..4333b2e7 100644 --- a/app/Service/TimeEntryAggregationService.php +++ b/app/Service/TimeEntryAggregationService.php @@ -353,6 +353,13 @@ class TimeEntryAggregationService 'color' => null, ]; } + } elseif ($type === TimeEntryAggregationType::Type) { + foreach ($keys as $key) { + $descriptorMap[$key] = [ + 'description' => $key === 'break' ? 'Break' : 'Work time', + 'color' => null, + ]; + } } elseif ($type === TimeEntryAggregationType::Tag) { $tags = Tag::query() ->whereIn('id', $keys) @@ -504,6 +511,8 @@ class TimeEntryAggregationService return 'client_id'; } elseif ($group === TimeEntryAggregationType::Billable) { return 'billable'; + } elseif ($group === TimeEntryAggregationType::Type) { + return 'type'; } elseif ($group === TimeEntryAggregationType::Description) { return 'description'; } elseif ($group === TimeEntryAggregationType::Tag) { diff --git a/app/Service/TimeEntryFilter.php b/app/Service/TimeEntryFilter.php index 150dbb05..1f4b8541 100644 --- a/app/Service/TimeEntryFilter.php +++ b/app/Service/TimeEntryFilter.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Service; use App\Enums\TagMatchType; +use App\Enums\TimeEntryType; use App\Models\Member; use App\Models\TimeEntry; use Illuminate\Database\Eloquent\Builder; @@ -144,6 +145,32 @@ class TimeEntryFilter return $this; } + public function addTypeFilter(?string $type): self + { + if ($type === null) { + return $this; + } + $typeEnum = TimeEntryType::tryFrom($type); + if ($typeEnum === null) { + Log::warning('Invalid type filter value', ['value' => $type]); + + return $this; + } + $this->addType($typeEnum); + + return $this; + } + + public function addType(?TimeEntryType $type): self + { + if ($type === null) { + return $this; + } + $this->builder->where('type', '=', $type->value); + + return $this; + } + /** * @param array|null $clientIds */ diff --git a/database/factories/OrganizationFactory.php b/database/factories/OrganizationFactory.php index bb870f3f..89ad6c12 100644 --- a/database/factories/OrganizationFactory.php +++ b/database/factories/OrganizationFactory.php @@ -33,6 +33,7 @@ class OrganizationFactory extends Factory 'user_id' => User::factory(), 'personal_team' => true, 'employees_can_see_billable_rates' => false, + 'breaks_enabled' => false, 'number_format' => $this->faker->randomElement(NumberFormat::values()), 'currency_format' => $this->faker->randomElement(CurrencyFormat::values()), 'date_format' => $this->faker->randomElement(DateFormat::values()), @@ -55,6 +56,13 @@ class OrganizationFactory extends Factory ]); } + public function withBreaksEnabled(): self + { + return $this->state(fn (array $attributes) => [ + 'breaks_enabled' => true, + ]); + } + public function withOwner(?User $owner = null): self { return $this->state(fn (array $attributes) => [ diff --git a/database/factories/TimeEntryFactory.php b/database/factories/TimeEntryFactory.php index 863df443..d0306c30 100644 --- a/database/factories/TimeEntryFactory.php +++ b/database/factories/TimeEntryFactory.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Database\Factories; +use App\Enums\TimeEntryType; use App\Models\Member; use App\Models\Organization; use App\Models\Project; @@ -33,6 +34,7 @@ class TimeEntryFactory extends Factory 'start' => $start, 'end' => $this->faker->dateTimeBetween($start, 'now'), 'billable' => $this->faker->boolean(), + 'type' => TimeEntryType::Work, 'is_imported' => false, 'tags' => [], 'user_id' => User::factory(), @@ -44,6 +46,18 @@ class TimeEntryFactory extends Factory ]; } + public function isBreak(): self + { + return $this->state(function (array $attributes): array { + return [ + 'type' => TimeEntryType::Break, + 'billable' => false, + 'project_id' => null, + 'task_id' => null, + ]; + }); + } + public function notBillable(): self { return $this->state(function (array $attributes): array { diff --git a/database/migrations/2026_07_11_000001_add_type_to_time_entries_table.php b/database/migrations/2026_07_11_000001_add_type_to_time_entries_table.php new file mode 100644 index 00000000..53368eca --- /dev/null +++ b/database/migrations/2026_07_11_000001_add_type_to_time_entries_table.php @@ -0,0 +1,24 @@ +string('type')->default('work'); + }); + } + + public function down(): void + { + Schema::table('time_entries', function (Blueprint $table): void { + $table->dropColumn('type'); + }); + } +}; diff --git a/database/migrations/2026_07_13_000001_add_breaks_enabled_to_organizations_table.php b/database/migrations/2026_07_13_000001_add_breaks_enabled_to_organizations_table.php new file mode 100644 index 00000000..9b230979 --- /dev/null +++ b/database/migrations/2026_07_13_000001_add_breaks_enabled_to_organizations_table.php @@ -0,0 +1,24 @@ +boolean('breaks_enabled')->default(false)->after('prevent_overlapping_time_entries'); + }); + } + + public function down(): void + { + Schema::table('organizations', function (Blueprint $table): void { + $table->dropColumn('breaks_enabled'); + }); + } +}; diff --git a/e2e/breaks.spec.ts b/e2e/breaks.spec.ts new file mode 100644 index 00000000..450d9d3f --- /dev/null +++ b/e2e/breaks.spec.ts @@ -0,0 +1,278 @@ +import { expect, test } from '../playwright/fixtures'; +import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; +import type { Page } from '@playwright/test'; +import { + assertThatTimerHasStarted, + assertThatTimerIsStopped, + newTimeEntryResponse, + startOrStopTimerWithButton, + stoppedTimeEntryResponse, +} from './utils/currentTimeEntry'; +import { createTimeEntryViaApi, updateOrganizationSettingViaApi } from './utils/api'; + +async function goToDashboard(page: Page) { + await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); +} + +function visibleBreakButton(page: Page) { + return page.getByRole('button', { name: 'Take a break' }).locator('visible=true').first(); +} + +// Breaks are disabled by default for new organizations, so enable them for the break flows. +// The tests that assert the disabled behaviour turn them back off explicitly. +test.beforeEach(async ({ ctx }) => { + await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true }); +}); + +test('test that switching to a break stops the work timer and starts a break entry', async ({ + page, +}) => { + await goToDashboard(page); + await expect(page.getByTestId('time_entry_description')).toBeEditable(); + await page.getByTestId('time_entry_description').fill('Work before break'); + await Promise.all([ + newTimeEntryResponse(page, { description: 'Work before break', type: 'work' }), + page.getByTestId('time_entry_description').press('Enter'), + ]); + await assertThatTimerHasStarted(page); + await page.waitForTimeout(1500); + + // Switch to break: stops the work entry and starts a break entry + await Promise.all([ + newTimeEntryResponse(page, { description: '', type: 'break' }), + visibleBreakButton(page).click(), + ]); + await expect(page.getByText('On break')).toBeVisible(); + + // The break bar offers a one-click resume that stops the break and restores + // the interrupted work context + await page.waitForTimeout(1500); + const resumeButton = page.getByRole('button', { name: 'Resume "Work before break"' }); + await expect(resumeButton).toBeVisible(); + await Promise.all([ + stoppedTimeEntryResponse(page, { type: 'break' }), + newTimeEntryResponse(page, { description: 'Work before break', type: 'work' }), + resumeButton.click(), + ]); + await assertThatTimerHasStarted(page); + await expect(page.getByTestId('time_entry_description')).toHaveValue('Work before break'); + + // Cleanup: stop the running entry + await Promise.all([ + stoppedTimeEntryResponse(page, { description: 'Work before break', type: 'work' }), + startOrStopTimerWithButton(page), + ]); + await assertThatTimerIsStopped(page); +}); + +test('test that stopping a break returns to an idle tracker where a fresh entry starts normally', async ({ + page, +}) => { + await goToDashboard(page); + await expect(page.getByTestId('time_entry_description')).toBeEditable(); + await page.getByTestId('time_entry_description').fill('Work before break'); + await Promise.all([ + newTimeEntryResponse(page, { description: 'Work before break', type: 'work' }), + page.getByTestId('time_entry_description').press('Enter'), + ]); + await assertThatTimerHasStarted(page); + await page.waitForTimeout(1500); + + // Switch to a break + await Promise.all([ + newTimeEntryResponse(page, { description: '', type: 'break' }), + visibleBreakButton(page).click(), + ]); + await expect(page.getByText('On break')).toBeVisible(); + + // Stopping the break just ends it — no modal, the tracker returns to the + // empty idle input with focus so typing starts a fresh entry + await page.waitForTimeout(1500); + await Promise.all([ + stoppedTimeEntryResponse(page, { type: 'break' }), + startOrStopTimerWithButton(page), + ]); + await assertThatTimerIsStopped(page); + await expect(page.getByTestId('time_entry_description')).toHaveValue(''); + await expect(page.getByTestId('time_entry_description')).toBeFocused(); + + // A fresh entry is the normal start flow: type + Enter + await page.getByTestId('time_entry_description').fill('Fresh after break'); + await Promise.all([ + newTimeEntryResponse(page, { description: 'Fresh after break', type: 'work' }), + page.getByTestId('time_entry_description').press('Enter'), + ]); + await assertThatTimerHasStarted(page); + + // Cleanup: stop the running entry + await Promise.all([ + stoppedTimeEntryResponse(page, { description: 'Fresh after break', type: 'work' }), + startOrStopTimerWithButton(page), + ]); + await assertThatTimerIsStopped(page); +}); + +test('test that the more options dropdown can start a break directly', async ({ page }) => { + await goToDashboard(page); + await expect(page.getByTestId('time_entry_description')).toBeEditable(); + + // Start a break straight from the more options dropdown (no create modal) + await page.getByRole('button', { name: 'Time entry actions' }).click(); + await Promise.all([ + newTimeEntryResponse(page, { description: '', type: 'break' }), + page.getByRole('menuitem', { name: 'Start Break' }).click(), + ]); + await expect(page.getByText('On break')).toBeVisible(); + + // Without interrupted work there is nothing to resume, so no resume button is offered + await expect(page.getByRole('button', { name: /^Resume/ })).toHaveCount(0); + + // Cleanup: stop the break + await page.waitForTimeout(1500); + await Promise.all([ + stoppedTimeEntryResponse(page, { type: 'break' }), + startOrStopTimerWithButton(page), + ]); + await assertThatTimerIsStopped(page); +}); + +test('test that disabling breaks hides every break-creation entry point', async ({ page, ctx }) => { + // Breaks disabled for the organization (delivered to the client via the organization endpoint) + await updateOrganizationSettingViaApi(ctx, { breaks_enabled: false }); + await createTimeEntryViaApi(ctx, { duration: '1h', description: 'Regular work' }); + + // Calendar: the empty-slot context menu offers "Create Time Entry" but no "Add Break", + // and the edit modal drops the work-time/break type selector + await page.goto(PLAYWRIGHT_BASE_URL + '/calendar'); + await expect(page.locator('.fc')).toBeVisible(); + const event = page.locator('.fc-event').filter({ hasText: 'Regular work' }).first(); + await event.scrollIntoViewIfNeeded(); + await expect(event).toBeVisible(); + + const box = await event.boundingBox(); + expect(box).not.toBeNull(); + await page.mouse.click(box!.x + box!.width / 2, box!.y + box!.height + 40, { button: 'right' }); + await expect(page.getByRole('menu')).toBeVisible(); + await expect(page.getByRole('menuitem', { name: 'Create Time Entry' })).toBeVisible(); + await expect(page.getByRole('menuitem', { name: 'Add Break' })).toHaveCount(0); + await page.keyboard.press('Escape'); + + await event.click({ button: 'right' }); + await expect(page.getByRole('menu')).toBeVisible(); + await page.getByRole('menuitem', { name: 'Edit' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await expect( + page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Work time' }) + ).toHaveCount(0); + await page.keyboard.press('Escape'); + + // Timesheet: no break row is shown + await page.goto(PLAYWRIGHT_BASE_URL + '/timesheet'); + await expect(page.getByRole('button', { name: 'Add row' }).first()).toBeVisible(); + await expect(page.getByText('Break', { exact: true })).toHaveCount(0); + + // Dashboard tracker: no "Start Break" in the more options dropdown + await goToDashboard(page); + await expect(page.getByTestId('time_entry_description')).toBeEditable(); + await page.getByRole('button', { name: 'Time entry actions' }).click(); + await expect(page.getByRole('menuitem', { name: 'Switch to simple mode' })).toBeVisible(); + await expect(page.getByRole('menuitem', { name: 'Start Break' })).toHaveCount(0); +}); + +// The employee fixture registers a second user and accepts an invitation via Mailpit, +// which does not fit into the default per-test timeout. +test.describe('Org-level breaks setting', () => { + test.describe.configure({ timeout: 60000 }); + + test('test that the org-level breaks setting is respected for employees', async ({ + ctx, + employee, + }) => { + const employeePage = employee.page; + + // Breaks enabled (via beforeEach): the employee sees "Start Break" in the more options dropdown + await employeePage.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); + await expect(employeePage.getByTestId('dashboard_view')).toBeVisible(); + await employeePage.getByRole('button', { name: 'Time entry actions' }).click(); + await expect( + employeePage.getByRole('menuitem', { name: 'Switch to simple mode' }) + ).toBeVisible(); + await expect(employeePage.getByRole('menuitem', { name: 'Start Break' })).toBeVisible(); + await employeePage.keyboard.press('Escape'); + + // The owner disables breaks for the whole organization + await updateOrganizationSettingViaApi(ctx, { breaks_enabled: false }); + + // The employee reloads: "Start Break" is gone from the dropdown + await employeePage.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); + await expect(employeePage.getByTestId('dashboard_view')).toBeVisible(); + await employeePage.getByRole('button', { name: 'Time entry actions' }).click(); + await expect( + employeePage.getByRole('menuitem', { name: 'Switch to simple mode' }) + ).toBeVisible(); + await expect(employeePage.getByRole('menuitem', { name: 'Start Break' })).toHaveCount(0); + await employeePage.keyboard.press('Escape'); + + // With an active timer the break (coffee) button is not shown either + await employeePage.getByTestId('time_entry_description').fill('Employee work'); + await Promise.all([ + newTimeEntryResponse(employeePage, { description: 'Employee work', type: 'work' }), + employeePage.getByTestId('time_entry_description').press('Enter'), + ]); + await assertThatTimerHasStarted(employeePage); + await expect(employeePage.getByRole('button', { name: 'Take a break' })).toHaveCount(0); + + // Cleanup: stop the running entry + await Promise.all([ + stoppedTimeEntryResponse(employeePage, { description: 'Employee work', type: 'work' }), + startOrStopTimerWithButton(employeePage), + ]); + await assertThatTimerIsStopped(employeePage); + }); +}); + +test('test that mass update warns about selected breaks and reports skipped entries instead of success', async ({ + page, + ctx, +}) => { + // One work entry and one break: a billable mass update applies to the work + // entry but the server skips the break entirely — the UI must say so. + await createTimeEntryViaApi(ctx, { duration: '1h', description: 'Mass update work entry' }); + await createTimeEntryViaApi(ctx, { duration: '30min', type: 'break' }); + + await page.goto(PLAYWRIGHT_BASE_URL + '/time'); + await expect(page.locator('[data-testid="time_entry_row"]')).toHaveCount(2); + await page.getByLabel('Select All').click(); + await expect(page.getByText('2 selected')).toBeVisible(); + await page.getByRole('button', { name: 'Edit' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + // No warning while the changeset is compatible with breaks + await expect(page.getByTestId('mass_update_break_warning')).not.toBeVisible(); + + // Making the entries billable is break-incompatible → warning appears + await page + .getByRole('dialog') + .getByRole('combobox') + .filter({ hasText: 'Set billable status' }) + .click(); + await page.getByRole('option', { name: 'Billable', exact: true }).click(); + await expect(page.getByTestId('mass_update_break_warning')).toBeVisible(); + await expect(page.getByTestId('mass_update_break_warning')).toContainText('skipped entirely'); + + // Submit: the work entry updates, the break is skipped, and the toast + // reports the skip instead of claiming success for all entries + const [massUpdateResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries') && + response.request().method() === 'PATCH' && + response.status() === 200 + ), + page.getByRole('button', { name: 'Update Time Entries' }).click(), + ]); + const massUpdateBody = await massUpdateResponse.json(); + expect(massUpdateBody.success.length).toBe(1); + expect(massUpdateBody.error.length).toBe(1); + await expect(page.getByText('1 of 2 time entries was skipped')).toBeVisible(); +}); diff --git a/e2e/calendar.spec.ts b/e2e/calendar.spec.ts index ad063a8c..52046666 100644 --- a/e2e/calendar.spec.ts +++ b/e2e/calendar.spec.ts @@ -2874,3 +2874,54 @@ test.describe('Daily Total After Create', () => { }).toPass({ timeout: 5000 }); }); }); + +test('test that calendar context menu can add a break that fills the gap between two entries', async ({ + page, + ctx, +}) => { + await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true }); + // Two work entries today (09:00-10:00 and 11:00-12:00 UTC) with a one hour gap + const today = new Date().toISOString().slice(0, 10); + const gapStart = `${today}T10:00:00Z`; + const gapEnd = `${today}T11:00:00Z`; + await createTimeEntryWithTimestampsViaApi(ctx, { + start: `${today}T09:00:00Z`, + end: gapStart, + description: 'Gap work A', + }); + await createTimeEntryWithTimestampsViaApi(ctx, { + start: gapEnd, + end: `${today}T12:00:00Z`, + description: 'Gap work B', + }); + + await goToCalendar(page); + const eventA = page.locator('.fc-event').filter({ hasText: 'Gap work A' }).first(); + await eventA.scrollIntoViewIfNeeded(); + await expect(eventA).toBeVisible(); + + // Right-click just below entry A (inside the gap, in the same day column) + const box = await eventA.boundingBox(); + expect(box).not.toBeNull(); + await page.mouse.click(box!.x + box!.width / 2, box!.y + box!.height + 15, { + button: 'right', + }); + await expect(page.getByRole('menu')).toBeVisible(); + await page.getByRole('menuitem', { name: 'Add Break' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + // The break is prefilled to fill the gap exactly + const [createResponse] = await Promise.all([ + page.waitForResponse( + async (response) => + response.url().includes('/time-entries') && + response.request().method() === 'POST' && + response.status() === 201 && + (await response.json()).data.type === 'break' + ), + page.getByRole('button', { name: 'Add Break' }).click(), + ]); + const body = await createResponse.json(); + expect(body.data.start).toBe(gapStart); + expect(body.data.end).toBe(gapEnd); +}); diff --git a/e2e/reporting.spec.ts b/e2e/reporting.spec.ts index d3d43852..50b773da 100644 --- a/e2e/reporting.spec.ts +++ b/e2e/reporting.spec.ts @@ -1019,3 +1019,24 @@ test.describe('Employee Reporting Restrictions', () => { await expect(employee.page.getByText('100,00 EUR').first()).toBeVisible(); }); }); + +test('test that reporting has a type filter that can show only breaks', async ({ page, ctx }) => { + await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true }); + await createTimeEntryViaApi(ctx, { duration: '1h', description: 'Regular work entry' }); + await createTimeEntryViaApi(ctx, { duration: '20min', type: 'break' }); + + await goToReporting(page); + // The type filter defaults to "Work time"; switching it to "Breaks" re-aggregates. + const typeFilter = page.getByRole('combobox').filter({ hasText: 'Work time' }); + await expect(typeFilter).toBeVisible(); + await typeFilter.click(); + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries/aggregate') && + response.url().includes('type=break') && + response.status() === 200 + ), + page.getByRole('option', { name: 'Breaks' }).click(), + ]); +}); diff --git a/e2e/time.spec.ts b/e2e/time.spec.ts index 87e1c6dd..91986602 100644 --- a/e2e/time.spec.ts +++ b/e2e/time.spec.ts @@ -2303,3 +2303,19 @@ test('test that aggregate row context menu delete removes all grouped entries', page.locator('[data-testid="time_entry_row"]').filter({ hasText: description }) ).not.toBeVisible(); }); + +test('test that break entries show a break badge and split day total on the time page', async ({ + page, + ctx, +}) => { + await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true }); + await createTimeEntryViaApi(ctx, { duration: '2h', description: 'Some work' }); + await createTimeEntryViaApi(ctx, { duration: '30min', type: 'break', description: '' }); + + await page.goto(PLAYWRIGHT_BASE_URL + '/time'); + await expect(page.getByTestId('break_badge').first()).toBeVisible(); + await expect(page.getByTestId('break_badge').first()).toContainText('Break'); + // Day heading shows the break portion separately from worked time + await expect(page.getByTestId('day_break_duration').first()).toBeVisible(); + await expect(page.getByTestId('day_break_duration').first()).toContainText('break'); +}); diff --git a/e2e/timesheet.spec.ts b/e2e/timesheet.spec.ts index 0830d988..f9c29c86 100644 --- a/e2e/timesheet.spec.ts +++ b/e2e/timesheet.spec.ts @@ -2,7 +2,14 @@ import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; import { test } from '../playwright/fixtures'; import { expect } from '@playwright/test'; import type { Page } from '@playwright/test'; -import { createProjectViaApi, createTaskViaApi, createTimeEntryOnDateViaApi } from './utils/api'; +import { + createProjectViaApi, + createTaskViaApi, + createTimeEntryOnDateViaApi, + createTimeEntryWithTimestampsViaApi, + getTimeEntriesViaApi, + updateOrganizationSettingViaApi, +} from './utils/api'; // ────────────────────────────────────────────────── // Helpers @@ -639,3 +646,279 @@ test('cell accepts various duration input formats', async ({ page, ctx }) => { // 1.5 hours = 1h 30min await expect(mondayInput).toHaveValue('1h 30min'); }); + +test('test that adding a timesheet break to a full day splits the work entry via the placement modal', async ({ + page, + ctx, +}) => { + // A single work entry filling the day leaves no gap for a break, so the placement + // modal must offer to split it (the only entry) and drop the break in the middle. + await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true }); + const day = getCurrentWeekMonday().toISOString().slice(0, 10); + await createTimeEntryWithTimestampsViaApi(ctx, { + start: `${day}T09:00:00Z`, + end: `${day}T17:00:00Z`, + description: 'Split me', + }); + + await goToTimesheet(page); + await expect(page.getByTestId('timesheet_view')).toBeVisible(); + + // The break row is always present — enter a 30m break on Monday + const breakRow = page + .locator('[data-testid="timesheet_row"]') + .filter({ has: page.getByText('Break', { exact: true }) }); + const breakCell = breakRow.locator('[data-testid="timesheet_cell"]').nth(0).locator('input'); + await breakCell.click(); + await breakCell.fill('0.5'); + await breakCell.press('Enter'); + + // The placement modal opens with the split preview + await expect(page.getByTestId('break_placement_summary')).toBeVisible(); + await Promise.all([ + page.waitForResponse( + async (resp) => + resp.url().includes('/time-entries') && + resp.request().method() === 'POST' && + resp.status() === 201 && + (await resp.json()).data.type === 'break' + ), + page.getByRole('button', { name: 'Add break' }).click(), + ]); + + // The day now has two work halves and one break, none overlapping + const entries = await getTimeEntriesViaApi(ctx); + const dayEntries = entries + .filter((e) => e.start.startsWith(day)) + .sort((a, b) => a.start.localeCompare(b.start)); + expect(dayEntries).toHaveLength(3); + expect(dayEntries.map((e) => e.type)).toEqual(['work', 'break', 'work']); + // The break sits flush between the two halves + expect(dayEntries[0].end).toBe(dayEntries[1].start); + expect(dayEntries[1].end).toBe(dayEntries[2].start); +}); + +test('test that adding a break into an oversized gap places it without moving other entries', async ({ + page, + ctx, +}) => { + // 09-12 and 15-17 leave a 3h gap — wider than the placement tolerance allows, + // but easily big enough to hold the break. Such a gap is deliberate (the app + // itself never creates one), so the break goes flush after the morning entry + // and nothing else moves — no placement modal. + await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true }); + const day = getCurrentWeekMonday().toISOString().slice(0, 10); + await createTimeEntryWithTimestampsViaApi(ctx, { + start: `${day}T09:00:00Z`, + end: `${day}T12:00:00Z`, + description: 'Morning', + }); + await createTimeEntryWithTimestampsViaApi(ctx, { + start: `${day}T15:00:00Z`, + end: `${day}T17:00:00Z`, + description: 'Afternoon', + }); + + await goToTimesheet(page); + await expect(page.getByTestId('timesheet_view')).toBeVisible(); + + const breakRow = page + .locator('[data-testid="timesheet_row"]') + .filter({ has: page.getByText('Break', { exact: true }) }); + const breakCell = breakRow.locator('[data-testid="timesheet_cell"]').nth(0).locator('input'); + await breakCell.click(); + await breakCell.fill('0.5'); + await Promise.all([ + page.waitForResponse( + async (resp) => + resp.url().includes('/time-entries') && + resp.request().method() === 'POST' && + resp.status() === 201 && + (await resp.json()).data.type === 'break' + ), + breakCell.press('Enter'), + ]); + + await expect(page.getByTestId('break_placement_summary')).not.toBeVisible(); + const entries = await getTimeEntriesViaApi(ctx); + const dayEntries = entries + .filter((e) => e.start.startsWith(day)) + .sort((a, b) => a.start.localeCompare(b.start)); + expect(dayEntries.map((e) => [e.type, e.start, e.end])).toEqual([ + ['work', `${day}T09:00:00Z`, `${day}T12:00:00Z`], + ['break', `${day}T12:00:00Z`, `${day}T12:30:00Z`], + ['work', `${day}T15:00:00Z`, `${day}T17:00:00Z`], + ]); +}); + +test('test that the placement modal warns when the chosen time would leave the break misaligned', async ({ + page, + ctx, +}) => { + // Back-to-back 09-12 and 12-17 leave no gap, so the placement modal opens. + // The suggested slot (flush at 12:00) is aligned — no warning. Moving the + // break to 07:00, before any work, keeps the plan feasible but the result + // would immediately carry the misaligned hint, so the modal warns upfront. + await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true }); + const day = getCurrentWeekMonday().toISOString().slice(0, 10); + await createTimeEntryWithTimestampsViaApi(ctx, { + start: `${day}T09:00:00Z`, + end: `${day}T12:00:00Z`, + description: 'Morning', + }); + await createTimeEntryWithTimestampsViaApi(ctx, { + start: `${day}T12:00:00Z`, + end: `${day}T17:00:00Z`, + description: 'Afternoon', + }); + + await goToTimesheet(page); + await expect(page.getByTestId('timesheet_view')).toBeVisible(); + + const breakRow = page + .locator('[data-testid="timesheet_row"]') + .filter({ has: page.getByText('Break', { exact: true }) }); + const breakCell = breakRow.locator('[data-testid="timesheet_cell"]').nth(0).locator('input'); + await breakCell.click(); + await breakCell.fill('0.5'); + await breakCell.press('Enter'); + + // Default suggestion sits flush between work → no warning + await expect(page.getByTestId('break_placement_summary')).toBeVisible(); + await expect(page.getByTestId('break_placement_misaligned_warning')).not.toBeVisible(); + + // Move the break to 07:00-07:30, before all work + const modal = page.getByRole('dialog'); + const startTimeInput = modal.getByTestId('time_picker_input').first(); + await startTimeInput.fill('07:00'); + await startTimeInput.press('Tab'); + const endTimeInput = modal.getByTestId('time_picker_input').nth(1); + await endTimeInput.fill('07:30'); + await endTimeInput.press('Tab'); + + // Feasible (nothing has to move), but flagged as misaligned beforehand + await expect(page.getByTestId('break_placement_misaligned_warning')).toBeVisible(); + await expect(page.getByTestId('break_placement_summary')).toContainText( + 'No entries need to move.' + ); + + // The warning is non-blocking: the break can still be added as chosen + await Promise.all([ + page.waitForResponse( + async (resp) => + resp.url().includes('/time-entries') && + resp.request().method() === 'POST' && + resp.status() === 201 && + (await resp.json()).data.type === 'break' + ), + page.getByRole('button', { name: 'Add break' }).click(), + ]); + + const entries = await getTimeEntriesViaApi(ctx); + const dayEntries = entries + .filter((e) => e.start.startsWith(day)) + .sort((a, b) => a.start.localeCompare(b.start)); + expect(dayEntries.map((e) => [e.type, e.start, e.end])).toEqual([ + ['break', `${day}T07:00:00Z`, `${day}T07:30:00Z`], + ['work', `${day}T09:00:00Z`, `${day}T12:00:00Z`], + ['work', `${day}T12:00:00Z`, `${day}T17:00:00Z`], + ]); + // ...and the timesheet now shows the misaligned-break hint for that day + await expect( + page.getByRole('button', { name: 'does not align with your work entries' }) + ).toBeVisible(); +}); + +test('test that a misplaced break shows a warning on its timesheet day cell', async ({ + page, + ctx, +}) => { + // Work ends at 10:00 and the break starts hours later with no work after it, + // so it is misplaced and its day header should carry the warning hint. + await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true }); + const day = getCurrentWeekMonday().toISOString().slice(0, 10); + await createTimeEntryWithTimestampsViaApi(ctx, { + start: `${day}T09:00:00Z`, + end: `${day}T10:00:00Z`, + }); + await createTimeEntryWithTimestampsViaApi(ctx, { + start: `${day}T14:00:00Z`, + end: `${day}T14:30:00Z`, + type: 'break', + }); + + await goToTimesheet(page); + await expect(page.getByTestId('timesheet_view')).toBeVisible(); + + // Exactly one warning, sitting in Monday's day header + const hint = page.getByRole('button', { + name: 'does not align with your work entries', + }); + await expect(hint).toHaveCount(1); + await expect( + page.getByTestId('timesheet_day_header').first().getByRole('button', { + name: 'does not align with your work entries', + }) + ).toBeVisible(); + + // The hint links to the calendar on the affected date + await hint.click(); + await expect(page.getByRole('link', { name: 'Fix in calendar' })).toHaveAttribute( + 'href', + `/calendar?date=${day}` + ); +}); + +test('test that editing a timesheet break re-places it as one entry instead of fragmenting it', async ({ + page, + ctx, +}) => { + // Two work entries with a 1h gap, and a 30m break created directly inside it (12:15–12:45). + await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true }); + const day = getCurrentWeekMonday().toISOString().slice(0, 10); + await createTimeEntryWithTimestampsViaApi(ctx, { + start: `${day}T09:00:00Z`, + end: `${day}T12:00:00Z`, + description: 'Work', + }); + await createTimeEntryWithTimestampsViaApi(ctx, { + start: `${day}T13:00:00Z`, + end: `${day}T17:00:00Z`, + description: 'Work', + }); + const breakEntry = await createTimeEntryWithTimestampsViaApi(ctx, { + start: `${day}T12:15:00Z`, + end: `${day}T12:45:00Z`, + type: 'break', + }); + + await goToTimesheet(page); + await expect(page.getByTestId('timesheet_view')).toBeVisible(); + const breakRow = page + .locator('[data-testid="timesheet_row"]') + .filter({ has: page.getByText('Break', { exact: true }) }); + const breakCell = breakRow.locator('[data-testid="timesheet_cell"]').nth(0).locator('input'); + await breakCell.click(); + await breakCell.fill('0.75'); // 45 minutes — still fits the 1h gap, so it stays anchored + await Promise.all([ + // A break that still fits its gap is re-placed in place (PUT on the same entry), + // not deleted and recreated — that's what keeps it a single entry. + page.waitForResponse( + async (resp) => + resp.url().includes(`/time-entries/${breakEntry.id}`) && + resp.request().method() === 'PUT' && + resp.status() === 200 && + (await resp.json()).data.type === 'break' + ), + breakCell.press('Enter'), + ]); + + // Still exactly one break on the day (not fragmented). It stays anchored at its current + // start (12:15) rather than re-centering, growing its end to 13:00 to reach 45 minutes. + const after = await getTimeEntriesViaApi(ctx); + const breaks = after.filter((e) => e.start.startsWith(day) && e.type === 'break'); + expect(breaks).toHaveLength(1); + expect(breaks[0].duration).toBe(2700); + expect(breaks[0].start).toBe(`${day}T12:15:00Z`); + expect(breaks[0].end).toBe(`${day}T13:00:00Z`); +}); diff --git a/e2e/timetracker.spec.ts b/e2e/timetracker.spec.ts index b49063d9..fb7e9e78 100644 --- a/e2e/timetracker.spec.ts +++ b/e2e/timetracker.spec.ts @@ -13,6 +13,7 @@ import { createProjectViaApi, createTaskViaApi, createClientViaApi, + createTimeEntryViaApi, archiveProjectViaApi, markTaskDoneViaApi, updateOrganizationCurrencyViaWeb, @@ -375,6 +376,66 @@ test('test that timer started on dashboard is visible on time page', async ({ pa await assertThatTimerIsStopped(page); }); +test('test that picking a recently tracked entry starts a timer with its fields', async ({ + page, + ctx, +}) => { + const project = await createProjectViaApi(ctx, { + name: `RecentProj ${Math.floor(Math.random() * 100000)}`, + is_billable: false, + }); + await createTimeEntryViaApi(ctx, { + description: 'Recent work item', + duration: '1h', + projectId: project.id, + }); + + await goToDashboard(page); + const description = page.getByTestId('time_entry_description'); + await expect(description).toBeEditable(); + + // Focusing the description opens the "Recently Tracked" dropdown listing the finished entry. + await description.click(); + const recentEntry = page.getByText('Recent work item').first(); + await expect(recentEntry).toBeVisible(); + + // Clicking it (mousedown) copies its fields — including the project — into a new running entry. + await Promise.all([ + page.waitForResponse(async (response) => { + if ( + !response.url().includes('/time-entries') || + response.request().method() !== 'POST' || + response.status() !== 201 + ) { + return false; + } + const body = await response.json(); + return ( + body.data.description === 'Recent work item' && + body.data.project_id === project.id && + body.data.end === null + ); + }), + recentEntry.click(), + ]); + await assertThatTimerHasStarted(page); + await expect(description).toHaveValue('Recent work item'); + await expect(page.getByRole('button', { name: project.name })).toBeVisible(); + + // Cleanup: stop the running (project-bearing) entry + await Promise.all([ + page.waitForResponse(async (response) => { + if (response.status() !== 200 || !response.url().includes('/time-entries/')) { + return false; + } + const body = await response.json(); + return body.data.description === 'Recent work item' && body.data.end !== null; + }), + startOrStopTimerWithButton(page), + ]); + await assertThatTimerIsStopped(page); +}); + test('test that creating a new project from the time tracker dropdown prefills the search text', async ({ page, ctx, @@ -681,3 +742,39 @@ test.describe('Project Task Dropdown', () => { await expect(page.getByRole('button', { name: projectName })).toBeVisible(); }); }); + +test('test that simple mode hides the project, tag and billable controls', async ({ page }) => { + await goToDashboard(page); + await expect(page.getByTestId('time_entry_description')).toBeEditable(); + // Project mode shows the project and billable controls + await expect(page.getByRole('button', { name: 'No Project' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Non Billable' }).first()).toBeVisible(); + + // Switch to simple mode via the more options dropdown (client-side preference, no request) + await page.getByRole('button', { name: 'Time entry actions' }).click(); + await page.getByRole('menuitem', { name: 'Switch to simple mode' }).click(); + + // Simple mode is the project tracker without the project/tag/billable selectors; the + // description input and clock-in/out stay. + await expect(page.getByTestId('time_entry_description')).toBeEditable(); + await expect(page.getByRole('button', { name: 'No Project' })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Non Billable' })).toHaveCount(0); + + // Clock in and out + await Promise.all([ + newTimeEntryResponse(page, { type: 'work' }), + startOrStopTimerWithButton(page), + ]); + await assertThatTimerHasStarted(page); + await page.waitForTimeout(1500); + await Promise.all([ + stoppedTimeEntryResponse(page, { type: 'work' }), + startOrStopTimerWithButton(page), + ]); + await assertThatTimerIsStopped(page); + + // Switch back to project mode: the controls return + await page.getByRole('button', { name: 'Time entry actions' }).click(); + await page.getByRole('menuitem', { name: 'Switch to project mode' }).click(); + await expect(page.getByRole('button', { name: 'No Project' })).toBeVisible(); +}); diff --git a/e2e/utils/api.ts b/e2e/utils/api.ts index 62892e6a..ac5ad1b5 100644 --- a/e2e/utils/api.ts +++ b/e2e/utils/api.ts @@ -406,6 +406,7 @@ export async function createTimeEntryViaApi( taskId?: string | null; tags?: string[]; billable?: boolean; + type?: 'work' | 'break'; } ) { const { start, end } = createTimestamps(data.duration); @@ -421,6 +422,7 @@ export async function createTimeEntryViaApi( task_id: data.taskId ?? null, tags: data.tags ?? [], billable: data.billable ?? false, + type: data.type ?? 'work', }, } ); @@ -754,6 +756,7 @@ export async function getTimeEntriesViaApi( project_id: string | null; task_id: string | null; description: string; + type: 'work' | 'break'; }> > { const params = new URLSearchParams(); @@ -779,6 +782,7 @@ export async function createTimeEntryWithTimestampsViaApi( taskId?: string | null; tags?: string[]; billable?: boolean; + type?: 'work' | 'break'; } ) { const response = await ctx.request.post( @@ -793,12 +797,19 @@ export async function createTimeEntryWithTimestampsViaApi( task_id: data.taskId ?? null, tags: data.tags ?? [], billable: data.billable ?? false, + type: data.type ?? 'work', }, } ); expect(response.status()).toBe(201); const body = await response.json(); - return body.data as { id: string; start: string; end: string; description: string }; + return body.data as { + id: string; + start: string; + end: string; + description: string; + type: 'work' | 'break'; + }; } // ────────────────────────────────────────────────── @@ -903,3 +914,71 @@ export async function createReportViaApi( public_until: string | null; }; } + +// ────────────────────────────────────────────────── +// Invoices +// ────────────────────────────────────────────────── + +export async function createInvoiceViaApi( + ctx: TestContext, + data: { + reference: string; + buyer_name?: string; + seller_name?: string; + currency?: string; + date?: string; + tax_rate?: number; + } +) { + const response = await ctx.request.post( + `${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/invoices`, + { + data: { + seller_name: data.seller_name ?? 'Test Seller', + buyer_name: data.buyer_name ?? 'Test Buyer', + reference: data.reference, + currency: data.currency ?? 'EUR', + date: data.date ?? new Date().toISOString().split('T')[0], + // Mirror the UI create form, which always sends a tax rate (default 0). + // Invoices with a null tax_rate currently crash PDF rendering. + tax_rate: data.tax_rate ?? 0, + }, + } + ); + expect(response.status()).toBe(201); + const body = await response.json(); + return body.data as { id: string; reference: string; buyer_name: string }; +} + +export async function updateInvoiceSettingsViaApi(ctx: TestContext, data: Record) { + const response = await ctx.request.put( + `${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/invoice-settings`, + { data } + ); + expect(response.status()).toBe(200); + const body = await response.json(); + return body.data as Record; +} + +export async function getInvoiceSettingsViaApi(ctx: TestContext) { + const response = await ctx.request.get( + `${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/invoice-settings` + ); + expect(response.status()).toBe(200); + const body = await response.json(); + return body.data as Record; +} + +export async function getInvoicesViaApi(ctx: TestContext) { + const response = await ctx.request.get( + `${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/invoices` + ); + expect(response.status()).toBe(200); + const body = await response.json(); + return body.data as Array<{ + id: string; + reference: string; + buyer_name: string; + paid_date: string | null; + }>; +} diff --git a/e2e/utils/currentTimeEntry.ts b/e2e/utils/currentTimeEntry.ts index 6b5986ba..d133febb 100644 --- a/e2e/utils/currentTimeEntry.ts +++ b/e2e/utils/currentTimeEntry.ts @@ -20,7 +20,17 @@ export async function assertThatTimerHasStarted(page: Page) { export function newTimeEntryResponse( page: Page, - { description = '', status = 201, tags = [] } = {} + { + description = '', + status = 201, + tags = [], + type, + }: { + description?: string; + status?: number; + tags?: string[]; + type?: 'work' | 'break'; + } = {} ) { return page.waitForResponse(async (response) => { return ( @@ -34,6 +44,7 @@ export function newTimeEntryResponse( (await response.json()).data.description === description && (await response.json()).data.task_id === null && (await response.json()).data.user_id !== null && + (type === undefined || (await response.json()).data.type === type) && JSON.stringify((await response.json()).data.tags) === JSON.stringify(tags) ); }); @@ -48,7 +59,18 @@ export async function assertThatTimerIsStopped(page: Page) { ).toHaveClass(/bg-accent-300\/70/); } -export async function stoppedTimeEntryResponse(page: Page, { description = '', tags = [] } = {}) { +export async function stoppedTimeEntryResponse( + page: Page, + { + description = '', + tags = [], + type, + }: { + description?: string; + tags?: string[]; + type?: 'work' | 'break'; + } = {} +) { return page.waitForResponse(async (response) => { return ( response.status() === 200 && @@ -62,6 +84,7 @@ export async function stoppedTimeEntryResponse(page: Page, { description = '', t (await response.json()).data.task_id === null && (await response.json()).data.duration !== null && (await response.json()).data.user_id !== null && + (type === undefined || (await response.json()).data.type === type) && JSON.stringify((await response.json()).data.tags) === JSON.stringify(tags) ); }); diff --git a/resources/js/Components/Common/Reporting/ReportingFilterBar.vue b/resources/js/Components/Common/Reporting/ReportingFilterBar.vue index 3b2b8fe9..c1b8fbf7 100644 --- a/resources/js/Components/Common/Reporting/ReportingFilterBar.vue +++ b/resources/js/Components/Common/Reporting/ReportingFilterBar.vue @@ -1,7 +1,8 @@ @@ -186,17 +207,29 @@ const { tags } = useTagsQuery(); :time-entries :create-tag :is-active + :is-on-break="isOnBreak" + :breaks-enabled="breaksEnabled" + :can-resume-after-break="canResumeAfterBreak" + :resume-description="lastWorkTimeEntry?.description ?? null" + :time-tracker-mode="timeTrackerMode" :currency="getOrganizationCurrencyString()" @start-live-timer="startLiveTimer" @stop-live-timer="stopLiveTimer" @start-timer="setActiveState(true)" @stop-timer="setActiveState(false)" + @start-break="startBreak" + @resume-after-break="resumePreviousWorkAfterBreak" @update-time-entry="updateTimeEntry" @create-time-entry="createTimeEntryFromCurrentEntry"> diff --git a/resources/js/Components/Timesheet/BreakPlacementModal.vue b/resources/js/Components/Timesheet/BreakPlacementModal.vue new file mode 100644 index 00000000..25b00a4a --- /dev/null +++ b/resources/js/Components/Timesheet/BreakPlacementModal.vue @@ -0,0 +1,225 @@ + + + + + diff --git a/resources/js/Components/Timesheet/TimesheetCell.test.ts b/resources/js/Components/Timesheet/TimesheetCell.test.ts index 590f470b..134b4f0f 100644 --- a/resources/js/Components/Timesheet/TimesheetCell.test.ts +++ b/resources/js/Components/Timesheet/TimesheetCell.test.ts @@ -93,4 +93,26 @@ describe('TimesheetCell', () => { expect((wrapper.get('input').element as HTMLInputElement).disabled).toBe(true); }); + + it('renders read-only and emits nothing when the row is read-only', async () => { + const wrapper = mount(TimesheetCell, { + props: { + cell: buildCell(2 * 3600), + dayIndex: 0, + date: '2026-04-13', + isToday: false, + hasRunningEntry: false, + readonly: true, + }, + }); + + const input = wrapper.get('input'); + expect((input.element as HTMLInputElement).disabled).toBe(true); + + await input.trigger('focus'); + await input.setValue('4h'); + await input.trigger('blur'); + + expect(wrapper.emitted('update')).toBeUndefined(); + }); }); diff --git a/resources/js/Components/Timesheet/TimesheetCell.vue b/resources/js/Components/Timesheet/TimesheetCell.vue index 9419b576..ec18a0e2 100644 --- a/resources/js/Components/Timesheet/TimesheetCell.vue +++ b/resources/js/Components/Timesheet/TimesheetCell.vue @@ -18,6 +18,7 @@ const props = defineProps<{ date: string; isToday: boolean; hasRunningEntry: boolean; + readonly?: boolean; saveStatus?: CellSaveStatus; pendingSeconds?: number; }>(); @@ -30,6 +31,16 @@ const emit = defineEmits<{ const displaySeconds = computed(() => props.pendingSeconds ?? props.cell?.totalSeconds ?? 0); const isSaving = computed(() => props.saveStatus === 'saving'); +// A cell is non-editable while its entry is running or when the row itself is +// read-only (e.g. a leftover break row after breaks were disabled). Both render +// the same disabled input, differing only in the tooltip explanation. +const isReadonly = computed(() => props.hasRunningEntry || props.readonly === true); +const readonlyTooltip = computed(() => + props.hasRunningEntry + ? 'Stop the running time entry to edit the timesheet' + : 'Breaks are disabled for this organization' +); + // Swap the border color (don't layer) to avoid same-specificity fights. const inputClass = computed(() => { const border = props.saveStatus === 'error' ? 'border-red-500/70' : 'border-input-border'; @@ -51,7 +62,7 @@ const inputClass = computed(() => { data-testid="timesheet_cell" class="flex items-center justify-center border-t border-default-background-separator" :class="{ 'bg-default-background': isToday }"> - + @@ -68,7 +79,7 @@ const inputClass = computed(() => { disabled:opacity-50 disabled:cursor-not-allowed" /> - Stop the running time entry to edit the timesheet + {{ readonlyTooltip }} diff --git a/resources/js/Pages/Time.vue b/resources/js/Pages/Time.vue index ebc210e9..6e10729c 100644 --- a/resources/js/Pages/Time.vue +++ b/resources/js/Pages/Time.vue @@ -1,6 +1,7 @@