This commit is contained in:
2025-10-14 23:59:10 +01:00
parent 29aaa7392e
commit 0ed89df995
12 changed files with 312 additions and 182 deletions
@@ -0,0 +1,18 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.ActionProps = $props();
</script>
<AlertDialogPrimitive.Action
bind:ref
data-slot="alert-dialog-action"
class={cn(buttonVariants(), className)}
{...restProps}
/>
@@ -0,0 +1,18 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.CancelProps = $props();
</script>
<AlertDialogPrimitive.Cancel
bind:ref
data-slot="alert-dialog-cancel"
class={cn(buttonVariants({ variant: "outline" }), className)}
{...restProps}
/>
@@ -0,0 +1,27 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import AlertDialogOverlay from "./alert-dialog-overlay.svelte";
import { cn, type WithoutChild, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
portalProps,
...restProps
}: WithoutChild<AlertDialogPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<AlertDialogPrimitive.PortalProps>;
} = $props();
</script>
<AlertDialogPrimitive.Portal {...portalProps}>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
bind:ref
data-slot="alert-dialog-content"
class={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...restProps}
/>
</AlertDialogPrimitive.Portal>
@@ -0,0 +1,17 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.DescriptionProps = $props();
</script>
<AlertDialogPrimitive.Description
bind:ref
data-slot="alert-dialog-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
/>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-dialog-footer"
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-dialog-header"
class={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.OverlayProps = $props();
</script>
<AlertDialogPrimitive.Overlay
bind:ref
data-slot="alert-dialog-overlay"
class={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...restProps}
/>
@@ -0,0 +1,17 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.TitleProps = $props();
</script>
<AlertDialogPrimitive.Title
bind:ref
data-slot="alert-dialog-title"
class={cn("text-lg font-semibold", className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: AlertDialogPrimitive.TriggerProps = $props();
</script>
<AlertDialogPrimitive.Trigger bind:ref data-slot="alert-dialog-trigger" {...restProps} />
@@ -0,0 +1,39 @@
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import Trigger from "./alert-dialog-trigger.svelte";
import Title from "./alert-dialog-title.svelte";
import Action from "./alert-dialog-action.svelte";
import Cancel from "./alert-dialog-cancel.svelte";
import Footer from "./alert-dialog-footer.svelte";
import Header from "./alert-dialog-header.svelte";
import Overlay from "./alert-dialog-overlay.svelte";
import Content from "./alert-dialog-content.svelte";
import Description from "./alert-dialog-description.svelte";
const Root = AlertDialogPrimitive.Root;
const Portal = AlertDialogPrimitive.Portal;
export {
Root,
Title,
Action,
Cancel,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
//
Root as AlertDialog,
Title as AlertDialogTitle,
Action as AlertDialogAction,
Cancel as AlertDialogCancel,
Portal as AlertDialogPortal,
Footer as AlertDialogFooter,
Header as AlertDialogHeader,
Trigger as AlertDialogTrigger,
Overlay as AlertDialogOverlay,
Content as AlertDialogContent,
Description as AlertDialogDescription,
};
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -13,10 +13,7 @@
<div <div
bind:this={ref} bind:this={ref}
data-slot="card" data-slot="card"
class={cn( class={cn('bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6', className)}
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
+106 -176
View File
@@ -10,6 +10,7 @@
import { Textarea } from '$lib/components/ui/textarea'; import { Textarea } from '$lib/components/ui/textarea';
import { Separator } from '$lib/components/ui/separator'; import { Separator } from '$lib/components/ui/separator';
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';
// Custom component // Custom component
import FileDropZone from '$lib/components/ui/file-drop-zone.svelte'; import FileDropZone from '$lib/components/ui/file-drop-zone.svelte';
@@ -26,6 +27,11 @@
} }
} }
// =============== Alert Dialog State ===============
let showSaveDefaultHoursAlert = $state(false);
let showDeleteExceptionAlert = $state(false);
let exceptionToDelete = $state<number | undefined>(undefined);
// =============== Image Upload =============== // =============== Image Upload ===============
let uploading = $state(false); let uploading = $state(false);
let uploadFiles = $state<File[]>([]); let uploadFiles = $state<File[]>([]);
@@ -84,7 +90,7 @@
function formatTime(time: string): string { function formatTime(time: string): string {
const [hours, minutes] = time.split(':').map(Number); const [hours, minutes] = time.split(':').map(Number);
// Special case for 12:00 PM // Special case for 12:00
if (hours === 12 && minutes === 0) { if (hours === 12 && minutes === 0) {
return 'Noon'; return 'Noon';
} else if (hours === 0 && minutes === 0) { } else if (hours === 0 && minutes === 0) {
@@ -96,6 +102,26 @@
return `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`; 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}`;
}
async function fetchDefaultHours() { async function fetchDefaultHours() {
defaultHoursIsLoading = true; defaultHoursIsLoading = true;
let error = null; let error = null;
@@ -191,8 +217,6 @@
onMount(loadWorkingHours); onMount(loadWorkingHours);
let editingException = $state<ExceptionGroup | null>(null);
let editingExceptionIndex = $state<number | null>(null);
let showExceptionModal = $state(false); let showExceptionModal = $state(false);
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
@@ -201,18 +225,17 @@
function prepareDefaultHoursEdit() { function prepareDefaultHoursEdit() {
// Deep copy the current default hours into the draft state // Deep copy the current default hours into the draft state
defaultHoursDraft = JSON.parse(JSON.stringify(defaultHours)); 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; showDefaultHoursModal = true;
} }
/** Saves the default hours draft after confirmation. */ /** Saves the default hours draft after confirmation. */
async function saveDefaultHours() { async function confirmSaveDefaultHours() {
if (
!confirm(
'Are you sure you want to save these default hours? This will affect future bookings.'
)
)
return;
savingHours = true; savingHours = true;
const loadingToast = toast.loading('Saving default hours...'); const loadingToast = toast.loading('Saving default hours...');
@@ -238,6 +261,7 @@
// 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 });
@@ -254,57 +278,21 @@
} }
async function saveExceptionGroup() { async function saveExceptionGroup() {
// Simplified save logic for demo // TODO
if (!editingException) return;
savingHours = true;
try {
await new Promise((r) => setTimeout(r, 1000));
if (editingExceptionIndex === null) {
// Mock ID assignment for new group
editingException.id = Math.max(0, ...exceptionGroups.map((g) => g.id ?? 0)) + 1;
exceptionGroups = [editingException, ...exceptionGroups];
} else {
// Update existing group
exceptionGroups = exceptionGroups.map((g, i) =>
i === editingExceptionIndex ? editingException! : g
);
}
showExceptionModal = false;
editingException = null;
} catch (err) {
console.error('save exception', err);
}
savingHours = false;
} }
async function deleteExceptionGroup(id?: number) { async function confirmDeleteExceptionGroup() {
if (!id) return;
if (!confirm('Delete this exception group? This will remove its rows too.')) return;
try { try {
await new Promise((r) => setTimeout(r, 500)); await new Promise((r) => setTimeout(r, 500));
exceptionGroups = exceptionGroups.filter((g) => g.id !== id); exceptionGroups = exceptionGroups.filter((g) => g.id !== exceptionToDelete);
showDeleteExceptionAlert = false;
exceptionToDelete = undefined;
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} }
} }
function createNewException() { function createNewException() {
editingExceptionIndex = null;
editingException = {
name: '',
description: '',
week_starts: [],
rows: Array.from({ length: 7 }, (_, i) => ({
weekday: i,
start_time: '09:00',
end_time: '17:00',
is_open: true
}))
};
showExceptionModal = true; showExceptionModal = true;
} }
@@ -621,18 +609,19 @@
<Card.Content class="space-y-4"> <Card.Content class="space-y-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<Button variant="outline" onclick={createNewException}>New schedule</Button> <Button variant="default" onclick={createNewException}>New schedule</Button>
</div> </div>
<div class="space-y-3"> <div class="grid grid-cols-1 gap-4 space-y-3 md:grid-cols-2">
{#if exceptionGroups.length === 0} {#if exceptionGroups.length === 0}
<p class="text-sm text-gray-500">No exception groups found.</p> <p class="text-sm text-gray-500">No exception groups found.</p>
{/if} {/if}
{#each exceptionGroups as g} {#each exceptionGroups as g}
<div class="rounded border p-3"> <div class="relative h-full rounded border p-3 pb-12">
<div class="flex items-start justify-between"> <!-- add bottom padding to avoid overlap -->
<div> <div class="flex h-full flex-col gap-3">
<div class="flex-1">
<div class="font-semibold">{g.name}</div> <div class="font-semibold">{g.name}</div>
<div class="text-sm text-gray-600">{g.description}</div> <div class="text-sm text-gray-600">{g.description}</div>
<div class="mt-1 text-xs text-gray-500"> <div class="mt-1 text-xs text-gray-500">
@@ -643,18 +632,25 @@
</div> </div>
</div> </div>
<div class="flex items-center gap-2"> <!-- Absolute positioned button -->
<div class="absolute bottom-3 right-3">
<Button <Button
variant="outline" variant="default"
class="w-20"
onclick={() => { onclick={() => {
editingExceptionIndex = exceptionGroups.indexOf(g); alert('TODO - view group');
editingException = JSON.parse(JSON.stringify(g));
showExceptionModal = true;
}} }}
> >
Edit View
</Button> </Button>
<Button variant="destructive" onclick={() => deleteExceptionGroup(g.id)}> <Button
variant="destructive"
class="w-20"
onclick={() => {
exceptionToDelete = g.id;
showDeleteExceptionAlert = true;
}}
>
Delete Delete
</Button> </Button>
</div> </div>
@@ -744,6 +740,7 @@
</Card.Root> </Card.Root>
</div> </div>
<!-- Default Hours Modal -->
<Modal.Root bind:open={showDefaultHoursModal}> <Modal.Root bind:open={showDefaultHoursModal}>
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto md:max-w-lg"> <Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto md:max-w-lg">
<Modal.Header> <Modal.Header>
@@ -807,119 +804,40 @@
> >
Cancel Cancel
</Button> </Button>
<Button onclick={saveDefaultHours} disabled={savingHours}> <Button onclick={() => (showSaveDefaultHoursAlert = true)} disabled={savingHours}>
{savingHours ? 'Saving…' : 'Save Defaults'} Save Defaults
</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>
<!-- Exception Group Modal -->
<Modal.Root bind:open={showExceptionModal}> <Modal.Root bind:open={showExceptionModal}>
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto p-4 md:max-w-lg"> <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.Header class="mb-4 p-0">
<Modal.Title class="text-lg font-semibold"> <Modal.Title class="text-lg font-semibold">New Exception Group</Modal.Title>
{editingExceptionIndex === null ? 'New Exception Group' : 'Edit Exception Group'}
</Modal.Title>
</Modal.Header> </Modal.Header>
{#if editingException}
<div class="space-y-4 pb-4">
<div>
<Label>Name</Label>
<Input bind:value={editingException.name} placeholder="e.g. Christmas" />
</div>
<div>
<Label>Description</Label>
<Textarea
rows={3}
bind:value={editingException.description}
placeholder="e.g. Extended weekend hours and shortened weekday hours for the holiday period"
/>
</div>
<div>
<Label>Week range to apply</Label>
<div class="flex flex-wrap gap-2">
<Input type="date" id="exception_from" class="min-w-[120px] flex-1" />
<Input type="date" id="exception_to" class="min-w-[120px] flex-1" />
<Button
class="w-full sm:w-auto"
onclick={() => {
const fromEl = document.getElementById('exception_from') as HTMLInputElement;
const toEl = document.getElementById('exception_to') as HTMLInputElement;
if (fromEl?.value && toEl?.value) {
addWeeksToException(fromEl.value, toEl.value, editingException!.week_starts);
editingException!.week_starts = Array.from(
new Set(editingException!.week_starts).values()
).sort();
}
}}
>
Add weeks
</Button>
</div>
<div class="mt-2 text-xs text-gray-500">
Weeks are added by their starting Monday (ISO date).
</div>
<div class="mt-2">
<div class="flex flex-wrap gap-2">
{#each editingException.week_starts as ws}
<div class="flex items-center gap-2 rounded bg-gray-100 px-2 py-1 text-xs">
<span>{ws}</span>
<button
class="text-red-500"
onclick={() =>
(editingException!.week_starts = editingException!.week_starts.filter(
(w) => w !== ws
))}
>
</button>
</div>
{/each}
</div>
</div>
</div>
<Separator />
<div>
<div class="mb-2 text-sm font-medium">Weekday rows (Override)</div>
<div class="grid gap-2">
{#each editingException.rows as r}
<div class="flex items-center gap-2">
<div style="width:48px" class="text-sm">{weekdayLabel(r.weekday)}</div>
<input
type="checkbox"
bind:checked={r.is_open}
class="text-primary focus:ring-primary h-4 w-4 rounded border-gray-300 bg-gray-100"
/>
<Input
type="time"
bind:value={r.start_time}
disabled={!r.is_open}
class="max-w-[70px] text-sm"
/>
<Input
type="time"
bind:value={r.end_time}
disabled={!r.is_open}
class="max-w-[70px] text-sm"
/>
</div>
{/each}
</div>
</div>
</div>
{/if}
<Modal.Footer class="flex items-center justify-end gap-2 p-0 pt-4"> <Modal.Footer class="flex items-center justify-end gap-2 p-0 pt-4">
<Button <Button
variant="outline" variant="outline"
onclick={() => { onclick={() => {
showExceptionModal = false; showExceptionModal = false;
editingException = null;
}} }}
> >
Cancel Cancel
@@ -931,6 +849,30 @@
</Modal.Content> </Modal.Content>
</Modal.Root> </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>
<!-- User Modal -->
{#if selectedUser} {#if selectedUser}
<Modal.Root bind:open={showUserModal}> <Modal.Root bind:open={showUserModal}>
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto md:max-w-lg"> <Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto md:max-w-lg">
@@ -988,24 +930,12 @@
<Modal.Footer class="flex items-center justify-end gap-2"> <Modal.Footer class="flex items-center justify-end gap-2">
<Button onclick={() => (showUserModal = false)}>Close</Button> <Button onclick={() => (showUserModal = false)}>Close</Button>
<Button
variant="destructive"
onclick={async () => {
if (!confirm('Delete user? This is irreversible.')) return;
// Mock delete
await new Promise((r) => setTimeout(r, 500));
users = users.filter((u) => u.id !== selectedUser!.id);
showUserModal = false;
alert(`User ${selectedUser!.fn || selectedUser!.id} deleted (mocked)`);
}}
>
Delete user
</Button>
</Modal.Footer> </Modal.Footer>
</Modal.Content> </Modal.Content>
</Modal.Root> </Modal.Root>
{/if} {/if}
<!-- Booking Modal -->
{#if selectedBooking} {#if selectedBooking}
<Modal.Root bind:open={showBookingModal}> <Modal.Root bind:open={showBookingModal}>
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto md:max-w-lg"> <Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto md:max-w-lg">