diff --git a/app/Exceptions/Api/OverlappingTimeEntryApiException.php b/app/Exceptions/Api/OverlappingTimeEntryApiException.php new file mode 100644 index 00000000..7c488955 --- /dev/null +++ b/app/Exceptions/Api/OverlappingTimeEntryApiException.php @@ -0,0 +1,10 @@ +getTimeFormat() !== null) { $organization->time_format = $request->getTimeFormat(); } + if ($request->getPreventOverlappingTimeEntries() !== null) { + $organization->prevent_overlapping_time_entries = $request->getPreventOverlappingTimeEntries(); + } $hasBillableRate = $request->has('billable_rate'); if ($hasBillableRate) { $oldBillableRate = $organization->billable_rate; diff --git a/app/Http/Controllers/Api/V1/TimeEntryController.php b/app/Http/Controllers/Api/V1/TimeEntryController.php index a36c9f71..ed1ca073 100644 --- a/app/Http/Controllers/Api/V1/TimeEntryController.php +++ b/app/Http/Controllers/Api/V1/TimeEntryController.php @@ -7,6 +7,7 @@ namespace App\Http\Controllers\Api\V1; use App\Enums\ExportFormat; use App\Enums\Role; use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException; +use App\Exceptions\Api\OverlappingTimeEntryApiException; use App\Exceptions\Api\PdfRendererIsNotConfiguredException; use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException; use App\Exceptions\Api\TimeEntryStillRunningApiException; @@ -45,6 +46,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\File; use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Blade; @@ -56,6 +58,43 @@ use Spatie\TemporaryDirectory\TemporaryDirectory; class TimeEntryController extends Controller { + private function assertNoOverlap(Organization $organization, Member $member, \Illuminate\Support\Carbon $start, ?\Illuminate\Support\Carbon $end, ?TimeEntry $exclude = null): void + { + if (! $organization->prevent_overlapping_time_entries) { + return; + } + + $query = TimeEntry::query() + ->where('organization_id', $organization->getKey()) + ->where('user_id', $member->user_id) + ->when($exclude !== null, function (Builder $q) use ($exclude): void { + $q->where('id', '!=', $exclude->getKey()); + }) + ->where(function (Builder $q) use ($start, $end): void { + $q->where(function (Builder $q2) use ($start): void { + $q2->where('end', '>', $start) + ->where('start', '<', $start); + }); + + if ($end !== null) { + $q->orWhere(function (Builder $q4) use ($end): void { + $q4->where('start', '<', $end) + ->where('end', '>', $end); + }); + // Check if the new entry completely surrounds an existing entry + $q->orWhere(function (Builder $q6) use ($start, $end): void { + $q6->where('start', '>=', $start) + ->where('end', '<=', $end); + }); + } + + }); + + if ($query->exists()) { + throw new OverlappingTimeEntryApiException; + } + } + protected function checkPermission(Organization $organization, string $permission, ?TimeEntry $timeEntry = null): void { parent::checkPermission($organization, $permission); @@ -549,17 +588,15 @@ class TimeEntryController extends Controller throw new TimeEntryStillRunningApiException; } + // Overlap check for create + $start = Carbon::parse($request->input('start')); + $end = $request->input('end') !== null ? Carbon::parse($request->input('end')) : null; + $this->assertNoOverlap($organization, $member, $start, $end); + $project = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id')) : null; $client = $project?->client; $task = $request->input('task_id') !== null ? $project->tasks()->findOrFail((string) $request->input('task_id')) : null; - if ($project !== null) { - RecalculateSpentTimeForProject::dispatch($project); - } - if ($task !== null) { - RecalculateSpentTimeForTask::dispatch($task); - } - $timeEntry = new TimeEntry; $timeEntry->fill($request->validated()); $timeEntry->client()->associate($client); @@ -569,6 +606,13 @@ class TimeEntryController extends Controller $timeEntry->setComputedAttributeValue('billable_rate'); $timeEntry->save(); + if ($project !== null) { + RecalculateSpentTimeForProject::dispatch($project); + } + if ($task !== null) { + RecalculateSpentTimeForTask::dispatch($task); + } + return new TimeEntryResource($timeEntry); } @@ -593,6 +637,13 @@ class TimeEntryController extends Controller throw new TimeEntryCanNotBeRestartedApiException; } + // Overlap check for update (exclude current) + /** @var Member $effectiveMember */ + $effectiveMember = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : $timeEntry->member; + $effectiveStart = $request->has('start') ? Carbon::parse($request->input('start')) : $timeEntry->start; + $effectiveEnd = $request->has('end') ? ($request->input('end') !== null ? Carbon::parse($request->input('end')) : null) : $timeEntry->end; + $this->assertNoOverlap($organization, $effectiveMember, $effectiveStart, $effectiveEnd, $timeEntry); + $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 074c4b80..042fcfc0 100644 --- a/app/Http/Requests/V1/Organization/OrganizationUpdateRequest.php +++ b/app/Http/Requests/V1/Organization/OrganizationUpdateRequest.php @@ -39,6 +39,9 @@ class OrganizationUpdateRequest extends BaseFormRequest 'employees_can_see_billable_rates' => [ 'boolean', ], + 'prevent_overlapping_time_entries' => [ + 'boolean', + ], 'number_format' => [ Rule::enum(NumberFormat::class), ], @@ -98,4 +101,9 @@ class OrganizationUpdateRequest extends BaseFormRequest { return $this->has('employees_can_see_billable_rates') ? $this->boolean('employees_can_see_billable_rates') : null; } + + public function getPreventOverlappingTimeEntries(): ?bool + { + return $this->has('prevent_overlapping_time_entries') ? $this->boolean('prevent_overlapping_time_entries') : null; + } } diff --git a/app/Http/Resources/V1/Organization/OrganizationResource.php b/app/Http/Resources/V1/Organization/OrganizationResource.php index f6ceee74..e7baa9e9 100644 --- a/app/Http/Resources/V1/Organization/OrganizationResource.php +++ b/app/Http/Resources/V1/Organization/OrganizationResource.php @@ -53,6 +53,8 @@ class OrganizationResource extends BaseResource 'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null, /** @var bool $employees_can_see_billable_rates Can members of the organization with role "employee" see the billable rates */ 'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates, + /** @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 string $currency Currency code (ISO 4217) */ 'currency' => $this->resource->currency, /** @var string $currency_symbol Currency symbol */ diff --git a/app/Models/Organization.php b/app/Models/Organization.php index a8b1f93e..35710e10 100644 --- a/app/Models/Organization.php +++ b/app/Models/Organization.php @@ -70,6 +70,7 @@ class Organization extends JetstreamTeam implements AuditableContract 'personal_team' => 'boolean', 'currency' => 'string', 'employees_can_see_billable_rates' => 'boolean', + 'prevent_overlapping_time_entries' => 'boolean', 'number_format' => NumberFormat::class, 'currency_format' => CurrencyFormat::class, 'date_format' => DateFormat::class, diff --git a/database/migrations/2025_10_02_000001_add_prevent_overlapping_time_entries_to_organizations_table.php b/database/migrations/2025_10_02_000001_add_prevent_overlapping_time_entries_to_organizations_table.php new file mode 100644 index 00000000..9157400d --- /dev/null +++ b/database/migrations/2025_10_02_000001_add_prevent_overlapping_time_entries_to_organizations_table.php @@ -0,0 +1,30 @@ +boolean('prevent_overlapping_time_entries')->default(false)->after('employees_can_see_billable_rates'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('organizations', function (Blueprint $table): void { + $table->dropColumn('prevent_overlapping_time_entries'); + }); + } +}; diff --git a/lang/en/exceptions.php b/lang/en/exceptions.php index b5af171d..fda420e6 100644 --- a/lang/en/exceptions.php +++ b/lang/en/exceptions.php @@ -14,6 +14,7 @@ use App\Exceptions\Api\OnlyOwnerCanChangeOwnership; use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember; use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException; use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner; +use App\Exceptions\Api\OverlappingTimeEntryApiException; use App\Exceptions\Api\PdfRendererIsNotConfiguredException; use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException; use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException; @@ -47,6 +48,7 @@ return [ OnlyPlaceholdersCanBeMergedIntoAnotherMember::KEY => 'Only placeholders can be merged into another member', ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException::KEY => 'This placeholder can not be invited use the merge tool instead', InvitationForTheEmailAlreadyExistsApiException::KEY => 'The email has already been invited to the organization. Please wait for the user to accept the invitation or resend the invitation email.', + OverlappingTimeEntryApiException::KEY => 'Overlapping time entries are not allowed.', ], 'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.', ]; diff --git a/lang/en/validation.php b/lang/en/validation.php index 61df3046..17baace2 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -203,6 +203,7 @@ return [ '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 and client already exists in the organization.', + 'overlapping_time_entry' => 'Overlapping time entries are not allowed.', '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.', diff --git a/resources/js/Pages/Teams/Partials/OrganizationFormatSettings.vue b/resources/js/Pages/Teams/Partials/OrganizationFormatSettings.vue index 45dba3e0..24f1c71e 100644 --- a/resources/js/Pages/Teams/Partials/OrganizationFormatSettings.vue +++ b/resources/js/Pages/Teams/Partials/OrganizationFormatSettings.vue @@ -27,7 +27,7 @@ interface FormValues { } const store = useOrganizationStore(); -const { fetchOrganization, updateOrganization } = store; +const { updateOrganization } = store; const { organization } = storeToRefs(store); const queryClient = useQueryClient(); @@ -47,7 +47,6 @@ const mutation = useMutation({ }); onMounted(async () => { - await fetchOrganization(); if (organization.value) { form.value = { number_format: organization.value.number_format as NumberFormat, diff --git a/resources/js/Pages/Teams/Partials/OrganizationTimeEntrySettings.vue b/resources/js/Pages/Teams/Partials/OrganizationTimeEntrySettings.vue new file mode 100644 index 00000000..ff7264e7 --- /dev/null +++ b/resources/js/Pages/Teams/Partials/OrganizationTimeEntrySettings.vue @@ -0,0 +1,68 @@ + + + + + Time Entry Settings + + Disallow overlapping time entries for members of this organization. When enabled, users + cannot create new time entries that overlap with their existing ones. This only affects + newly created entries. + + + + + + + + + + + + + + + Save + + + diff --git a/resources/js/Pages/Teams/Show.vue b/resources/js/Pages/Teams/Show.vue index d98f8738..2bb47bac 100644 --- a/resources/js/Pages/Teams/Show.vue +++ b/resources/js/Pages/Teams/Show.vue @@ -8,12 +8,25 @@ import type { Permissions, Role } from '@/types/jetstream'; import { canUpdateOrganization } from '@/utils/permissions'; import OrganizationBillableRate from '@/Pages/Teams/Partials/OrganizationBillableRate.vue'; import OrganizationFormatSettings from '@/Pages/Teams/Partials/OrganizationFormatSettings.vue'; +import OrganizationTimeEntrySettings from '@/Pages/Teams/Partials/OrganizationTimeEntrySettings.vue'; +import { onMounted, ref } from 'vue'; +import { useOrganizationStore } from '@/utils/useOrganization'; +import { storeToRefs } from 'pinia'; defineProps<{ team: Organization; availableRoles: Role[]; permissions: Permissions; }>(); + +const loading = ref(true); +const orgStore = useOrganizationStore(); +const { organization } = storeToRefs(orgStore); + +onMounted(async () => { + await orgStore.fetchOrganization(); + loading.value = false; +}); @@ -26,17 +39,25 @@ defineProps<{ - + + Loading organization settings... + + + - - - + + + - - + + - - + + + + + + diff --git a/resources/js/packages/api/src/openapi.json.client.ts b/resources/js/packages/api/src/openapi.json.client.ts index 5eb2bf9d..695ba6de 100644 --- a/resources/js/packages/api/src/openapi.json.client.ts +++ b/resources/js/packages/api/src/openapi.json.client.ts @@ -320,6 +320,7 @@ const OrganizationResource = z is_personal: z.boolean(), billable_rate: z.union([z.number(), z.null()]), employees_can_see_billable_rates: z.boolean(), + prevent_overlapping_time_entries: z.boolean(), currency: z.string(), currency_symbol: z.string(), number_format: NumberFormat, @@ -334,6 +335,7 @@ const OrganizationUpdateRequest = z name: z.string().max(255), billable_rate: z.union([z.number(), z.null()]), employees_can_see_billable_rates: z.boolean(), + prevent_overlapping_time_entries: z.boolean(), number_format: NumberFormat, currency_format: CurrencyFormat, date_format: DateFormat, diff --git a/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php b/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php index 6e7e6cdc..585a68b6 100644 --- a/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php @@ -3393,6 +3393,241 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract ]); } + public function test_store_endpoint_blocks_overlapping_entries_when_start_overlaps(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:create:own', + ]); + $data->organization->prevent_overlapping_time_entries = true; + $data->organization->save(); + $baseStart = Carbon::create(2025, 1, 1, 12, 0, 0, 'UTC'); + $baseEnd = Carbon::create(2025, 1, 1, 13, 0, 0, 'UTC'); + TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member) + ->create([ + 'start' => $baseStart, + 'end' => $baseEnd, + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [ + 'member_id' => $data->member->getKey(), + 'billable' => true, + 'start' => $baseStart->copy()->addMinutes(30)->toIso8601ZuluString(), + 'end' => $baseEnd->copy()->addMinutes(30)->toIso8601ZuluString(), + ]); + + // Assert + $response->assertStatus(400); + $response->assertExactJson([ + 'error' => true, + 'key' => 'overlapping_time_entry', + 'message' => 'Overlapping time entries are not allowed.', + ]); + } + + public function test_store_endpoint_blocks_overlapping_entries_when_end_overlaps(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:create:own', + ]); + $data->organization->prevent_overlapping_time_entries = true; + $data->organization->save(); + $baseStart = Carbon::create(2025, 1, 1, 12, 0, 0, 'UTC'); + $baseEnd = Carbon::create(2025, 1, 1, 13, 0, 0, 'UTC'); + TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member) + ->create([ + 'start' => $baseStart, + 'end' => $baseEnd, + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [ + 'member_id' => $data->member->getKey(), + 'billable' => true, + 'start' => $baseStart->copy()->subMinutes(30)->toIso8601ZuluString(), + 'end' => $baseStart->copy()->addMinutes(30)->toIso8601ZuluString(), + ]); + + // Assert + $response->assertStatus(400); + $response->assertExactJson([ + 'error' => true, + 'key' => 'overlapping_time_entry', + 'message' => 'Overlapping time entries are not allowed.', + ]); + } + + public function test_store_endpoint_blocks_overlapping_entries_when_new_entry_is_within_existing(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:create:own', + ]); + $data->organization->prevent_overlapping_time_entries = true; + $data->organization->save(); + $baseStart = Carbon::create(2025, 1, 1, 12, 0, 0, 'UTC'); + $baseEnd = Carbon::create(2025, 1, 1, 13, 0, 0, 'UTC'); + TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member) + ->create([ + 'start' => $baseStart, + 'end' => $baseEnd, + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [ + 'member_id' => $data->member->getKey(), + 'billable' => true, + 'start' => $baseStart->copy()->addMinutes(15)->toIso8601ZuluString(), + 'end' => $baseStart->copy()->addMinutes(45)->toIso8601ZuluString(), + ]); + + // Assert + $response->assertStatus(400); + $response->assertExactJson([ + 'error' => true, + 'key' => 'overlapping_time_entry', + 'message' => 'Overlapping time entries are not allowed.', + ]); + } + + public function test_store_endpoint_blocks_overlapping_entries_when_new_entry_surrounds_existing(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:create:own', + ]); + $data->organization->prevent_overlapping_time_entries = true; + $data->organization->save(); + $baseStart = Carbon::create(2025, 1, 1, 12, 0, 0, 'UTC'); + $baseEnd = Carbon::create(2025, 1, 1, 13, 0, 0, 'UTC'); + TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member) + ->create([ + 'start' => $baseStart, + 'end' => $baseEnd, + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [ + 'member_id' => $data->member->getKey(), + 'billable' => true, + 'start' => $baseStart->copy()->subMinutes(30)->toIso8601ZuluString(), + 'end' => $baseEnd->copy()->addMinutes(30)->toIso8601ZuluString(), + ]); + + // Assert + $response->assertStatus(400); + $response->assertExactJson([ + 'error' => true, + 'key' => 'overlapping_time_entry', + 'message' => 'Overlapping time entries are not allowed.', + ]); + } + + public function test_store_endpoint_blocks_starting_active_entry_when_it_overlaps_with_existing(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:create:own', + ]); + $data->organization->prevent_overlapping_time_entries = true; + $data->organization->save(); + $baseStart = Carbon::create(2025, 1, 1, 12, 0, 0, 'UTC'); + $baseEnd = Carbon::create(2025, 1, 1, 13, 0, 0, 'UTC'); + TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member) + ->create([ + 'start' => $baseStart, + 'end' => $baseEnd, + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [ + 'member_id' => $data->member->getKey(), + 'billable' => true, + 'start' => $baseStart->copy()->addMinutes(30)->toIso8601ZuluString(), + 'end' => null, + ]); + + // Assert + $response->assertStatus(400); + $response->assertExactJson([ + 'error' => true, + 'key' => 'overlapping_time_entry', + 'message' => 'Overlapping time entries are not allowed.', + ]); + } + + public function test_store_endpoint_allows_future_time_entries_even_with_running_now(): void + { + // Arrange + $now = Carbon::create(2025, 1, 1, 12, 0, 0, 'UTC'); + $this->travelTo($now); + $data = $this->createUserWithPermission([ + 'time-entries:create:own', + ]); + $data->organization->prevent_overlapping_time_entries = true; + $data->organization->save(); + TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member) + ->create([ + 'start' => $now->copy()->subHour(), + 'end' => null, + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [ + 'member_id' => $data->member->getKey(), + 'billable' => true, + 'start' => $now->copy()->addDay()->toIso8601ZuluString(), + 'end' => $now->copy()->addDay()->addHour()->toIso8601ZuluString(), + ]); + + // Assert + $response->assertStatus(201); + } + + public function test_update_endpoint_blocks_overlap_and_excludes_current_entry(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:update:own', + ]); + $data->organization->prevent_overlapping_time_entries = true; + $data->organization->save(); + $baseStart = Carbon::create(2025, 1, 1, 14, 0, 0, 'UTC'); + $baseEnd = Carbon::create(2025, 1, 1, 15, 0, 0, 'UTC'); + $base = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member) + ->create([ + 'start' => $baseStart, + 'end' => $baseEnd, + ]); + $toUpdate = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member) + ->create([ + 'start' => $baseEnd->copy()->addMinutes(30), + 'end' => $baseEnd->copy()->addHour(), + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $toUpdate->getKey()]), [ + 'start' => $baseStart->copy()->addMinutes(30)->toIso8601ZuluString(), + ]); + + // Assert + $response->assertStatus(400); + $response->assertExactJson([ + 'error' => true, + 'key' => 'overlapping_time_entry', + 'message' => 'Overlapping time entries are not allowed.', + ]); + } + public function test_update_multiple_refreshes_billable_rate_on_updates_time_entries(): void { // Arrange