From 5523672b6a5d1ec2621c8dffea89b3e4f379fcfb Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Mon, 22 Jun 2026 12:55:40 +0100 Subject: [PATCH] feat(frontend): add out-of-hours booking UI components Add out_of_hours toggle, slot detection and badge to BookingCreateModal. Show out-of-hours badge on BookingModal detail view. Display warning banner on SelectedTimeSummary for out-of-hours slots. Highlight out-of-hours slots with red styling in TimeSlotList. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../admin/BookingCreateModal.svelte | 348 +++++++++++++++--- .../lib/components/admin/BookingModal.svelte | 14 + .../booking/SelectedTimeSummary.svelte | 34 +- .../components/booking/TimeSlotList.svelte | 37 +- 4 files changed, 366 insertions(+), 67 deletions(-) diff --git a/frontend/src/lib/components/admin/BookingCreateModal.svelte b/frontend/src/lib/components/admin/BookingCreateModal.svelte index 926906e..0644f85 100644 --- a/frontend/src/lib/components/admin/BookingCreateModal.svelte +++ b/frontend/src/lib/components/admin/BookingCreateModal.svelte @@ -12,6 +12,7 @@ import * as Card from '$lib/components/ui/card'; // Note: We are using native inputs for Steps 1 and 3 to fix reactivity bugs // but keeping the Label and other components. + import { Checkbox } from '$lib/components/ui/checkbox'; import { Label } from '$lib/components/ui/label'; import { Input } from '$lib/components/ui/input'; import { Separator } from '$lib/components/ui/separator'; @@ -27,7 +28,12 @@ import SelectedTimeSummary from '$lib/components/booking/SelectedTimeSummary.svelte'; // Types - import type { Service, CustomService, WorkingHoursDay, AvailableHoursDay } from '$lib/types/booking'; + import type { + Service, + CustomService, + WorkingHoursDay, + AvailableHoursDay + } from '$lib/types/booking'; import { buildLunchProtection, @@ -35,6 +41,7 @@ generateGroupedTimeSlots, formatTime, calculateEndTime, + timeToMinutes, getDayWithOrdinal, type DayHours, type DayAvailability @@ -56,7 +63,15 @@ let userQuery = $state(''); // Updated type to include account_role for filtering let users = $state< - Array<{ id: string; fullName: string; email?: string; phone?: string; account_role: string; previousFirstName?: string | null; previousLastName?: string | null }> + Array<{ + id: string; + fullName: string; + email?: string; + phone?: string; + account_role: string; + previousFirstName?: string | null; + previousLastName?: string | null; + }> >([]); let selectedUserId = $state(null); let guestName = $state(''); @@ -74,7 +89,13 @@ let customSearchQuery = $state(''); let loadingCustomServices = $state(false); let showCustomCreateForm = $state(false); - let newCustomService = $state({ name: '', description: '', price: '', duration_minutes: '', minimum_age_required: '' }); + let newCustomService = $state({ + name: '', + description: '', + price: '', + duration_minutes: '', + minimum_age_required: '' + }); let creatingCustomService = $state(false); let customServiceErrors = $state>({}); @@ -116,16 +137,22 @@ } let isCustomFormValid = $derived( (newCustomService.name ?? '').trim() !== '' && - !customServiceErrors.name && - !customServiceErrors.price && - !customServiceErrors.duration_minutes && - !customServiceErrors.minimum_age_required + !customServiceErrors.name && + !customServiceErrors.price && + !customServiceErrors.duration_minutes && + !customServiceErrors.minimum_age_required ); function toggleCustomForm(show: boolean) { showCustomCreateForm = show; if (show) { - newCustomService = { name: '', description: '', price: '', duration_minutes: '', minimum_age_required: '' }; + newCustomService = { + name: '', + description: '', + price: '', + duration_minutes: '', + minimum_age_required: '' + }; customServiceErrors = { name: '', price: '', duration_minutes: '', minimum_age_required: '' }; requestAnimationFrame(() => { const modalContent = document.querySelector('[data-custom-form-container]'); @@ -161,6 +188,34 @@ let loadingAvailableHours = $state(false); let hoursRangeGeneration = $state(0); let hoursMonthGeneration = $state(0); + let outOfHours = $state(false); + // Keep the last-known normal (non-out-of-hours) working hours for per-slot styling + let normalWorkingHours = $state | null>(null); + // Clear caches and force re-fetch when out-of-hours toggled + let prevOutOfHours = $state(false); + $effect(() => { + if (outOfHours !== prevOutOfHours) { + prevOutOfHours = outOfHours; + console.log('[DEBUG] Out-of-hours mode TOGGLED', { now: outOfHours }); + // Save normal hours BEFORE clearing, so we can show which slots are genuinely out-of-hours + if (outOfHours && workingHours) { + normalWorkingHours = { ...workingHours }; + } else if (!outOfHours) { + normalWorkingHours = null; + } + // Clear data and caches + workingHoursCache = {}; + availableHoursCache = {}; + workingHours = null; + availableHours = null; + selectedTime = null; + // Directly re-fetch since caches aren't reactive (plain `let` not `$state`) + // so the safety net effect won't detect the cache deletion + if (currentStep === 4 && placeholder) { + fetchHoursRange(placeholder, 2); + } + } + }); // Date Boundaries const today = new SvelteDate(); @@ -206,11 +261,56 @@ // =============== Lunch Protection =============== const lunchProtection = $derived( - selectedDate && selectedServices.length > 0 + selectedDate && selectedServices.length > 0 && !outOfHours ? buildLunchProtection(selectedDate, workingHours, availableHours, getTotalDuration(), true) : new Map() ); + // =============== Debug: Slot generation =============== + /** Check if a slot time falls outside the normal (non-out-of-hours) business hours */ + function isSlotOutOfHours( + dateStr: string, + timeStr: string, + duration: number, + normalWH: Record | null + ): boolean { + if (!normalWH) return false; + const normalDay = normalWH[dateStr]; + if (!normalDay) return false; + // Day is normally closed → ALL slots are out-of-hours + if (!normalDay.isOpen) return true; + // Slot starts before normal opening + if (timeToMinutes(timeStr) < timeToMinutes(normalDay.startTime)) return true; + // Slot ends after normal closing + if (timeToMinutes(timeStr) + duration > timeToMinutes(normalDay.endTime)) return true; + return false; + } + + const groupedTimeSlots = $derived.by(() => { + const base = + currentStep === 4 && selectedServices.length > 0 && selectedDate + ? generateGroupedTimeSlots( + selectedDate, + workingHours, + availableHours, + getTotalDuration(), + lunchProtection + ) + : []; + if (!outOfHours || !normalWorkingHours || !selectedDate) return base; + const dateStr = selectedDate.toString(); + const duration = getTotalDuration(); + return base.map((slot) => { + if (slot.type === 'available') { + return { + ...slot, + outOfHours: isSlotOutOfHours(dateStr, slot.startTime, duration, normalWorkingHours) + }; + } + return slot; + }); + }); + function formatDuration(minutes: number): string { const hours = Math.floor(minutes / 60); const remainingMinutes = minutes % 60; @@ -233,6 +333,18 @@ const canProceedStep3 = $derived(true); // Overrides are optional const canProceedStep4 = $derived(!!(selectedDate && selectedTime)); + /** Whether the currently selected time slot is out-of-hours */ + const selectedTimeOutOfHours = $derived( + outOfHours && selectedDate && selectedTime && normalWorkingHours + ? isSlotOutOfHours( + selectedDate.toString(), + selectedTime, + getTotalDuration(), + normalWorkingHours + ) + : false + ); + // =============== Effects =============== let wasOpen = false; let userNavigatedCalendar = $state(false); @@ -306,7 +418,17 @@ checkDate.getMonth() + 1, checkDate.getDate() ); - if (workingHours[dateStr]?.isOpen && !isDateUnavailable(calDate)) { + if (outOfHours) { + // Out-of-hours: just check available hours exist with slots + const dayAH = availableHours?.[dateStr]; + if (dayAH?.slots?.length > 0 && !isDateUnavailable(calDate)) { + selectedDate = calDate; + if (!userNavigatedCalendar) { + placeholder = new CalendarDate(checkDate.getFullYear(), checkDate.getMonth() + 1, 1); + } + break; + } + } else if (workingHours[dateStr]?.isOpen && !isDateUnavailable(calDate)) { selectedDate = calDate; if (!userNavigatedCalendar) { placeholder = new CalendarDate(checkDate.getFullYear(), checkDate.getMonth() + 1, 1); @@ -339,6 +461,8 @@ userNavigatedCalendar = false; bookingCreateAutoSelectDone = false; loadingMonthKeys = new Set(); + outOfHours = false; + normalWorkingHours = null; // Clear reservation state reservationId = null; reservationExpiresAt = null; @@ -419,18 +543,40 @@ try { const [whRes, ahRes] = await Promise.all([ - fetch(`/api/scheduling/working-hours?start=${startStr}&end=${endStr}`, { - headers: { Authorization: `Bearer ${authStore.currentToken}` } - }), - fetch(`/api/scheduling/available-hours?start=${startStr}&end=${endStr}`, { - headers: { Authorization: `Bearer ${authStore.currentToken}` } - }) + fetch( + `/api/scheduling/working-hours?start=${startStr}&end=${endStr}${outOfHours ? '&out_of_hours=true' : ''}`, + { + headers: { Authorization: `Bearer ${authStore.currentToken}` } + } + ), + fetch( + `/api/scheduling/available-hours?start=${startStr}&end=${endStr}${outOfHours ? '&out_of_hours=true' : ''}`, + { + headers: { Authorization: `Bearer ${authStore.currentToken}` } + } + ) ]); if (whRes.ok && ahRes.ok) { const whData: WorkingHoursDay[] = await whRes.json(); const ahData: AvailableHoursDay[] = await ahRes.json(); + console.log('[DEBUG] API response for hours', { + outOfHours, + start: startStr, + end: endStr, + mode: outOfHours ? 'out_of_hours' : 'normal', + whSample: whData.slice(0, 3).map((d) => ({ + date: d.date, + isOpen: d.isOpen, + startTime: d.startTime, + endTime: d.endTime + })), + ahSample: ahData + .slice(0, 3) + .map((d) => ({ date: d.date, isOpen: d.isOpen, slotsCount: d.slots?.length })) + }); + const whMap: Record = {}; const ahMap: Record = {}; @@ -496,12 +642,18 @@ ); const [whRes, ahRes] = await Promise.all([ - fetch(`/api/scheduling/working-hours?start=${startOfMonth}&end=${endOfMonth}`, { - headers: { Authorization: `Bearer ${authStore.currentToken}` } - }), - fetch(`/api/scheduling/available-hours?start=${startOfMonth}&end=${endOfMonth}`, { - headers: { Authorization: `Bearer ${authStore.currentToken}` } - }) + fetch( + `/api/scheduling/working-hours?start=${startOfMonth}&end=${endOfMonth}${outOfHours ? '&out_of_hours=true' : ''}`, + { + headers: { Authorization: `Bearer ${authStore.currentToken}` } + } + ), + fetch( + `/api/scheduling/available-hours?start=${startOfMonth}&end=${endOfMonth}${outOfHours ? '&out_of_hours=true' : ''}`, + { + headers: { Authorization: `Bearer ${authStore.currentToken}` } + } + ) ]); if (whRes.ok && ahRes.ok) { @@ -547,8 +699,10 @@ localDate.setHours(hours, minutes, 0, 0); const startTimeISO = localDate.toISOString(); - const serviceIds = selectedServices.filter(s => !(s as any).is_custom).map((s) => s.id); - const customServiceIds = selectedServices.filter(s => (s as any).is_custom).map((s) => s.id); + const serviceIds = selectedServices.filter((s) => !(s as any).is_custom).map((s) => s.id); + const customServiceIds = selectedServices + .filter((s) => (s as any).is_custom) + .map((s) => s.id); // Build service overrides payload const overrides = []; @@ -568,7 +722,8 @@ service_ids: serviceIds, service_overrides: overrides.length > 0 ? overrides : [], ttl_minutes: 15, - reservation_type: 'callin' + reservation_type: 'callin', + out_of_hours: outOfHours }; if (customServiceIds.length > 0) { payload.custom_service_ids = customServiceIds; @@ -731,7 +886,13 @@ } }; showCustomCreateForm = false; - newCustomService = { name: '', description: '', price: '', duration_minutes: '', minimum_age_required: '' }; + newCustomService = { + name: '', + description: '', + price: '', + duration_minutes: '', + minimum_age_required: '' + }; customServiceErrors = { name: '', price: '', duration_minutes: '' }; toast.success('Custom service created and added'); } else { @@ -745,24 +906,29 @@ } } - const groupedTimeSlots = $derived( - currentStep === 4 && selectedServices.length > 0 && selectedDate - ? generateGroupedTimeSlots( - selectedDate, - workingHours, - availableHours, - getTotalDuration(), - lunchProtection - ) - : [] - ); - function isDateUnavailable(date: DateValue): boolean { if (!(date instanceof CalendarDate)) return true; if (date.compare(minDate) < 0 || date.compare(maxCalendarDate) > 0) return true; if (!workingHours) return true; const dateStr = date.toString(); + + // Out-of-hours: only check if available hours exist with slots + if (outOfHours) { + const ahDay = availableHours?.[dateStr]; + const whDay = workingHours[dateStr]; + const result = !ahDay?.slots || ahDay.slots.length === 0; + console.log('[DEBUG] isDateUnavailable (outOfHours)', { + dateStr, + result, + hasSlots: ahDay?.slots?.length, + whIsOpen: whDay?.isOpen, + whStart: whDay?.startTime, + whEnd: whDay?.endTime + }); + return result; + } + const dayHours = workingHours[dateStr]; if (!dayHours?.isOpen) return true; @@ -865,10 +1031,11 @@ const payload: Record = { user_id: finalUserId, start_time: dateTimeStr, - service_ids: selectedServices.filter(s => !(s as any).is_custom).map((s) => s.id), - custom_service_ids: selectedServices.filter(s => (s as any).is_custom).map((s) => s.id), + service_ids: selectedServices.filter((s) => !(s as any).is_custom).map((s) => s.id), + custom_service_ids: selectedServices.filter((s) => (s as any).is_custom).map((s) => s.id), service_overrides: overrides.length > 0 ? overrides : undefined, - notes: notes.trim() || null + notes: notes.trim() || null, + out_of_hours: outOfHours }; const res = await fetch('/api/admin/bookings', { @@ -1042,7 +1209,13 @@ onclick={() => (selectedUserId = user.id)} >
-
{formatUserName(user.fullName, user.previousFirstName, user.previousLastName)}
+
+ {formatUserName( + user.fullName, + user.previousFirstName, + user.previousLastName + )} +
{#if user.email && user.phone} {user.email} • {user.phone} @@ -1149,7 +1322,9 @@ { if (e.key === 'Enter') fetchCustomServices(); }} + onkeydown={(e) => { + if (e.key === 'Enter') fetchCustomServices(); + }} class="flex-1" /> @@ -1182,12 +1357,23 @@ }} > {cs.name} - {cs.duration_minutes} min • £{cs.price.toFixed(2)}{cs.usage_count > 0 ? ` (${cs.usage_count}×)` : ''} + {cs.duration_minutes} min • £{cs.price.toFixed(2)}{cs.usage_count > 0 + ? ` (${cs.usage_count}×)` + : ''} {/each}
{/if} -
@@ -1198,8 +1384,10 @@ customServiceErrors.name = validateCsName(newCustomService.name)} - onblur={() => customServiceErrors.name = validateCsName(newCustomService.name)} + oninput={() => + (customServiceErrors.name = validateCsName(newCustomService.name))} + onblur={() => + (customServiceErrors.name = validateCsName(newCustomService.name))} placeholder="e.g., Bridal Party French Tips" class={customServiceErrors.name ? 'border-red-500' : ''} /> @@ -1225,8 +1413,10 @@ step="0.01" min="0" bind:value={newCustomService.price} - oninput={() => customServiceErrors.price = validateCsPrice(newCustomService.price)} - onblur={() => customServiceErrors.price = validateCsPrice(newCustomService.price)} + oninput={() => + (customServiceErrors.price = validateCsPrice(newCustomService.price))} + onblur={() => + (customServiceErrors.price = validateCsPrice(newCustomService.price))} placeholder="0.00" class={customServiceErrors.price ? 'border-red-500' : ''} /> @@ -1239,12 +1429,21 @@ {#if customServiceErrors.duration_minutes} @@ -1262,8 +1461,13 @@ max="100" placeholder="0" bind:value={newCustomService.minimum_age_required} - oninput={() => customServiceErrors.minimum_age_required = validateCsMinimumAge(newCustomService.minimum_age_required)} - class="w-full {customServiceErrors.minimum_age_required ? 'border-red-500' : ''}" + oninput={() => + (customServiceErrors.minimum_age_required = validateCsMinimumAge( + newCustomService.minimum_age_required + ))} + class="w-full {customServiceErrors.minimum_age_required + ? 'border-red-500' + : ''}" /> {#if customServiceErrors.minimum_age_required}

{customServiceErrors.minimum_age_required}

@@ -1271,10 +1475,33 @@

0 for no age restriction

- -
@@ -1481,6 +1708,14 @@ /> + +
+ + +
+ {#if loadingAvailableHours && selectedDate}

Loading times...

@@ -1512,6 +1747,7 @@ endTime={formatTime(calculateEndTime(selectedTime, getTotalDuration()))} duration={getTotalDuration()} protection={lunchProtection.get(selectedTime)} + outOfHours={selectedTimeOutOfHours} />
{/if} diff --git a/frontend/src/lib/components/admin/BookingModal.svelte b/frontend/src/lib/components/admin/BookingModal.svelte index bf85b1f..bba3d40 100644 --- a/frontend/src/lib/components/admin/BookingModal.svelte +++ b/frontend/src/lib/components/admin/BookingModal.svelte @@ -125,6 +125,7 @@ updated_at: data.updated_at, created_by: data.created_by, created_by_name: data.created_by_name, + out_of_hours: data.out_of_hours ?? false, // Deposit fields deposit_required: data.deposit_required ?? false, @@ -316,6 +317,19 @@ {/if} + + {#if selectedBooking.out_of_hours} +
+ + + + + Out-of-hours + +
+ {/if} {/if} diff --git a/frontend/src/lib/components/booking/SelectedTimeSummary.svelte b/frontend/src/lib/components/booking/SelectedTimeSummary.svelte index 1de221a..fb5203c 100644 --- a/frontend/src/lib/components/booking/SelectedTimeSummary.svelte +++ b/frontend/src/lib/components/booking/SelectedTimeSummary.svelte @@ -8,9 +8,10 @@ endTime: string; duration: number; protection: LunchProtectionResult | undefined; + outOfHours?: boolean; } - let { selectedDate, selectedTime, endTime, duration, protection }: Props = $props(); + let { selectedDate, selectedTime, endTime, duration, protection, outOfHours = false }: Props = $props(); @@ -46,9 +47,38 @@ {/if} +{#if outOfHours} +
+
+ + + +
+
Out-of-hours booking
+
+ This time slot is outside regular business hours (06:00-22:00). +
+
+
+
+{/if}
diff --git a/frontend/src/lib/components/booking/TimeSlotList.svelte b/frontend/src/lib/components/booking/TimeSlotList.svelte index 6233192..98ca150 100644 --- a/frontend/src/lib/components/booking/TimeSlotList.svelte +++ b/frontend/src/lib/components/booking/TimeSlotList.svelte @@ -35,17 +35,36 @@ variant="outline" onclick={() => onSelect(slot.startTime)} class={`w-full hover:bg-fuchsia-50 ${ - slot.startTime === selectedTime - ? p?.showWarning - ? 'border-amber-500 bg-fuchsia-200' - : 'bg-fuchsia-100' - : p?.showWarning - ? 'border-amber-400 bg-amber-100' - : '' + slot.outOfHours + ? slot.startTime === selectedTime + ? 'border-red-500 bg-red-100' + : 'border-red-300 bg-red-50' + : slot.startTime === selectedTime + ? p?.showWarning + ? 'border-amber-500 bg-fuchsia-200' + : 'bg-fuchsia-100' + : p?.showWarning + ? 'border-amber-400 bg-amber-100' + : '' }`} - title={p?.warningMessage} + title={slot.outOfHours ? 'Out-of-hours booking' : p?.warningMessage} > - {#if p?.showWarning} + {#if slot.outOfHours} + + + + + + {:else if p?.showWarning}