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 { 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<WorkingHourRow[]>([]);
let defaultHoursIsLoading = $state(true);
let defaultHoursDraft = $state<WorkingHourRow[]>([]);
let showDefaultHoursModal = $state(false);
let showSaveDefaultHoursAlert = $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'];
@@ -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();
}
});
</script>
<!-- Default Hours Card -->
@@ -244,6 +428,25 @@
Edit Schedule
</Button>
</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 -->
<div class="hidden w-full overflow-x-auto sm:block">
<table class="w-full table-auto border-collapse">
@@ -656,6 +859,119 @@
</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">
<Button
variant="outline"
@@ -665,25 +981,9 @@
>
Cancel
</Button>
<Button onclick={() => (showSaveDefaultHoursAlert = true)} disabled={savingHours}>
Save Defaults
<Button onclick={stageDefaultHoursChange} disabled={stagingSave || hasOverlap}>
{stagingSave ? 'Scheduling…' : 'Schedule Change'}
</Button>
</Modal.Footer>
</Modal.Content>
</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 weekHours = $state<DayWorkingHours[]>([]);
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 error = $state(false);
@@ -104,7 +111,11 @@
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 (groupsRes.ok) exceptionalGroups = await groupsRes.json();
@@ -190,7 +201,12 @@
// Only show days that differ from default hours
const differing = best.group.hours.filter((h) => {
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;
@@ -212,7 +228,6 @@
})
};
});
</script>
<div class="mx-auto w-full max-w-sm lg:h-[420px] lg:max-w-none">
@@ -274,6 +289,35 @@
{/each}
{/if}
</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}
</div>
</div>
+3 -2
View File
@@ -126,7 +126,8 @@
]);
if (hoursRes.ok) {
defaultHours = await hoursRes.json();
const result = await hoursRes.json();
defaultHours = result.current || [];
}
if (servicesRes.ok) {
services = await servicesRes.json();
@@ -379,7 +380,7 @@
<div class="space-y-4 p-4">
<TimeBlockers {openUserModal} {openBookingModal} {rescheduleVersion} {defaultHours} />
<HolidayHours {openUserModal} {openBookingModal} {rescheduleVersion} />
<WeeklySchedule {defaultHours} />
<WeeklySchedule {defaultHours} {openUserModal} {openBookingModal} {rescheduleVersion} />
</div>
{/if}
</div>