Files
Crussell/frontend/src/lib/components/admin/WeeklySchedule.svelte
T
popertotsandSisyphus 92124158bf feat(frontend): add apiFetch wrapper for automatic auth token injection
Centralizes auth token management into a reusable apiFetch() helper and getAuthHeaders() utility, eliminating inline Bearer token logic across all frontend files.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-06 19:21:34 +01:00

570 lines
18 KiB
Svelte

<script lang="ts">
import { toast } from 'svelte-sonner';
import { browser } from '$app/environment';
import { apiFetch } from '$lib/utils/api';
// 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';
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 WorkingHourRow = {
weekday: number;
start_time: string;
end_time: string;
is_open: boolean;
};
// =============== 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);
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
// =============== Helper Functions ===============
/** 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}`;
}
/** Convert formatted time back to HH:MM for input fields */
function timeToInputValue(time: string): string {
// Handle special cases
if (time === 'Noon') return '12:00';
if (time === 'Midnight') return '00:00';
// Parse 12-hour format
const match = time.match(/^(\d{1,2}):(\d{2})\s*(AM|PM)$/i);
if (!match) return time; // Return as-is if not in expected format
let hours = parseInt(match[1]);
const minutes = match[2];
const period = match[3].toUpperCase();
if (period === 'PM' && hours !== 12) hours += 12;
if (period === 'AM' && hours === 12) hours = 0;
return `${hours.toString().padStart(2, '0')}:${minutes}`;
}
function weekdayLabel(i: number): string {
return dayNames[i];
}
/** Calculate hours between start and end time */
function calculateHours(startTime: string, endTime: string): string {
// Convert formatted times to 24-hour format for calculation
const start = timeToInputValue(startTime);
const end = timeToInputValue(endTime);
const [startHours, startMinutes] = start.split(':').map(Number);
const [endHours, endMinutes] = end.split(':').map(Number);
const startTotalMinutes = startHours * 60 + startMinutes;
const endTotalMinutes = endHours * 60 + endMinutes;
const diffMinutes = endTotalMinutes - startTotalMinutes;
const hours = Math.floor(diffMinutes / 60);
const minutes = diffMinutes % 60;
if (minutes === 0) {
return `${hours}`;
}
return `${hours}.${minutes === 30 ? '5' : Math.round((minutes / 60) * 10)}`;
}
// =============== API Functions ===============
async function fetchDefaultHours() {
if (!browser) return;
defaultHoursIsLoading = true;
let error = null;
try {
const response = await apiFetch('/api/scheduling/default-hours', {
headers: {
'Content-Type': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
defaultHours = data.map(
(hour: { weekday: number; startTime: string; endTime: string; isOpen: boolean }) => ({
weekday: hour.weekday,
start_time: formatTime(hour.startTime),
end_time: formatTime(hour.endTime),
is_open: hour.isOpen
})
);
} else {
const text = await response.text();
error = 'Failed to load working hours: ' + text;
console.error('Error fetching default hours:', text);
}
} catch (err) {
error = 'Network error: ' + (err instanceof Error ? err.message : 'Unknown error');
console.error('Error fetching default hours:', err);
} finally {
if (error) toast.error(error);
defaultHoursIsLoading = false;
}
}
/** Opens the modal and creates a deep copy of current hours for editing. */
function prepareDefaultHoursEdit() {
// Deep copy the current default hours into the draft state
defaultHoursDraft = JSON.parse(JSON.stringify(defaultHours));
// Convert display format back to input format
defaultHoursDraft = defaultHoursDraft.map((row) => ({
...row,
start_time: timeToInputValue(row.start_time),
end_time: timeToInputValue(row.end_time)
}));
showDefaultHoursModal = true;
}
/** Saves the default hours draft after confirmation. */
async function confirmSaveDefaultHours() {
savingHours = true;
const loadingToast = toast.loading('Saving default hours...');
try {
// Map snake_case to camelCase for API
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', {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (response.ok) {
// 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 });
} else {
const text = await response.text();
toast.error('Failed to save: ' + text, { id: loadingToast });
}
} catch (err) {
console.error('save default hours', err);
toast.error('Network error saving hours', { id: loadingToast });
} finally {
savingHours = false;
}
}
// =============== Effects ===============
$effect(() => {
if (defaultHoursProp !== undefined) {
// Parent provides data via prop — map camelCase to display format
if (defaultHoursProp.length > 0) {
defaultHours = defaultHoursProp.map(
(hour: { weekday: number; startTime: string; endTime: string; isOpen: boolean }) => ({
weekday: hour.weekday,
start_time: formatTime(hour.startTime),
end_time: formatTime(hour.endTime),
is_open: hour.isOpen
})
);
}
defaultHoursIsLoading = false;
} else if (browser) {
// Backward compatible: self-fetch
fetchDefaultHours();
}
});
</script>
<!-- Default Hours Card -->
<Card.Root>
{#if !defaultHoursIsLoading}
<Card.Content class="space-y-4">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold">Weekly Schedule</h3>
<p class="text-sm text-gray-500">
Your standard operating hours for each day of the week
</p>
</div>
<Button onclick={prepareDefaultHoursEdit} disabled={savingHours}>
<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"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
Edit Schedule
</Button>
</div>
<!-- Desktop Table -->
<div class="hidden w-full overflow-x-auto sm:block">
<table class="w-full table-auto border-collapse">
<thead>
<tr class="border-b text-left text-xs text-gray-500">
<th class="px-4 py-3">Day</th>
<th class="px-4 py-3 text-center">Status</th>
<th class="px-4 py-3">Opening Time</th>
<th class="px-4 py-3">Closing Time</th>
<th class="px-4 py-3 text-right">Total Hours</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{#each defaultHours as row (row.weekday)}
<tr class="transition-colors hover:bg-gray-50">
<td class="p-3">
<div class="flex items-center gap-2">
<span class="font-medium"
>{weekdayLabel(row.weekday) === 'Mon'
? 'Monday'
: weekdayLabel(row.weekday) === 'Tue'
? 'Tuesday'
: weekdayLabel(row.weekday) === 'Wed'
? 'Wednesday'
: weekdayLabel(row.weekday) === 'Thu'
? 'Thursday'
: weekdayLabel(row.weekday) === 'Fri'
? 'Friday'
: weekdayLabel(row.weekday) === 'Sat'
? 'Saturday'
: 'Sunday'}</span
>
</div>
</td>
<td class="px-4 py-4 text-center">
<span
class="inline-flex items-center rounded-full px-3 py-1 text-xs font-medium {row.is_open
? 'bg-emerald-100 text-emerald-800'
: 'bg-gray-100 text-gray-800'}"
>
<span
class="mr-1.5 h-1.5 w-1.5 rounded-full {row.is_open
? 'bg-emerald-600'
: 'bg-gray-600'}"
></span>
{row.is_open ? 'Open' : 'Closed'}
</span>
</td>
<td class="px-4 py-4">
{#if row.is_open}
<div class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 text-gray-400"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span class="font-medium text-gray-900">{row.start_time}</span>
</div>
{:else}
<span class="text-gray-400"></span>
{/if}
</td>
<td class="px-4 py-4">
{#if row.is_open}
<div class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 text-gray-400"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span class="font-medium text-gray-900">{row.end_time}</span>
</div>
{:else}
<span class="text-gray-400"></span>
{/if}
</td>
<td class="px-4 py-4 text-right">
{#if row.is_open}
<span class="inline-flex items-center gap-1 text-sm font-medium text-gray-700">
{calculateHours(row.start_time, row.end_time)}
<span class="text-xs text-gray-500">hrs</span>
</span>
{:else}
<span class="text-gray-400"></span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Mobile Cards -->
<div class="space-y-3 sm:hidden">
{#each defaultHours as row (row.weekday)}
<div class="rounded-lg border p-4 transition-colors hover:bg-gray-50">
<div class="mb-3 flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-semibold text-gray-900">
{weekdayLabel(row.weekday) === 'Mon'
? 'Monday'
: weekdayLabel(row.weekday) === 'Tue'
? 'Tuesday'
: weekdayLabel(row.weekday) === 'Wed'
? 'Wednesday'
: weekdayLabel(row.weekday) === 'Thu'
? 'Thursday'
: weekdayLabel(row.weekday) === 'Fri'
? 'Friday'
: weekdayLabel(row.weekday) === 'Sat'
? 'Saturday'
: 'Sunday'}
</span>
</div>
<span
class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium {row.is_open
? 'bg-emerald-100 text-emerald-800'
: 'bg-gray-100 text-gray-800'}"
>
<span
class="mr-1.5 h-1.5 w-1.5 rounded-full {row.is_open
? 'bg-emerald-600'
: 'bg-gray-600'}"
></span>
{row.is_open ? 'Open' : 'Closed'}
</span>
</div>
{#if row.is_open}
<div class="grid grid-cols-2 gap-3 text-sm">
<div>
<div class="mb-1 text-xs text-gray-500">Opening</div>
<div class="font-medium text-gray-900">{row.start_time}</div>
</div>
<div>
<div class="mb-1 text-xs text-gray-500">Closing</div>
<div class="font-medium text-gray-900">{row.end_time}</div>
</div>
</div>
<div class="mt-3 border-t pt-3 text-xs text-gray-600">
Total: <span class="font-medium text-gray-900"
>{calculateHours(row.start_time, row.end_time)} hours</span
>
</div>
{:else}
<div class="text-sm text-gray-500">No hours scheduled for this day</div>
{/if}
</div>
{/each}
</div>
</Card.Content>
{:else}
<!-- Skeleton loading -->
<Card.Content class="space-y-4">
<!-- Desktop Skeleton -->
<div class="hidden w-full overflow-x-auto md:block">
<table class="w-full table-auto border-collapse">
<thead>
<tr
class="border-b bg-gray-50 text-left text-xs font-medium tracking-wider text-gray-600 uppercase"
>
<th class="px-4 py-3">Day</th>
<th class="px-4 py-3 text-center">Status</th>
<th class="px-4 py-3">Opening Time</th>
<th class="px-4 py-3">Closing Time</th>
<th class="px-4 py-3 text-right">Total Hours</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{#each Array(7) as _, i (i)}
<tr>
<td class="px-4 py-4">
<div class="flex items-center gap-2">
<Skeleton class="h-8 w-8 rounded-full" />
<Skeleton class="h-4 w-20" />
</div>
</td>
<td class="px-4 py-4 text-center">
<Skeleton class="mx-auto h-6 w-16 rounded-full" />
</td>
<td class="px-4 py-4">
<Skeleton class="h-4 w-20" />
</td>
<td class="px-4 py-4">
<Skeleton class="h-4 w-20" />
</td>
<td class="px-4 py-4 text-right">
<Skeleton class="ml-auto h-4 w-12" />
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Mobile Skeleton -->
<div class="space-y-3 md:hidden">
{#each Array(7) as _, i (i)}
<div class="rounded-lg border p-4">
<div class="mb-3 flex items-center justify-between">
<div class="flex items-center gap-2">
<Skeleton class="h-10 w-10 rounded-full" />
<Skeleton class="h-5 w-24" />
</div>
<Skeleton class="h-6 w-16 rounded-full" />
</div>
<div class="grid grid-cols-2 gap-3">
<Skeleton class="h-12 w-full" />
<Skeleton class="h-12 w-full" />
</div>
</div>
{/each}
</div>
</Card.Content>
{/if}
</Card.Root>
<!-- Default Hours Modal -->
<Modal.Root bind:open={showDefaultHoursModal}>
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto md:max-w-lg">
<Modal.Header>
<Modal.Title class="text-lg font-semibold">Edit Default Working Hours</Modal.Title>
<Modal.Description>
Set the standard open and close times for your business.
</Modal.Description>
</Modal.Header>
<div class="px-4 pb-4">
<div class="w-full overflow-x-auto">
<table class="w-full table-auto">
<thead>
<tr class="border-b 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 defaultHoursDraft as row (row.weekday)}
<tr class="border-t">
<td class="py-2 text-sm">{weekdayLabel(row.weekday)}</td>
<td class="py-2">
<Checkbox bind:checked={row.is_open} />
</td>
<td class="py-2">
<select
bind:value={row.start_time}
disabled={!row.is_open}
class="flex h-9 max-w-[70px] rounded-md border border-input bg-background px-2 py-1 text-sm shadow-xs ring-offset-background transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
>
{#each Array(24) as _, hour (hour)}
{#each TIME15 as min (min)}
<option value="{String(hour).padStart(2, '0')}:{min}"
>{String(hour).padStart(2, '0')}:{min}</option
>
{/each}
{/each}
</select>
</td>
<td class="py-2">
<select
bind:value={row.end_time}
disabled={!row.is_open}
class="flex h-9 max-w-[70px] rounded-md border border-input bg-background px-2 py-1 text-sm shadow-xs ring-offset-background transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
>
{#each Array(24) as _, hour (hour)}
{#each TIME15 as min (min)}
<option value="{String(hour).padStart(2, '0')}:{min}"
>{String(hour).padStart(2, '0')}:{min}</option
>
{/each}
{/each}
</select>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<Modal.Footer class="flex items-center justify-end gap-2">
<Button
variant="outline"
onclick={() => {
showDefaultHoursModal = false;
}}
>
Cancel
</Button>
<Button onclick={() => (showSaveDefaultHoursAlert = true)} disabled={savingHours}>
Save Defaults
</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>