Add exceptional hour modals

This commit is contained in:
2025-10-19 21:17:28 +01:00
parent 5efc893269
commit 17ebaeba94
4 changed files with 667 additions and 156 deletions
+458 -92
View File
@@ -178,43 +178,83 @@
id?: number;
name: string;
description: string;
week_starts: string[];
rows: WorkingHourRow[];
weekStarts: string[];
hours: WorkingHourRow[];
};
// DEMO DATA: Exception Groups
let exceptionGroups = $state<ExceptionGroup[]>([
{
id: 1,
name: 'Christmas Week',
description: 'Closed from Mon-Wed, open reduced hours Thu/Fri',
week_starts: ['2025-12-22'],
rows: [
{ weekday: 0, start_time: '09:00', end_time: '17:00', is_open: false },
{ weekday: 1, start_time: '09:00', end_time: '17:00', is_open: false },
{ weekday: 2, start_time: '09:00', end_time: '17:00', is_open: false },
{ weekday: 3, start_time: '10:00', end_time: '15:00', is_open: true },
{ weekday: 4, start_time: '10:00', end_time: '15:00', is_open: true },
{ weekday: 5, start_time: '09:00', end_time: '17:00', is_open: false },
{ weekday: 6, start_time: '09:00', end_time: '17:00', is_open: false }
]
},
{
id: 2,
name: 'Summer Holiday',
description: 'Closed on Mondays/Tuesdays only',
week_starts: ['2026-07-06', '2026-07-13', '2026-07-20'],
rows: [
{ weekday: 0, start_time: '09:00', end_time: '17:00', is_open: false },
{ weekday: 1, start_time: '09:00', end_time: '17:00', is_open: false },
{ weekday: 2, start_time: '09:00', end_time: '17:00', is_open: true },
{ weekday: 3, start_time: '09:00', end_time: '17:00', is_open: true },
{ weekday: 4, start_time: '09:00', end_time: '17:00', is_open: true },
{ weekday: 5, start_time: '09:00', end_time: '17:00', is_open: false },
{ weekday: 6, start_time: '09:00', end_time: '17:00', is_open: false }
]
// Replace the demo data with empty array and add loading state
let exceptionGroups = $state<ExceptionGroup[]>([]);
let exceptionGroupsLoading = $state(true);
// Add state for the exception modal
let exceptionDraft = $state<ExceptionGroup>({
name: '',
description: '',
weekStarts: [],
hours: [
{ weekday: 0, start_time: '00:00', end_time: '00:00', is_open: false },
{ weekday: 1, start_time: '09:00', end_time: '17:00', is_open: true },
{ weekday: 2, start_time: '09:00', end_time: '17:00', is_open: true },
{ weekday: 3, start_time: '09:00', end_time: '17:00', is_open: true },
{ weekday: 4, start_time: '12:00', end_time: '20:00', is_open: true },
{ weekday: 5, start_time: '09:00', end_time: '17:00', is_open: true },
{ weekday: 6, start_time: '00:00', end_time: '00:00', is_open: false }
]
});
let weekRangeFrom = $state('');
let weekRangeTo = $state('');
async function fetchExceptionGroups() {
if (pageState !== 'authorized') return;
exceptionGroupsLoading = true;
try {
const response = await fetch('/api/scheduling/exceptional-groups', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (response.ok) {
const data = await response.json();
if (data === null || data.length === 0) {
return;
}
exceptionGroups = data.map((group: any) => ({
id: group.id,
name: group.name,
description: group.description,
weekStarts: group.weekStarts || [],
hours:
group.hours?.map((h: any) => ({
id: h.id,
weekday: h.weekday,
start_time: formatTime(h.startTime),
end_time: formatTime(h.endTime),
is_open: h.isOpen
})) || []
}));
} else {
console.error('Failed to fetch exception groups:', response.status);
toast.error('Failed to load exception groups');
}
} catch (err) {
console.error('Error fetching exception groups:', err);
toast.error('Network error loading exception groups');
} finally {
exceptionGroupsLoading = false;
}
]);
}
// Fetch on mount
$effect(() => {
if (pageState === 'authorized') {
fetchExceptionGroups();
}
});
let loadingHours = $state(false);
let savingHours = $state(false);
@@ -283,24 +323,133 @@
}
async function saveExceptionGroup() {
// TODO
}
// Validate
if (!exceptionDraft.name.trim()) {
toast.error('Please enter a group name');
return;
}
if (exceptionDraft.weekStarts.length === 0) {
toast.error('Please add at least one week');
return;
}
savingHours = true;
const loadingToast = toast.loading('Creating exception group...');
async function confirmDeleteExceptionGroup() {
try {
await new Promise((r) => setTimeout(r, 500));
exceptionGroups = exceptionGroups.filter((g) => g.id !== exceptionToDelete);
showDeleteExceptionAlert = false;
exceptionToDelete = undefined;
// Map to API format
const payload = {
name: exceptionDraft.name,
description: exceptionDraft.description,
weekStarts: exceptionDraft.weekStarts,
hours: exceptionDraft.hours.map((h) => ({
weekday: h.weekday,
startTime: h.start_time,
endTime: h.end_time,
isOpen: h.is_open
}))
};
const response = await fetch('/api/scheduling/exceptional-groups', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify(payload)
});
if (response.ok) {
toast.success('Exception group created successfully!', { id: loadingToast });
showExceptionModal = false;
resetExceptionForm();
await fetchExceptionGroups();
} else {
const text = await response.text();
toast.error('Failed to create: ' + text, { id: loadingToast });
}
} catch (err) {
console.error(err);
console.error('Error creating exception group:', err);
toast.error('Network error creating exception group', { id: loadingToast });
} finally {
savingHours = false;
}
}
async function confirmDeleteExceptionGroup() {
if (exceptionToDelete === undefined) return;
const loadingToast = toast.loading('Deleting exception group...');
try {
const response = await fetch(`/api/scheduling/exceptional-groups?id=${exceptionToDelete}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (response.ok || response.status === 204) {
toast.success('Exception group deleted successfully!', { id: loadingToast });
showDeleteExceptionAlert = false;
exceptionToDelete = undefined;
// Refresh the exception groups list
await fetchExceptionGroups();
} else {
const text = await response.text();
toast.error('Failed to delete: ' + text, { id: loadingToast });
}
} catch (err) {
console.error('Error deleting exception group:', err);
toast.error('Network error deleting exception group', { id: loadingToast });
}
}
function openViewExceptionModal(exception: ExceptionGroup) {
viewingException = exception;
showViewExceptionModal = true;
}
function resetExceptionForm() {
exceptionDraft = {
name: '',
description: '',
weekStarts: [],
hours: [
{ weekday: 0, start_time: '00:00', end_time: '00:00', is_open: false },
{ weekday: 1, start_time: '09:00', end_time: '17:00', is_open: true },
{ weekday: 2, start_time: '09:00', end_time: '17:00', is_open: true },
{ weekday: 3, start_time: '09:00', end_time: '17:00', is_open: true },
{ weekday: 4, start_time: '12:00', end_time: '20:00', is_open: true },
{ weekday: 5, start_time: '09:00', end_time: '17:00', is_open: true },
{ weekday: 6, start_time: '00:00', end_time: '00:00', is_open: false }
]
};
weekRangeFrom = '';
weekRangeTo = '';
}
function createNewException() {
resetExceptionForm();
showExceptionModal = true;
}
function addWeekRange() {
if (!weekRangeFrom || !weekRangeTo) {
toast.error('Please select both start and end dates');
return;
}
addWeeksToException(weekRangeFrom, weekRangeTo, exceptionDraft.weekStarts);
weekRangeFrom = '';
weekRangeTo = '';
}
function removeWeek(index: number) {
exceptionDraft.weekStarts = exceptionDraft.weekStarts.filter((_, i) => i !== index);
}
// =============== Users & Bookings ===============
type User = {
id: string;
@@ -464,8 +613,12 @@
const to = new Date(toISO + 'T00:00:00');
const first = new Date(from);
const day = first.getDay();
const mondayOffset = (day + 6) % 7;
first.setDate(first.getDate() - mondayOffset);
const daysToMonday = day === 0 ? -6 : 1 - day;
// Set to the Monday of the current week
first.setDate(first.getDate() + daysToMonday);
// Add all Mondays in the range
for (let d = new Date(first); d <= to; d.setDate(d.getDate() + 7)) {
dest.push(isoDateOf(new Date(d)));
}
@@ -819,6 +972,8 @@
let showSaveDefaultHoursAlert = $state(false);
let showDeleteExceptionAlert = $state(false);
let exceptionToDelete = $state<number | undefined>(undefined);
let showViewExceptionModal = $state(false);
let viewingException = $state<ExceptionGroup | null>(null);
</script>
{#if pageState === 'loading'}
@@ -1130,52 +1285,52 @@
<Button variant="default" onclick={createNewException}>New schedule</Button>
</div>
<div class="grid grid-cols-1 gap-4 space-y-3 md:grid-cols-2">
{#if exceptionGroups.length === 0}
<p class="text-sm text-gray-500">No exception groups found.</p>
{/if}
{#if exceptionGroupsLoading}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
{#each Array(2) as _, i}
<Skeleton class="h-32 w-full" />
{/each}
</div>
{:else}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
{#if exceptionGroups.length === 0}
<p class="col-span-2 text-sm text-gray-500">No exception groups found.</p>
{/if}
{#each exceptionGroups as g}
<div class="relative h-full rounded border p-3 pb-12">
<!-- add bottom padding to avoid overlap -->
<div class="flex h-full flex-col gap-3">
<div class="flex-1">
<div class="font-semibold">{g.name}</div>
<div class="text-sm text-gray-600">{g.description}</div>
<div class="mt-1 text-xs text-gray-500">
Applies to weeks: {g.week_starts?.slice(0, 5).join(', ')}
{#if (g.week_starts?.length ?? 0) > 5}
(+{(g.week_starts?.length ?? 0) - 5} more)
{/if}
{#each exceptionGroups as g}
<div class="relative h-full rounded border p-3 pb-12">
<div class="flex h-full flex-col gap-3">
<div class="flex-1">
<div class="font-semibold">{g.name}</div>
<div class="text-sm text-gray-600">{g.description}</div>
<div class="mt-1 text-xs text-gray-500">
Applies to weeks: {g.weekStarts?.slice(0, 5).join(', ')}
{#if (g.weekStarts?.length ?? 0) > 5}
(+{(g.weekStarts?.length ?? 0) - 5} more)
{/if}
</div>
</div>
<div class="absolute bottom-3 right-3 flex gap-2">
<Button variant="default" size="sm" onclick={() => openViewExceptionModal(g)}>
View
</Button>
<Button
variant="destructive"
size="sm"
onclick={() => {
exceptionToDelete = g.id;
showDeleteExceptionAlert = true;
}}
>
Delete
</Button>
</div>
</div>
<!-- Absolute positioned button -->
<div class="absolute bottom-3 right-3">
<Button
variant="default"
class="w-20"
onclick={() => {
alert('TODO - view group');
}}
>
View
</Button>
<Button
variant="destructive"
class="w-20"
onclick={() => {
exceptionToDelete = g.id;
showDeleteExceptionAlert = true;
}}
>
Delete
</Button>
</div>
</div>
</div>
{/each}
</div>
{/each}
</div>
{/if}
</Card.Content>
</Card.Root>
@@ -1530,21 +1685,149 @@
<!-- Exception Group Modal -->
<Modal.Root bind:open={showExceptionModal}>
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto p-4 md:max-w-lg">
<Modal.Header class="mb-4 p-0">
<Modal.Title class="text-lg font-semibold">New Exception Group</Modal.Title>
<Modal.Content class="max-h-[90vh] max-w-2xl overflow-y-auto">
<Modal.Header>
<Modal.Title class="text-lg font-semibold">Create Exception Schedule</Modal.Title>
<Modal.Description>
Define custom working hours for holidays, closures, or special events.
</Modal.Description>
</Modal.Header>
<Modal.Footer class="flex items-center justify-end gap-2 p-0 pt-4">
<div class="space-y-6 px-4 pb-4">
<!-- Basic Info -->
<div class="space-y-4">
<div class="space-y-2">
<label for="exception-name" class="text-sm font-medium">Schedule Name *</label>
<Input
id="exception-name"
type="text"
placeholder="e.g., Christmas Week, Summer Holiday"
bind:value={exceptionDraft.name}
/>
</div>
<div class="space-y-2">
<label for="exception-description" class="text-sm font-medium">Description</label>
<Input
id="exception-description"
type="text"
placeholder="Brief description of the service, will be shown to customers"
bind:value={exceptionDraft.description}
/>
</div>
</div>
<Separator />
<!-- Week Selection -->
<div class="space-y-4">
<div>
<h3 class="mb-2 text-sm font-medium">Apply to Weeks *</h3>
<p class="mb-3 text-xs text-gray-500">
Select a date range to add all Mondays within that range
</p>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div class="space-y-2">
<label for="week-from" class="text-xs text-gray-600">From Date</label>
<Input id="week-from" type="date" bind:value={weekRangeFrom} />
</div>
<div class="space-y-2">
<label for="week-to" class="text-xs text-gray-600">To Date</label>
<Input id="week-to" type="date" bind:value={weekRangeTo} />
</div>
</div>
<Button variant="outline" size="sm" onclick={addWeekRange} class="mt-3">
Add Week Range
</Button>
</div>
{#if exceptionDraft.weekStarts.length > 0}
<div class="space-y-2">
<div class="text-xs text-gray-600">
Selected weeks ({exceptionDraft.weekStarts.length}):
</div>
<div class="max-h-32 space-y-1 overflow-y-auto rounded border p-2">
{#each exceptionDraft.weekStarts as week, index}
<div class="flex items-center justify-between text-sm">
<span>Week starting: {week}</span>
<button
class="text-xs text-red-500 hover:text-red-700"
onclick={() => removeWeek(index)}
>
Remove
</button>
</div>
{/each}
</div>
</div>
{/if}
</div>
<Separator />
<!-- Working Hours -->
<div class="space-y-4">
<h3 class="text-sm font-medium">Working Hours for these Weeks *</h3>
<div class="w-full overflow-x-auto">
<table class="w-full table-auto text-sm">
<thead>
<tr class="text-left text-xs text-gray-500">
<th class="py-2">Day</th>
<th class="py-2">Open</th>
<th class="py-2">Start</th>
<th class="py-2">End</th>
</tr>
</thead>
<tbody>
{#each exceptionDraft.hours as row}
<tr class="border-t">
<td class="py-2">{weekdayLabel(row.weekday)}</td>
<td class="py-2">
<input
type="checkbox"
bind:checked={row.is_open}
class="h-4 w-4 rounded border-gray-300 bg-gray-100"
/>
</td>
<td class="py-2">
<Input
type="time"
bind:value={row.start_time}
disabled={!row.is_open}
class="w-24 text-sm"
/>
</td>
<td class="py-2">
<Input
type="time"
bind:value={row.end_time}
disabled={!row.is_open}
class="w-24 text-sm"
/>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>
<Modal.Footer class="flex items-center justify-end gap-2">
<Button
variant="outline"
onclick={() => {
showExceptionModal = false;
resetExceptionForm();
}}
disabled={savingHours}
>
Cancel
</Button>
<Button onclick={saveExceptionGroup} disabled={savingHours}>
{savingHours ? 'Saving…' : 'Save'}
{savingHours ? 'Creating…' : 'Create Schedule'}
</Button>
</Modal.Footer>
</Modal.Content>
@@ -1573,6 +1856,89 @@
</AlertDialog.Content>
</AlertDialog.Root>
<!-- View Exception Modal -->
{#if viewingException}
<Modal.Root bind:open={showViewExceptionModal}>
<Modal.Content class="max-h-[90vh] max-w-2xl overflow-y-auto">
<Modal.Header>
<Modal.Title class="text-lg font-semibold">{viewingException.name}</Modal.Title>
<Modal.Description>
{viewingException.description || 'Holiday schedule details'}
</Modal.Description>
</Modal.Header>
<div class="space-y-6 px-4 pb-4">
<!-- Applied Weeks -->
<div class="space-y-2">
<h3 class="text-sm font-medium">Applied to Weeks</h3>
<div class="max-h-48 space-y-1 overflow-y-auto rounded border bg-gray-50 p-3">
{#if viewingException.weekStarts && viewingException.weekStarts.length > 0}
<div class="grid grid-cols-2 gap-2 md:grid-cols-3">
{#each viewingException.weekStarts as week}
<div class="text-sm">
Week of {new Date(week).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric'
})}
</div>
{/each}
</div>
{:else}
<p class="text-sm text-gray-500">No weeks specified</p>
{/if}
</div>
</div>
<Separator />
<!-- Working Hours -->
<div class="space-y-4">
<h3 class="text-sm font-medium">Working Hours</h3>
<div class="w-full overflow-x-auto">
<table class="w-full table-auto">
<thead>
<tr class="text-left text-xs text-gray-500">
<th class="py-2">Day</th>
<th class="py-2">Status</th>
<th class="py-2">Start</th>
<th class="py-2">End</th>
</tr>
</thead>
<tbody>
{#each viewingException.hours as row}
<tr class="border-t">
<td class="py-2 text-sm">{weekdayLabel(row.weekday)}</td>
<td class="py-2">
<span
class="text-sm font-medium {row.is_open
? 'text-emerald-600'
: 'text-red-600'}"
>
{row.is_open ? 'Open' : 'Closed'}
</span>
</td>
<td class="py-2 text-sm">
{row.is_open ? row.start_time : '—'}
</td>
<td class="py-2 text-sm">
{row.is_open ? row.end_time : '—'}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>
<Modal.Footer class="flex items-center justify-end gap-2">
<Button onclick={() => (showViewExceptionModal = false)}>Close</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
{/if}
<!-- User Modal -->
{#if selectedUser}
<Modal.Root bind:open={showUserModal}>
@@ -1669,7 +2035,7 @@
<Input
id="service-description"
type="text"
placeholder="Brief description of the service"
placeholder="Brief description of the service, will be shown to customers"
bind:value={newService.description}
class="w-full"
/>