mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-13 12:52:41 +01:00
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
This commit is contained in:
10
app/Exceptions/Api/OverlappingTimeEntryApiException.php
Normal file
10
app/Exceptions/Api/OverlappingTimeEntryApiException.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class OverlappingTimeEntryApiException extends ApiException
|
||||
{
|
||||
public const string KEY = 'overlapping_time_entry';
|
||||
}
|
||||
@@ -61,6 +61,9 @@ class OrganizationController extends Controller
|
||||
if ($request->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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('organizations', function (Blueprint $table): void {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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.',
|
||||
];
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import FormSection from '@/Components/FormSection.vue';
|
||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
|
||||
import { Checkbox } from '@/packages/ui/src';
|
||||
import type { UpdateOrganizationBody } from '@/packages/api/src';
|
||||
import { useOrganizationStore } from '@/utils/useOrganization';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useMutation, useQueryClient } from '@tanstack/vue-query';
|
||||
|
||||
const store = useOrganizationStore();
|
||||
const { updateOrganization } = store;
|
||||
const { organization } = storeToRefs(store);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const form = ref<{ prevent_overlapping_time_entries: boolean }>({
|
||||
prevent_overlapping_time_entries: false,
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
form.value.prevent_overlapping_time_entries =
|
||||
organization.value?.prevent_overlapping_time_entries ?? false;
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (values: Partial<UpdateOrganizationBody>) => updateOrganization(values),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['organization'] });
|
||||
},
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
await mutation.mutateAsync({
|
||||
prevent_overlapping_time_entries: form.value.prevent_overlapping_time_entries,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormSection>
|
||||
<template #title>Time Entry Settings</template>
|
||||
<template #description>
|
||||
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.
|
||||
</template>
|
||||
|
||||
<template #form>
|
||||
<div class="col-span-6">
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="preventOverlappingTimeEntries"
|
||||
v-model:checked="form.prevent_overlapping_time_entries" />
|
||||
<InputLabel
|
||||
for="preventOverlappingTimeEntries"
|
||||
value="Prevent overlapping time entries (new entries only)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<PrimaryButton :disabled="mutation.isPending.value" @click="submit">Save</PrimaryButton>
|
||||
</template>
|
||||
</FormSection>
|
||||
</template>
|
||||
@@ -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;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -26,17 +39,25 @@ defineProps<{
|
||||
|
||||
<div>
|
||||
<div class="max-w-7xl mx-auto py-10 sm:px-6 lg:px-8">
|
||||
<UpdateTeamNameForm :team="team" :permissions="permissions" />
|
||||
<div v-if="loading || !organization" class="py-16 text-center text-text-secondary">
|
||||
Loading organization settings...
|
||||
</div>
|
||||
<template v-else>
|
||||
<UpdateTeamNameForm :team="team" :permissions="permissions" />
|
||||
|
||||
<SectionBorder />
|
||||
<OrganizationBillableRate v-if="canUpdateOrganization()" :team="team" />
|
||||
<SectionBorder />
|
||||
<SectionBorder />
|
||||
<OrganizationBillableRate v-if="canUpdateOrganization()" :team="team" />
|
||||
<SectionBorder />
|
||||
|
||||
<OrganizationFormatSettings v-if="canUpdateOrganization()" :team="team" />
|
||||
<SectionBorder />
|
||||
<OrganizationFormatSettings v-if="canUpdateOrganization()" :team="team" />
|
||||
<SectionBorder />
|
||||
|
||||
<template v-if="permissions.canDeleteTeam && !team.personal_team">
|
||||
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" />
|
||||
<OrganizationTimeEntrySettings v-if="canUpdateOrganization()" />
|
||||
<SectionBorder />
|
||||
|
||||
<template v-if="permissions.canDeleteTeam && !team.personal_team">
|
||||
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" />
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -317,6 +317,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,
|
||||
@@ -331,6 +332,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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user