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}