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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import CallInBooking from '$lib/components/admin/CallInBooking.svelte';
|
||||
import WalkInBooking from '$lib/components/admin/WalkInBooking.svelte';
|
||||
import BookingModal from '$lib/components/admin/BookingModal.svelte';
|
||||
import EditBookingModal from '$lib/components/admin/EditBookingModal.svelte';
|
||||
import UserModal from '$lib/components/admin/UserModal.svelte';
|
||||
|
||||
// =============== Auth & Permissions ===============
|
||||
@@ -42,15 +43,23 @@
|
||||
|
||||
// =============== Modal State ===============
|
||||
let showBookingModal = $state(false);
|
||||
let showEditBookingModal = $state(false);
|
||||
let showUserModal = $state(false);
|
||||
let selectedBookingId = $state<string | null>(null);
|
||||
let selectedUserId = $state<string | null>(null);
|
||||
let editBookingNextStart = $state<string | null>(null);
|
||||
|
||||
function openBookingModal(bookingId: string) {
|
||||
selectedBookingId = bookingId;
|
||||
showBookingModal = true;
|
||||
}
|
||||
|
||||
function openEditBookingModal(bookingId: string, nextAppointmentStart: string | null = null) {
|
||||
selectedBookingId = bookingId;
|
||||
editBookingNextStart = nextAppointmentStart;
|
||||
showEditBookingModal = true;
|
||||
}
|
||||
|
||||
function openUserModal(userId: string) {
|
||||
selectedUserId = userId;
|
||||
showUserModal = true;
|
||||
@@ -104,7 +113,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Current/Next Appointment Card (Full Width) -->
|
||||
<CurrentAppointment {openBookingModal} {openUserModal} />
|
||||
<CurrentAppointment {openBookingModal} {openEditBookingModal} {openUserModal} />
|
||||
|
||||
<!-- quick booking Grid -->
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
@@ -148,6 +157,18 @@
|
||||
<BookingModal bind:open={showBookingModal} bookingId={selectedBookingId} />
|
||||
{/if}
|
||||
|
||||
{#if showEditBookingModal && selectedBookingId}
|
||||
<EditBookingModal
|
||||
bind:open={showEditBookingModal}
|
||||
bookingId={selectedBookingId}
|
||||
nextAppointmentStart={editBookingNextStart}
|
||||
onSaved={() => {
|
||||
// Refresh the page data after save
|
||||
showBookingModal = false;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showUserModal && selectedUserId}
|
||||
<UserModal bind:open={showUserModal} userId={selectedUserId} {openBookingModal} />
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user