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">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -13,10 +13,7 @@
<div
bind:this={ref}
data-slot="card"
class={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
class={cn('bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6', className)}
{...restProps}
>
{@render children?.()}
+106 -176
View File
@@ -10,6 +10,7 @@
import { Textarea } from '$lib/components/ui/textarea';
import { Separator } from '$lib/components/ui/separator';
import * as Modal from '$lib/components/ui/dialog';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
// Custom component
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 ===============
let uploading = $state(false);
let uploadFiles = $state<File[]>([]);
@@ -84,7 +90,7 @@
function formatTime(time: string): string {
const [hours, minutes] = time.split(':').map(Number);
// Special case for 12:00 PM
// Special case for 12:00
if (hours === 12 && minutes === 0) {
return 'Noon';
} else if (hours === 0 && minutes === 0) {
@@ -96,6 +102,26 @@
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() {
defaultHoursIsLoading = true;
let error = null;
@@ -191,8 +217,6 @@
onMount(loadWorkingHours);
let editingException = $state<ExceptionGroup | null>(null);
let editingExceptionIndex = $state<number | null>(null);
let showExceptionModal = $state(false);
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
@@ -201,18 +225,17 @@
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 saveDefaultHours() {
if (
!confirm(
'Are you sure you want to save these default hours? This will affect future bookings.'
)
)
return;
async function confirmSaveDefaultHours() {
savingHours = true;
const loadingToast = toast.loading('Saving default hours...');
@@ -238,6 +261,7 @@
// 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 });
@@ -254,57 +278,21 @@
}
async function saveExceptionGroup() {
// Simplified save logic for demo
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;
// TODO
}
async function deleteExceptionGroup(id?: number) {
if (!id) return;
if (!confirm('Delete this exception group? This will remove its rows too.')) return;
async function confirmDeleteExceptionGroup() {
try {
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) {
console.error(err);
}
}
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;
}
@@ -621,18 +609,19 @@
<Card.Content class="space-y-4">
<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 class="space-y-3">
<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}
{#each exceptionGroups as g}
<div class="rounded border p-3">
<div class="flex items-start justify-between">
<div>
<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">
@@ -643,18 +632,25 @@
</div>
</div>
<div class="flex items-center gap-2">
<!-- Absolute positioned button -->
<div class="absolute bottom-3 right-3">
<Button
variant="outline"
variant="default"
class="w-20"
onclick={() => {
editingExceptionIndex = exceptionGroups.indexOf(g);
editingException = JSON.parse(JSON.stringify(g));
showExceptionModal = true;
alert('TODO - view group');
}}
>
Edit
View
</Button>
<Button variant="destructive" onclick={() => deleteExceptionGroup(g.id)}>
<Button
variant="destructive"
class="w-20"
onclick={() => {
exceptionToDelete = g.id;
showDeleteExceptionAlert = true;
}}
>
Delete
</Button>
</div>
@@ -744,6 +740,7 @@
</Card.Root>
</div>
<!-- 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>
@@ -807,119 +804,40 @@
>
Cancel
</Button>
<Button onclick={saveDefaultHours} disabled={savingHours}>
{savingHours ? 'Saving…' : 'Save Defaults'}
<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>
<!-- 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">
{editingExceptionIndex === null ? 'New Exception Group' : 'Edit Exception Group'}
</Modal.Title>
<Modal.Title class="text-lg font-semibold">New Exception Group</Modal.Title>
</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">
<Button
variant="outline"
onclick={() => {
showExceptionModal = false;
editingException = null;
}}
>
Cancel
@@ -931,6 +849,30 @@
</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>
<!-- User Modal -->
{#if selectedUser}
<Modal.Root bind:open={showUserModal}>
<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">
<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.Content>
</Modal.Root>
{/if}
<!-- Booking Modal -->
{#if selectedBooking}
<Modal.Root bind:open={showBookingModal}>
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto md:max-w-lg">