WIP admin page
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
@@ -10,7 +10,7 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
-- ENUMS
|
||||
-- =======================================
|
||||
|
||||
CREATE TYPE account_role AS ENUM ('unverified_email', 'verified_email', 'admin', 'guest');
|
||||
CREATE TYPE account_role AS ENUM ('unverified_email', 'verified_email', 'admin', 'guest', 'affiliate');
|
||||
CREATE TYPE account_type AS ENUM ('email', 'google', 'microsoft', 'facebook', 'guest');
|
||||
CREATE TYPE payment_type AS ENUM ('deposit', 'full', 'tip', 'balance', 'partial');
|
||||
CREATE TYPE payment_method AS ENUM ('online_square', 'in_person_card', 'cash', 'giftcard', 'discount');
|
||||
@@ -213,6 +213,11 @@ CREATE TABLE exceptional_working_hours (
|
||||
start_time TIME NOT NULL, -- e.g. 09:00
|
||||
end_time TIME NOT NULL, -- e.g. 17:00
|
||||
is_open BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
group_id INT NOT NULL REFERENCES exceptional_working_hours_groups(id) ON DELETE CASCADE,
|
||||
);
|
||||
|
||||
CREATE TABLE exceptional_working_hours_groups (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL, -- e.g. "Christmas"
|
||||
description TEXT NOT NULL -- e.g. "Extended hours for holiday period"
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user