From 6470a3f6c92f31ee5eeda08efa34a0f2d55204be Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Thu, 30 Jul 2026 08:59:12 +0100 Subject: [PATCH] feat: add scheduled change UI and conflict display to weekly schedule Convert WeeklySchedule to schedule staged changes with an effective date picker and conflict detection UI. Show pending scheduled changes in BusinessHours component. Update admin page to pass through props. --- .../components/admin/WeeklySchedule.svelte | 364 ++++++++++++++++-- .../components/layout/BusinessHours.svelte | 50 ++- frontend/src/routes/admin/+page.svelte | 5 +- 3 files changed, 382 insertions(+), 37 deletions(-) diff --git a/frontend/src/lib/components/admin/WeeklySchedule.svelte b/frontend/src/lib/components/admin/WeeklySchedule.svelte index 7c77379..3ec19fa 100644 --- a/frontend/src/lib/components/admin/WeeklySchedule.svelte +++ b/frontend/src/lib/components/admin/WeeklySchedule.svelte @@ -3,26 +3,43 @@ import { extractErrorMessage } from '$lib/utils/toast-safe'; import { browser } from '$app/environment'; import { apiFetch } from '$lib/utils/api'; - import { range } from '$lib/utils/format'; + import { formatUserName } from '$lib/utils/nameDisplay'; + import { parseWallClockDate } from '$lib/utils/timeSlots'; + import { formatDuration, range } from '$lib/utils/format'; // shadcn-svelte components import { Button } from '$lib/components/ui/button'; import * as Card from '$lib/components/ui/card'; import { Checkbox } from '$lib/components/ui/checkbox'; import * as Modal from '$lib/components/ui/dialog'; - import * as AlertDialog from '$lib/components/ui/alert-dialog'; import { Skeleton } from '$lib/components/ui/skeleton'; + import { Input } from '$lib/components/ui/input'; const TIME15 = ['00', '15', '30', '45']; - // =============== Props =============== - interface Props { - defaultHours?: Array<{ weekday: number; startTime: string; endTime: string; isOpen: boolean }>; - } - - let { defaultHours: defaultHoursProp }: Props = $props(); - // =============== Types =============== + + 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[]; + }; + + type ScheduledChangeInfo = { + effective_date: string; + hours: Array<{ weekday: number; startTime: string; endTime: string; isOpen: boolean }>; + }; + type WorkingHourRow = { weekday: number; start_time: string; @@ -30,13 +47,38 @@ is_open: boolean; }; + // =============== Props =============== + interface Props { + defaultHours?: + | Array<{ weekday: number; startTime: string; endTime: string; isOpen: boolean }> + | { + current: Array<{ weekday: number; startTime: string; endTime: string; isOpen: boolean }>; + scheduled_change?: ScheduledChangeInfo; + }; + openUserModal?: (userId: string) => void; + openBookingModal?: (bookingId: string) => void; + rescheduleVersion?: number; + } + + let { + defaultHours: defaultHoursProp, + openUserModal, + openBookingModal, + rescheduleVersion = 0 + }: Props = $props(); + // =============== State =============== let defaultHours = $state([]); let defaultHoursIsLoading = $state(true); let defaultHoursDraft = $state([]); let showDefaultHoursModal = $state(false); - let showSaveDefaultHoursAlert = $state(false); let savingHours = $state(false); + let effectiveDate = $state(''); + let overlappingBookings = $state([]); + let checkingOverlap = $state(false); + let hasOverlap = $derived(overlappingBookings.length > 0); + let scheduledChange = $state(null); + let stagingSave = $state(false); const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; @@ -118,7 +160,8 @@ }); if (response.ok) { - const data = await response.json(); + const result = await response.json(); + const data = result.current || result; defaultHours = data.map( (hour: { weekday: number; startTime: string; endTime: string; isOpen: boolean }) => ({ weekday: hour.weekday, @@ -127,6 +170,9 @@ is_open: hour.isOpen }) ); + if (result.scheduled_change) { + scheduledChange = result.scheduled_change; + } } else { const text = await response.text(); error = 'Failed to load working hours: ' + extractErrorMessage(text); @@ -180,7 +226,6 @@ // Update the main state from the draft state if successful defaultHours = JSON.parse(JSON.stringify(defaultHoursDraft)); showDefaultHoursModal = false; - showSaveDefaultHoursAlert = false; toast.success('Default hours saved successfully!', { id: loadingToast }); } else if (response.status === 401 || response.status === 403) { toast.error('Unauthorized. Please log in again.', { id: loadingToast }); @@ -196,12 +241,139 @@ } } + async function checkConflictingBookings() { + const proposedHours = defaultHoursDraft.map((h) => ({ + weekday: h.weekday, + startTime: h.start_time, + endTime: h.end_time, + isOpen: h.is_open + })); + + checkingOverlap = true; + try { + const response = await apiFetch('/api/scheduling/default-hours/conflicting', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + proposedHours, + effective_date: effectiveDate || getDefaultEffectiveDate() + }) + }); + 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; + } + } + + function getDefaultEffectiveDate(): string { + const d = new Date(); + d.setDate(d.getDate() + 1); + return d.toISOString().slice(0, 10); + } + + async function stageDefaultHoursChange() { + if (!effectiveDate && !getDefaultEffectiveDate()) return; + if (hasOverlap && overlappingBookings.length > 0) return; + + // Re-check conflicts before finalizing (race condition guard) + await checkConflictingBookings(); + if (overlappingBookings.length > 0) { + toast.error('New conflicting bookings found. Please resolve them first.'); + return; + } + + stagingSave = true; + const loadingToast = toast.loading('Scheduling default hours change...'); + + try { + const payload = defaultHoursDraft.map((hour) => ({ + weekday: hour.weekday, + startTime: hour.start_time, + endTime: hour.end_time, + isOpen: hour.is_open + })); + + const response = await apiFetch('/api/scheduling/default-hours/schedule', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + hours: payload, + effective_date: effectiveDate || getDefaultEffectiveDate() + }) + }); + + if (response.ok) { + const result = await response.json(); + toast.success( + result.message || + 'Default hours will change at 23:59 on ' + (effectiveDate || getDefaultEffectiveDate()), + { id: loadingToast } + ); + showDefaultHoursModal = false; + // Update local state + scheduledChange = { + effective_date: effectiveDate || getDefaultEffectiveDate(), + hours: payload + }; + } else if (response.status === 409) { + const text = await response.text(); + toast.error(extractErrorMessage(text), { id: loadingToast }); + } else { + const text = await response.text(); + toast.error('Failed to schedule: ' + extractErrorMessage(text), { id: loadingToast }); + } + } catch (err) { + console.error('stage default hours', err); + toast.error('Network error scheduling hours', { id: loadingToast }); + } finally { + stagingSave = false; + } + } + + async function cancelScheduledChange() { + try { + const response = await apiFetch('/api/scheduling/default-hours/scheduled', { + method: 'DELETE' + }); + if (response.ok) { + scheduledChange = null; + toast.success('Scheduled change cancelled.'); + } else { + toast.error('Failed to cancel change'); + } + } catch (err) { + console.error('cancel scheduled change', err); + toast.error('Network error cancelling change'); + } + } + // =============== Effects =============== $effect(() => { if (defaultHoursProp !== undefined) { // Parent provides data via prop — map camelCase to display format - if (defaultHoursProp.length > 0) { - defaultHours = defaultHoursProp.map( + // Support both legacy array format and new { current, scheduled_change } object format + const raw = defaultHoursProp as + | Array<{ weekday: number; startTime: string; endTime: string; isOpen: boolean }> + | { + current: Array<{ + weekday: number; + startTime: string; + endTime: string; + isOpen: boolean; + }>; + scheduled_change?: ScheduledChangeInfo; + }; + const hoursArray = Array.isArray(raw) ? raw : raw.current || []; + if (hoursArray.length > 0) { + defaultHours = hoursArray.map( (hour: { weekday: number; startTime: string; endTime: string; isOpen: boolean }) => ({ weekday: hour.weekday, start_time: formatTime(hour.startTime), @@ -209,6 +381,8 @@ is_open: hour.isOpen }) ); + const sc = !Array.isArray(raw) ? raw.scheduled_change : undefined; + if (sc) scheduledChange = sc; } defaultHoursIsLoading = false; } else if (browser) { @@ -216,6 +390,16 @@ fetchDefaultHours(); } }); + + // Trigger conflict check when the modal opens or hours/date change + $effect(() => { + const open = showDefaultHoursModal; + const hours = defaultHoursDraft; + const date = effectiveDate; + if (open) { + checkConflictingBookings(); + } + }); @@ -244,6 +428,25 @@ Edit Schedule + {#if scheduledChange} +
+
+
+

+ Default hours are scheduled to change +

+

+ New hours take effect from {new Date( + scheduledChange.effective_date + ).toLocaleDateString('en-GB', { timeZone: 'UTC' })} +

+
+ +
+
+ {/if}