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.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent a6cb803ff2
commit 6470a3f6c9
3 changed files with 382 additions and 37 deletions
@@ -3,26 +3,43 @@
import { extractErrorMessage } from '$lib/utils/toast-safe'; import { extractErrorMessage } from '$lib/utils/toast-safe';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { apiFetch } from '$lib/utils/api'; 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 // shadcn-svelte components
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import { Checkbox } from '$lib/components/ui/checkbox'; import { Checkbox } from '$lib/components/ui/checkbox';
import * as Modal from '$lib/components/ui/dialog'; 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 { Skeleton } from '$lib/components/ui/skeleton';
import { Input } from '$lib/components/ui/input';
const TIME15 = ['00', '15', '30', '45']; 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 =============== // =============== 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 = { type WorkingHourRow = {
weekday: number; weekday: number;
start_time: string; start_time: string;
@@ -30,13 +47,38 @@
is_open: boolean; 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 =============== // =============== State ===============
let defaultHours = $state<WorkingHourRow[]>([]); let defaultHours = $state<WorkingHourRow[]>([]);
let defaultHoursIsLoading = $state(true); let defaultHoursIsLoading = $state(true);
let defaultHoursDraft = $state<WorkingHourRow[]>([]); let defaultHoursDraft = $state<WorkingHourRow[]>([]);
let showDefaultHoursModal = $state(false); let showDefaultHoursModal = $state(false);
let showSaveDefaultHoursAlert = $state(false);
let savingHours = $state(false); let savingHours = $state(false);
let effectiveDate = $state('');
let overlappingBookings = $state<OverlappingBooking[]>([]);
let checkingOverlap = $state(false);
let hasOverlap = $derived(overlappingBookings.length > 0);
let scheduledChange = $state<ScheduledChangeInfo | null>(null);
let stagingSave = $state(false);
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
@@ -118,7 +160,8 @@
}); });
if (response.ok) { if (response.ok) {
const data = await response.json(); const result = await response.json();
const data = result.current || result;
defaultHours = data.map( defaultHours = data.map(
(hour: { weekday: number; startTime: string; endTime: string; isOpen: boolean }) => ({ (hour: { weekday: number; startTime: string; endTime: string; isOpen: boolean }) => ({
weekday: hour.weekday, weekday: hour.weekday,
@@ -127,6 +170,9 @@
is_open: hour.isOpen is_open: hour.isOpen
}) })
); );
if (result.scheduled_change) {
scheduledChange = result.scheduled_change;
}
} else { } else {
const text = await response.text(); const text = await response.text();
error = 'Failed to load working hours: ' + extractErrorMessage(text); error = 'Failed to load working hours: ' + extractErrorMessage(text);
@@ -180,7 +226,6 @@
// Update the main state from the draft state if successful // Update the main state from the draft state if successful
defaultHours = JSON.parse(JSON.stringify(defaultHoursDraft)); defaultHours = JSON.parse(JSON.stringify(defaultHoursDraft));
showDefaultHoursModal = false; showDefaultHoursModal = false;
showSaveDefaultHoursAlert = false;
toast.success('Default hours saved successfully!', { id: loadingToast }); toast.success('Default hours saved successfully!', { id: loadingToast });
} else if (response.status === 401 || response.status === 403) { } else if (response.status === 401 || response.status === 403) {
toast.error('Unauthorized. Please log in again.', { id: loadingToast }); 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 =============== // =============== Effects ===============
$effect(() => { $effect(() => {
if (defaultHoursProp !== undefined) { if (defaultHoursProp !== undefined) {
// Parent provides data via prop — map camelCase to display format // Parent provides data via prop — map camelCase to display format
if (defaultHoursProp.length > 0) { // Support both legacy array format and new { current, scheduled_change } object format
defaultHours = defaultHoursProp.map( 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 }) => ({ (hour: { weekday: number; startTime: string; endTime: string; isOpen: boolean }) => ({
weekday: hour.weekday, weekday: hour.weekday,
start_time: formatTime(hour.startTime), start_time: formatTime(hour.startTime),
@@ -209,6 +381,8 @@
is_open: hour.isOpen is_open: hour.isOpen
}) })
); );
const sc = !Array.isArray(raw) ? raw.scheduled_change : undefined;
if (sc) scheduledChange = sc;
} }
defaultHoursIsLoading = false; defaultHoursIsLoading = false;
} else if (browser) { } else if (browser) {
@@ -216,6 +390,16 @@
fetchDefaultHours(); 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();
}
});
</script> </script>
<!-- Default Hours Card --> <!-- Default Hours Card -->
@@ -244,6 +428,25 @@
Edit Schedule Edit Schedule
</Button> </Button>
</div> </div>
{#if scheduledChange}
<div class="rounded-md border border-amber-200 bg-amber-50 p-3">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-amber-800">
Default hours are scheduled to change
</p>
<p class="text-xs text-amber-700">
New hours take effect from {new Date(
scheduledChange.effective_date
).toLocaleDateString('en-GB', { timeZone: 'UTC' })}
</p>
</div>
<Button variant="outline" size="sm" class="h-7 text-xs" onclick={cancelScheduledChange}>
Cancel
</Button>
</div>
</div>
{/if}
<!-- Desktop Table --> <!-- Desktop Table -->
<div class="hidden w-full overflow-x-auto sm:block"> <div class="hidden w-full overflow-x-auto sm:block">
<table class="w-full table-auto border-collapse"> <table class="w-full table-auto border-collapse">
@@ -656,6 +859,119 @@
</div> </div>
</div> </div>
<!-- Effective Date & Conflict Resolution -->
<div class="border-t px-4 pb-4 pt-4">
<div class="space-y-3">
<div>
<label for="effective_date" class="mb-1 block text-sm font-medium text-gray-700">
Apply new hours from
</label>
<Input
id="effective_date"
type="date"
min={getDefaultEffectiveDate()}
bind:value={effectiveDate}
class="w-full"
/>
<p class="mt-0.5 text-xs text-gray-500">Leave empty to apply from tomorrow (default)</p>
</div>
{#if checkingOverlap}
<div class="flex items-center gap-2 text-sm text-gray-500">
<svg
class="h-4 w-4 animate-spin"
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>
Checking for conflicting bookings…
</div>
{:else if hasOverlap}
<div class="rounded-lg border border-amber-200 bg-amber-50 p-3">
<div class="mb-2 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'}
</span>
<Button variant="outline" size="sm" class="h-6 text-xs ml-auto" onclick={checkConflictingBookings}>
Refresh
</Button>
</div>
<div class="space-y-2">
{#each overlappingBookings as booking (booking.id)}
<div class="rounded-md border border-amber-200 bg-white p-2">
<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 class="mt-1 flex gap-2">
{#if openBookingModal}
<Button
variant="outline"
size="sm"
class="h-6 text-xs"
onclick={() => openBookingModal(booking.id)}>View Booking</Button
>
{/if}
{#if openUserModal && booking.user?.id}
<Button
variant="outline"
size="sm"
class="h-6 text-xs"
onclick={() => openUserModal(booking.user!.id)}>View Client</Button
>
{/if}
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
</div>
<Modal.Footer class="flex items-center justify-end gap-2"> <Modal.Footer class="flex items-center justify-end gap-2">
<Button <Button
variant="outline" variant="outline"
@@ -665,25 +981,9 @@
> >
Cancel Cancel
</Button> </Button>
<Button onclick={() => (showSaveDefaultHoursAlert = true)} disabled={savingHours}> <Button onclick={stageDefaultHoursChange} disabled={stagingSave || hasOverlap}>
Save Defaults {stagingSave ? 'Scheduling…' : 'Schedule Change'}
</Button> </Button>
</Modal.Footer> </Modal.Footer>
</Modal.Content> </Modal.Content>
</Modal.Root> </Modal.Root>
<!-- Save Default Hours Confirmation -->
<AlertDialog.Root bind:open={showSaveDefaultHoursAlert}>
<AlertDialog.Content class="z-60">
<AlertDialog.Header>
<AlertDialog.Title>Save default hours?</AlertDialog.Title>
<AlertDialog.Description>
Are you sure you want to save these default hours? This will affect future bookings.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action onclick={confirmSaveDefaultHours}>Continue</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -40,6 +40,13 @@
let defaultHours = $state<DefaultHours[]>([]); let defaultHours = $state<DefaultHours[]>([]);
let weekHours = $state<DayWorkingHours[]>([]); let weekHours = $state<DayWorkingHours[]>([]);
let exceptionalGroups = $state<ExceptionalGroup[]>([]); let exceptionalGroups = $state<ExceptionalGroup[]>([]);
let scheduledChange = $state<ScheduledChangeInfo | null>(null);
type ScheduledChangeInfo = {
effective_date: string;
hours: Array<{ weekday: number; startTime: string; endTime: string; isOpen: boolean }>;
};
let loading = $state(true); let loading = $state(true);
let error = $state(false); let error = $state(false);
@@ -104,7 +111,11 @@
fetch('/api/scheduling/exceptional-groups') fetch('/api/scheduling/exceptional-groups')
]); ]);
if (defRes.ok) defaultHours = await defRes.json(); if (defRes.ok) {
const result = await defRes.json();
defaultHours = result.current || result;
if (result.scheduled_change) scheduledChange = result.scheduled_change;
}
if (weekRes.ok) weekHours = await weekRes.json(); if (weekRes.ok) weekHours = await weekRes.json();
if (groupsRes.ok) exceptionalGroups = await groupsRes.json(); if (groupsRes.ok) exceptionalGroups = await groupsRes.json();
@@ -190,7 +201,12 @@
// Only show days that differ from default hours // Only show days that differ from default hours
const differing = best.group.hours.filter((h) => { const differing = best.group.hours.filter((h) => {
const def = defaultHours.find((d) => d.weekday === h.weekday); const def = defaultHours.find((d) => d.weekday === h.weekday);
return !def || def.startTime !== h.startTime || def.endTime !== h.endTime || def.isOpen !== h.isOpen; return (
!def ||
def.startTime !== h.startTime ||
def.endTime !== h.endTime ||
def.isOpen !== h.isOpen
);
}); });
if (differing.length === 0) return null; if (differing.length === 0) return null;
@@ -212,7 +228,6 @@
}) })
}; };
}); });
</script> </script>
<div class="mx-auto w-full max-w-sm lg:h-[420px] lg:max-w-none"> <div class="mx-auto w-full max-w-sm lg:h-[420px] lg:max-w-none">
@@ -274,6 +289,35 @@
{/each} {/each}
{/if} {/if}
</div> </div>
{#if scheduledChange}
<hr class="my-2 border-gray-200" />
<p class="mb-2 text-center text-xs font-medium text-amber-600">
Opening hours will change from
{new Date(scheduledChange.effective_date + 'T00:00:00').toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
timeZone: 'UTC'
})}
</p>
{#each scheduledChange.hours as h}
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700">
{['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'][
h.weekday
]}
</span>
<span class="text-gray-500">
{#if h.isOpen}
{formatTime(h.startTime)} {formatTime(h.endTime)}
{:else}
Closed
{/if}
</span>
</div>
{/each}
{/if}
{/if} {/if}
</div> </div>
</div> </div>
+3 -2
View File
@@ -126,7 +126,8 @@
]); ]);
if (hoursRes.ok) { if (hoursRes.ok) {
defaultHours = await hoursRes.json(); const result = await hoursRes.json();
defaultHours = result.current || [];
} }
if (servicesRes.ok) { if (servicesRes.ok) {
services = await servicesRes.json(); services = await servicesRes.json();
@@ -379,7 +380,7 @@
<div class="space-y-4 p-4"> <div class="space-y-4 p-4">
<TimeBlockers {openUserModal} {openBookingModal} {rescheduleVersion} {defaultHours} /> <TimeBlockers {openUserModal} {openBookingModal} {rescheduleVersion} {defaultHours} />
<HolidayHours {openUserModal} {openBookingModal} {rescheduleVersion} /> <HolidayHours {openUserModal} {openBookingModal} {rescheduleVersion} />
<WeeklySchedule {defaultHours} /> <WeeklySchedule {defaultHours} {openUserModal} {openBookingModal} {rescheduleVersion} />
</div> </div>
{/if} {/if}
</div> </div>