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:
@@ -12,6 +12,7 @@
|
||||
// Note: We are using native inputs for Steps 1 and 3 to fix reactivity bugs
|
||||
// but keeping the Label and other components.
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
import CharCounter from '$lib/components/ui/CharCounter.svelte';
|
||||
@@ -25,7 +26,7 @@
|
||||
import SelectedTimeSummary from '$lib/components/booking/SelectedTimeSummary.svelte';
|
||||
|
||||
// Types
|
||||
import type { Service, WorkingHoursDay, AvailableHoursDay } from '$lib/types/booking';
|
||||
import type { Service, CustomService, WorkingHoursDay, AvailableHoursDay } from '$lib/types/booking';
|
||||
|
||||
import {
|
||||
buildLunchProtection,
|
||||
@@ -67,6 +68,73 @@
|
||||
let selectedServices = $state<Service[]>([]);
|
||||
let loadingServices = $state(true);
|
||||
|
||||
// Custom Services
|
||||
let customServices = $state<Array<CustomService & { is_custom: boolean }>>([]);
|
||||
let customSearchQuery = $state('');
|
||||
let loadingCustomServices = $state(false);
|
||||
let showCustomCreateForm = $state(false);
|
||||
let newCustomService = $state({ name: '', description: '', price: '', duration_minutes: '', minimum_age_required: '' });
|
||||
let creatingCustomService = $state(false);
|
||||
let customServiceErrors = $state<Record<string, string>>({});
|
||||
|
||||
const durationOptions = Array.from({ length: 32 }, (_, i) => (i + 1) * 15);
|
||||
|
||||
function validateCsName(name: unknown): string {
|
||||
const n = name === null || name === undefined ? '' : String(name);
|
||||
if (!n.trim()) return 'Name is required';
|
||||
return '';
|
||||
}
|
||||
function validateCsPrice(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) || num <= 0) return 'Must be greater than 0';
|
||||
return '';
|
||||
}
|
||||
function validateCsDuration(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) || num <= 0) return 'Must be greater than 0';
|
||||
return '';
|
||||
}
|
||||
function validateCsMinimumAge(value: unknown): string {
|
||||
const v = value === null || value === undefined ? '' : String(value);
|
||||
if (!v.trim()) return 'Required';
|
||||
const num = parseInt(v);
|
||||
if (isNaN(num) || num < 0 || num > 100) return 'Must be between 0 and 100';
|
||||
return '';
|
||||
}
|
||||
function validateCsAll() {
|
||||
customServiceErrors = {
|
||||
name: validateCsName(newCustomService.name),
|
||||
price: validateCsPrice(newCustomService.price),
|
||||
duration_minutes: validateCsDuration(newCustomService.duration_minutes),
|
||||
minimum_age_required: validateCsMinimumAge(newCustomService.minimum_age_required)
|
||||
};
|
||||
}
|
||||
let isCustomFormValid = $derived(
|
||||
(newCustomService.name ?? '').trim() !== '' &&
|
||||
!customServiceErrors.name &&
|
||||
!customServiceErrors.price &&
|
||||
!customServiceErrors.duration_minutes &&
|
||||
!customServiceErrors.minimum_age_required
|
||||
);
|
||||
|
||||
function toggleCustomForm(show: boolean) {
|
||||
showCustomCreateForm = show;
|
||||
if (show) {
|
||||
newCustomService = { name: '', description: '', price: '', duration_minutes: '', minimum_age_required: '' };
|
||||
customServiceErrors = { name: '', price: '', duration_minutes: '', minimum_age_required: '' };
|
||||
requestAnimationFrame(() => {
|
||||
const modalContent = document.querySelector('[data-custom-form-container]');
|
||||
if (modalContent) {
|
||||
modalContent.scrollTop = modalContent.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Service Overrides & Notes
|
||||
let notes = $state('');
|
||||
let serviceOverrides = $state<
|
||||
@@ -478,7 +546,8 @@
|
||||
localDate.setHours(hours, minutes, 0, 0);
|
||||
const startTimeISO = localDate.toISOString();
|
||||
|
||||
const serviceIds = selectedServices.map((s) => s.id);
|
||||
const serviceIds = selectedServices.filter(s => !(s as any).is_custom).map((s) => s.id);
|
||||
const customServiceIds = selectedServices.filter(s => (s as any).is_custom).map((s) => s.id);
|
||||
|
||||
// Build service overrides payload
|
||||
const overrides = [];
|
||||
@@ -492,7 +561,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
const payload: Record<string, any> = {
|
||||
user_id: selectedUserId || null,
|
||||
start_time: startTimeISO,
|
||||
service_ids: serviceIds,
|
||||
@@ -500,6 +569,9 @@
|
||||
ttl_minutes: 15,
|
||||
reservation_type: 'callin'
|
||||
};
|
||||
if (customServiceIds.length > 0) {
|
||||
payload.custom_service_ids = customServiceIds;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/admin/bookings/reserve', {
|
||||
method: 'POST',
|
||||
@@ -598,6 +670,80 @@
|
||||
selectedTime = null;
|
||||
}
|
||||
|
||||
async function fetchCustomServices() {
|
||||
loadingCustomServices = true;
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (customSearchQuery.trim()) {
|
||||
params.set('q', customSearchQuery.trim());
|
||||
} else {
|
||||
params.set('popular', '3');
|
||||
}
|
||||
const response = await fetch(`/api/admin/custom-services?${params}`, {
|
||||
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const list = data.services || data;
|
||||
customServices = list.map((cs: any) => ({ ...cs, is_custom: true }));
|
||||
}
|
||||
} catch {
|
||||
console.error('Failed to fetch custom services');
|
||||
} finally {
|
||||
loadingCustomServices = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createCustomService() {
|
||||
validateCsAll();
|
||||
if (!isCustomFormValid) {
|
||||
toast.error('Please fix the validation errors');
|
||||
return;
|
||||
}
|
||||
creatingCustomService = 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: (newCustomService.name ?? '').trim(),
|
||||
description: (newCustomService.description ?? '').trim() || undefined,
|
||||
price: parseFloat(newCustomService.price ?? '0'),
|
||||
duration_minutes: parseInt(newCustomService.duration_minutes ?? '0'),
|
||||
minimum_age_required: parseInt(newCustomService.minimum_age_required ?? '0') || 0
|
||||
})
|
||||
});
|
||||
if (response.ok) {
|
||||
const cs = await response.json();
|
||||
const customService = { ...cs, is_custom: true };
|
||||
selectedServices = [...selectedServices, customService];
|
||||
serviceOverrides = {
|
||||
...serviceOverrides,
|
||||
[cs.id]: {
|
||||
price: cs.price.toFixed(2),
|
||||
duration: cs.duration_minutes.toString(),
|
||||
originalPrice: cs.price,
|
||||
originalDuration: cs.duration_minutes
|
||||
}
|
||||
};
|
||||
showCustomCreateForm = false;
|
||||
newCustomService = { name: '', price: '', duration_minutes: '' };
|
||||
customServiceErrors = { name: '', price: '', duration_minutes: '' };
|
||||
toast.success('Custom service created and added');
|
||||
} else {
|
||||
const err = await response.text();
|
||||
toast.error(`Failed: ${err}`);
|
||||
}
|
||||
} catch {
|
||||
toast.error('Network error');
|
||||
} finally {
|
||||
creatingCustomService = false;
|
||||
}
|
||||
}
|
||||
|
||||
const groupedTimeSlots = $derived(
|
||||
currentStep === 4 && selectedServices.length > 0 && selectedDate
|
||||
? generateGroupedTimeSlots(
|
||||
@@ -715,10 +861,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
const payload: Record<string, any> = {
|
||||
user_id: finalUserId,
|
||||
start_time: dateTimeStr,
|
||||
service_ids: selectedServices.map((s) => s.id),
|
||||
service_ids: selectedServices.filter(s => !(s as any).is_custom).map((s) => s.id),
|
||||
custom_service_ids: selectedServices.filter(s => (s as any).is_custom).map((s) => s.id),
|
||||
service_overrides: overrides.length > 0 ? overrides : undefined,
|
||||
notes: notes.trim() || null
|
||||
};
|
||||
@@ -993,6 +1140,147 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<Separator />
|
||||
<div class="space-y-3">
|
||||
<h4 class="text-sm font-medium text-gray-600">Or book a custom service</h4>
|
||||
<div class={showCustomCreateForm ? 'hidden' : ''}>
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
placeholder="Search existing custom services..."
|
||||
bind:value={customSearchQuery}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') fetchCustomServices(); }}
|
||||
class="flex-1"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onclick={fetchCustomServices}>Search</Button>
|
||||
</div>
|
||||
{#if loadingCustomServices}
|
||||
<div class="space-y-2">
|
||||
{#each Array(3) as _, i (i)}
|
||||
<Skeleton class="h-10 w-full" />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if customServices.length > 0}
|
||||
<div class="space-y-2">
|
||||
{#each customServices as cs (cs.id)}
|
||||
<button
|
||||
class="flex w-full items-center justify-between rounded-lg border px-3 py-2 text-sm hover:bg-gray-50"
|
||||
onclick={() => {
|
||||
if (!selectedServices.some((s) => s.id === cs.id)) {
|
||||
selectedServices = [...selectedServices, cs];
|
||||
serviceOverrides = {
|
||||
...serviceOverrides,
|
||||
[cs.id]: {
|
||||
price: cs.price.toFixed(2),
|
||||
duration: cs.duration_minutes.toString(),
|
||||
originalPrice: cs.price,
|
||||
originalDuration: cs.duration_minutes
|
||||
}
|
||||
};
|
||||
toast.success(`Added "${cs.name}"`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span class="font-medium">{cs.name}</span>
|
||||
<span class="text-gray-500">{cs.duration_minutes} min • £{cs.price.toFixed(2)}{cs.usage_count > 0 ? ` (${cs.usage_count}×)` : ''}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<Button variant="ghost" size="sm" onclick={() => { showCustomCreateForm = true; }} class="w-full">
|
||||
+ Create new custom service
|
||||
</Button>
|
||||
</div>
|
||||
<div class={showCustomCreateForm ? '' : 'hidden'}>
|
||||
<div class="space-y-3 rounded-lg border p-4">
|
||||
<div class="space-y-1">
|
||||
<label for="booking-cs-name" class="text-sm font-medium">Name *</label>
|
||||
<Input
|
||||
id="booking-cs-name"
|
||||
bind:value={newCustomService.name}
|
||||
oninput={() => customServiceErrors.name = validateCsName(newCustomService.name)}
|
||||
onblur={() => customServiceErrors.name = validateCsName(newCustomService.name)}
|
||||
placeholder="e.g., Bridal Party French Tips"
|
||||
class={customServiceErrors.name ? 'border-red-500' : ''}
|
||||
/>
|
||||
{#if customServiceErrors.name}
|
||||
<p class="text-xs text-red-600">{customServiceErrors.name}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label for="booking-cs-desc" class="text-sm font-medium">Description</label>
|
||||
<Input
|
||||
id="booking-cs-desc"
|
||||
bind:value={newCustomService.description}
|
||||
placeholder="Brief description"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="space-y-1">
|
||||
<label for="booking-cs-price" class="text-sm font-medium">Price (£) *</label>
|
||||
<Input
|
||||
id="booking-cs-price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
bind:value={newCustomService.price}
|
||||
oninput={() => customServiceErrors.price = validateCsPrice(newCustomService.price)}
|
||||
onblur={() => customServiceErrors.price = validateCsPrice(newCustomService.price)}
|
||||
placeholder="0.00"
|
||||
class={customServiceErrors.price ? 'border-red-500' : ''}
|
||||
/>
|
||||
{#if customServiceErrors.price}
|
||||
<p class="text-xs text-red-600">{customServiceErrors.price}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label for="booking-cs-dur" class="text-sm font-medium">Duration *</label>
|
||||
<select
|
||||
id="booking-cs-dur"
|
||||
bind:value={newCustomService.duration_minutes}
|
||||
onchange={() => customServiceErrors.duration_minutes = validateCsDuration(newCustomService.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 {customServiceErrors.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 customServiceErrors.duration_minutes}
|
||||
<p class="text-xs text-red-600">{customServiceErrors.duration_minutes}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label for="booking-cs-age" class="text-sm font-medium">Minimum Age</label>
|
||||
<Input
|
||||
id="booking-cs-age"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
min="0"
|
||||
max="100"
|
||||
placeholder="0"
|
||||
bind:value={newCustomService.minimum_age_required}
|
||||
oninput={() => customServiceErrors.minimum_age_required = validateCsMinimumAge(newCustomService.minimum_age_required)}
|
||||
class="w-full {customServiceErrors.minimum_age_required ? 'border-red-500' : ''}"
|
||||
/>
|
||||
{#if customServiceErrors.minimum_age_required}
|
||||
<p class="text-xs text-red-600">{customServiceErrors.minimum_age_required}</p>
|
||||
{/if}
|
||||
<p class="text-xs text-gray-500">0 for no age restriction</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button size="sm" onclick={createCustomService} disabled={creatingCustomService || !isCustomFormValid}>
|
||||
{creatingCustomService ? 'Creating...' : 'Save & Add'}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onclick={() => { showCustomCreateForm = false; newCustomService = { name: '', description: '', price: '', duration_minutes: '', minimum_age_required: '' }; customServiceErrors = { name: '', price: '', duration_minutes: '', minimum_age_required: '' }; }}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if selectedServices.length > 0}
|
||||
<div class="rounded-lg bg-gray-50 p-4">
|
||||
<h4 class="mb-2 font-semibold">Selected Services</h4>
|
||||
|
||||
Reference in New Issue
Block a user