Skip to content

Commit 19a206d

Browse files
committed
add prevent_overlapping_time_entries setting to organization
when enabled users are blocked from creating or editing new time entries that are overlapping with other time entries
1 parent c0788c2 commit 19a206d

File tree

14 files changed

+450
-17
lines changed

14 files changed

+450
-17
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Exceptions\Api;
6+
7+
class OverlappingTimeEntryApiException extends ApiException
8+
{
9+
public const string KEY = 'overlapping_time_entry';
10+
}

app/Http/Controllers/Api/V1/OrganizationController.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ public function update(Organization $organization, OrganizationUpdateRequest $re
6161
if ($request->getTimeFormat() !== null) {
6262
$organization->time_format = $request->getTimeFormat();
6363
}
64+
if ($request->getPreventOverlappingTimeEntries() !== null) {
65+
$organization->prevent_overlapping_time_entries = $request->getPreventOverlappingTimeEntries();
66+
}
6467
$hasBillableRate = $request->has('billable_rate');
6568
if ($hasBillableRate) {
6669
$oldBillableRate = $organization->billable_rate;

app/Http/Controllers/Api/V1/TimeEntryController.php

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use App\Enums\ExportFormat;
88
use App\Enums\Role;
99
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
10+
use App\Exceptions\Api\OverlappingTimeEntryApiException;
1011
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
1112
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
1213
use App\Exceptions\Api\TimeEntryStillRunningApiException;
@@ -45,6 +46,7 @@
4546
use Illuminate\Http\File;
4647
use Illuminate\Http\JsonResponse;
4748
use Illuminate\Http\Resources\Json\JsonResource;
49+
use Illuminate\Support\Carbon;
4850
use Illuminate\Support\Collection;
4951
use Illuminate\Support\Facades\Auth;
5052
use Illuminate\Support\Facades\Blade;
@@ -56,6 +58,43 @@
5658

5759
class TimeEntryController extends Controller
5860
{
61+
private function assertNoOverlap(Organization $organization, Member $member, \Illuminate\Support\Carbon $start, ?\Illuminate\Support\Carbon $end, ?TimeEntry $exclude = null): void
62+
{
63+
if (! $organization->prevent_overlapping_time_entries) {
64+
return;
65+
}
66+
67+
$query = TimeEntry::query()
68+
->where('organization_id', $organization->getKey())
69+
->where('user_id', $member->user_id)
70+
->when($exclude !== null, function (Builder $q) use ($exclude): void {
71+
$q->where('id', '!=', $exclude->getKey());
72+
})
73+
->where(function (Builder $q) use ($start, $end): void {
74+
$q->where(function (Builder $q2) use ($start): void {
75+
$q2->where('end', '>', $start)
76+
->where('start', '<', $start);
77+
});
78+
79+
if ($end !== null) {
80+
$q->orWhere(function (Builder $q4) use ($end): void {
81+
$q4->where('start', '<', $end)
82+
->where('end', '>', $end);
83+
});
84+
// Check if the new entry completely surrounds an existing entry
85+
$q->orWhere(function (Builder $q6) use ($start, $end): void {
86+
$q6->where('start', '>=', $start)
87+
->where('end', '<=', $end);
88+
});
89+
}
90+
91+
});
92+
93+
if ($query->exists()) {
94+
throw new OverlappingTimeEntryApiException;
95+
}
96+
}
97+
5998
protected function checkPermission(Organization $organization, string $permission, ?TimeEntry $timeEntry = null): void
6099
{
61100
parent::checkPermission($organization, $permission);
@@ -549,17 +588,15 @@ public function store(Organization $organization, TimeEntryStoreRequest $request
549588
throw new TimeEntryStillRunningApiException;
550589
}
551590

591+
// Overlap check for create
592+
$start = Carbon::parse($request->input('start'));
593+
$end = $request->input('end') !== null ? Carbon::parse($request->input('end')) : null;
594+
$this->assertNoOverlap($organization, $member, $start, $end);
595+
552596
$project = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id')) : null;
553597
$client = $project?->client;
554598
$task = $request->input('task_id') !== null ? $project->tasks()->findOrFail((string) $request->input('task_id')) : null;
555599

556-
if ($project !== null) {
557-
RecalculateSpentTimeForProject::dispatch($project);
558-
}
559-
if ($task !== null) {
560-
RecalculateSpentTimeForTask::dispatch($task);
561-
}
562-
563600
$timeEntry = new TimeEntry;
564601
$timeEntry->fill($request->validated());
565602
$timeEntry->client()->associate($client);
@@ -569,6 +606,13 @@ public function store(Organization $organization, TimeEntryStoreRequest $request
569606
$timeEntry->setComputedAttributeValue('billable_rate');
570607
$timeEntry->save();
571608

609+
if ($project !== null) {
610+
RecalculateSpentTimeForProject::dispatch($project);
611+
}
612+
if ($task !== null) {
613+
RecalculateSpentTimeForTask::dispatch($task);
614+
}
615+
572616
return new TimeEntryResource($timeEntry);
573617
}
574618

@@ -593,6 +637,13 @@ public function update(Organization $organization, TimeEntry $timeEntry, TimeEnt
593637
throw new TimeEntryCanNotBeRestartedApiException;
594638
}
595639

640+
// Overlap check for update (exclude current)
641+
/** @var Member $effectiveMember */
642+
$effectiveMember = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : $timeEntry->member;
643+
$effectiveStart = $request->has('start') ? Carbon::parse($request->input('start')) : $timeEntry->start;
644+
$effectiveEnd = $request->has('end') ? ($request->input('end') !== null ? Carbon::parse($request->input('end')) : null) : $timeEntry->end;
645+
$this->assertNoOverlap($organization, $effectiveMember, $effectiveStart, $effectiveEnd, $timeEntry);
646+
596647
$oldProject = $timeEntry->project;
597648
$oldTask = $timeEntry->task;
598649

app/Http/Requests/V1/Organization/OrganizationUpdateRequest.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ public function rules(): array
3939
'employees_can_see_billable_rates' => [
4040
'boolean',
4141
],
42+
'prevent_overlapping_time_entries' => [
43+
'boolean',
44+
],
4245
'number_format' => [
4346
Rule::enum(NumberFormat::class),
4447
],
@@ -98,4 +101,9 @@ public function getEmployeesCanSeeBillableRates(): ?bool
98101
{
99102
return $this->has('employees_can_see_billable_rates') ? $this->boolean('employees_can_see_billable_rates') : null;
100103
}
104+
105+
public function getPreventOverlappingTimeEntries(): ?bool
106+
{
107+
return $this->has('prevent_overlapping_time_entries') ? $this->boolean('prevent_overlapping_time_entries') : null;
108+
}
101109
}

app/Http/Resources/V1/Organization/OrganizationResource.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ public function toArray(Request $request): array
5353
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
5454
/** @var bool $employees_can_see_billable_rates Can members of the organization with role "employee" see the billable rates */
5555
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
56+
/** @var bool $prevent_overlapping_time_entries Prevent creating overlapping time entries (only new entries) */
57+
'prevent_overlapping_time_entries' => $this->resource->prevent_overlapping_time_entries,
5658
/** @var string $currency Currency code (ISO 4217) */
5759
'currency' => $this->resource->currency,
5860
/** @var string $currency_symbol Currency symbol */

app/Models/Organization.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ class Organization extends JetstreamTeam implements AuditableContract
7070
'personal_team' => 'boolean',
7171
'currency' => 'string',
7272
'employees_can_see_billable_rates' => 'boolean',
73+
'prevent_overlapping_time_entries' => 'boolean',
7374
'number_format' => NumberFormat::class,
7475
'currency_format' => CurrencyFormat::class,
7576
'date_format' => DateFormat::class,
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Illuminate\Database\Migrations\Migration;
6+
use Illuminate\Database\Schema\Blueprint;
7+
use Illuminate\Support\Facades\Schema;
8+
9+
return new class extends Migration
10+
{
11+
/**
12+
* Run the migrations.
13+
*/
14+
public function up(): void
15+
{
16+
Schema::table('organizations', function (Blueprint $table): void {
17+
$table->boolean('prevent_overlapping_time_entries')->default(false)->after('employees_can_see_billable_rates');
18+
});
19+
}
20+
21+
/**
22+
* Reverse the migrations.
23+
*/
24+
public function down(): void
25+
{
26+
Schema::table('organizations', function (Blueprint $table): void {
27+
$table->dropColumn('prevent_overlapping_time_entries');
28+
});
29+
}
30+
};

lang/en/exceptions.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
1515
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
1616
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
17+
use App\Exceptions\Api\OverlappingTimeEntryApiException;
1718
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
1819
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
1920
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
@@ -47,6 +48,7 @@
4748
OnlyPlaceholdersCanBeMergedIntoAnotherMember::KEY => 'Only placeholders can be merged into another member',
4849
ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException::KEY => 'This placeholder can not be invited use the merge tool instead',
4950
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.',
51+
OverlappingTimeEntryApiException::KEY => 'Overlapping time entries are not allowed.',
5052
],
5153
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
5254
];

lang/en/validation.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@
203203
'organization' => 'The :attribute does not exist.',
204204
'task_belongs_to_project' => 'The :attribute is not part of the given project.',
205205
'project_name_already_exists' => 'A project with the same name and client already exists in the organization.',
206+
'overlapping_time_entry' => 'Overlapping time entries are not allowed.',
206207
'tag_name_already_exists' => 'A tag with the same name already exists in the organization.',
207208
'client_name_already_exists' => 'A client with the same name already exists in the organization.',
208209
'task_name_already_exists' => 'A task with the same name already exists in the project.',

resources/js/Pages/Teams/Partials/OrganizationFormatSettings.vue

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ interface FormValues {
2727
}
2828
2929
const store = useOrganizationStore();
30-
const { fetchOrganization, updateOrganization } = store;
30+
const { updateOrganization } = store;
3131
const { organization } = storeToRefs(store);
3232
const queryClient = useQueryClient();
3333
@@ -47,7 +47,6 @@ const mutation = useMutation({
4747
});
4848
4949
onMounted(async () => {
50-
await fetchOrganization();
5150
if (organization.value) {
5251
form.value = {
5352
number_format: organization.value.number_format as NumberFormat,

0 commit comments

Comments
 (0)