feat: add booking edit modal for /today page with service management

Replace placeholder BookingModal on the Next Appointment edit button with a
dedicated EditBookingModal that allows admins to add, remove, and override
services on an active booking. Includes backend PUT endpoint with overlap
detection and full test suite (20 tests).
This commit is contained in:
2026-05-16 17:23:00 +01:00
parent 44259a32ab
commit 80621e0d77
6 changed files with 2305 additions and 3 deletions
@@ -0,0 +1,679 @@
<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import * as Modal from '$lib/components/ui/dialog';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Textarea } from '$lib/components/ui/textarea';
import type { Booking, BookingService, Service } from '$lib/types/booking';
interface Props {
open: boolean;
bookingId: string;
nextAppointmentStart?: string | null;
onSaved?: () => void;
}
let { open = $bindable(), bookingId, nextAppointmentStart = null, onSaved }: Props = $props();
let booking = $state<Booking | null>(null);
let services = $state<BookingService[]>([]);
let loading = $state(false);
let saving = $state(false);
let notes = $state('');
let showRemoveConfirm = $state(false);
let serviceToRemoveIndex = $state(-1);
let serviceToRemoveName = $state('');
let showOverrideModal = $state(false);
let overrideServiceIndex = $state(-1);
let overridePrice = $state('');
let overrideDuration = $state('');
let overrideOriginalPrice = $state(0);
let overrideOriginalDuration = $state(0);
let showAddServiceModal = $state(false);
let availableServices = $state<Service[]>([]);
let loadingServices = $state(false);
$effect(() => {
if (open && bookingId) {
fetchBooking();
}
});
async function fetchBooking() {
loading = true;
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();
booking = {
id: data.id,
start_time: data.start_time,
status: data.status,
notes: data.notes,
created_at: data.created_at,
updated_at: data.updated_at,
created_by: data.created_by,
deposit_required: data.deposit_required ?? false,
deposit_amount: data.deposit_amount,
deposit_paid: data.deposit_paid ?? false,
deposit_deadline: data.deposit_deadline,
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: BookingService) => ({
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,
override_price: s.override_price,
override_duration_minutes: s.override_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
};
services = booking.services || [];
notes = booking.notes || '';
} else {
const text = await response.text();
toast.error('Failed to load booking: ' + text);
}
} catch (err) {
console.error('Error fetching booking:', err);
toast.error('Network error loading booking');
} finally {
loading = false;
}
}
let totalDuration = $derived(
services.reduce((sum, s) => sum + (s.override_duration_minutes ?? s.duration_minutes ?? 0), 0)
);
let totalPrice = $derived(
services.reduce((sum, s) => sum + (s.override_price ?? s.price ?? 0), 0)
);
function confirmRemove(index: number) {
serviceToRemoveIndex = index;
serviceToRemoveName = services[index]?.service_name || 'this service';
showRemoveConfirm = true;
}
function removeService() {
if (serviceToRemoveIndex >= 0 && serviceToRemoveIndex < services.length) {
services = services.filter((_, i) => i !== serviceToRemoveIndex);
}
showRemoveConfirm = false;
serviceToRemoveIndex = -1;
}
function openOverrideModal(index: number) {
overrideServiceIndex = index;
const service = services[index];
overrideOriginalPrice = service.price ?? 0;
overrideOriginalDuration = service.duration_minutes ?? 0;
overridePrice = service.override_price !== undefined ? service.override_price.toFixed(2) : '';
overrideDuration = service.override_duration_minutes !== undefined ? service.override_duration_minutes.toString() : '';
showOverrideModal = true;
}
function handlePriceInput(value: string) {
let cleaned = value.replace(/[^\d.]/g, '');
const parts = cleaned.split('.');
if (parts.length > 2) {
cleaned = parts[0] + '.' + parts.slice(1).join('');
}
if (cleaned.includes('.')) {
const [integer, decimal] = cleaned.split('.');
if (decimal.length > 2) {
cleaned = integer + '.' + decimal.substring(0, 2);
}
}
overridePrice = cleaned;
}
function handleDurationInput(value: string) {
overrideDuration = value.replace(/\D/g, '');
}
function saveOverride() {
if (overrideServiceIndex < 0 || overrideServiceIndex >= services.length) return;
const service = services[overrideServiceIndex];
const newServices = [...services];
const updated = { ...service };
if (overridePrice !== '') {
const price = parseFloat(overridePrice);
if (!isNaN(price)) {
updated.override_price = price;
} else {
delete updated.override_price;
}
} else {
delete updated.override_price;
}
if (overrideDuration !== '') {
const duration = parseInt(overrideDuration);
if (!isNaN(duration) && duration > 0) {
updated.override_duration_minutes = duration;
} else {
delete updated.override_duration_minutes;
}
} else {
delete updated.override_duration_minutes;
}
newServices[overrideServiceIndex] = updated;
services = newServices;
showOverrideModal = false;
overrideServiceIndex = -1;
}
async function fetchAvailableServices() {
loadingServices = true;
try {
const response = await fetch('/api/services', {
headers: {
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (response.ok) {
const data = await response.json();
availableServices = data.services || data;
}
} catch (err) {
console.error('Error fetching services:', err);
toast.error('Failed to load services');
} finally {
loadingServices = false;
}
}
function openAddServiceModal() {
fetchAvailableServices();
showAddServiceModal = true;
}
function addService(service: Service) {
const newService: BookingService = {
booking_id: bookingId,
service_id: service.id,
service_name: service.name,
service_description: service.description,
price: service.price,
duration_minutes: service.duration_minutes
};
services = [...services, newService];
}
function isServiceAdded(serviceId: string): boolean {
return services.some((s) => s.service_id === serviceId);
}
let maxAvailableDuration = $derived.by(() => {
if (!booking?.start_time || !nextAppointmentStart) return null;
const bookingStart = new Date(booking.start_time).getTime();
const currentDurationMs = totalDuration * 60 * 1000;
const bookingEnd = bookingStart + currentDurationMs;
const nextStart = new Date(nextAppointmentStart).getTime();
const availableMs = nextStart - bookingEnd;
return Math.max(0, Math.floor(availableMs / (60 * 1000)));
});
let filteredServices = $derived.by(() => {
return availableServices.filter((s) => {
if (isServiceAdded(s.id)) return false;
if (maxAvailableDuration !== null && s.duration_minutes > maxAvailableDuration) return false;
return true;
});
});
async function handleSave() {
saving = true;
try {
const payload: Record<string, unknown> = {
service_ids: services.map((s) => s.service_id),
service_overrides: services
.filter(
(s) =>
s.override_price !== undefined || s.override_duration_minutes !== undefined
)
.map((s) => ({
service_id: s.service_id,
...(s.override_price !== undefined ? { override_price: s.override_price } : {}),
...(s.override_duration_minutes !== undefined
? { override_duration_minutes: s.override_duration_minutes }
: {})
}))
};
if (notes !== booking?.notes) {
payload.notes = notes || undefined;
}
const response = await fetch(`/api/admin/bookings/${bookingId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify(payload)
});
if (response.ok) {
toast.success('Booking updated successfully');
open = false;
onSaved?.();
} else {
const text = await response.text();
toast.error('Failed to update booking: ' + text);
}
} catch (err) {
console.error('Error saving booking:', err);
toast.error('Network error saving booking');
} finally {
saving = false;
}
}
</script>
<Modal.Root bind:open>
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto md:max-w-3xl">
<Modal.Header>
<div class="flex items-center justify-between">
<div>
<Modal.Title class="text-lg font-semibold">Edit Booking</Modal.Title>
{#if booking}
<div class="mt-1 text-sm text-gray-500">ID: {booking.id}</div>
{/if}
</div>
</div>
</Modal.Header>
{#if loading}
<div class="px-4 pb-4 text-center text-sm text-gray-500">Loading booking...</div>
{:else if booking}
<div class="space-y-6 px-4 pb-4">
<!-- Customer Info -->
<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
</h3>
<div class="grid gap-3 md:grid-cols-2">
<div>
<div class="text-xs text-gray-500">Name</div>
<div class="font-medium">{booking.user?.full_name || '—'}</div>
</div>
<div>
<div class="text-xs text-gray-500">Email</div>
<div class="font-medium break-all">{booking.user?.email || '—'}</div>
</div>
<div>
<div class="text-xs text-gray-500">Phone</div>
<div class="font-medium">{booking.user?.phone || '—'}</div>
</div>
<div>
<div class="text-xs text-gray-500">Time</div>
<div class="font-medium">
{(() => {
const date = new SvelteDate(booking.start_time);
return date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
})()}
</div>
</div>
</div>
</div>
<!-- Services -->
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<div class="mb-3 flex items-center justify-between">
<h3 class="text-sm font-semibold tracking-wide text-gray-600 uppercase">
Services ({services.length})
</h3>
<Button size="sm" onclick={openAddServiceModal}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-1 h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"
clip-rule="evenodd"
/>
</svg>
Add Service
</Button>
</div>
{#if services.length === 0}
<div class="rounded-md border border-dashed border-gray-300 p-4 text-center text-sm text-gray-500">
No services added yet. Click "Add Service" to begin.
</div>
{:else}
<div class="space-y-2">
{#each services as service, index (index)}
<div class="flex items-center justify-between rounded-md border border-gray-300 bg-white p-3">
<div class="flex-1">
<div class="font-medium">{service.service_name}</div>
<div class="mt-1 text-sm text-gray-600">
{service.override_duration_minutes ?? service.duration_minutes} min • £{(service.override_price ?? service.price ?? 0).toFixed(2)}
</div>
{#if service.override_price !== undefined || service.override_duration_minutes !== undefined}
<div class="text-xs text-amber-600">Modified</div>
{/if}
</div>
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
onclick={() => openOverrideModal(index)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"
/>
</svg>
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-red-600 hover:bg-red-50 hover:text-red-700"
onclick={() => confirmRemove(index)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</Button>
</div>
</div>
{/each}
</div>
<div class="mt-3 flex items-center justify-between border-t border-gray-200 pt-3 text-sm">
<span class="text-gray-600">Total: {totalDuration} min</span>
<span class="font-semibold">£{totalPrice.toFixed(2)}</span>
</div>
{/if}
</div>
<!-- Notes -->
<div>
<label for="edit-notes" class="mb-2 block text-sm font-medium">Notes</label>
<Textarea
id="edit-notes"
bind:value={notes}
placeholder="Add any notes about this booking..."
rows={3}
class="w-full"
/>
{#if notes.trim() !== (booking.notes || '')}
<div class="mt-1 text-xs text-emerald-600">
Notes will be saved
</div>
{/if}
</div>
</div>
{/if}
<Modal.Footer class="flex items-center justify-end gap-2">
<Button variant="outline" onclick={() => (open = false)}>Cancel</Button>
<Button onclick={handleSave} disabled={saving || loading}>
{saving ? 'Saving...' : 'Save Changes'}
</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
<!-- Remove Service Confirmation -->
<AlertDialog.Root bind:open={showRemoveConfirm}>
<AlertDialog.Content class="z-[60]">
<AlertDialog.Header>
<AlertDialog.Title>Remove Service?</AlertDialog.Title>
<AlertDialog.Description>
This will remove {serviceToRemoveName} from the booking.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action onclick={removeService} class="bg-red-600 hover:bg-red-700">
Remove
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<!-- Service Override Submodal -->
<Modal.Root open={showOverrideModal} onOpenChange={(v) => (showOverrideModal = v)}>
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto">
<Modal.Header>
<Modal.Title class="text-lg font-semibold">Edit Service</Modal.Title>
<Modal.Description>
{overrideServiceIndex >= 0 ? services[overrideServiceIndex]?.service_name : ''}
</Modal.Description>
</Modal.Header>
<div class="space-y-4 px-4 pb-4">
<div class="grid gap-4 md:grid-cols-2">
<div>
<label for="override-price" class="mb-1 block text-xs text-gray-600">
Price Override
</label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<span class="text-gray-500">£</span>
</div>
<Input
id="override-price"
type="text"
inputmode="decimal"
value={overridePrice}
oninput={(e) => handlePriceInput(e.target.value)}
onblur={() => {
if (overridePrice) {
if (!overridePrice.includes('.')) {
const num = parseFloat(overridePrice);
if (!isNaN(num)) {
overridePrice = num.toFixed(2);
}
} else {
const parts = overridePrice.split('.');
if (parts[1].length === 0) {
overridePrice = parts[0] + '.00';
} else if (parts[1].length === 1) {
overridePrice = parts[0] + '.' + parts[1] + '0';
}
}
}
}}
class="no-spin w-full pl-7"
placeholder={overrideOriginalPrice.toFixed(2)}
/>
</div>
<div class="mt-1 flex items-center justify-between">
<div class="text-xs text-gray-500">
Original: £{overrideOriginalPrice.toFixed(2)}
</div>
{#if overridePrice !== '' && parseFloat(overridePrice) !== overrideOriginalPrice}
<div class="text-xs font-medium text-emerald-600">Changed</div>
{/if}
</div>
</div>
<div>
<label for="override-duration" class="mb-1 block text-xs text-gray-600">
Duration Override (min)
</label>
<div class="relative">
<Input
id="override-duration"
type="number"
min="1"
step="1"
value={overrideDuration}
oninput={(e) => handleDurationInput(e.target.value)}
class="no-spin w-full"
placeholder={overrideOriginalDuration.toString()}
/>
</div>
<div class="mt-1 flex items-center justify-between">
<div class="text-xs text-gray-500">
Original: {overrideOriginalDuration} min
</div>
{#if overrideDuration !== '' && parseInt(overrideDuration) !== overrideOriginalDuration}
<div class="text-xs font-medium text-emerald-600">Changed</div>
{/if}
</div>
</div>
</div>
</div>
<Modal.Footer class="flex items-center justify-end gap-2">
<Button variant="outline" onclick={() => (showOverrideModal = false)}>Cancel</Button>
<Button onclick={saveOverride}>Save</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
<!-- Add Service Submodal -->
<Modal.Root open={showAddServiceModal} onOpenChange={(v) => (showAddServiceModal = v)}>
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto">
<Modal.Header>
<Modal.Title class="text-lg font-semibold">Add Service</Modal.Title>
<Modal.Description>
Select a service to add to this booking.
</Modal.Description>
</Modal.Header>
<div class="space-y-4 px-4 pb-4">
{#if maxAvailableDuration !== null}
<div class="rounded-md border border-blue-200 bg-blue-50 p-3 text-sm">
<div class="font-medium text-blue-800">Available time: {maxAvailableDuration} min</div>
<div class="text-xs text-blue-600">
Current total: {totalDuration} min
</div>
</div>
{/if}
{#if loadingServices}
<div class="text-center text-sm text-gray-500">Loading services...</div>
{:else if filteredServices.length === 0}
<div class="rounded-md border border-dashed border-gray-300 p-4 text-center text-sm text-gray-500">
{#if availableServices.length === 0}
No services available.
{:else}
All services have been added or exceed available time.
{/if}
</div>
{:else}
<div class="space-y-2">
{#each filteredServices as service (service.id)}
<button
type="button"
class="w-full rounded-md border border-gray-300 bg-white p-3 text-left transition-colors hover:bg-gray-50"
onclick={() => addService(service)}
>
<div class="font-medium">{service.name}</div>
<div class="mt-1 text-sm text-gray-600">
{service.duration_minutes} min • £{service.price.toFixed(2)}
</div>
</button>
{/each}
</div>
{/if}
</div>
<Modal.Footer class="flex items-center justify-end">
<Button onclick={() => (showAddServiceModal = false)}>Close</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
<style>
:global(input[type='number']) {
-moz-appearance: textfield;
appearance: textfield;
}
:global(input[type='number']::-webkit-outer-spin-button),
:global(input[type='number']::-webkit-inner-spin-button) {
-webkit-appearance: none;
margin: 0;
}
</style>
@@ -9,10 +9,11 @@
interface Props {
openBookingModal: (bookingId: string) => void;
openEditBookingModal: (bookingId: string, nextAppointmentStart?: string | null) => void;
openUserModal: (userId: string) => void;
}
let { openBookingModal, openUserModal }: Props = $props();
let { openBookingModal, openEditBookingModal, openUserModal }: Props = $props();
type Booking = {
id: string;
@@ -153,7 +154,7 @@
function handleEdit() {
if (currentAppointment) {
openBookingModal(currentAppointment.id);
openEditBookingModal(currentAppointment.id, nextAppointment?.start_time ?? null);
}
}