Files
Crussell/frontend/src/routes/admin/+page._svelte
T
2026-01-19 22:32:35 +00:00

3405 lines
103 KiB
Plaintext
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { SvelteDate } from 'svelte/reactivity';
// 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 { 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';
import { browser } from '$app/environment';
import { toast } from 'svelte-sonner';
import { Skeleton } from '$lib/components/ui/skeleton';
// =============== Auth & Permissions ===============
let pageState = $state<'loading' | 'authorized' | 'unauthorized'>('loading');
// Check permissions immediately and on auth changes
$effect(() => {
if (!browser) return;
if (authStore.isLoading) {
pageState = 'loading';
return;
}
if (!authStore.isAuthenticated) {
pageState = 'unauthorized';
goto('/login', { replaceState: true });
return;
}
if (authStore.currentUser?.role !== 'admin') {
pageState = 'unauthorized';
goto('/', { replaceState: true });
return;
}
pageState = 'authorized';
});
// =============== 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;
}
/** Helper: turn any File into a JPEGencoded Blob. */
function toJpegBlob(file: File): Promise<Blob> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) return reject(new Error('2D context not available'));
ctx.drawImage(img, 0, 0);
canvas.toBlob(
(blob) => {
if (!blob) return reject(new Error('Canvas toBlob failed'));
resolve(blob);
},
'image/jpeg',
0.92
);
};
img.onerror = () => reject(new Error('Image load failed'));
img.src = URL.createObjectURL(file);
});
}
/** Resize to max 1500px on the *short* side, only scale down, never up. */
function resizeShortSide(blob: Blob): Promise<Blob> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
let { width, height } = img;
const maxShortSide = 1500;
// Only resize if image is larger than target
const shortSide = Math.min(width, height);
if (shortSide > maxShortSide) {
if (width < height) {
const scale = maxShortSide / width;
width = maxShortSide;
height = Math.round(height * scale);
} else {
const scale = maxShortSide / height;
height = maxShortSide;
width = Math.round(width * scale);
}
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) return reject(new Error('2D context not available'));
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob(
(blob) => {
if (!blob) return reject(new Error('Canvas toBlob failed'));
resolve(blob);
},
'image/jpeg',
0.92
);
};
img.onerror = () => reject(new Error('Image load failed'));
img.src = URL.createObjectURL(blob);
});
}
/** Create a 250×250 thumbnail (square, centercropped). */
function createThumbnail(blob: Blob): Promise<Blob> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const thumbSize = 250;
const { width, height } = img;
// Scale up *or* down so that the image covers 250×250
const scale = Math.max(thumbSize / width, thumbSize / height);
const scaledW = Math.round(width * scale);
const scaledH = Math.round(height * scale);
const canvas = document.createElement('canvas');
canvas.width = thumbSize;
canvas.height = thumbSize;
const ctx = canvas.getContext('2d');
if (!ctx) return reject(new Error('2D context not available'));
// Draw the scaled image, then crop the center 250×250
ctx.drawImage(
img,
(scaledW - thumbSize) / -2, // offset to center
(scaledH - thumbSize) / -2,
scaledW,
scaledH,
0,
0,
thumbSize,
thumbSize
);
canvas.toBlob(
(blob) => {
if (!blob) return reject(new Error('Canvas toBlob failed'));
resolve(blob);
},
'image/jpeg',
0.92
);
};
img.onerror = () => reject(new Error('Image load failed'));
img.src = URL.createObjectURL(blob);
});
}
const knownTags = [
'portfolio',
'gel',
'acrylic',
'french',
'ombre',
'summer',
'wedding',
'holiday',
'pink',
'red',
'style:french',
'style:minimal',
'colour:red',
'colour:pink',
'season:summer'
];
let tags = $state<string[]>([]);
let input = $state('');
const suggestions = $derived.by(() => {
const q = input.trim().toLowerCase();
if (!q) return [];
return knownTags
.map((t) => t.toLowerCase())
.filter((t) => t.startsWith(q) && !tags.includes(t))
.slice(0, 6);
});
function handleTagInput(e: Event) {
const value = (e.target as HTMLInputElement).value;
if (value.includes(',')) {
addTag(value);
input = '';
}
}
function isSemantic(tag: string) {
return tag.includes(':');
}
function addTag(raw: string) {
raw.split(',').forEach((p) => {
const t = p.trim().toLowerCase();
if (t && !tags.includes(t)) tags = [...tags, t];
});
}
function handleKey(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
addTag(input);
input = '';
return;
}
if (e.key === 'Backspace' && !input && tags.length) {
tags = tags.slice(0, -1);
}
}
function selectSuggestion(tag: string) {
addTag(tag);
input = '';
}
function removeTag(tag: string) {
tags = tags.filter((t) => t !== tag);
}
/** Core upload function now processes the images before sending. */
async function uploadOneOrMany() {
if (!uploadFiles.length) return;
uploading = true;
uploadResults = [];
uploadProgress = 0;
const startTs = Date.now(); // timestamp of button click
for (let i = 0; i < uploadFiles.length; i++) {
const file = uploadFiles[i];
const fd = new FormData();
try {
/* -------- 1. Turn whatever the user gave us into JPEG ------- */
const jpegBlob = await toJpegBlob(file);
/* -------- 2. Create the two processed versions ------------- */
const resizedBlob = await resizeShortSide(jpegBlob);
const thumbBlob = await createThumbnail(jpegBlob);
/* -------- 3. Generate filenames -------------------------------- */
const ts = startTs - i; // 1ms decrement per file
const baseName = `${ts}.jpg`;
const thumbName = `${ts}_thumb.jpg`;
/* -------- 4. Attach to FormData -------------------------------- */
fd.append('file', resizedBlob, baseName); // this will be the "original"
fd.append('file', thumbBlob, thumbName); // the thumbnail
/* -------- 5. Mock the API call --------------------------------- */
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 {
// In a real app you would `await fetch('/api/upload', {method:'POST', body:fd})`
uploadResults.push({
name: file.name,
url: `/images/${baseName}` // pretend this is the returned URL
});
}
} catch (err: unknown) {
uploadResults.push({
name: file.name,
error: err instanceof Error ? err.message : 'Unknown error'
});
}
uploadProgress = Math.round(((i + 1) / uploadFiles.length) * 100);
}
uploading = false;
uploadFiles = [];
}
// =============== Working Hours ===============
type WorkingHourRow = {
weekday: number;
start_time: string;
end_time: string;
is_open: boolean;
};
let defaultHours = $state<WorkingHourRow[]>([]);
let defaultHoursIsLoading = $state(true);
/** Format time from HH:MM:SS to 12-hour format, with "Noon" for 12:00 PM */
function formatTime(time: string): string {
const [hours, minutes] = time.split(':').map(Number);
// Special case for 12:00
if (hours === 12 && minutes === 0) {
return 'Noon';
} else if (hours === 0 && minutes === 0) {
return 'Midnight';
}
const period = hours >= 12 ? 'PM' : 'AM';
const displayHours = hours % 12 || 12;
return `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`;
}
/** Convert formatted time back to HH:MM for input fields */
function timeToInputValue(time: string): string {
// Handle special cases
if (time === 'Noon') return '12:00';
if (time === 'Midnight') return '00:00';
// Parse 12-hour format
const match = time.match(/^(\d{1,2}):(\d{2})\s*(AM|PM)$/i);
if (!match) return time; // Return as-is if not in expected format
let hours = parseInt(match[1]);
const minutes = match[2];
const period = match[3].toUpperCase();
if (period === 'PM' && hours !== 12) hours += 12;
if (period === 'AM' && hours === 12) hours = 0;
return `${hours.toString().padStart(2, '0')}:${minutes}`;
}
async function fetchDefaultHours() {
if (pageState !== 'authorized') return;
defaultHoursIsLoading = true;
let error = null;
try {
const response = await fetch('/api/scheduling/default-hours', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (response.ok) {
const data = await response.json();
defaultHours = data.map((hour) => ({
weekday: hour.weekday,
start_time: formatTime(hour.startTime),
end_time: formatTime(hour.endTime),
is_open: hour.isOpen
}));
} else {
const text = await response.text();
error = 'Failed to load working hours: ' + text;
console.error('Error fetching default hours:', text);
}
} catch (err) {
error = 'Network error: ' + (err instanceof Error ? err.message : 'Unknown error');
console.error('Error fetching default hours:', err);
} finally {
if (error) toast.error(error);
defaultHoursIsLoading = false;
}
}
$effect(() => {
if (pageState === 'authorized') {
fetchDefaultHours();
}
});
type ExceptionGroup = {
id?: number;
name: string;
description: string;
weekStarts: string[];
hours: WorkingHourRow[];
};
// Replace the demo data with empty array and add loading state
let exceptionGroups = $state<ExceptionGroup[]>([]);
let exceptionGroupsLoading = $state(true);
// Add state for the exception modal
let exceptionDraft = $state<ExceptionGroup>({
name: '',
description: '',
weekStarts: [],
hours: [
{ weekday: 0, start_time: '00:00', end_time: '00:00', is_open: false },
{ weekday: 1, start_time: '09:00', end_time: '17:00', is_open: true },
{ 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: '12:00', end_time: '20:00', is_open: true },
{ weekday: 5, start_time: '09:00', end_time: '17:00', is_open: true },
{ weekday: 6, start_time: '00:00', end_time: '00:00', is_open: false }
]
});
let weekRangeFrom = $state('');
let weekRangeTo = $state('');
async function fetchExceptionGroups() {
if (pageState !== 'authorized') return;
exceptionGroupsLoading = true;
try {
const response = await fetch('/api/scheduling/exceptional-groups', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (response.ok) {
const data = await response.json();
if (data === null || data.length === 0) {
return;
}
exceptionGroups = data.map((group) => ({
id: group.id,
name: group.name,
description: group.description,
weekStarts: group.weekStarts || [],
hours:
group.hours?.map((h) => ({
id: h.id,
weekday: h.weekday,
start_time: formatTime(h.startTime),
end_time: formatTime(h.endTime),
is_open: h.isOpen
})) || []
}));
} else {
console.error('Failed to fetch exception groups:', response.status);
toast.error('Failed to load exception groups');
}
} catch (err) {
console.error('Error fetching exception groups:', err);
toast.error('Network error loading exception groups');
} finally {
exceptionGroupsLoading = false;
}
}
// Fetch on mount
$effect(() => {
if (pageState === 'authorized') {
fetchExceptionGroups();
}
});
let loadingHours = $state(false);
let savingHours = $state(false);
let defaultHoursDraft = $state<WorkingHourRow[]>([]);
let showDefaultHoursModal = $state(false);
let showExceptionModal = $state(false);
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));
// Convert display format back to input format
defaultHoursDraft = defaultHoursDraft.map((row) => ({
...row,
start_time: timeToInputValue(row.start_time),
end_time: timeToInputValue(row.end_time)
}));
showDefaultHoursModal = true;
}
/** Saves the default hours draft after confirmation. */
async function confirmSaveDefaultHours() {
savingHours = true;
const loadingToast = toast.loading('Saving default hours...');
try {
// Map snake_case to camelCase for API
const payload = defaultHoursDraft.map((hour) => ({
weekday: hour.weekday,
startTime: hour.start_time,
endTime: hour.end_time,
isOpen: hour.is_open
}));
const response = await fetch('/api/scheduling/default-hours', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify(payload)
});
if (response.ok) {
// Update the main state from the draft state if successful
defaultHours = JSON.parse(JSON.stringify(defaultHoursDraft));
showDefaultHoursModal = false;
showSaveDefaultHoursAlert = false;
toast.success('Default hours saved successfully!', { id: loadingToast });
} else if (response.status === 401 || response.status === 403) {
toast.error('Unauthorized. Please log in again.', { id: loadingToast });
} else {
const text = await response.text();
toast.error('Failed to save: ' + text, { id: loadingToast });
}
} catch (err) {
console.error('save default hours', err);
toast.error('Network error saving hours', { id: loadingToast });
} finally {
savingHours = false;
}
}
async function saveExceptionGroup() {
// Validate
if (!exceptionDraft.name.trim()) {
toast.error('Please enter a group name');
return;
}
if (exceptionDraft.weekStarts.length === 0) {
toast.error('Please add at least one week');
return;
}
savingHours = true;
const loadingToast = toast.loading('Creating exception group...');
try {
// Map to API format
const payload = {
name: exceptionDraft.name,
description: exceptionDraft.description,
weekStarts: exceptionDraft.weekStarts,
hours: exceptionDraft.hours.map((h) => ({
weekday: h.weekday,
startTime: h.start_time,
endTime: h.end_time,
isOpen: h.is_open
}))
};
const response = await fetch('/api/scheduling/exceptional-groups', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify(payload)
});
if (response.ok) {
toast.success('Exception group created successfully!', { id: loadingToast });
showExceptionModal = false;
resetExceptionForm();
await fetchExceptionGroups();
} else {
const text = await response.text();
toast.error('Failed to create: ' + text, { id: loadingToast });
}
} catch (err) {
console.error('Error creating exception group:', err);
toast.error('Network error creating exception group', { id: loadingToast });
} finally {
savingHours = false;
}
}
async function confirmDeleteExceptionGroup() {
if (exceptionToDelete === undefined) return;
const loadingToast = toast.loading('Deleting exception group...');
try {
const response = await fetch(`/api/scheduling/exceptional-groups?id=${exceptionToDelete}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (response.ok || response.status === 204) {
toast.success('Exception group deleted successfully!', { id: loadingToast });
showDeleteExceptionAlert = false;
exceptionToDelete = undefined;
// Refresh the exception groups list
await fetchExceptionGroups();
} else {
const text = await response.text();
toast.error('Failed to delete: ' + text, { id: loadingToast });
}
} catch (err) {
console.error('Error deleting exception group:', err);
toast.error('Network error deleting exception group', { id: loadingToast });
}
}
function openViewExceptionModal(exception: ExceptionGroup) {
viewingException = exception;
showViewExceptionModal = true;
}
function resetExceptionForm() {
exceptionDraft = {
name: '',
description: '',
weekStarts: [],
hours: [
{ weekday: 0, start_time: '00:00', end_time: '00:00', is_open: false },
{ weekday: 1, start_time: '09:00', end_time: '17:00', is_open: true },
{ 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: '12:00', end_time: '20:00', is_open: true },
{ weekday: 5, start_time: '09:00', end_time: '17:00', is_open: true },
{ weekday: 6, start_time: '00:00', end_time: '00:00', is_open: false }
]
};
weekRangeFrom = '';
weekRangeTo = '';
}
function createNewException() {
resetExceptionForm();
showExceptionModal = true;
}
function addWeekRange() {
if (!weekRangeFrom || !weekRangeTo) {
toast.error('Please select both start and end dates');
return;
}
addWeeksToException(weekRangeFrom, weekRangeTo, exceptionDraft.weekStarts);
weekRangeFrom = '';
weekRangeTo = '';
}
function removeWeek(index: number) {
exceptionDraft.weekStarts = exceptionDraft.weekStarts.filter((_, i) => i !== index);
}
// =============== 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;
};
let userQuery = $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
}
]);
type Booking = {
id: string;
start_time: string; // ISO 8601
status:
| 'pending'
| 'confirmed'
| 'in_progress'
| 'completed'
| 'client_cancelled'
| 'we_cancelled'
| 're-schedule'
| 'no_show';
notes?: string;
created_at: string; // ISO 8601
updated_at: string; // ISO 8601
created_by?: string;
// Nested user object
user?: {
id: string;
first_name: string;
last_name: string;
full_name: string;
email?: string;
phone?: string;
profile_pic_url?: string;
date_of_birth?: string;
account_role: string;
loyalty_stamps?: number;
referral_code?: string;
referral_code_uses?: number;
created_at: string;
notes?: string;
};
// Services array - always present (backend ensures this)
services: Array<{
booking_id: string;
service_id: string;
override_price?: number;
override_duration_minutes?: number;
service_name?: string;
service_description?: string;
price?: number;
duration_minutes?: number;
}>;
// Payments array
payments: Array<{
id: string;
booking_id: string;
payment_type: 'deposit' | 'full' | 'tip' | 'balance' | 'partial';
payment_method: 'online_square' | 'in_person_card' | 'cash' | 'giftcard' | 'discount';
vendor_code?: string;
invoice_number?: number;
status: 'pending' | 'completed' | 'failed' | 'refunded';
amount: number;
is_vat_applicable: boolean;
vat_rate?: number;
vat_amount?: number;
net_amount?: number;
created_at: string;
updated_at: string;
created_by?: string;
}>;
// Computed/derived fields
total_amount: number;
amount_paid: number;
amount_due: number;
duration_minutes: number;
};
let bookings = $state<Booking[]>([]);
let bookingQuery = $state('');
let loadingSearch = $state(false);
let selectedBooking = $state<Booking | null>(null);
let showBookingModal = $state(false);
// Fetch bookings from API
async function fetchBookings() {
if (pageState !== 'authorized') return;
loadingSearch = true;
try {
const response = await fetch('/api/admin/bookings', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (response.ok) {
const data = await response.json();
if (data.bookings && data.bookings.length === 0) {
bookings = [];
return;
}
// Map the response correctly - the backend returns the full Booking objects
bookings = data.bookings.map((b) => ({
id: b.id,
start_time: b.start_time,
status: b.status,
notes: b.notes,
created_at: b.created_at,
updated_at: b.updated_at,
created_by: b.created_by,
// User info is nested under user object
user: b.user
? {
id: b.user.id,
full_name: b.user.full_name
// Add other user fields if needed
}
: undefined,
// Services array should be present (even if empty)
services: b.services || [],
// Other computed fields from backend
total_amount: b.total_amount || 0,
amount_paid: b.amount_paid || 0,
amount_due: b.amount_due || 0,
duration_minutes: b.duration_minutes || 0
}));
} else {
const text = await response.text();
toast.error('Failed to load bookings: ' + text);
}
} catch (err) {
console.error('Error fetching bookings:', err);
toast.error('Network error loading bookings');
} finally {
loadingSearch = false;
}
}
// Search bookings via API
async function searchBookings() {
if (pageState !== 'authorized') return;
loadingSearch = true;
// If no search query, use the regular get-all endpoint
if (!bookingQuery.trim()) {
await fetchBookings();
loadingSearch = false;
return;
}
try {
const response = await fetch(
`/api/admin/bookings/search?q=${encodeURIComponent(bookingQuery)}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
}
);
if (response.ok) {
const data = await response.json();
// Map the search response correctly (same structure as fetchBookings)
bookings = data.bookings.map((b) => ({
id: b.id,
start_time: b.start_time,
status: b.status,
notes: b.notes,
created_at: b.created_at,
updated_at: b.updated_at,
created_by: b.created_by,
user: b.user
? {
id: b.user.id,
full_name: b.user.full_name
}
: undefined,
services: b.services || [],
total_amount: b.total_amount || 0,
amount_paid: b.amount_paid || 0,
amount_due: b.amount_due || 0,
duration_minutes: b.duration_minutes || 0
}));
} else {
const text = await response.text();
toast.error('Failed to search bookings: ' + text);
}
} catch (err) {
console.error('Error searching bookings:', err);
toast.error('Network error searching bookings');
} finally {
loadingSearch = false;
}
}
// Open booking modal with full details
async function openBookingModal(bookingId: string) {
if (pageState !== 'authorized') return;
try {
const response = await fetch(`/api/admin/bookings/${bookingId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (response.ok) {
const data = await response.json();
selectedBooking = {
id: data.id,
start_time: data.start_time,
status: data.status,
notes: data.notes,
user: data.user
? {
id: data.user.id,
first_name: data.user.first_name,
last_name: data.user.last_name,
full_name: data.user.full_name,
email: data.user.email,
phone: data.user.phone,
profile_pic_url: data.user.profile_pic_url,
date_of_birth: data.user.date_of_birth,
account_role: data.user.account_role,
loyalty_stamps: data.user.loyalty_stamps,
referral_code: data.user.referral_code,
referral_code_uses: data.user.referral_code_uses,
created_at: data.user.created_at,
notes: data.user.notes
}
: undefined,
services: (data.services || []).map((s) => ({
booking_id: s.booking_id,
service_id: s.service_id,
service_name: s.service_name,
service_description: s.service_description,
price: s.price,
duration_minutes: s.duration_minutes
})),
payments: (data.payments || []).map((p) => ({
id: p.id,
booking_id: p.booking_id,
payment_type: p.payment_type,
payment_method: p.payment_method,
vendor_code: p.vendor_code,
invoice_number: p.invoice_number,
status: p.status,
amount: p.amount,
is_vat_applicable: p.is_vat_applicable,
vat_rate: p.vat_rate,
vat_amount: p.vat_amount,
net_amount: p.net_amount,
created_at: p.created_at,
updated_at: p.updated_at,
created_by: p.created_by
})),
total_amount: data.total_amount || 0,
amount_paid: data.amount_paid || 0,
amount_due: data.amount_due || 0,
duration_minutes: data.duration_minutes || 0,
created_at: data.created_at,
updated_at: data.updated_at,
created_by: data.created_by
};
showBookingModal = true;
} else {
const text = await response.text();
toast.error('Failed to load booking details: ' + text);
}
} catch (err) {
console.error('Error fetching booking details:', err);
toast.error('Network error loading booking details');
}
}
// Fetch bookings on page load
$effect(() => {
if (pageState === 'authorized') {
fetchBookings();
}
});
let selectedUser = $state<User | null>(null);
let bookingUserHistory = $state<Booking[]>([]);
let showUserModal = $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;
}
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 SvelteDate(b.created_at).getTime() - new SvelteDate(a.created_at).getTime()
);
showUserModal = 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 SvelteDate(fromISO + 'T00:00:00');
const to = new SvelteDate(toISO + 'T00:00:00');
const first = new SvelteDate(from);
const day = first.getDay();
const daysToMonday = day === 0 ? -6 : 1 - day;
// Set to the Monday of the current week
first.setDate(first.getDate() + daysToMonday);
// Add all Mondays in the range
for (let d = new SvelteDate(first); d <= to; d.setDate(d.getDate() + 7)) {
dest.push(isoDateOf(new SvelteDate(d)));
}
}
// =============== Services Management ===============
type Service = {
id: string;
name: string;
description: string;
price: number;
duration_minutes: number;
is_active: boolean;
patch_test_duration_hours: number;
minimum_age_required: number;
created_at: string;
updated_at?: string;
created_by?: string;
updated_by?: string;
};
let services = $state<Service[]>([]);
let servicesLoading = $state(true);
let servicesUpdating = $state<Record<string, boolean>>({});
// Fetch services from API
async function fetchServices() {
if (pageState !== 'authorized') return;
servicesLoading = true;
try {
const response = await fetch('/api/admin/services', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (response.ok) {
const data = await response.json();
// Ensure each service has an ID
services = data.filter((s: Service) => s.id);
if (data.length !== services.length) {
console.warn('Some services missing IDs were filtered out');
}
} else {
console.error('Failed to fetch services:', response.status);
toast.error('Failed to load services');
}
} catch (err) {
console.error('Error fetching services:', err);
toast.error('Network error loading services');
} finally {
servicesLoading = false;
}
}
// Toggle service active status
async function toggleService(serviceId: string) {
servicesUpdating[serviceId] = true;
try {
const response = await fetch(`/api/admin/services/${serviceId}/toggle`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (response.ok) {
toast.success('Service status updated');
// Refresh the services list
await fetchServices();
} else {
const errorText = await response.text();
toast.error(`Failed to update service: ${errorText}`);
}
} catch (err) {
console.error('Error toggling service:', err);
toast.error('Network error updating service');
} finally {
servicesUpdating[serviceId] = false;
}
}
// Delete service
async function deleteService(serviceId: string) {
if (!confirm('Are you sure you want to delete this service? This action cannot be undone.')) {
return;
}
servicesUpdating[serviceId] = true;
try {
const response = await fetch(`/api/admin/services/${serviceId}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (response.ok) {
toast.success('Service deleted successfully');
// Refresh the services list
await fetchServices();
} else {
const errorText = await response.text();
toast.error(`Failed to delete service: ${errorText}`);
}
} catch (err) {
console.error('Error deleting service:', err);
toast.error('Network error deleting service');
} finally {
servicesUpdating[serviceId] = false;
}
}
// =============== Service Creation ===============
let showServiceModal = $state(false);
let creatingService = $state(false);
let newService = $state({
name: '',
description: '',
price: '',
duration_minutes: 60,
patch_test_duration_hours: 0,
minimum_age_required: 0
});
let serviceErrors = $state({
name: '',
price: '',
duration_minutes: '',
patch_test_duration_hours: '',
minimum_age_required: ''
});
// Correct $derived syntax
let isFormValid = $derived(
newService.name.trim() !== '' &&
/^\d+(\.\d{1,2})?$/.test(newService.price) &&
parseFloat(newService.price) > 0 &&
Number.isInteger(newService.duration_minutes) &&
newService.duration_minutes > 0 &&
Number.isInteger(newService.patch_test_duration_hours) &&
newService.patch_test_duration_hours >= 0 &&
Number.isInteger(newService.minimum_age_required) &&
newService.minimum_age_required >= 0 &&
newService.minimum_age_required <= 100
);
// Validation functions (unchanged)
function validatePrice(price: string): string {
// First check if it's a valid number format (allows only digits and one decimal point)
const validFormat = /^\d*\.?\d*$/.test(price);
if (!validFormat) {
return 'Price must be a valid number (e.g., 4.50)';
}
const numPrice = parseFloat(price);
if (isNaN(numPrice)) {
return 'Price must be a valid number';
}
if (numPrice <= 0) {
return 'Price must be greater than 0';
}
// Check for exactly 0-2 decimal places
const decimalRegex = /^\d+(\.\d{1,2})?$/;
if (!decimalRegex.test(price)) {
return 'Price can have up to 2 decimal places';
}
return '';
}
function validateDuration(value: number, field: string): string {
if (isNaN(value)) {
return 'Must be a valid number';
}
if (!Number.isInteger(value)) {
return 'Must be a whole number';
}
if (field === 'duration_minutes' && value <= 0) {
return 'Duration must be greater than 0';
}
if (field === 'patch_test_duration_hours' && value < 0) {
return 'Cannot be negative';
}
if (field === 'minimum_age_required' && (value < 0 || value > 100)) {
return 'Must be between 0 and 100';
}
return '';
}
function validateName(name: string): string {
if (!name.trim()) {
return 'Service name is required';
}
return '';
}
// Update all errors at once
function updateAllErrors() {
serviceErrors = {
name: validateName(newService.name),
price: validatePrice(newService.price),
duration_minutes: validateDuration(newService.duration_minutes, 'duration_minutes'),
patch_test_duration_hours: validateDuration(
newService.patch_test_duration_hours,
'patch_test_duration_hours'
),
minimum_age_required: validateDuration(
newService.minimum_age_required,
'minimum_age_required'
)
};
}
// Individual field validation functions
function validateNameField() {
serviceErrors.name = validateName(newService.name);
}
function validatePriceField() {
serviceErrors.price = validatePrice(newService.price);
}
function validateDurationField() {
serviceErrors.duration_minutes = validateDuration(
newService.duration_minutes,
'duration_minutes'
);
}
function validatePatchTestField() {
serviceErrors.patch_test_duration_hours = validateDuration(
newService.patch_test_duration_hours,
'patch_test_duration_hours'
);
}
function validateMinimumAgeField() {
serviceErrors.minimum_age_required = validateDuration(
newService.minimum_age_required,
'minimum_age_required'
);
}
// Create new service
async function createService() {
// Update all errors before final validation
updateAllErrors();
// Check if any errors exist
const hasErrors = Object.values(serviceErrors).some((error) => error !== '');
if (hasErrors) {
toast.error('Please fix the validation errors before submitting');
return;
}
// Additional safety check with the derived property
if (!isFormValid) {
toast.error('Form validation failed');
return;
}
creatingService = true;
const loadingToast = toast.loading('Creating service...');
try {
const payload = {
name: newService.name.trim(),
description: newService.description.trim() || undefined,
price: parseFloat(newService.price),
duration_minutes: newService.duration_minutes,
patch_test_duration_hours: newService.patch_test_duration_hours,
minimum_age_required: newService.minimum_age_required
};
const response = await fetch('/api/admin/services', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify(payload)
});
if (response.ok) {
await response.json();
toast.success('Service created successfully!', { id: loadingToast });
// Reset form and close modal
resetServiceForm();
showServiceModal = false;
// Refresh services list
await fetchServices();
} else if (response.status === 409) {
toast.error('A service with this name already exists', { id: loadingToast });
} else if (response.status === 400) {
const errorText = await response.text();
toast.error(`Validation error: ${errorText}`, { id: loadingToast });
} else {
const errorText = await response.text();
toast.error(`Failed to create service: ${errorText}`, { id: loadingToast });
}
} catch (err) {
console.error('Error creating service:', err);
toast.error('Network error creating service', { id: loadingToast });
} finally {
creatingService = false;
}
}
function resetServiceForm() {
newService = {
name: '',
description: '',
price: '',
duration_minutes: 60,
patch_test_duration_hours: 0,
minimum_age_required: 0
};
serviceErrors = {
name: '',
price: '',
duration_minutes: '',
patch_test_duration_hours: '',
minimum_age_required: ''
};
}
function openServiceModal() {
resetServiceForm();
showServiceModal = true;
}
/** Calculate hours between start and end time */
function calculateHours(startTime: string, endTime: string): string {
// Convert formatted times to 24-hour format for calculation
const start = timeToInputValue(startTime);
const end = timeToInputValue(endTime);
const [startHours, startMinutes] = start.split(':').map(Number);
const [endHours, endMinutes] = end.split(':').map(Number);
const startTotalMinutes = startHours * 60 + startMinutes;
const endTotalMinutes = endHours * 60 + endMinutes;
const diffMinutes = endTotalMinutes - startTotalMinutes;
const hours = Math.floor(diffMinutes / 60);
const minutes = diffMinutes % 60;
if (minutes === 0) {
return `${hours}`;
}
return `${hours}.${minutes === 30 ? '5' : Math.round((minutes / 60) * 10)}`;
}
// Fetch services on component mount
$effect(() => {
if (pageState === 'authorized') {
fetchServices();
}
});
// =============== Alert Dialog State ===============
let showSaveDefaultHoursAlert = $state(false);
let showDeleteExceptionAlert = $state(false);
let exceptionToDelete = $state<number | undefined>(undefined);
let showViewExceptionModal = $state(false);
let viewingException = $state<ExceptionGroup | null>(null);
</script>
{#if pageState === 'loading'}
<!-- Full page skeleton loading -->
<div class="mx-auto max-w-6xl space-y-6 p-6">
<!-- Header Skeleton -->
<div class="mb-8 flex items-center justify-center text-center">
<div class="space-y-2">
<Skeleton class="h-8 w-64" />
<Skeleton class="h-4 w-96" />
</div>
</div>
<!-- Image Upload Card Skeleton -->
<Card.Root>
<Card.Header>
<Skeleton class="h-6 w-32" />
<Skeleton class="h-4 w-48" />
</Card.Header>
<Card.Content class="space-y-4">
<Skeleton class="h-32 w-full" />
<div class="flex justify-end">
<Skeleton class="h-10 w-40" />
</div>
</Card.Content>
</Card.Root>
<!-- Users & Bookings Grid Skeleton -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Users Card Skeleton -->
<Card.Root>
<Card.Header>
<Skeleton class="h-6 w-20" />
<Skeleton class="h-4 w-40" />
</Card.Header>
<Card.Content class="space-y-4">
<div class="flex gap-2">
<Skeleton class="h-10 flex-1" />
<Skeleton class="h-10 w-20" />
</div>
<div class="space-y-2">
{#each Array(3) as _, i (i)}
<Skeleton class="h-16 w-full" />
{/each}
</div>
</Card.Content>
</Card.Root>
<!-- Bookings Card Skeleton -->
<Card.Root>
<Card.Header>
<Skeleton class="h-6 w-24" />
<Skeleton class="h-4 w-40" />
</Card.Header>
<Card.Content class="space-y-4">
<div class="flex gap-2">
<Skeleton class="h-10 flex-1" />
<Skeleton class="h-10 w-20" />
</div>
<div class="space-y-2">
{#each Array(3) as _, i (i)}
<Skeleton class="h-16 w-full" />
{/each}
</div>
</Card.Content>
</Card.Root>
</div>
<!-- Holiday Hours Card Skeleton -->
<Card.Root>
<Card.Header>
<Skeleton class="h-6 w-32" />
<Skeleton class="h-4 w-64" />
</Card.Header>
<Card.Content class="space-y-4">
<div class="flex justify-between">
<Skeleton class="h-10 w-32" />
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
{#each Array(2) as _, i (i)}
<Skeleton class="h-32 w-full" />
{/each}
</div>
</Card.Content>
</Card.Root>
<!-- Working Hours Card Skeleton -->
<Card.Root>
<Card.Header>
<Skeleton class="h-6 w-32" />
<Skeleton class="h-4 w-64" />
</Card.Header>
<Card.Content class="space-y-4">
<div class="flex justify-between">
<Skeleton class="h-6 w-40" />
<Skeleton class="h-10 w-24" />
</div>
<div class="w-full overflow-x-auto">
<table class="w-full table-auto">
<thead>
<tr class="text-left text-xs text-gray-500">
<th class="py-2"><Skeleton class="h-4 w-12" /></th>
<th class="py-2"><Skeleton class="h-4 w-16" /></th>
<th class="py-2"><Skeleton class="h-4 w-16" /></th>
<th class="py-2"><Skeleton class="h-4 w-16" /></th>
</tr>
</thead>
<tbody>
{#each Array(7) as _, i (i)}
<tr class="border-t">
<td class="py-2"><Skeleton class="h-4 w-20" /></td>
<td class="py-2"><Skeleton class="h-4 w-12" /></td>
<td class="py-2"><Skeleton class="h-4 w-12" /></td>
<td class="py-2"><Skeleton class="h-4 w-12" /></td>
</tr>
{/each}
</tbody>
</table>
</div>
</Card.Content>
</Card.Root>
<!-- Services Management Card Skeleton -->
<Card.Root>
<Card.Header>
<Skeleton class="h-6 w-40" />
<Skeleton class="h-4 w-80" />
</Card.Header>
<Card.Content class="space-y-4">
<div class="flex justify-between">
<Skeleton class="h-10 w-32" />
</div>
<div class="hidden w-full overflow-x-auto md:block">
<table class="w-full table-auto border-collapse text-sm">
<thead>
<tr class="border-b text-left text-xs text-gray-500">
<th class="py-3"><Skeleton class="h-4 w-20" /></th>
<th class="py-3"><Skeleton class="h-4 w-32" /></th>
<th class="py-3"><Skeleton class="h-4 w-16" /></th>
<th class="py-3"><Skeleton class="h-4 w-20" /></th>
<th class="py-3"><Skeleton class="h-4 w-16" /></th>
<th class="py-3"><Skeleton class="h-4 w-24" /></th>
</tr>
</thead>
<tbody>
{#each Array(3) as _, i (i)}
<tr class="border-b">
<td class="py-3"><Skeleton class="h-4 w-32" /></td>
<td class="py-3"><Skeleton class="h-4 w-48" /></td>
<td class="py-3"><Skeleton class="h-4 w-16" /></td>
<td class="py-3"><Skeleton class="h-4 w-20" /></td>
<td class="py-3"><Skeleton class="h-4 w-16" /></td>
<td class="py-3">
<div class="flex justify-center gap-2">
<Skeleton class="h-8 w-16" />
<Skeleton class="h-8 w-16" />
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</Card.Content>
</Card.Root>
</div>
{:else if pageState === 'authorized'}
<div class="mx-auto max-w-6xl space-y-6 p-6">
<div class="mb-4 flex items-center justify-center text-center">
<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>
<div class="flex items-center justify-between">
<div>
<Card.Title>Image Upload</Card.Title>
<Card.Description>Upload images for the portfolio or other uses.</Card.Description>
</div>
<div class="hidden rounded-lg p-2 md:block">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
</div>
</div>
</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 (f.name)}
<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 (result.url || result.name)}
<div
class="rounded p-2 text-xs {result.error
? 'bg-red-100 text-red-800'
: 'bg-emerald-100 text-emerald-800'}"
>
{result.name}: {result.error
? `Failed: ${result.error}`
: `Success: ${result.url}`}
</div>
{/each}
</div>
{/if}
</div>
<div class="space-y-1">
<label class="text-sm font-medium text-gray-700">Tags</label>
<div class="relative">
<div
class="flex min-h-[38px] w-full flex-wrap gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:outline-none"
>
{#each tags as tag (tag)}
<span
class="flex items-center gap-1 rounded-full px-2 py-0.5 text-xs
{isSemantic(tag) ? 'bg-indigo-100 text-indigo-800' : 'bg-emerald-100 text-emerald-800'}"
>
{tag}
<button
type="button"
class="ml-1 leading-none
{isSemantic(tag)
? 'text-indigo-700 hover:text-indigo-900'
: 'text-emerald-700 hover:text-emerald-900'}"
onmousedown={(e) => {
e.preventDefault();
removeTag(tag);
}}
aria-label={`Remove ${tag}`}
>
×
</button>
</span>
{/each}
<input
class="min-w-[120px] flex-1 border-none bg-transparent text-sm outline-none placeholder:text-muted-foreground"
bind:value={input}
onkeydown={handleKey}
oninput={handleTagInput}
placeholder={tags.length ? '' : 'Add tags…'}
/>
</div>
{#if input.length && suggestions.length}
<div class="absolute right-0 left-0 z-10 mt-1 rounded-md border bg-white shadow">
{#each suggestions as s (s)}
<div
class="cursor-pointer px-3 py-2 text-sm hover:bg-gray-100"
onmousedown={(e) => {
e.preventDefault();
selectSuggestion(s);
}}
>
{s}
</div>
{/each}
</div>
{/if}
</div>
<p class="text-xs text-gray-500">
Add searchable tags here such as <code>`scooby doo`</code> or filterable categories like
<code>`style:french`</code>
or
<code>`colour:green`</code>
</p>
</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>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card.Root class="h-full">
<Card.Header>
<div class="flex items-start justify-between">
<div>
<Card.Title class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
Users
</Card.Title>
<Card.Description>Search and manage user details.</Card.Description>
</div>
<div class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium">
<span class="text-xs font-semibold">{users.length}</span>
</div>
</div>
</Card.Header>
<Card.Content class="space-y-4">
<div>
<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, i (i)}
<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>
</Card.Content>
</Card.Root>
<Card.Root class="h-full">
<Card.Header>
<div class="flex items-start justify-between">
<div>
<Card.Title class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
Bookings
</Card.Title>
<Card.Description>Search and manage booking history.</Card.Description>
</div>
<div class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium">
<span class="text-xs font-semibold">{bookings.length}</span>
</div>
</div>
</Card.Header>
<Card.Content class="space-y-4">
<div>
<div class="flex gap-2">
<Input
placeholder="Search by customer name, email, phone, or service"
bind:value={bookingQuery}
onkeyup={(e) => {
if ((e as KeyboardEvent).key === 'Enter') searchBookings();
}}
/>
<Button onclick={searchBookings} disabled={loadingSearch}>
{loadingSearch ? 'Searching...' : 'Search'}
</Button>
</div>
<div class="mt-3 max-h-60 space-y-2 overflow-y-auto">
{#if loadingSearch}
<div class="flex items-center justify-center p-4">
<Skeleton class="h-4 w-32" />
</div>
{:else if bookings.length === 0}
<div class="text-center text-sm text-gray-500">No bookings found.</div>
{:else}
{#each bookings as b, i (i)}
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
<div class="flex-1">
<div class="font-medium">
{(() => {
const date = new SvelteDate(b.start_time);
const now = new SvelteDate();
const today = new SvelteDate(
now.getFullYear(),
now.getMonth(),
now.getDate()
);
const bookingDate = new SvelteDate(
date.getFullYear(),
date.getMonth(),
date.getDate()
);
const daysDiff = Math.floor(
(bookingDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)
);
const days = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday'
];
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'June',
'July',
'Aug',
'Sept',
'Oct',
'Nov',
'Dec'
];
const day = days[date.getDay()];
const dateNum = date.getDate();
const month = months[date.getMonth()];
const year = date.getFullYear();
const currentYear = now.getFullYear();
const hours = date.getHours();
const minutes = date.getMinutes().toString().padStart(2, '0');
const ampm = hours >= 12 ? 'pm' : 'am';
const hour12 = hours % 12 || 12;
const time = `${hour12}:${minutes}${ampm}`;
// Today
if (daysDiff === 0) {
return `Today, ${time}`;
}
// Tomorrow
if (daysDiff === 1) {
return `Tomorrow, ${time}`;
}
// Within next 6 days (2-6 days ahead)
if (daysDiff > 1 && daysDiff <= 6) {
return `${day}, ${time}`;
}
// Last 6 days (1-6 days ago)
if (daysDiff < 0 && daysDiff >= -6) {
return `Last ${day}, ${time}`;
}
// Otherwise, full date
const suffix =
dateNum === 1 || dateNum === 21 || dateNum === 31
? 'st'
: dateNum === 2 || dateNum === 22
? 'nd'
: dateNum === 3 || dateNum === 23
? 'rd'
: 'th';
const yearStr = year !== currentYear ? ` ${year}` : '';
return `${day} the ${dateNum}${suffix} of ${month}${yearStr}, ${time}`;
})()}
</div>
<div class="mt-1 flex items-center gap-2 text-xs text-gray-500">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {b.status ===
'confirmed'
? 'bg-emerald-100 text-emerald-800'
: b.status === 'pending'
? 'bg-amber-100 text-amber-800'
: b.status === 'in_progress'
? 'bg-blue-100 text-blue-800'
: b.status === 'completed'
? 'bg-green-100 text-green-800'
: b.status === 'client_cancelled'
? 'bg-red-100 text-red-800'
: b.status === 'we_cancelled'
? 'bg-rose-100 text-rose-800'
: b.status === 're-schedule'
? 'bg-purple-100 text-purple-800'
: b.status === 'no_show'
? 'bg-gray-100 text-gray-800'
: 'bg-gray-100 text-gray-800'}"
>
<span
class="mr-1 h-1.5 w-1.5 rounded-full {b.status === 'confirmed'
? 'bg-emerald-600'
: b.status === 'pending'
? 'bg-amber-600'
: b.status === 'in_progress'
? 'bg-blue-600'
: b.status === 'completed'
? 'bg-green-600'
: b.status === 'client_cancelled'
? 'bg-red-600'
: b.status === 'we_cancelled'
? 'bg-rose-600'
: b.status === 're-schedule'
? 'bg-purple-600'
: b.status === 'no_show'
? 'bg-gray-600'
: 'bg-gray-600'}"
></span>
{b.status}
</span>
<span>• {b.user?.full_name || 'Unknown User'}</span>
<span>
- {(() => {
const services = (b.services || []).map(
(s) => s.service_name || 'Unknown Service'
);
if (services.length === 0) return 'No services';
if (services.length === 1) return services[0];
if (services.length === 2) return services.join(' and ');
return `${services[0]} and ${services.length - 1} other${services.length - 1 > 1 ? 's' : ''}`;
})()}
</span>
</div>
</div>
<Button variant="outline" onclick={() => openBookingModal(b.id)}>View</Button>
</div>
{/each}
{/if}
</div>
</div>
</Card.Content>
</Card.Root>
</div>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Holiday Hours</Card.Title>
<Card.Description>
Manage temporary schedules for holidays, closures, and special events.
</Card.Description>
</div>
<Button variant="default" onclick={createNewException}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
New Schedule
</Button>
</div>
</Card.Header>
<Card.Content class="space-y-4">
{#if exceptionGroupsLoading}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
{#each Array(2) as _, i (i)}
<Skeleton class="h-32 w-full" />
{/each}
</div>
{:else}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
{#if exceptionGroups.length === 0}
<p class="col-span-2 text-sm text-gray-500">No exception groups found.</p>
{/if}
{#each exceptionGroups as g (g.weekStarts)}
<div class="group relative h-full rounded-lg border p-4 transition-all">
<div class="flex h-full flex-col gap-3">
<div class="flex-1">
<div class="mb-2 flex items-start justify-between">
<div class="flex items-center gap-2">
<div class="rounded-lg bg-gray-50 p-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</div>
<h3 class="font-semibold text-gray-900">{g.name}</h3>
</div>
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium">
{g.weekStarts?.length || 0} weeks
</span>
</div>
<p class="mb-3 text-sm text-gray-600">{g.description}</p>
<div class="rounded-lg bg-gray-50 p-2">
<div class="mb-1 text-xs font-medium text-gray-500">Applies to weeks:</div>
<div class="text-xs text-gray-700">
{g.weekStarts
?.slice(0, 3)
.map((w) =>
new SvelteDate(w).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short'
})
)
.join(', ')}
{#if (g.weekStarts?.length ?? 0) > 3}
<span class="text-gray-500">
(+{(g.weekStarts?.length ?? 0) - 3} more)</span
>
{/if}
</div>
</div>
</div>
<div class="flex gap-2 border-t pt-2">
<Button
variant="outline"
size="sm"
onclick={() => openViewExceptionModal(g)}
class="flex-1"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-1 h-3 w-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
View Details
</Button>
<Button
variant="destructive"
size="sm"
onclick={() => {
exceptionToDelete = g.id;
showDeleteExceptionAlert = true;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-3 w-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="3 6 5 6 21 6" />
<path
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
/>
</svg>
</Button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</Card.Content>
</Card.Root>
<!-- Default Hours Card -->
<Card.Root>
{#if !defaultHoursIsLoading}
<Card.Content class="space-y-4">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold">Weekly Schedule</h3>
<p class="text-sm text-gray-500">
Your standard operating hours for each day of the week
</p>
</div>
<Button onclick={prepareDefaultHoursEdit} disabled={loadingHours || savingHours}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
Edit Schedule
</Button>
</div>
<!-- Desktop Table -->
<div class="hidden w-full overflow-x-auto sm:block">
<table class="w-full table-auto border-collapse">
<thead>
<tr class="border-b text-left text-xs text-gray-500">
<th class="px-4 py-3">Day</th>
<th class="px-4 py-3 text-center">Status</th>
<th class="px-4 py-3">Opening Time</th>
<th class="px-4 py-3">Closing Time</th>
<th class="px-4 py-3 text-right">Total Hours</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{#each defaultHours as row (row.weekday)}
<tr class="transition-colors hover:bg-gray-50">
<td class="p-3">
<div class="flex items-center gap-2">
<span class="font-medium"
>{weekdayLabel(row.weekday) === 'Mon'
? 'Monday'
: weekdayLabel(row.weekday) === 'Tue'
? 'Tuesday'
: weekdayLabel(row.weekday) === 'Wed'
? 'Wednesday'
: weekdayLabel(row.weekday) === 'Thu'
? 'Thursday'
: weekdayLabel(row.weekday) === 'Fri'
? 'Friday'
: weekdayLabel(row.weekday) === 'Sat'
? 'Saturday'
: 'Sunday'}</span
>
</div>
</td>
<td class="px-4 py-4 text-center">
<span
class="inline-flex items-center rounded-full px-3 py-1 text-xs font-medium {row.is_open
? 'bg-emerald-100 text-emerald-800'
: 'bg-gray-100 text-gray-800'}"
>
<span
class="mr-1.5 h-1.5 w-1.5 rounded-full {row.is_open
? 'bg-emerald-600'
: 'bg-gray-600'}"
></span>
{row.is_open ? 'Open' : 'Closed'}
</span>
</td>
<td class="px-4 py-4">
{#if row.is_open}
<div class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 text-gray-400"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span class="font-medium text-gray-900">{row.start_time}</span>
</div>
{:else}
<span class="text-gray-400">—</span>
{/if}
</td>
<td class="px-4 py-4">
{#if row.is_open}
<div class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 text-gray-400"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span class="font-medium text-gray-900">{row.end_time}</span>
</div>
{:else}
<span class="text-gray-400">—</span>
{/if}
</td>
<td class="px-4 py-4 text-right">
{#if row.is_open}
<span
class="inline-flex items-center gap-1 text-sm font-medium text-gray-700"
>
{calculateHours(row.start_time, row.end_time)}
<span class="text-xs text-gray-500">hrs</span>
</span>
{:else}
<span class="text-gray-400">—</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Mobile Cards -->
<div class="space-y-3 sm:hidden">
{#each defaultHours as row (row.weekday)}
<div class="rounded-lg border p-4 transition-colors hover:bg-gray-50">
<div class="mb-3 flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-semibold text-gray-900">
{weekdayLabel(row.weekday) === 'Mon'
? 'Monday'
: weekdayLabel(row.weekday) === 'Tue'
? 'Tuesday'
: weekdayLabel(row.weekday) === 'Wed'
? 'Wednesday'
: weekdayLabel(row.weekday) === 'Thu'
? 'Thursday'
: weekdayLabel(row.weekday) === 'Fri'
? 'Friday'
: weekdayLabel(row.weekday) === 'Sat'
? 'Saturday'
: 'Sunday'}
</span>
</div>
<span
class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium {row.is_open
? 'bg-emerald-100 text-emerald-800'
: 'bg-gray-100 text-gray-800'}"
>
<span
class="mr-1.5 h-1.5 w-1.5 rounded-full {row.is_open
? 'bg-emerald-600'
: 'bg-gray-600'}"
></span>
{row.is_open ? 'Open' : 'Closed'}
</span>
</div>
{#if row.is_open}
<div class="grid grid-cols-2 gap-3 text-sm">
<div>
<div class="mb-1 text-xs text-gray-500">Opening</div>
<div class="font-medium text-gray-900">{row.start_time}</div>
</div>
<div>
<div class="mb-1 text-xs text-gray-500">Closing</div>
<div class="font-medium text-gray-900">{row.end_time}</div>
</div>
</div>
<div class="mt-3 border-t pt-3 text-xs text-gray-600">
Total: <span class="font-medium text-gray-900"
>{calculateHours(row.start_time, row.end_time)} hours</span
>
</div>
{:else}
<div class="text-sm text-gray-500">No hours scheduled for this day</div>
{/if}
</div>
{/each}
</div>
</Card.Content>
{:else}
<!-- Skeleton loading -->
<Card.Content class="space-y-4">
<!-- Desktop Skeleton -->
<div class="hidden w-full overflow-x-auto md:block">
<table class="w-full table-auto border-collapse">
<thead>
<tr
class="border-b bg-gray-50 text-left text-xs font-medium tracking-wider text-gray-600 uppercase"
>
<th class="px-4 py-3">Day</th>
<th class="px-4 py-3 text-center">Status</th>
<th class="px-4 py-3">Opening Time</th>
<th class="px-4 py-3">Closing Time</th>
<th class="px-4 py-3 text-right">Total Hours</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{#each Array(7) as _, i (i)}
<tr>
<td class="px-4 py-4">
<div class="flex items-center gap-2">
<Skeleton class="h-8 w-8 rounded-full" />
<Skeleton class="h-4 w-20" />
</div>
</td>
<td class="px-4 py-4 text-center">
<Skeleton class="mx-auto h-6 w-16 rounded-full" />
</td>
<td class="px-4 py-4">
<Skeleton class="h-4 w-20" />
</td>
<td class="px-4 py-4">
<Skeleton class="h-4 w-20" />
</td>
<td class="px-4 py-4 text-right">
<Skeleton class="ml-auto h-4 w-12" />
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Mobile Skeleton -->
<div class="space-y-3 md:hidden">
{#each Array(7) as _, i (i)}
<div class="rounded-lg border p-4">
<div class="mb-3 flex items-center justify-between">
<div class="flex items-center gap-2">
<Skeleton class="h-10 w-10 rounded-full" />
<Skeleton class="h-5 w-24" />
</div>
<Skeleton class="h-6 w-16 rounded-full" />
</div>
<div class="grid grid-cols-2 gap-3">
<Skeleton class="h-12 w-full" />
<Skeleton class="h-12 w-full" />
</div>
</div>
{/each}
</div>
</Card.Content>
{/if}
</Card.Root>
<!-- Services Management Card -->
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Services Management</Card.Title>
<Card.Description>
Manage your services - add, edit, toggle availability, or delete services.
</Card.Description>
</div>
<Button onclick={openServiceModal}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Add Service
</Button>
</div>
</Card.Header>
<Card.Content class="space-y-4">
<!-- Desktop Table -->
<div class="hidden w-full overflow-x-auto md:block">
<table class="w-full table-auto border-collapse text-sm">
<thead>
<tr class="border-b text-left text-xs text-gray-500">
<th class="w-[20%] py-3 font-medium">Name</th>
<th class="w-[30%] py-3 font-medium">Description</th>
<th class="w-[10%] py-3 text-right font-medium">Price</th>
<th class="w-[12%] py-3 text-right font-medium">Duration</th>
<th class="w-[12%] py-3 text-center font-medium">Status</th>
<th class="w-[16%] py-3 text-center font-medium">Actions</th>
</tr>
</thead>
<tbody>
{#if servicesLoading}
{#each Array(3) as _, i (i)}
<tr class="border-b">
<td class="py-3"><Skeleton class="h-4 w-32" /></td>
<td class="py-3"><Skeleton class="h-4 w-48" /></td>
<td class="py-3 text-right"><Skeleton class="ml-auto h-4 w-16" /></td>
<td class="py-3 text-right"><Skeleton class="ml-auto h-4 w-20" /></td>
<td class="py-3 text-center"><Skeleton class="mx-auto h-4 w-16" /></td>
<td class="py-3 text-center">
<div class="flex justify-center gap-2">
<Skeleton class="h-8 w-16" />
<Skeleton class="h-8 w-16" />
</div>
</td>
</tr>
{/each}
{:else}
{#each services as service (service.id)}
<tr class="border-b hover:bg-gray-50">
<td class="py-3 font-medium">{service.name}</td>
<td class="py-3 text-gray-600">
{#if service.description}
<div class="line-clamp-2" title={service.description}>
{service.description}
</div>
{:else}
<span class="text-gray-400">—</span>
{/if}
</td>
<td class="py-3 text-right font-medium">£{service.price.toFixed(2)}</td>
<td class="py-3 text-right">{service.duration_minutes} min</td>
<td class="py-3 text-center">
<span
class="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium {service.is_active
? 'bg-emerald-100 text-emerald-800'
: 'bg-red-100 text-red-800'}"
>
{service.is_active ? 'Active' : 'Inactive'}
</span>
</td>
<td class="py-3">
<div class="flex justify-center gap-2">
<Button
variant="outline"
size="sm"
onclick={() => toggleService(service.id)}
disabled={servicesUpdating[service.id]}
>
{servicesUpdating[service.id]
? '...'
: service.is_active
? 'Deactivate'
: 'Activate'}
</Button>
<Button
variant="destructive"
size="sm"
onclick={() => deleteService(service.id)}
disabled={servicesUpdating[service.id]}
>
Delete
</Button>
</div>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
<!-- Mobile Cards (keep the same as before) -->
<div class="space-y-4 md:hidden">
{#if servicesLoading}
{#each Array(3) as _, i (i)}
<div class="rounded-lg border p-4">
<div class="space-y-3">
<Skeleton class="h-5 w-32" />
<Skeleton class="h-4 w-48" />
<div class="flex justify-between">
<Skeleton class="h-4 w-16" />
<Skeleton class="h-4 w-20" />
</div>
<div class="flex gap-2">
<Skeleton class="h-8 w-16" />
<Skeleton class="h-8 w-16" />
</div>
</div>
</div>
{/each}
{:else}
{#each services as service (service.id)}
<div class="rounded-lg border p-4 hover:bg-gray-50">
<div class="space-y-3">
<div class="flex items-start justify-between">
<h3 class="font-medium">{service.name}</h3>
<span
class="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium {service.is_active
? 'bg-emerald-100 text-emerald-800'
: 'bg-red-100 text-red-800'}"
>
{service.is_active ? 'Active' : 'Inactive'}
</span>
</div>
{#if service.description}
<p class="text-sm text-gray-600">{service.description}</p>
{/if}
<div class="flex justify-between text-sm">
<div>
<span class="font-medium">Price:</span> £{service.price.toFixed(2)}
</div>
<div>
<span class="font-medium">Duration:</span>
{service.duration_minutes} min
</div>
</div>
<div class="flex gap-2 pt-2">
<Button
variant="outline"
size="sm"
onclick={() => toggleService(service.id)}
disabled={servicesUpdating[service.id]}
class="flex-1"
>
{servicesUpdating[service.id]
? '...'
: service.is_active
? 'Deactivate'
: 'Activate'}
</Button>
<Button
variant="destructive"
size="sm"
onclick={() => deleteService(service.id)}
disabled={servicesUpdating[service.id]}
class="flex-1"
>
Delete
</Button>
</div>
</div>
</div>
{/each}
{/if}
</div>
{#if !servicesLoading && services.length === 0}
<div class="py-8 text-center text-gray-500">
No services found. Click "Add Service" to create your first service.
</div>
{/if}
</Card.Content>
</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>
<Modal.Title class="text-lg font-semibold">Edit Default Working Hours</Modal.Title>
<Modal.Description>
Set the standard open and close times for your business.
</Modal.Description>
</Modal.Header>
<div class="px-4 pb-4">
<div class="w-full overflow-x-auto">
<table class="w-full table-auto">
<thead>
<tr class="border-b text-left text-xs text-gray-500">
<th class="py-2">Day</th>
<th class="py-2">Open</th>
<th class="py-2">Start</th>
<th class="py-2">End</th>
</tr>
</thead>
<tbody>
{#each defaultHoursDraft as row (row.weekday)}
<tr class="border-t">
<td class="py-2 text-sm">{weekdayLabel(row.weekday)}</td>
<td class="py-2">
<input
type="checkbox"
bind:checked={row.is_open}
class="h-4 w-4 rounded border-gray-300 bg-gray-100 text-primary focus:ring-primary"
/>
</td>
<td class="py-2">
<Input
type="time"
bind:value={row.start_time}
disabled={!row.is_open}
class="max-w-[70px] text-sm"
/>
</td>
<td class="py-2">
<Input
type="time"
bind:value={row.end_time}
disabled={!row.is_open}
class="max-w-[70px] text-sm"
/>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<Modal.Footer class="flex items-center justify-end gap-2">
<Button
variant="outline"
onclick={() => {
showDefaultHoursModal = false;
}}
>
Cancel
</Button>
<Button onclick={() => (showSaveDefaultHoursAlert = true)} disabled={savingHours}>
Save Defaults
</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
<!-- Save Default Hours Confirmation -->
<AlertDialog.Root bind:open={showSaveDefaultHoursAlert}>
<AlertDialog.Content class="z-[60]">
<AlertDialog.Header>
<AlertDialog.Title>Save default hours?</AlertDialog.Title>
<AlertDialog.Description>
Are you sure you want to save these default hours? This will affect future bookings.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action onclick={confirmSaveDefaultHours}>Continue</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<!-- Exception Group Modal -->
<Modal.Root bind:open={showExceptionModal}>
<Modal.Content class="max-h-[90vh] max-w-2xl overflow-y-auto">
<Modal.Header>
<Modal.Title class="text-lg font-semibold">Create Exception Schedule</Modal.Title>
<Modal.Description>
Define custom working hours for holidays, closures, or special events.
</Modal.Description>
</Modal.Header>
<div class="space-y-6 px-4 pb-4">
<!-- Basic Info -->
<div class="space-y-4">
<div class="space-y-2">
<label for="exception-name" class="text-sm font-medium">Schedule Name *</label>
<Input
id="exception-name"
type="text"
placeholder="e.g., Christmas Week, Summer Holiday"
bind:value={exceptionDraft.name}
/>
</div>
<div class="space-y-2">
<label for="exception-description" class="text-sm font-medium">Description</label>
<Input
id="exception-description"
type="text"
placeholder="Brief description of the service, will be shown to customers"
bind:value={exceptionDraft.description}
/>
</div>
</div>
<Separator />
<!-- Week Selection -->
<div class="space-y-4">
<div>
<h3 class="mb-2 text-sm font-medium">Apply to Weeks *</h3>
<p class="mb-3 text-xs text-gray-500">
Select a date range to add all Mondays within that range
</p>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div class="space-y-2">
<label for="week-from" class="text-xs text-gray-600">From Date</label>
<Input id="week-from" type="date" bind:value={weekRangeFrom} />
</div>
<div class="space-y-2">
<label for="week-to" class="text-xs text-gray-600">To Date</label>
<Input id="week-to" type="date" bind:value={weekRangeTo} />
</div>
</div>
<Button variant="outline" size="sm" onclick={addWeekRange} class="mt-3">
Add Week Range
</Button>
</div>
{#if exceptionDraft.weekStarts.length > 0}
<div class="space-y-2">
<div class="text-xs text-gray-600">
Selected weeks ({exceptionDraft.weekStarts.length}):
</div>
<div class="max-h-32 space-y-1 overflow-y-auto rounded border p-2">
{#each exceptionDraft.weekStarts as week, index (week)}
<div class="flex items-center justify-between text-sm">
<span>Week starting: {week}</span>
<button
class="text-xs text-red-500 hover:text-red-700"
onclick={() => removeWeek(index)}
>
Remove
</button>
</div>
{/each}
</div>
</div>
{/if}
</div>
<Separator />
<!-- Working Hours -->
<div class="space-y-4">
<h3 class="text-sm font-medium">Working Hours for these Weeks *</h3>
<div class="w-full overflow-x-auto">
<table class="w-full table-auto text-sm">
<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 exceptionDraft.hours as row (row.weekday)}
<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="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="w-24 text-sm"
/>
</td>
<td class="py-2">
<Input
type="time"
bind:value={row.end_time}
disabled={!row.is_open}
class="w-24 text-sm"
/>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>
<Modal.Footer class="flex items-center justify-end gap-2">
<Button
variant="outline"
onclick={() => {
showExceptionModal = false;
resetExceptionForm();
}}
disabled={savingHours}
>
Cancel
</Button>
<Button onclick={saveExceptionGroup} disabled={savingHours}>
{savingHours ? 'Creating…' : 'Create Schedule'}
</Button>
</Modal.Footer>
</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>
<!-- View Exception Modal -->
{#if viewingException}
<Modal.Root bind:open={showViewExceptionModal}>
<Modal.Content class="max-h-[90vh] max-w-2xl overflow-y-auto">
<Modal.Header>
<Modal.Title class="text-lg font-semibold">{viewingException.name}</Modal.Title>
<Modal.Description>
{viewingException.description || 'Holiday schedule details'}
</Modal.Description>
</Modal.Header>
<div class="space-y-6 px-4 pb-4">
<!-- Applied Weeks -->
<div class="space-y-2">
<h3 class="text-sm font-medium">Applied to Weeks</h3>
<div class="max-h-48 space-y-1 overflow-y-auto rounded border bg-gray-50 p-3">
{#if viewingException.weekStarts && viewingException.weekStarts.length > 0}
<div class="grid grid-cols-2 gap-2 md:grid-cols-3">
{#each viewingException.weekStarts as week (week)}
<div class="text-sm">
Week of {new SvelteDate(week).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric'
})}
</div>
{/each}
</div>
{:else}
<p class="text-sm text-gray-500">No weeks specified</p>
{/if}
</div>
</div>
<Separator />
<!-- Working Hours -->
<div class="space-y-4">
<h3 class="text-sm font-medium">Working Hours</h3>
<div class="w-full 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 viewingException.hours as row (row.weekday)}
<tr class="border-t">
<td class="py-2 text-sm">{weekdayLabel(row.weekday)}</td>
<td class="py-2">
<span
class="text-sm font-medium {row.is_open
? 'text-emerald-600'
: 'text-red-600'}"
>
{row.is_open ? 'Open' : 'Closed'}
</span>
</td>
<td class="py-2 text-sm">
{row.is_open ? row.start_time : '—'}
</td>
<td class="py-2 text-sm">
{row.is_open ? row.end_time : '—'}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>
<Modal.Footer class="flex items-center justify-end gap-2">
<Button onclick={() => (showViewExceptionModal = false)}>Close</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
{/if}
<!-- 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">
<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 SvelteDate(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 (hb.id)}
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
<div>
<div class="font-medium">{new SvelteDate(hb.start_time).toLocaleString()}</div>
<div class="text-xs text-gray-500">
{hb.status} • {hb.services.map((s) => s.service_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>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
{/if}
{#if selectedBooking}
<Modal.Root bind:open={showBookingModal}>
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto md:max-w-3xl">
<Modal.Header>
<Modal.Title class="text-lg font-semibold">Booking Details</Modal.Title>
<div class="mt-1 text-sm text-gray-500">ID: {selectedBooking.id}</div>
</Modal.Header>
<div class="space-y-6 px-4 pb-4">
<!-- Status Badge -->
<div class="flex items-center gap-2">
<span
class="inline-flex items-center rounded-full px-3 py-1 text-sm font-medium
{selectedBooking.status === 'pending'
? 'bg-yellow-100 text-yellow-800'
: selectedBooking.status === 'confirmed'
? 'bg-emerald-100 text-emerald-800'
: selectedBooking.status === 'in_progress'
? 'bg-blue-100 text-blue-800'
: selectedBooking.status === 'completed'
? 'bg-green-100 text-green-800'
: selectedBooking.status === 'client_cancelled'
? 'bg-red-100 text-red-800'
: selectedBooking.status === 'we_cancelled'
? 'bg-rose-100 text-rose-800'
: selectedBooking.status === 're-schedule'
? 'bg-purple-100 text-purple-800'
: selectedBooking.status === 'no_show'
? 'bg-gray-100 text-gray-800'
: 'bg-gray-100 text-gray-800'}"
>
{selectedBooking.status.charAt(0).toUpperCase() + selectedBooking.status.slice(1)}
</span>
</div>
<!-- Appointment Details -->
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Appointment Details
</h3>
<div class="grid gap-3 md:grid-cols-2">
<div>
<div class="text-xs text-gray-500">Scheduled Date & Time</div>
<div class="font-medium">
{new SvelteDate(selectedBooking.start_time).toLocaleString()}
</div>
</div>
<div>
<div class="text-xs text-gray-500">Duration</div>
<div class="font-medium">{selectedBooking.duration_minutes} minutes</div>
</div>
<div>
<div class="text-xs text-gray-500">Created</div>
<div class="text-sm">
{new SvelteDate(selectedBooking.created_at).toLocaleString()}
</div>
</div>
<div>
<div class="text-xs text-gray-500">Last Updated</div>
<div class="text-sm">
{new SvelteDate(selectedBooking.updated_at).toLocaleString()}
</div>
</div>
{#if selectedBooking.created_by}
<div class="md:col-span-2">
<div class="text-xs text-gray-500">Created By</div>
<div class="text-sm">{selectedBooking.created_by}</div>
</div>
{/if}
</div>
{#if selectedBooking.notes}
<div class="mt-3 rounded-md border border-amber-200 bg-amber-50 p-3">
<div class="mb-1 text-xs font-semibold text-amber-800">Booking Notes</div>
<div class="text-sm text-amber-900">{selectedBooking.notes}</div>
</div>
{/if}
</div>
<!-- Customer Information -->
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Customer Information
</h3>
<div class="grid gap-3 md:grid-cols-2">
<div>
<div class="text-xs text-gray-500">Name</div>
<div class="font-medium">{selectedBooking.user?.full_name || '—'}</div>
</div>
<div>
<div class="text-xs text-gray-500">Email</div>
<div class="font-medium break-all">{selectedBooking.user?.email || '—'}</div>
</div>
<div>
<div class="text-xs text-gray-500">Phone</div>
<div class="font-medium">{selectedBooking.user?.phone || '—'}</div>
</div>
<div>
<div class="text-xs text-gray-500">Customer ID</div>
<div class="font-medium">{selectedBooking.user?.id || '—'}</div>
</div>
{#if selectedBooking.user?.loyalty_stamps !== undefined && selectedBooking.user?.loyalty_stamps !== null}
<div>
<div class="text-xs text-gray-500">Loyalty Stamps</div>
<div class="font-medium">{selectedBooking.user.loyalty_stamps}</div>
</div>
{/if}
{#if selectedBooking.user?.referral_code}
<div>
<div class="text-xs text-gray-500">Referral Code</div>
<div class="font-medium">{selectedBooking.user.referral_code}</div>
</div>
{/if}
{#if selectedBooking.user?.referral_code_uses !== undefined && selectedBooking.user?.referral_code_uses !== null}
<div>
<div class="text-xs text-gray-500">Referral Uses</div>
<div class="font-medium">{selectedBooking.user.referral_code_uses}</div>
</div>
{/if}
</div>
{#if selectedBooking.user?.notes}
<div class="mt-3 rounded-md border border-blue-200 bg-blue-50 p-3">
<div class="mb-1 text-xs font-semibold text-blue-800">Customer Notes</div>
<div class="text-sm text-blue-900">{selectedBooking.user.notes}</div>
</div>
{/if}
</div>
<!-- Services -->
{#if selectedBooking.services && selectedBooking.services.length > 0}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Services
</h3>
<div class="space-y-3">
{#each selectedBooking.services as service (service.service_id)}
<div class="rounded-md border border-gray-300 bg-white p-3">
<div class="font-medium">{service.service_name || '—'}</div>
{#if service.service_description}
<div class="mt-1 text-sm text-gray-600">{service.service_description}</div>
{/if}
<div class="mt-2 flex items-center justify-between text-sm">
<span class="text-gray-600">{service.duration_minutes} min</span>
<span class="font-semibold">£{service.price?.toFixed(2) || '0.00'}</span>
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Financial Summary -->
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Financial Summary
</h3>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">Total Amount</span>
<span class="font-semibold">£{selectedBooking.total_amount.toFixed(2)}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">Amount Paid</span>
<span class="font-semibold text-green-700"
>£{selectedBooking.amount_paid.toFixed(2)}</span
>
</div>
<div class="flex items-center justify-between border-t border-gray-300 pt-2">
<span class="font-medium text-gray-900">Amount Due</span>
<span
class="text-lg font-bold {selectedBooking.amount_due > 0
? 'text-red-600'
: 'text-green-600'}"
>
£{selectedBooking.amount_due.toFixed(2)}
</span>
</div>
</div>
</div>
<!-- Payments -->
{#if selectedBooking.payments && selectedBooking.payments.length > 0}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Payment History
</h3>
<div class="space-y-3">
{#each selectedBooking.payments as payment (payment.id)}
<div class="rounded-md border border-gray-300 bg-white p-3">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="font-medium capitalize"
>{payment.payment_method.replace('_', ' ')}</span
>
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
{payment.status === 'completed'
? 'bg-green-100 text-green-800'
: payment.status === 'pending'
? 'bg-yellow-100 text-yellow-800'
: payment.status === 'failed'
? 'bg-red-100 text-red-800'
: 'bg-gray-100 text-gray-800'}"
>
{payment.status}
</span>
</div>
<div class="mt-1 text-xs text-gray-500">
{payment.payment_type.charAt(0).toUpperCase() +
payment.payment_type.slice(1)}
</div>
{#if payment.vendor_code || payment.invoice_number}
<div class="mt-1 text-xs text-gray-500">
{#if payment.vendor_code}Vendor: {payment.vendor_code}{/if}
{#if payment.vendor_code && payment.invoice_number}
{/if}
{#if payment.invoice_number}Invoice: #{payment.invoice_number}{/if}
</div>
{/if}
{#if payment.is_vat_applicable}
<div class="mt-2 text-xs text-gray-600">
<div>Net: £{payment.net_amount?.toFixed(2) || '0.00'}</div>
<div>
VAT ({(payment.vat_rate || 0) * 100}%): £{payment.vat_amount?.toFixed(
2
) || '0.00'}
</div>
</div>
{/if}
<div class="mt-1 text-xs text-gray-400">
{new SvelteDate(payment.created_at).toLocaleString()}
</div>
</div>
<div class="text-right font-semibold">
£{payment.amount.toFixed(2)}
</div>
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
<Modal.Footer class="flex items-center justify-end gap-2">
<Button onclick={() => (showBookingModal = false)}>Close</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
{/if}
<!-- Booking Modal -->
{#if showServiceModal}
<!-- Add Service Modal -->
<Modal.Root bind:open={showServiceModal}>
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto md:max-w-lg">
<Modal.Header>
<Modal.Title class="text-lg font-semibold">Add New Service</Modal.Title>
<Modal.Description>Create a new service that customers can book.</Modal.Description>
</Modal.Header>
<div class="space-y-4 px-4 pb-4">
<!-- Service Name -->
<div class="space-y-2">
<label for="service-name" class="text-sm font-medium">Service Name *</label>
<Input
id="service-name"
type="text"
placeholder="e.g., Haircut, Color, Blowdry"
bind:value={newService.name}
onblur={validateNameField}
class="w-full border-red-500={serviceErrors.name}"
/>
{#if serviceErrors.name}
<p class="text-sm text-red-600">{serviceErrors.name}</p>
{/if}
</div>
<!-- Description -->
<div class="space-y-2">
<label for="service-description" class="text-sm font-medium">Description</label>
<Input
id="service-description"
type="text"
placeholder="Brief description of the service, will be shown to customers"
bind:value={newService.description}
class="w-full"
/>
</div>
<!-- Price and Duration - Side by side on desktop -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Price -->
<div class="space-y-2">
<label for="service-price" class="text-sm font-medium">Price (£) *</label>
<div class="relative">
<span class="absolute top-1/2 left-3 -translate-y-1/2 text-sm text-gray-500">£</span
>
<Input
id="service-price"
type="number"
step="0.01"
min="0"
placeholder="0.00"
bind:value={newService.price}
onblur={validatePriceField}
class="w-full pl-8 border-red-500={serviceErrors.price}"
/>
</div>
{#if serviceErrors.price}
<p class="text-sm text-red-600">{serviceErrors.price}</p>
{/if}
</div>
<!-- Duration -->
<div class="space-y-2">
<label for="service-duration" class="text-sm font-medium">Duration (minutes) *</label>
<Input
id="service-duration"
type="number"
min="1"
step="1"
placeholder="60"
bind:value={newService.duration_minutes}
onblur={validateDurationField}
class="w-full border-red-500={serviceErrors.duration_minutes}"
/>
{#if serviceErrors.duration_minutes}
<p class="text-sm text-red-600">{serviceErrors.duration_minutes}</p>
{/if}
</div>
</div>
<!-- Patch Test and Minimum Age - Side by side on desktop -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Patch Test Duration -->
<div class="space-y-2">
<label for="patch-test-duration" class="text-sm font-medium"
>Patch Test Duration (hours)</label
>
<Input
id="patch-test-duration"
type="number"
min="0"
step="1"
placeholder="0"
bind:value={newService.patch_test_duration_hours}
onblur={validatePatchTestField}
class="w-full border-red-500={serviceErrors.patch_test_duration_hours}"
/>
{#if serviceErrors.patch_test_duration_hours}
<p class="text-sm text-red-600">{serviceErrors.patch_test_duration_hours}</p>
{/if}
<p class="text-xs text-gray-500">Hours required before service (0 for none)</p>
</div>
<!-- Minimum Age -->
<div class="space-y-2">
<label for="minimum-age" class="text-sm font-medium">Minimum Age</label>
<Input
id="minimum-age"
type="number"
min="0"
max="100"
step="1"
placeholder="0"
bind:value={newService.minimum_age_required}
onblur={validateMinimumAgeField}
class="w-full border-red-500={serviceErrors.minimum_age_required}"
/>
{#if serviceErrors.minimum_age_required}
<p class="text-sm text-red-600">{serviceErrors.minimum_age_required}</p>
{/if}
<p class="text-xs text-gray-500">0 for no age restriction</p>
</div>
</div>
</div>
<Modal.Footer class="flex items-center justify-end gap-2">
<Button
variant="outline"
onclick={() => {
showServiceModal = false;
resetServiceForm();
}}
disabled={creatingService}
>
Cancel
</Button>
<Button onclick={createService} disabled={creatingService || !isFormValid}>
{creatingService ? 'Creating...' : 'Create Service'}
</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
{/if}
{/if}