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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-22 12:55:40 +01:00
co-authored by Sisyphus
parent 08d62080c1
commit 5523672b6a
4 changed files with 366 additions and 67 deletions
@@ -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<string | null>(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<Record<string, string>>({});
@@ -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<Record<string, DayHours> | 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<string, DayHours> | 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<string, any> = {};
const ahMap: Record<string, any> = {};
@@ -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<string, any> = {
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)}
>
<div>
<div class="text-base font-medium">{formatUserName(user.fullName, user.previousFirstName, user.previousLastName)}</div>
<div class="text-base font-medium">
{formatUserName(
user.fullName,
user.previousFirstName,
user.previousLastName
)}
</div>
<div class="text-xs text-gray-500">
{#if user.email && user.phone}
{user.email}{user.phone}
@@ -1149,7 +1322,9 @@
<Input
placeholder="Search existing custom services..."
bind:value={customSearchQuery}
onkeydown={(e) => { if (e.key === 'Enter') fetchCustomServices(); }}
onkeydown={(e) => {
if (e.key === 'Enter') fetchCustomServices();
}}
class="flex-1"
/>
<Button variant="outline" size="sm" onclick={fetchCustomServices}>Search</Button>
@@ -1182,12 +1357,23 @@
}}
>
<span class="font-medium">{cs.name}</span>
<span class="text-gray-500">{cs.duration_minutes} min £{cs.price.toFixed(2)}{cs.usage_count > 0 ? ` (${cs.usage_count}×)` : ''}</span>
<span class="text-gray-500"
>{cs.duration_minutes} min £{cs.price.toFixed(2)}{cs.usage_count > 0
? ` (${cs.usage_count}×)`
: ''}</span
>
</button>
{/each}
</div>
{/if}
<Button variant="ghost" size="sm" onclick={() => { showCustomCreateForm = true; }} class="w-full">
<Button
variant="ghost"
size="sm"
onclick={() => {
showCustomCreateForm = true;
}}
class="w-full"
>
+ Create new custom service
</Button>
</div>
@@ -1198,8 +1384,10 @@
<Input
id="booking-cs-name"
bind:value={newCustomService.name}
oninput={() => 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 @@
<select
id="booking-cs-dur"
bind:value={newCustomService.duration_minutes}
onchange={() => customServiceErrors.duration_minutes = validateCsDuration(newCustomService.duration_minutes)}
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 {customServiceErrors.duration_minutes ? 'border-red-500' : ''}"
onchange={() =>
(customServiceErrors.duration_minutes = validateCsDuration(
newCustomService.duration_minutes
))}
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none {customServiceErrors.duration_minutes
? 'border-red-500'
: ''}"
>
<option value="">Select...</option>
{#each durationOptions as mins (mins)}
<option value={mins}>{mins} min{mins >= 60 ? ` (${Math.floor(mins / 60)}h${mins % 60 > 0 ? ` ${mins % 60}m` : ''})` : ''}</option>
<option value={mins}
>{mins} min{mins >= 60
? ` (${Math.floor(mins / 60)}h${mins % 60 > 0 ? ` ${mins % 60}m` : ''})`
: ''}</option
>
{/each}
</select>
{#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}
<p class="text-xs text-red-600">{customServiceErrors.minimum_age_required}</p>
@@ -1271,10 +1475,33 @@
<p class="text-xs text-gray-500">0 for no age restriction</p>
</div>
<div class="flex gap-2">
<Button size="sm" onclick={createCustomService} disabled={creatingCustomService || !isCustomFormValid}>
<Button
size="sm"
onclick={createCustomService}
disabled={creatingCustomService || !isCustomFormValid}
>
{creatingCustomService ? 'Creating...' : 'Save & Add'}
</Button>
<Button variant="outline" size="sm" onclick={() => { showCustomCreateForm = false; newCustomService = { name: '', description: '', price: '', duration_minutes: '', minimum_age_required: '' }; customServiceErrors = { name: '', price: '', duration_minutes: '', minimum_age_required: '' }; }}>
<Button
variant="outline"
size="sm"
onclick={() => {
showCustomCreateForm = false;
newCustomService = {
name: '',
description: '',
price: '',
duration_minutes: '',
minimum_age_required: ''
};
customServiceErrors = {
name: '',
price: '',
duration_minutes: '',
minimum_age_required: ''
};
}}
>
Cancel
</Button>
</div>
@@ -1481,6 +1708,14 @@
/>
</div>
<!-- Out-of-hours toggle -->
<div class="mx-6 flex items-center gap-2">
<Checkbox id="out-of-hours" bind:checked={outOfHours} />
<Label for="out-of-hours" class="cursor-pointer text-sm font-medium text-amber-600">
Out-of-hours booking
</Label>
</div>
{#if loadingAvailableHours && selectedDate}
<div class="flex items-center justify-center border-t p-6">
<p class="text-sm text-gray-500">Loading times...</p>
@@ -1512,6 +1747,7 @@
endTime={formatTime(calculateEndTime(selectedTime, getTotalDuration()))}
duration={getTotalDuration()}
protection={lunchProtection.get(selectedTime)}
outOfHours={selectedTimeOutOfHours}
/>
</div>
{/if}
@@ -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 @@
</span>
</div>
{/if}
{#if selectedBooking.out_of_hours}
<div class="flex items-center gap-2">
<span
class="inline-flex items-center rounded-full bg-red-100 px-3 py-1 text-sm font-medium text-red-800"
>
<svg xmlns="http://www.w3.org/2000/svg" class="mr-1 h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
</svg>
Out-of-hours
</span>
</div>
{/if}
{/if}
</div>
</Modal.Header>