feat: add conflict resolution UI to holiday hours modal

Add conflict detection to the Create Exception Schedule modal with auto-checking, amber warning display, and View Booking/View Client buttons. Wire openUserModal and openBookingModal props from admin page. Fix TimeBlockers placeholder duration from hardcoded 60 to booking.duration_minutes. Remove dead placeholder creation code (isFormValid prevents save while conflicts exist). Fix formatTime overwriting raw hour data with display strings.
This commit is contained in:
2026-08-22 00:34:48 +01:00
parent 6567ff4904
commit 9170990340
3 changed files with 196 additions and 15 deletions
@@ -1,9 +1,11 @@
<script lang="ts">
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { sanitizeText } from '$lib/utils/toast-safe';
import { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe';
import { apiFetch } from '$lib/utils/api';
import { range } from '$lib/utils/format';
import { range, formatDuration } from '$lib/utils/format';
import { formatUserName } from '$lib/utils/nameDisplay';
import { parseWallClockDate } from '$lib/utils/timeSlots';
// shadcn-svelte components
import { Button } from '$lib/components/ui/button';
@@ -33,6 +35,30 @@
hours: WorkingHourRow[];
};
type OverlappingBooking = {
id: string;
start_time: string;
duration_minutes: number;
status: string;
user: {
id: string;
full_name: string;
email: string | null;
phone: string | null;
previous_first_name?: string | null;
previous_last_name?: string | null;
} | null;
services: string[];
};
interface Props {
openUserModal?: (userId: string) => void;
openBookingModal?: (bookingId: string) => void;
rescheduleVersion?: number;
}
let { openUserModal, openBookingModal, rescheduleVersion = 0 }: Props = $props();
// =============== State ===============
let exceptionGroups = $state<ExceptionGroup[]>([]);
let exceptionGroupsLoading = $state(true);
@@ -61,7 +87,10 @@
});
const isFormValid = $derived(
exceptionDraft.name.trim() !== '' && exceptionDraft.weekStarts.length > 0
exceptionDraft.name.trim() !== '' &&
exceptionDraft.weekStarts.length > 0 &&
!hasOverlap &&
!checkingOverlap
);
function validateNameField() {
@@ -91,6 +120,51 @@
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
// =============== Conflict Resolution State ===============
let overlappingBookings = $state<OverlappingBooking[]>([]);
let checkingOverlap = $state(false);
let hasOverlap = $derived(overlappingBookings.length > 0);
async function checkConflictingBookings() {
if (exceptionDraft.weekStarts.length === 0) {
overlappingBookings = [];
return;
}
checkingOverlap = true;
try {
const payload = {
weekStarts: exceptionDraft.weekStarts,
proposedHours: exceptionDraft.hours.map((h) => ({
weekday: h.weekday,
startTime: h.start_time,
endTime: h.end_time,
isOpen: h.is_open
}))
};
const response = await apiFetch('/api/admin/bookings/conflicting-for-exception', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (response.ok) {
const data = await response.json();
overlappingBookings = data.bookings || [];
} else {
overlappingBookings = [];
}
} catch (err) {
console.error('Error checking conflicting bookings:', err);
overlappingBookings = [];
} finally {
checkingOverlap = false;
}
}
// =============== Helper Functions ===============
function weekdayLabel(i: number) {
return dayNames[i];
@@ -167,8 +241,8 @@
}) => ({
id: h.id,
weekday: h.weekday,
start_time: formatTime(h.startTime),
end_time: formatTime(h.endTime),
start_time: h.startTime,
end_time: h.endTime,
is_open: h.isOpen
})
) || []
@@ -224,7 +298,7 @@
await fetchExceptionGroups();
} else {
const text = await response.text();
toast.error('Failed to create: ' + sanitizeText(text), { id: loadingToast });
toast.error('Failed to create: ' + sanitizeText(extractErrorMessage(text)), { id: loadingToast });
}
} catch (err) {
console.error('Error creating exception group:', err);
@@ -255,7 +329,7 @@
await fetchExceptionGroups();
} else {
const text = await response.text();
toast.error('Failed to delete: ' + sanitizeText(text), { id: loadingToast });
toast.error('Failed to delete: ' + sanitizeText(extractErrorMessage(text)), { id: loadingToast });
}
} catch (err) {
console.error('Error deleting exception group:', err);
@@ -281,6 +355,7 @@
};
weekRangeFrom = '';
weekRangeTo = '';
overlappingBookings = [];
}
function createNewException() {
@@ -288,6 +363,18 @@
showExceptionModal = true;
}
// Check for conflicts whenever weekStarts, hours, or rescheduleVersion change
$effect(() => {
// Track these reactive values so the effect re-runs when they change
const weekStarts = exceptionDraft.weekStarts;
const hours = exceptionDraft.hours;
const rv = rescheduleVersion;
// Avoid triggering check on initial empty state
if (weekStarts.length > 0 && showExceptionModal) {
checkConflictingBookings();
}
});
function addWeekRange() {
if (!weekRangeFrom || !weekRangeTo) {
toast.error('Please select both start and end dates');
@@ -353,7 +440,7 @@
<p class="col-span-2 text-sm text-gray-500">No exception groups found.</p>
{/if}
{#each exceptionGroups as g (g.weekStarts)}
{#each exceptionGroups as g (g.id)}
<div class="group relative h-full rounded-lg border p-4 transition-all">
<div class="flex h-full flex-col gap-3">
<div class="flex-1">
@@ -601,6 +688,100 @@
</div>
</div>
<!-- Conflict Resolution -->
{#if checkingOverlap}
<div class="flex items-center gap-2 px-4 pb-4">
<svg
class="h-4 w-4 animate-spin text-gray-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
<span class="text-sm text-gray-500">Checking for conflicting bookings…</span>
</div>
{:else if hasOverlap}
<div class="mx-4 rounded-lg border border-amber-200 bg-amber-50 p-4">
<div class="mb-3 flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 text-amber-600"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"
/>
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
<span class="text-sm font-medium text-amber-800">
{overlappingBookings.length} booking{overlappingBookings.length > 1 ? 's' : ''} conflict{overlappingBookings.length >
1
? ''
: 's'} with the proposed hours
</span>
</div>
<div class="space-y-3">
{#each overlappingBookings as booking (booking.id)}
<div class="rounded-md border border-amber-200 bg-white p-3">
<div class="min-w-0">
<div class="text-sm font-medium">
{formatUserName(
booking.user?.full_name || 'Unknown',
booking.user?.previous_first_name,
booking.user?.previous_last_name
)}
</div>
<div class="text-xs text-gray-500">
{parseWallClockDate(booking.start_time).toLocaleTimeString('en-GB', {
hour: 'numeric',
minute: '2-digit',
hour12: true
})}} ·
{formatDuration(booking.duration_minutes)}
{#if booking.services?.length}
·
{booking.services.join(', ')}
{/if}
</div>
</div>
<div class="mt-2 flex gap-2">
{#if openBookingModal}
<Button
variant="outline"
size="sm"
class="h-7 text-xs"
onclick={() => openBookingModal(booking.id)}
>
View Booking
</Button>
{/if}
{#if openUserModal && booking.user?.id}
<Button
variant="outline"
size="sm"
class="h-7 text-xs"
onclick={() => openUserModal(booking.user!.id)}
>
View Client
</Button>
{/if}
</div>
</div>
{/each}
</div>
</div>
{/if}
<Modal.Footer class="flex items-center justify-end gap-2">
<Button
variant="outline"
@@ -705,10 +886,10 @@
</span>
</td>
<td class="py-2 text-sm">
{row.is_open ? row.start_time : '—'}
{row.is_open ? formatTime(row.start_time) : '—'}
</td>
<td class="py-2 text-sm">
{row.is_open ? row.end_time : '—'}
{row.is_open ? formatTime(row.end_time) : '—'}
</td>
</tr>
{/each}
@@ -3,7 +3,7 @@
import { apiFetch } from '$lib/utils/api';
import { CalendarDate } from '@internationalized/date';
import { toast } from 'svelte-sonner';
import { sanitizeText } from '$lib/utils/toast-safe';
import { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe';
import { formatDuration, range } from '$lib/utils/format';
import { formatUserName } from '$lib/utils/nameDisplay';
import { formatLocalDateTime, parseWallClockDate } from '$lib/utils/timeSlots';
@@ -410,7 +410,7 @@
},
body: JSON.stringify({
start_time: startIso,
duration_minutes: 60,
duration_minutes: booking.duration_minutes,
description: `RESERVATION:placeholder:${booking.id}`
})
});
@@ -440,7 +440,7 @@
await fetchBlockers();
} else {
const text = await response.text();
toast.error('Failed to create: ' + sanitizeText(text), { id: loadingToast });
toast.error('Failed to create: ' + sanitizeText(extractErrorMessage(text)), { id: loadingToast });
}
} catch (err) {
console.error('Error creating time blocker:', err);
@@ -467,7 +467,7 @@
await fetchBlockers();
} else {
const text = await response.text();
toast.error('Failed to delete: ' + sanitizeText(text), { id: loadingToast });
toast.error('Failed to delete: ' + sanitizeText(extractErrorMessage(text)), { id: loadingToast });
}
} catch (err) {
console.error('Error deleting time blocker:', err);