Files
Crussell/frontend/src/lib/components/admin/HolidayHours.svelte
T

679 lines
20 KiB
Svelte

<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
// shadcn-svelte components
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Separator } from '$lib/components/ui/separator';
import * as Modal from '$lib/components/ui/dialog';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Skeleton } from '$lib/components/ui/skeleton';
// =============== Types ===============
type WorkingHourRow = {
weekday: number;
start_time: string;
end_time: string;
is_open: boolean;
};
type ExceptionGroup = {
id?: number;
name: string;
description: string;
weekStarts: string[];
hours: WorkingHourRow[];
};
// =============== State ===============
let exceptionGroups = $state<ExceptionGroup[]>([]);
let exceptionGroupsLoading = $state(true);
let savingHours = $state(false);
// Exception modal state
let showExceptionModal = $state(false);
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('');
// Delete confirmation state
let showDeleteExceptionAlert = $state(false);
let exceptionToDelete = $state<number | undefined>(undefined);
// View exception state
let showViewExceptionModal = $state(false);
let viewingException = $state<ExceptionGroup | null>(null);
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
// =============== Helper Functions ===============
function weekdayLabel(i: number) {
return dayNames[i];
}
function isoDateOf(d: Date) {
return d.toISOString().slice(0, 10);
}
function addWeeksToException(fromISO: string, toISO: string, dest: string[]) {
const from = new SvelteDate(fromISO + 'T00:00:00');
const to = new SvelteDate(toISO + 'T00:00:00');
const first = new SvelteDate(from);
const day = first.getDay();
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 SvelteDate(first); d <= to; d.setDate(d.getDate() + 7)) {
dest.push(isoDateOf(new SvelteDate(d)));
}
}
/** Format time from HH:MM:SS to 12-hour format, with "Noon" for 12:00 PM */
function formatTime(time: string): string {
const [hours, minutes] = time.split(':').map(Number);
// Special case for 12:00
if (hours === 12 && minutes === 0) {
return 'Noon';
} else if (hours === 0 && minutes === 0) {
return 'Midnight';
}
const period = hours >= 12 ? 'PM' : 'AM';
const displayHours = hours % 12 || 12;
return `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`;
}
// =============== API Functions ===============
async function fetchExceptionGroups() {
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) => ({
id: group.id,
name: group.name,
description: group.description,
weekStarts: group.weekStarts || [],
hours:
group.hours?.map((h) => ({
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;
}
}
async function saveExceptionGroup() {
// 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...');
try {
// 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('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 });
}
}
// =============== UI Actions ===============
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);
}
function openViewExceptionModal(exception: ExceptionGroup) {
viewingException = exception;
showViewExceptionModal = true;
}
// =============== Effects ===============
$effect(() => {
fetchExceptionGroups();
});
</script>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Holiday Hours</Card.Title>
<Card.Description>
Manage temporary schedules for holidays, closures, and special events.
</Card.Description>
</div>
<Button variant="default" onclick={createNewException}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
New Schedule
</Button>
</div>
</Card.Header>
<Card.Content class="space-y-4">
{#if exceptionGroupsLoading}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
{#each Array(2) as _, i (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 (g.weekStarts)}
<div class="group relative h-full rounded-lg border p-4 transition-all">
<div class="flex h-full flex-col gap-3">
<div class="flex-1">
<div class="mb-2 flex items-start justify-between">
<div class="flex items-center gap-2">
<div class="rounded-lg bg-gray-50 p-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</div>
<h3 class="font-semibold text-gray-900">{g.name}</h3>
</div>
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium">
{g.weekStarts?.length || 0} weeks
</span>
</div>
<p class="mb-3 text-sm text-gray-600">{g.description}</p>
<div class="rounded-lg bg-gray-50 p-2">
<div class="mb-1 text-xs font-medium text-gray-500">Applies to weeks:</div>
<div class="text-xs text-gray-700">
{g.weekStarts
?.slice(0, 3)
.map((w) =>
new SvelteDate(w).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short'
})
)
.join(', ')}
{#if (g.weekStarts?.length ?? 0) > 3}
<span class="text-gray-500"> (+{(g.weekStarts?.length ?? 0) - 3} more)</span>
{/if}
</div>
</div>
</div>
<div class="flex gap-2 border-t pt-2">
<Button
variant="outline"
size="sm"
onclick={() => openViewExceptionModal(g)}
class="flex-1"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-1 h-3 w-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
View Details
</Button>
<Button
variant="destructive"
size="sm"
onclick={() => {
exceptionToDelete = g.id;
showDeleteExceptionAlert = true;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-3 w-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="3 6 5 6 21 6" />
<path
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
/>
</svg>
</Button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</Card.Content>
</Card.Root>
<!-- Exception Group Modal -->
<Modal.Root bind:open={showExceptionModal}>
<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>
<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 (week)}
<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 (row.weekday)}
<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 ? 'Creating…' : 'Create Schedule'}
</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
<!-- Delete Exception Confirmation -->
<AlertDialog.Root bind:open={showDeleteExceptionAlert}>
<AlertDialog.Content class="z-[60]">
<AlertDialog.Header>
<AlertDialog.Title>Delete exception group?</AlertDialog.Title>
<AlertDialog.Description>
This action cannot be undone. This will permanently delete this exception group and all its
associated schedule rows.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel
onclick={() => {
exceptionToDelete = undefined;
}}
>
Cancel
</AlertDialog.Cancel>
<AlertDialog.Action onclick={confirmDeleteExceptionGroup}>Delete</AlertDialog.Action>
</AlertDialog.Footer>
</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 (week)}
<div class="text-sm">
Week of {new SvelteDate(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 (row.weekday)}
<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}