WIP admin page

This commit is contained in:
2025-10-13 22:35:37 +01:00
parent 708b741a32
commit b474b8a41c
10 changed files with 1097 additions and 37 deletions
+4 -3
View File
@@ -17,7 +17,7 @@
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.22.0",
"@internationalized/date": "^3.9.0",
"@lucide/svelte": "^0.515.0",
"@lucide/svelte": "^0.544.0",
"@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/adapter-static": "^3.0.9",
"@sveltejs/kit": "^2.22.0",
@@ -25,7 +25,7 @@
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^22",
"@types/swiper": "^5.4.3",
"bits-ui": "^2.10.0",
"bits-ui": "^2.11.5",
"clsx": "^2.1.1",
"eslint": "^9.22.0",
"eslint-config-prettier": "^10.0.1",
@@ -36,12 +36,13 @@
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"shadcn-svelte": "^1.0.8",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"svelte-sonner": "^1.0.5",
"sveltekit-superforms": "^2.27.1",
"tailwind-merge": "^3.3.1",
"tailwind-variants": "^1.0.0",
"tailwind-variants": "^3.1.1",
"tailwindcss": "^4.0.0",
"tw-animate-css": "^1.3.8",
"typescript": "^5.0.0",
@@ -1,37 +1,38 @@
<script lang="ts" module>
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
import { type VariantProps, tv } from 'tailwind-variants';
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
import { type VariantProps, tv } from "tailwind-variants";
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
default:
'bg-primary text-primary-foreground shadow-xs hover:bg-fuchsia-200 hover:text-foreground',
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
'bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white',
"bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white",
outline:
'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline'
"bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border",
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9'
}
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: 'default',
size: 'default'
}
variant: "default",
size: "default",
},
});
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
@@ -43,11 +44,11 @@
<script lang="ts">
let {
class: className,
variant = 'default',
size = 'default',
variant = "default",
size = "default",
ref = $bindable(null),
href = undefined,
type = 'button',
type = "button",
disabled,
children,
...restProps
@@ -61,7 +62,7 @@
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? 'link' : undefined}
role={disabled ? "link" : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>
@@ -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,7 +13,10 @@
<div
bind:this={ref}
data-slot="card"
class={cn('flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground', className)}
class={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...restProps}
>
{@render children?.()}
@@ -0,0 +1,71 @@
<script lang="ts">
const { onfiles = () => {}, accept = '*', multiple = false, children } = $props();
let dragging = $state(false);
let fileInputEl: HTMLInputElement; // Reference to the hidden file input
function handleDrop(e: DragEvent) {
e.preventDefault();
dragging = false;
const files = Array.from(e.dataTransfer?.files ?? []);
if (files.length) {
onfiles(files);
}
}
function handleDragOver(e: DragEvent) {
e.preventDefault();
dragging = true;
}
function handleDragLeave(e: DragEvent) {
e.preventDefault();
dragging = false;
}
/** Handles file selection from the native dialog */
function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
const files = Array.from(input.files ?? []);
if (files.length) {
onfiles(files);
}
// IMPORTANT: Reset the input value so the same file can be selected again
input.value = '';
}
/** Programmatically clicks the hidden file input */
function openFileSelect() {
fileInputEl.click();
}
</script>
<div
role="button"
aria-label="Drop files or click to upload"
tabindex="0"
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
onclick={openFileSelect}
onkeydown={(e) => {
// Allow keyboard users to trigger the click with Enter or Space
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openFileSelect();
}
}}
class="rounded-lg border-2 border-dashed p-6 text-center transition-colors
{dragging ? 'border-primary bg-primary/5' : 'border-gray-300'}"
>
<input
type="file"
bind:this={fileInputEl}
{accept}
{multiple}
onchange={handleFileSelect}
class="sr-only"
/>
{@render children?.()}
</div>
@@ -15,6 +15,7 @@
type,
files = $bindable(),
class: className,
"data-slot": dataSlot = "input",
...restProps
}: Props = $props();
</script>
@@ -22,9 +23,9 @@
{#if type === "file"}
<input
bind:this={ref}
data-slot="input"
data-slot={dataSlot}
class={cn(
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
@@ -37,7 +38,7 @@
{:else}
<input
bind:this={ref}
data-slot="input"
data-slot={dataSlot}
class={cn(
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
@@ -5,13 +5,14 @@
let {
ref = $bindable(null),
class: className,
"data-slot": dataSlot = "separator",
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<SeparatorPrimitive.Root
bind:ref
data-slot="separator"
data-slot={dataSlot}
class={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px",
className
@@ -6,13 +6,14 @@
ref = $bindable(null),
value = $bindable(),
class: className,
"data-slot": dataSlot = "textarea",
...restProps
}: WithoutChildren<WithElementRef<HTMLTextareaAttributes>> = $props();
</script>
<textarea
bind:this={ref}
data-slot="textarea"
data-slot={dataSlot}
class={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 field-sizing-content shadow-xs flex min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
+1 -1
View File
@@ -2,7 +2,7 @@
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
export type UserRole = 'unverified_email' | 'verified_email' | 'admin' | 'guest';
export type UserRole = 'unverified_email' | 'verified_email' | 'admin' | 'guest' | 'affiliate';
export interface DecodedToken {
user_id: string;
+976
View File
@@ -0,0 +1,976 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
// 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 { Label } from '$lib/components/ui/label';
import { Textarea } from '$lib/components/ui/textarea';
import { Separator } from '$lib/components/ui/separator';
import * as Modal from '$lib/components/ui/dialog';
// Custom component
import FileDropZone from '$lib/components/ui/file-drop-zone.svelte';
// Auth guard
onMount(() => {
const user = authStore.currentUser;
if (!user || user.role !== 'admin') {
goto('/', { replaceState: true });
}
});
// =============== Image Upload ===============
let uploading = $state(false);
let uploadFiles = $state<File[]>([]);
let uploadProgress = $state(0);
let uploadResults = $state<{ name: string; url?: string; error?: string }[]>([]);
function handleFilesDropped(files: File[]) {
uploadFiles = files;
}
async function uploadOneOrMany() {
if (!uploadFiles.length) return;
uploading = true;
uploadResults = [];
uploadProgress = 0;
for (let i = 0; i < uploadFiles.length; i++) {
const file = uploadFiles[i];
const fd = new FormData();
fd.append('file', file);
try {
// Mock success/fail
await new Promise((r) => setTimeout(r, 500)); // Simulate network delay
if (file.name.toLowerCase().includes('fail')) {
uploadResults.push({ name: file.name, error: 'Mocked API error' });
} else {
uploadResults.push({ name: file.name, url: `/images/${file.name}` });
}
} catch (err: any) {
uploadResults.push({ name: file.name, error: err?.message || 'Network error' });
}
uploadProgress = Math.round(((i + 1) / uploadFiles.length) * 100);
}
uploading = false;
uploadFiles = [];
}
// =============== Working Hours ===============
type WorkingHourRow = {
id?: number;
weekday: number;
start_time: string;
end_time: string;
is_open: boolean;
};
let defaultHours = $state<WorkingHourRow[]>( // Initial default values
Array.from({ length: 7 }, (_, i) => ({
weekday: i,
start_time: '09:00',
end_time: '17:00',
is_open: i >= 0 && i <= 4 // Mon-Fri default open
}))
);
type ExceptionGroup = {
id?: number;
name: string;
description: string;
week_starts: string[];
rows: 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 }
]
}
]);
let loadingHours = $state(false);
let savingHours = $state(false);
let defaultHoursDraft = $state<WorkingHourRow[]>([]);
let showDefaultHoursModal = $state(false);
// Mocked load - in a real app, this would fetch from your backend
async function loadWorkingHours() {
loadingHours = true;
// Simulate loading delay
await new Promise((r) => setTimeout(r, 500));
// Set defaultHours to the initial demo values (in a real app, it would be API data)
loadingHours = false;
}
onMount(loadWorkingHours);
let editingException = $state<ExceptionGroup | null>(null);
let editingExceptionIndex = $state<number | null>(null);
let showExceptionModal = $state(false);
let showUploadModal = $state(false); // Kept in case you want to toggle visibility
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
/** 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));
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;
savingHours = true;
try {
// Mock API call to save defaults
await new Promise((r) => setTimeout(r, 1000));
// Update the main state from the draft state if successful
defaultHours = defaultHoursDraft;
showDefaultHoursModal = false;
alert('Default hours saved successfully!');
} catch (err) {
console.error('save default hours', err);
alert('Failed to save default hours.');
}
savingHours = false;
}
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;
}
async function deleteExceptionGroup(id?: number) {
if (!id) return;
if (!confirm('Delete this exception group? This will remove its rows too.')) return;
try {
await new Promise((r) => setTimeout(r, 500));
exceptionGroups = exceptionGroups.filter((g) => g.id !== id);
} 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;
}
// =============== Users & Bookings ===============
type User = {
id: string;
n_first_name: string;
n_last_name: string;
fn?: string;
email?: string;
phone?: string;
created_at?: string;
profile_pic_url?: string;
loyalty_stamps?: number;
};
type Booking = {
id: string;
user_id?: string;
start_time: string;
status: 'Confirmed' | 'Completed' | 'Cancelled';
created_at: string;
services?: { id: string; name: string }[];
};
let userQuery = $state('');
let bookingQuery = $state('');
// DEMO DATA: Users
let users = $state<User[]>([
{
id: 'user1',
n_first_name: 'John',
n_last_name: 'Doe',
fn: 'John Doe',
email: 'john.d@example.com',
phone: '07700 900001',
created_at: '2023-01-10T10:00:00Z',
loyalty_stamps: 3
},
{
id: 'user2',
n_first_name: 'Jane',
n_last_name: 'Smith',
fn: 'Jane Smith',
email: 'jane.s@example.com',
phone: '07700 900002',
created_at: '2023-05-20T14:30:00Z',
loyalty_stamps: 10
}
]);
// DEMO DATA: Bookings
let bookings = $state<Booking[]>([
{
id: 'book1',
user_id: 'user1',
start_time: '2025-10-15T10:00:00Z',
status: 'Confirmed',
created_at: '2025-10-01T09:00:00Z',
services: [{ id: 'svc1', name: 'Haircut' }]
},
{
id: 'book2',
user_id: 'user2',
start_time: '2025-10-14T15:00:00Z',
status: 'Completed',
created_at: '2025-09-28T12:00:00Z',
services: [
{ id: 'svc2', name: 'Color' },
{ id: 'svc1', name: 'Haircut' }
]
},
{
id: 'book3',
user_id: 'user1',
start_time: '2025-10-22T11:00:00Z',
status: 'Confirmed',
created_at: '2025-10-10T12:00:00Z',
services: [{ id: 'svc3', name: 'Blowdry' }]
}
]);
let loadingSearch = $state(false);
let selectedUser = $state<User | null>(null);
let selectedBooking = $state<Booking | null>(null);
let bookingUserHistory = $state<Booking[]>([]);
let showUserModal = $state(false);
let showBookingModal = $state(false);
// Uses DEMO data for search
async function searchUsers() {
loadingSearch = true;
await new Promise((r) => setTimeout(r, 500));
const query = userQuery.toLowerCase();
users = [
{
id: 'user3',
n_first_name: 'Test',
n_last_name: 'Search',
email: 'test@search.com',
phone: '000',
created_at: '2025-01-01T00:00:00Z',
loyalty_stamps: 1
},
...users
].filter(
(u) =>
u.n_first_name.toLowerCase().includes(query) ||
u.n_last_name.toLowerCase().includes(query) ||
u.email?.toLowerCase().includes(query) ||
u.phone?.includes(query)
);
loadingSearch = false;
}
// Uses DEMO data for search
async function searchBookings() {
loadingSearch = true;
await new Promise((r) => setTimeout(r, 500));
const query = bookingQuery.toLowerCase();
bookings = bookings.filter(
(b) =>
b.id.includes(query) ||
b.services?.some((s) => s.name.toLowerCase().includes(query)) ||
users
.find((u) => u.id === b.user_id)
?.fn?.toLowerCase()
.includes(query)
);
loadingSearch = false;
}
async function openUserModal(userId: string) {
selectedUser = users.find((u) => u.id === userId) || null;
if (!selectedUser) return;
// Filter demo bookings for this user
bookingUserHistory = bookings
.filter((b) => b.user_id === userId)
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
showUserModal = true;
}
async function openBookingModal(bookingId: string) {
selectedBooking = bookings.find((b) => b.id === bookingId) || null;
if (!selectedBooking) return;
showBookingModal = true;
}
// =============== Helpers ===============
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 Date(fromISO + 'T00:00:00');
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);
for (let d = new Date(first); d <= to; d.setDate(d.getDate() + 7)) {
dest.push(isoDateOf(new Date(d)));
}
}
</script>
<div class="mx-auto max-w-6xl space-y-6 p-6">
<div class="mb-4 flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold">Admin Dashboard</h1>
<p class="text-gray-600">Manage portfolio images, working hours & user bookings</p>
</div>
</div>
<Card.Root>
<Card.Header>
<Card.Title>Image Upload</Card.Title>
<Card.Description>Upload images for the portfolio or other uses.</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
<FileDropZone onfiles={handleFilesDropped} accept="image/*" multiple>
<div class="p-6 text-center">
<p class="text-sm text-gray-500">Drop files here, or click to open the file picker</p>
</div>
</FileDropZone>
<div class="mt-4">
<div class="text-sm text-gray-600">Selected files ({uploadFiles.length})</div>
<div class="mt-2 max-h-40 space-y-2 overflow-y-auto">
{#each uploadFiles as f}
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
<div>{f.name}{Math.round(f.size / 1024)}KB</div>
<button
class="text-red-500"
onclick={() => (uploadFiles = uploadFiles.filter((x) => x !== f))}
>
Remove
</button>
</div>
{/each}
</div>
{#if uploadResults.length > 0}
<div class="mt-4 text-sm text-gray-600">Upload Results</div>
<div class="mt-2 max-h-40 space-y-2 overflow-y-auto">
{#each uploadResults as result}
<div
class="rounded p-2 text-xs {result.error
? 'bg-red-100 text-red-800'
: 'bg-green-100 text-green-800'}"
>
{result.name}: {result.error ? `Failed: ${result.error}` : `Success: ${result.url}`}
</div>
{/each}
</div>
{/if}
</div>
<div class="flex items-center justify-end gap-2">
<Button onclick={uploadOneOrMany} disabled={!uploadFiles.length || uploading}>
{uploading ? `Uploading (${uploadProgress}%)` : 'Upload Selected Files'}
</Button>
</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>Working Hours</Card.Title>
<Card.Description>
View default hours for each weekday, and manage exceptions.
</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="font-semibold">Default Hours</h3>
<Button onclick={prepareDefaultHoursEdit} disabled={loadingHours || savingHours}>
Edit Defaults
</Button>
</div>
<div class="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 defaultHours as row, i}
<tr class="border-t">
<td class="py-2">{weekdayLabel(row.weekday)}</td>
<td class="py-2">
<span
class="text-sm font-medium {row.is_open ? 'text-green-600' : 'text-red-600'}"
>
{row.is_open ? 'Open' : 'Closed'}
</span>
</td>
<td class="py-2">
<input
class="rounded border bg-gray-100 px-2 py-1"
type="time"
value={row.start_time}
disabled
/>
</td>
<td class="py-2">
<input
class="rounded border bg-gray-100 px-2 py-1"
type="time"
value={row.end_time}
disabled
/>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<Separator />
<div class="flex items-center justify-between">
<div>
<h3 class="font-semibold">Exception Groups</h3>
<p class="text-sm text-gray-500">Temporary schedules such as 'Christmas' or 'Holiday'</p>
</div>
<Button variant="outline" onclick={createNewException}>New Exception Group</Button>
</div>
<div class="space-y-3">
{#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="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}
</div>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
onclick={() => {
editingExceptionIndex = exceptionGroups.indexOf(g);
editingException = JSON.parse(JSON.stringify(g));
showExceptionModal = true;
}}
>
Edit
</Button>
<Button variant="destructive" onclick={() => deleteExceptionGroup(g.id)}>
Delete
</Button>
</div>
</div>
</div>
{/each}
</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>Users & Bookings</Card.Title>
<Card.Description>
Search users or bookings. Click an item to view details and history.
</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
<div class="grid gap-4 md:grid-cols-2">
<div>
<Label>Search Users</Label>
<div class="flex gap-2">
<Input
placeholder="Name, email or phone"
bind:value={userQuery}
onkeyup={(e) => {
if ((e as KeyboardEvent).key === 'Enter') searchUsers();
}}
/>
<Button onclick={searchUsers} disabled={loadingSearch}>Search</Button>
</div>
<div class="mt-3 max-h-60 space-y-2 overflow-y-auto">
{#each users as u}
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
<div>
<div class="font-medium">{u.fn || `${u.n_first_name} ${u.n_last_name}`}</div>
<div class="text-xs text-gray-500">{u.email} • {u.phone}</div>
</div>
<Button variant="outline" onclick={() => openUserModal(u.id)}>View</Button>
</div>
{/each}
</div>
</div>
<div>
<Label>Search Bookings</Label>
<div class="flex gap-2">
<Input
placeholder="Booking ID or user"
bind:value={bookingQuery}
onkeyup={(e) => {
if ((e as KeyboardEvent).key === 'Enter') searchBookings();
}}
/>
<Button onclick={searchBookings} disabled={loadingSearch}>Search</Button>
</div>
<div class="mt-3 max-h-60 space-y-2 overflow-y-auto">
{#each bookings as b}
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
<div>
<div class="font-medium">{new Date(b.start_time).toLocaleString()}</div>
<div class="text-xs text-gray-500">
{b.status} • {b.services?.map((s) => s.name).join(', ')}
</div>
</div>
<Button variant="outline" onclick={() => openBookingModal(b.id)}>View</Button>
</div>
{/each}
</div>
</div>
</div>
</Card.Content>
</Card.Root>
</div>
<Modal.Root bind:open={showDefaultHoursModal}>
<Modal.Content>
<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="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">Open</th>
<th class="py-2">Start</th>
<th class="py-2">End</th>
</tr>
</thead>
<tbody>
{#each defaultHoursDraft 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="text-primary focus:ring-primary 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="max-w-24"
/>
</td>
<td class="py-2">
<Input
type="time"
bind:value={row.end_time}
disabled={!row.is_open}
class="max-w-24"
/>
</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={saveDefaultHours} disabled={savingHours}>
{savingHours ? 'Saving…' : 'Save Defaults'}
</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
<Modal.Root bind:open={showExceptionModal}>
<Modal.Content>
<Modal.Header>
<Modal.Title class="text-lg font-semibold">
{editingExceptionIndex === null ? 'New Exception Group' : 'Edit Exception Group'}
</Modal.Title>
</Modal.Header>
{#if editingException}
<div class="space-y-4 px-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="A short description of the exception"
/>
</div>
<div>
<Label>Week range to apply</Label>
<div class="flex gap-2">
<Input type="date" id="exception_from" />
<Input type="date" id="exception_to" />
<Button
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-24"
/>
<Input type="time" bind:value={r.end_time} disabled={!r.is_open} class="max-w-24" />
</div>
{/each}
</div>
</div>
</div>
{/if}
<Modal.Footer class="flex items-center justify-end gap-2">
<Button
variant="outline"
onclick={() => {
showExceptionModal = false;
editingException = null;
}}
>
Cancel
</Button>
<Button onclick={saveExceptionGroup} disabled={savingHours}>
{savingHours ? 'Saving…' : 'Save'}
</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
<Modal.Root bind:open={showUploadModal}>
<Modal.Content>
<Modal.Header>
<Modal.Title class="text-lg font-semibold">Upload Images</Modal.Title>
</Modal.Header>
<div class="px-4 pb-4">
<FileDropZone onfiles={handleFilesDropped} accept="image/*" multiple>
<div class="p-6 text-center">
<p class="text-sm text-gray-500">Drop files here, or click to open the file picker</p>
</div>
</FileDropZone>
<div class="mt-4">
<div class="text-sm text-gray-600">Selected files</div>
<div class="mt-2 space-y-2">
{#each uploadFiles as f}
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
<div>{f.name} • {Math.round(f.size / 1024)}KB</div>
<button
class="text-red-500"
onclick={() => (uploadFiles = uploadFiles.filter((x) => x !== f))}
>
Remove
</button>
</div>
{/each}
</div>
</div>
</div>
<Modal.Footer class="flex items-center justify-end gap-2">
<Button variant="outline" onclick={() => (showUploadModal = false)}>Close</Button>
<Button onclick={uploadOneOrMany} disabled={!uploadFiles.length || uploading}>
{uploading ? `Uploading (${uploadProgress}%)` : 'Upload'}
</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
{#if selectedUser}
<Modal.Root bind:open={showUserModal}>
<Modal.Content>
<Modal.Header>
<Modal.Title class="text-lg font-semibold">
User: {selectedUser.fn || `${selectedUser.n_first_name} ${selectedUser.n_last_name}`}
</Modal.Title>
</Modal.Header>
<div class="grid gap-4 px-4 pb-4 md:grid-cols-2">
<div>
<div class="text-sm text-gray-500">Email</div>
<div class="font-medium">{selectedUser.email}</div>
<div class="mt-2 text-sm text-gray-500">Phone</div>
<div class="font-medium">{selectedUser.phone}</div>
<div class="mt-2 text-sm text-gray-500">Joined</div>
<div class="font-medium">
{new Date(selectedUser.created_at || '').toLocaleString()}
</div>
</div>
<div>
<div class="text-sm text-gray-500">Profile</div>
{#if selectedUser.profile_pic_url}
<img src={selectedUser.profile_pic_url} alt="profile" class="max-h-32 rounded" />
{/if}
<div class="mt-3">
<div class="text-xs text-gray-500">Loyalty stamps</div>
<div class="font-medium">{selectedUser.loyalty_stamps ?? 0}</div>
</div>
</div>
</div>
<Separator class="my-3" />
<div class="px-4 pb-4">
<div class="mb-2 text-sm text-gray-600">Recent bookings</div>
{#if bookingUserHistory.length === 0}
<div class="text-sm text-gray-500">No recent bookings</div>
{/if}
<div class="space-y-2">
{#each bookingUserHistory as hb}
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
<div>
<div class="font-medium">{new Date(hb.start_time).toLocaleString()}</div>
<div class="text-xs text-gray-500">
{hb.status} • {hb.services?.map((s) => s.name).join(', ')}
</div>
</div>
<Button variant="outline" onclick={() => openBookingModal(hb.id)}>Open</Button>
</div>
{/each}
</div>
</div>
<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}
{#if selectedBooking}
<Modal.Root bind:open={showBookingModal}>
<Modal.Content>
<Modal.Header>
<Modal.Title class="text-lg font-semibold">Booking {selectedBooking.id}</Modal.Title>
</Modal.Header>
<div class="px-4 pb-4">
<div>
<div class="text-sm text-gray-500">Start</div>
<div class="font-medium">{new Date(selectedBooking.start_time).toLocaleString()}</div>
<div class="mt-2 text-sm text-gray-500">Status</div>
<div class="font-medium">{selectedBooking.status}</div>
<div class="mt-2 text-sm text-gray-500">Services</div>
<div class="font-medium">{selectedBooking.services?.map((s) => s.name).join(', ')}</div>
{#if selectedBooking.user_id}
<div class="mt-2 text-sm text-gray-500">Booked by</div>
<Button
variant="link"
class="h-auto p-0"
onclick={() => {
showBookingModal = false;
openUserModal(selectedBooking!.user_id!);
}}
>
{users.find((u) => u.id === selectedBooking!.user_id)?.fn || selectedBooking.user_id}
</Button>
{/if}
</div>
</div>
<Modal.Footer class="flex items-center justify-end gap-2">
<Button onclick={() => (showBookingModal = false)}>Close</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
{/if}