Files
Crussell/frontend/src/lib/components/admin/CustomServicesManagement.svelte
T
popertots 3eec71a56c fix: sanitize API error text display and add time_blockers tests
Add extractErrorMessage helper for JSON error body parsing and apply sanitizeText across all toast displays. Add time_blockers test coverage for new holiday placeholder cleanup and overlapping scenarios.
2026-08-22 00:34:48 +01:00

537 lines
16 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { apiFetch } from '$lib/utils/api';
import { toast } from 'svelte-sonner';
import { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe';
import { range } from '$lib/utils/format';
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';
import { SvelteURLSearchParams } from 'svelte/reactivity';
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);
const 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);
}
const 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 SvelteURLSearchParams({
page: page.toString(),
per_page: perPage.toString()
});
if (searchQuery.trim()) params.set('q', searchQuery.trim());
const response = await apiFetch(`/api/admin/custom-services?${params}`);
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 apiFetch('/api/admin/custom-services', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
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: ' + sanitizeText(extractErrorMessage(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 apiFetch(`/api/admin/custom-services/${id}/promote`, {
method: 'POST'
});
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 {
const errText = await response.text();
toast.error('Failed: ' + sanitizeText(extractErrorMessage(errText)));
}
} catch {
toast.error('Network error');
}
}
async function deleteService(id: string) {
if (!confirm('Delete this custom service?')) return;
try {
const response = await apiFetch(`/api/admin/custom-services/${id}`, {
method: 'DELETE'
});
if (response.ok) {
toast.success('Deleted');
await fetchServices();
} else if (response.status === 409) {
toast.error('Cannot delete: used in bookings. Promote first.');
} else {
const errText = await response.text();
toast.error('Failed: ' + sanitizeText(extractErrorMessage(errText)));
}
} 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 range(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('en-GB', { timeZone: 'Europe/London' })})`
: ''}
</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-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-xs ring-offset-background transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 {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>