feat(frontend): add custom services management UI components

Add CustomServicesManagement component for CRUD operations on custom services. Update BookingCreateModal, WalkInCreateModal, and BookingsCard to support custom service selection. Minor improvements to ServicesManagement and ImageUpload.

Ultraworked with Sisyphus (https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-15 16:58:28 +01:00
co-authored by Sisyphus
parent c62e8322b5
commit 8f8bf83b64
6 changed files with 1036 additions and 19 deletions
@@ -0,0 +1,449 @@
<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { toast } from 'svelte-sonner';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import * as Modal from '$lib/components/ui/dialog';
import { Skeleton } from '$lib/components/ui/skeleton';
import { Separator } from '$lib/components/ui/separator';
type CustomService = {
id: string;
name: string;
description: string | null;
price: number;
duration_minutes: number;
notes: string | null;
created_at: string;
created_by?: string;
usage_count: number;
last_used_at: string | null;
};
let services = $state<CustomService[]>([]);
let total = $state(0);
let page = $state(1);
let perPage = $state(10);
let searchQuery = $state('');
let loading = $state(true);
let creating = $state(false);
let showCreateModal = $state(false);
let serviceErrors = $state<Record<string, string>>({});
let newService = $state({
name: '',
description: '',
price: '',
duration_minutes: '',
minimum_age_required: ''
});
const durationOptions = Array.from({ length: 32 }, (_, i) => (i + 1) * 15);
function validatePrice(price: unknown): string {
const p = price === null || price === undefined ? '' : String(price);
if (!p.trim()) return 'Price is required';
const num = parseFloat(p);
if (isNaN(num)) return 'Must be a valid number';
if (num <= 0) return 'Must be greater than 0';
if (!/^\d+(\.\d{1,2})?$/.test(p.trim())) return 'Up to 2 decimal places';
return '';
}
function validateDuration(value: unknown): string {
const v = value === null || value === undefined ? '' : String(value);
if (!v.trim()) return 'Duration is required';
const num = parseInt(v);
if (isNaN(num)) return 'Must be a valid number';
if (num <= 0) return 'Must be greater than 0';
return '';
}
function validateName(name: unknown): string {
const n = name === null || name === undefined ? '' : String(name);
if (!n.trim()) return 'Name is required';
if (n.trim().length > 100) return 'Max 100 characters';
return '';
}
function validateMinimumAge(value: unknown): string {
const v = value === null || value === undefined ? '' : String(value);
if (!v.trim()) return 'Required';
const num = parseInt(v);
if (isNaN(num)) return 'Must be a valid number';
if (num < 0 || num > 100) return 'Must be between 0 and 100';
return '';
}
function updateAllErrors() {
serviceErrors = {
name: validateName(newService.name),
price: validatePrice(newService.price),
duration_minutes: validateDuration(newService.duration_minutes),
minimum_age_required: validateMinimumAge(newService.minimum_age_required)
};
}
function validateField(field: string) {
if (field === 'name') serviceErrors.name = validateName(newService.name);
if (field === 'price') serviceErrors.price = validatePrice(newService.price);
if (field === 'duration_minutes') serviceErrors.duration_minutes = validateDuration(newService.duration_minutes);
if (field === 'minimum_age_required') serviceErrors.minimum_age_required = validateMinimumAge(newService.minimum_age_required);
}
let isFormValid = $derived(
(newService.name ?? '').trim() !== '' &&
!serviceErrors.name &&
!serviceErrors.price &&
!serviceErrors.duration_minutes &&
!serviceErrors.minimum_age_required
);
async function fetchServices() {
loading = true;
try {
const params = new URLSearchParams({
page: page.toString(),
per_page: perPage.toString()
});
if (searchQuery.trim()) params.set('q', searchQuery.trim());
const response = await fetch(`/api/admin/custom-services?${params}`, {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
});
if (response.ok) {
const data = await response.json();
services = data.services;
total = data.total;
} else {
toast.error('Failed to load custom services');
}
} catch {
toast.error('Network error loading custom services');
} finally {
loading = false;
}
}
async function createService() {
updateAllErrors();
if (!isFormValid) {
toast.error('Please fix the validation errors');
return;
}
creating = true;
try {
const response = await fetch('/api/admin/custom-services', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify({
name: (newService.name ?? '').trim(),
description: (newService.description ?? '').trim() || undefined,
price: parseFloat(newService.price ?? '0'),
duration_minutes: parseInt(newService.duration_minutes ?? '0'),
minimum_age_required: parseInt(newService.minimum_age_required ?? '0') || 0
})
});
if (response.ok) {
toast.success('Custom service created');
showCreateModal = false;
resetForm();
await fetchServices();
} else {
const err = await response.text();
toast.error(`Failed: ${err}`);
}
} catch {
toast.error('Network error');
} finally {
creating = false;
}
}
async function promoteService(id: string, name: string) {
if (!confirm(`Promote "${name}" to a regular catalog service? This will migrate all booking references.`)) return;
try {
const response = await fetch(`/api/admin/custom-services/${id}/promote`, {
method: 'POST',
headers: { Authorization: `Bearer ${authStore.currentToken}` }
});
if (response.ok) {
const data = await response.json();
toast.success(`Promoted to service "${name}" (ID: ${data.new_service_id})`);
await fetchServices();
} else if (response.status === 409) {
toast.error('A service with this name already exists');
} else {
toast.error(`Failed: ${await response.text()}`);
}
} catch {
toast.error('Network error');
}
}
async function deleteService(id: string) {
if (!confirm('Delete this custom service?')) return;
try {
const response = await fetch(`/api/admin/custom-services/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${authStore.currentToken}` }
});
if (response.ok) {
toast.success('Deleted');
await fetchServices();
} else if (response.status === 409) {
toast.error('Cannot delete: used in bookings. Promote first.');
} else {
toast.error(`Failed: ${await response.text()}`);
}
} catch {
toast.error('Network error');
}
}
function resetForm() {
newService = { name: '', description: '', price: '', duration_minutes: '', minimum_age_required: '' };
serviceErrors = { name: '', price: '', duration_minutes: '', minimum_age_required: '' };
}
$effect(() => {
fetchServices();
});
</script>
<Card.Root>
<Card.Header>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<Card.Title>Custom Services</Card.Title>
<Card.Description>
One-off services for bridal parties, special requests, and custom bookings.
</Card.Description>
</div>
<Button onclick={() => { resetForm(); showCreateModal = true; }}>
<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 Custom Service
</Button>
</div>
</Card.Header>
<Card.Content class="space-y-4">
<div class="flex gap-2">
<Input
placeholder="Search custom services..."
bind:value={searchQuery}
onkeydown={(e) => { if (e.key === 'Enter') { page = 1; fetchServices(); } }}
class="max-w-sm"
/>
<Button variant="outline" onclick={() => { page = 1; fetchServices(); }}>Search</Button>
</div>
<Separator />
{#if loading}
<div class="space-y-3">
{#each Array(3) as _, i (i)}
<Skeleton class="h-12 w-full" />
{/each}
</div>
{:else if services.length === 0}
<div class="py-8 text-center text-gray-500">
{searchQuery ? 'No custom services match your search.' : 'No custom services yet.'}
</div>
{:else}
<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 font-medium">Name</th>
<th class="py-3 font-medium">Description</th>
<th class="py-3 text-right font-medium">Price</th>
<th class="py-3 text-right font-medium">Duration</th>
<th class="py-3 text-right font-medium">Used</th>
<th class="py-3 text-center font-medium">Actions</th>
</tr>
</thead>
<tbody>
{#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-1" 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-right">
{service.usage_count}×{service.last_used_at ? ` (last: ${new Date(service.last_used_at).toLocaleDateString()})` : ''}
</td>
<td class="py-3">
<div class="flex justify-center gap-2">
<Button variant="outline" size="sm" onclick={() => promoteService(service.id, service.name)}>
Promote
</Button>
<Button variant="destructive" size="sm" onclick={() => deleteService(service.id)}>
Delete
</Button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<div class="space-y-4 md:hidden">
{#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="text-xs text-gray-500">{service.usage_count}× used</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" class="flex-1" onclick={() => promoteService(service.id, service.name)}>Promote</Button>
<Button variant="destructive" size="sm" class="flex-1" onclick={() => deleteService(service.id)}>Delete</Button>
</div>
</div>
</div>
{/each}
</div>
{#if total > perPage}
<div class="flex items-center justify-between">
<span class="text-sm text-gray-500">
Page {page} of {Math.ceil(total / perPage)} ({total} total)
</span>
<div class="flex gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => { page--; fetchServices(); }}>Previous</Button>
<Button variant="outline" size="sm" disabled={page >= Math.ceil(total / perPage)} onclick={() => { page++; fetchServices(); }}>Next</Button>
</div>
</div>
{/if}
{/if}
</Card.Content>
</Card.Root>
<Modal.Root bind:open={showCreateModal}>
<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 Custom Service</Modal.Title>
<Modal.Description>Create a one-off service for special requests.</Modal.Description>
</Modal.Header>
<div class="space-y-4 px-4 pb-4">
<div class="space-y-2">
<label for="cs-name" class="text-sm font-medium">Name *</label>
<Input
id="cs-name"
type="text"
maxlength={100}
placeholder="e.g., Bridal Party French Tips"
bind:value={newService.name}
oninput={() => validateField('name')}
onblur={() => validateField('name')}
class="w-full {serviceErrors.name ? 'border-red-500' : ''}"
/>
{#if serviceErrors.name}
<p class="text-sm text-red-600">{serviceErrors.name}</p>
{/if}
</div>
<div class="space-y-2">
<label for="cs-desc" class="text-sm font-medium">Description</label>
<Input
id="cs-desc"
type="text"
placeholder="Brief description"
bind:value={newService.description}
class="w-full"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="space-y-2">
<label for="cs-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="cs-price"
type="number"
inputmode="decimal"
step="0.01"
min="0"
placeholder="0.00"
bind:value={newService.price}
oninput={() => validateField('price')}
onblur={() => validateField('price')}
class="w-full pl-8 {serviceErrors.price ? 'border-red-500' : ''}"
/>
</div>
{#if serviceErrors.price}
<p class="text-sm text-red-600">{serviceErrors.price}</p>
{/if}
</div>
<div class="space-y-2">
<label for="cs-duration" class="text-sm font-medium">Duration *</label>
<select
id="cs-duration"
bind:value={newService.duration_minutes}
onchange={() => validateField('duration_minutes')}
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 {serviceErrors.duration_minutes ? 'border-red-500' : ''}"
>
<option value="">Select...</option>
{#each durationOptions as mins (mins)}
<option value={mins}>{mins} min{mins >= 60 ? ` (${Math.floor(mins / 60)}h${mins % 60 > 0 ? ` ${mins % 60}m` : ''})` : ''}</option>
{/each}
</select>
{#if serviceErrors.duration_minutes}
<p class="text-sm text-red-600">{serviceErrors.duration_minutes}</p>
{/if}
</div>
</div>
<div class="space-y-2">
<label for="cs-min-age" class="text-sm font-medium">Minimum Age</label>
<Input
id="cs-min-age"
type="number"
inputmode="numeric"
min="0"
max="100"
placeholder="0"
bind:value={newService.minimum_age_required}
oninput={() => validateField('minimum_age_required')}
onblur={() => validateField('minimum_age_required')}
class="w-full {serviceErrors.minimum_age_required ? 'border-red-500' : ''}"
/>
{#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>
<Modal.Footer class="flex items-center justify-end gap-2">
<Button variant="outline" onclick={() => { showCreateModal = false; resetForm(); }} disabled={creating}>Cancel</Button>
<Button onclick={createService} disabled={creating || !isFormValid}>
{creating ? 'Creating...' : 'Create'}
</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>