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