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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user