3405 lines
103 KiB
Plaintext
3405 lines
103 KiB
Plaintext
<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 JPEG‑encoded 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, center‑cropped). */
|
||
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; // 1 ms 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}
|