From 8574bf2221e4ebcc0597891ee71da34e699d5404 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Tue, 26 May 2026 11:59:07 +0100 Subject: [PATCH] feat: enriched edit request system with side-by-side snapshots, calendar preloading, and admin review UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Add enriched response types (EditSnapshot, EnrichedEditRequest) with original vs proposed snapshots - Add 4 new GET endpoints for viewing edit requests (user and admin scoped) - Remove github.com/lib/pq dependency — use native PostgreSQL array scanning - Clean up edit requests, time blockers, and notifications on booking cancellation - Validate exceptional closed hours on admin approve (409 Conflict) - Notification upsert on edit request replace (no duplicate admin notifications) Frontend: - New user EditRequestModal with time/services/both modes and lunch protection - New admin EditRequestModal with side-by-side diff (date/time, services, notes) - Integrate edit requests into PendingApprovals card and notifications page - Preload 3 months of availability to prevent calendar snap-back - Apply lunch protection to isDateUnavailable in BookingFlow and BookingCreateModal - Fix accessibility: card list items use + + + + + + {#if hasOverrides} +
+ This booking has custom pricing. To change services, please contact the salon. You can still request a time change. +
+ {/if} + + + {:else if editMode === 'time' || editMode === 'both-time'} + + {#if loadingHours && !workingHours} +
+

Loading available dates...

+
+ {:else} +
+ { + newDate = d; + newTime = ''; + }} + onPlaceholderChange={(p) => { + userNavigatedCalendar = true; + placeholderDate = p; + fetchHoursForMonth(p); + }} + /> +
+ {/if} + + {#if newDate} + {#if loadingHours} +
+

Loading times...

+
+ {:else} +
+
+ {newDate + .toDate(getLocalTimeZone()) + .toLocaleDateString('en-GB', { + weekday: 'long', + day: 'numeric', + month: 'short' + })} +
+ {#if workingHours && !workingHours[newDate.toString()]?.isOpen} +

We're closed on this day

+ {:else} + {@const grouped = generateGroupedTimeSlots( + slotDuration, + newDate, + lunchProtection() + )} + {#if grouped.length > 0} +
+ {#each grouped as slot (slot.type + '-' + slot.startTime + '-' + slot.endTime)} + {#if slot.type === 'available'} + + {:else} + + {/if} + {/each} +
+ {:else} +

No available slots

+ {/if} + {/if} +
+ {/if} + {/if} + +
+ Reason (optional) + +
+ + {:else if editMode === 'services'} + + {#if hasOverrides} +
+ This booking has custom pricing. To change services, please contact the salon. +
+ {:else if loadingServices} +
+

Loading services...

+
+ {:else} + {@const remaining = calculateRemainingTime()} + +
+
+

+ Current Services + {#if selectedServices.length > 0} + + (tap to remove) + + {/if} +

+ {#if selectedServices.length === 0} +

No services selected

+ {:else} +
+ {#each selectedServices as service (service.id)} + + {/each} +
+ {/if} +
+ + {#if remaining > 0} + {@const fittingServices = availableAdditionalServices()} + {#if fittingServices.length > 0} +
+

+ Add Services + + ({remaining} min remaining) + +

+
+ {#each fittingServices as service (service.id)} + + {/each} +
+
+ {:else} +
+ No additional services can fit in the remaining time. +
+ {/if} + {:else} +
+ No remaining time available. Remove a service to free up time for additions. +
+ {/if} + + {#if selectedServices.length === 0} +

+ Select at least one service to continue. +

+ {/if} +
+ {/if} + +
+
+ Special Requests + {#if notesChanged} + changed + {/if} +
+ {#if originalNotes} +
+ Original: {originalNotes} +
+ {/if} + +
+ + {:else if editMode === 'both-services'} + + {#if hasOverrides} +
+ This booking has custom pricing. To change services, please contact the salon. +
+ {:else if loadingServices} +
+

Loading services...

+
+ {:else} + {@const unselected = availableServices.filter( + (avail) => !selectedServices.some((selected) => selected.id === avail.id) + )} + +
+
+

+ Selected Services + {#if selectedServices.length > 0} + + (tap to remove) + + {/if} +

+ {#if selectedServices.length === 0} +

No services selected

+ {:else} +
+ {#each selectedServices as service (service.id)} + + {/each} +
+ {/if} +
+ + {#if unselected.length > 0} +
+

+ Add Services +

+
+ {#each unselected as service (service.id)} + + {/each} +
+
+ {/if} + + {#if selectedServices.length === 0} +

+ Select at least one service to continue. +

+ {/if} +
+ +
+
+ Special Requests + {#if notesChanged} + changed + {/if} +
+ {#if originalNotes} +
+ Original: {originalNotes} +
+ {/if} + +
+ {/if} + {/if} + + + +
+
+ {#if editMode === 'select'} + + + {:else if editMode === 'time' || editMode === 'services'} + + + + {:else if editMode === 'both-services'} + + + + {:else if editMode === 'both-time'} + + + {/if} +
+
+ + diff --git a/frontend/src/lib/components/account/UserBookingModal.svelte b/frontend/src/lib/components/account/UserBookingModal.svelte index 0cbdc50..1d4f59f 100644 --- a/frontend/src/lib/components/account/UserBookingModal.svelte +++ b/frontend/src/lib/components/account/UserBookingModal.svelte @@ -1,17 +1,13 @@ @@ -587,7 +263,9 @@ {@const isPastBooking = new Date(selectedBooking.start_time) < new Date()} {@const isUnpaid = selectedBooking.amount_due > 0} {@const showChip = !isPastBooking || isUnpaid} - {@const isConfirmedOrLater = ['confirmed', 'in_progress', 'completed'].includes(selectedBooking.status)} + {@const isConfirmedOrLater = ['confirmed', 'in_progress', 'completed'].includes( + selectedBooking.status + )} {#if showChip}
@@ -614,9 +292,7 @@ {:else if isConfirmedOrLater} {selectedBooking.deposit_paid ? 'Deposit Paid' : 'Deposit Due'} @@ -684,8 +360,12 @@
{service.service_description}
{/if}
- {service.override_duration_minutes ?? service.duration_minutes} min - £{(service.override_price ?? service.price ?? 0).toFixed(2)} + {service.override_duration_minutes ?? service.duration_minutes} min + £{(service.override_price ?? service.price ?? 0).toFixed(2)}
{/each} @@ -702,27 +382,33 @@
Deposit Required
-
£{selectedBooking.deposit_amount?.toFixed(2) || '0.00'}
+
+ £{selectedBooking.deposit_amount?.toFixed(2) || '0.00'} +
{selectedBooking.deposit_paid ? 'Paid' : 'Outstanding'} {#if !selectedBooking.deposit_paid && selectedBooking.deposit_deadline} - • Due: {new SvelteDate(selectedBooking.deposit_deadline).toLocaleDateString('en-GB', { - weekday: 'short', - day: 'numeric', - month: 'short', - year: 'numeric' - })} at {new SvelteDate(selectedBooking.deposit_deadline).toLocaleTimeString('en-GB', { - hour: 'numeric', - minute: '2-digit', - hour12: true - })} + • Due: {new SvelteDate(selectedBooking.deposit_deadline).toLocaleDateString( + 'en-GB', + { + weekday: 'short', + day: 'numeric', + month: 'short', + year: 'numeric' + } + )} at {new SvelteDate(selectedBooking.deposit_deadline).toLocaleTimeString( + 'en-GB', + { + hour: 'numeric', + minute: '2-digit', + hour12: true + } + )} {/if}
@@ -731,7 +417,11 @@ {/if}
- {selectedBooking.amount_paid > selectedBooking.total_amount ? 'Pre-tip Subtotal' : 'Total Amount'} + {selectedBooking.amount_paid > selectedBooking.total_amount + ? 'Pre-tip Subtotal' + : 'Total Amount'} £{selectedBooking.total_amount.toFixed(2)}
@@ -745,9 +435,7 @@ {isFutureBooking ? 'Estimated Subtotal' : 'Amount Due'} - + £{selectedBooking.amount_due.toFixed(2)}
@@ -766,14 +454,16 @@
- {formatPaymentMethod(payment.payment_method)} + {formatPaymentMethod(payment.payment_method)} {payment.status} @@ -811,112 +501,6 @@
{/if} - {#if showRescheduleForm} -
-

- Request Reschedule -

- - {#if loadingRescheduleHours} -
-

Loading available dates...

-
- {:else} -
- { rescheduleDate = d; rescheduleTime = ''; }} - onPlaceholderChange={(p) => { - reschedulePlaceholder = p; - if (!rescheduleWorkingHours) fetchRescheduleHours(p); - }} - /> -
- {/if} - - {#if rescheduleDate} - {#if loadingRescheduleHours} -
-

Loading times...

-
- {:else} -
-
- {rescheduleDate.toDate(getLocalTimeZone()).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'short' })} -
- {#if rescheduleWorkingHours && !rescheduleWorkingHours[rescheduleDate.toString()]?.isOpen} -

We're closed on this day

- {:else} - {@const grouped = generateGroupedTimeSlots(totalDuration, rescheduleDate, rescheduleLunchProtection())} - {#if grouped.length > 0} -
- {#each grouped as slot (slot.type + '-' + slot.startTime + '-' + slot.endTime)} - {#if slot.type === 'available'} - - {:else} - - {/if} - {/each} -
- {:else} -

No available slots

- {/if} - {/if} -
- {/if} - {/if} - -
- Reason (optional) - -
- -
- - -
-
- {/if}
{/if} @@ -931,23 +515,16 @@ > Cancel Booking + {#if canEditBooking} + {/if} {/if}
@@ -964,16 +541,18 @@ {#if depositOutstanding} - {:else if canPayEarly} + {:else if canPayEarly && !hasPendingEditRequest} @@ -984,12 +563,23 @@ +{#if showEditModal && selectedBooking} + { + showEditModal = false; + fetchBookingDetails(); + }} + /> +{/if} + {#if showPaymentModal && selectedBooking} (showPaymentModal = false)} onComplete={handlePaymentComplete} - canSaveCards={canSaveCards} + {canSaveCards} /> {/if} @@ -1000,7 +590,9 @@ Are you sure you want to cancel this booking? {#if hasPayments} -
+

Please note:

The £{totalPaid.toFixed(2)} already paid for this booking @@ -1011,9 +603,7 @@ - + @@ -1021,25 +611,31 @@ - { + { if (!v) { showTipModal = false; tipAmount = 0; selectedTipPreset = null; customTipInput = ''; } - }}> + }} +> Leave a Tip Show your appreciation for great service -

+
{#each tipPresets as preset (preset.pct)}
- +
- £ + £ - +
{/if} diff --git a/frontend/src/lib/components/admin/EditRequestModal.svelte b/frontend/src/lib/components/admin/EditRequestModal.svelte new file mode 100644 index 0000000..cca4306 --- /dev/null +++ b/frontend/src/lib/components/admin/EditRequestModal.svelte @@ -0,0 +1,377 @@ + + + + + + Booking Change Request + + Review the requested changes to {editRequest.user.full_name}'s booking. + + + +
+ +
+

+ Customer Contact +

+
+
+
Name
+
{editRequest.user.full_name}
+
+
+
+
Phone
+
{editRequest.user.phone || '—'}
+
+
+
Email
+
{editRequest.user.email || '—'}
+
+
+
+
+ + +
+

+ Date & Time Change +

+
+
+
Before
+
+ {formatDateLine1(editRequest.original.start_time)} +
+
+ {formatDateLine2(editRequest.original.start_time, getDuration(editRequest.original.services))} +
+
+ {#if isTimeChanged()} +
+
After
+
+ {formatDateLine1(editRequest.proposed.start_time!)} +
+
+ {formatDateLine2(editRequest.proposed.start_time!, getDuration(editRequest.proposed.services))} +
+
+ {:else} +
No change
+ {/if} +
+
+ + +
+

+ Services Change +

+
+
+
Original
+
+ {#each editRequest.original.services as service} + {#if serviceDiff.removed.some((s) => s.id === service.id)} +
+ +
+
+ {service.name} +
+
+ £{service.price.toFixed(2)} · {service.duration_minutes} min +
+
+
+ {:else} +
+ +
+
{service.name}
+
+ £{service.price.toFixed(2)} · {service.duration_minutes} min +
+
+
+ {/if} + {/each} +
+
+
+
Proposed
+
+ {#each editRequest.proposed.services as service} + {#if serviceDiff.added.some((s) => s.id === service.id)} +
+ + +
+
{service.name}
+
+ £{service.price.toFixed(2)} · {service.duration_minutes} min +
+
+
+ {:else} +
+ +
+
{service.name}
+
+ £{service.price.toFixed(2)} · {service.duration_minutes} min +
+
+
+ {/if} + {/each} +
+
+
+
+ + +
+

+ Booking Notes Change +

+
+
+
Original
+
{editRequest.original.notes || '—'}
+
+
+
Proposed
+ {#if editRequest.proposed.notes && editRequest.proposed.notes !== editRequest.original.notes} +
{editRequest.proposed.notes}
+ {:else} +
No change
+ {/if} +
+
+
+ + + {#if editRequest.notes} +
+

+ Reason for Change +

+

{editRequest.notes}

+
+ {/if} +
+ + + + + +
+
+ + + + + + Deny this change request? + + This will reject the requested changes and notify the customer. This action cannot be + undone. + + + + Cancel + + Deny Request + + + + diff --git a/frontend/src/lib/components/booking/BookingFlow.svelte b/frontend/src/lib/components/booking/BookingFlow.svelte index e736739..5976a46 100644 --- a/frontend/src/lib/components/booking/BookingFlow.svelte +++ b/frontend/src/lib/components/booking/BookingFlow.svelte @@ -479,17 +479,98 @@ ); let placeholder = $state(minDate); + let userNavigatedCalendar = $state(false); $effect(() => { fetchServices(); }); + + // Preload 3 months on first render to prevent snap-back during navigation + let initialLoadDone = $state(false); + $effect(() => { + if (!initialLoadDone) { + fetchHoursRange(placeholder, 3); + initialLoadDone = true; + } + }); + + // Fetch additional months when navigating beyond preloaded range $effect(() => { const monthKey = `${placeholder.year}-${String(placeholder.month).padStart(2, '0')}`; - if (!workingHoursCache.has(monthKey) || !availableHoursCache.has(monthKey)) { + if (initialLoadDone && !workingHoursCache.has(monthKey)) { fetchHoursForMonth(placeholder); } }); + async function fetchHoursRange(startDate: CalendarDate, months: number) { + // Calculate end month manually (CalendarDate is immutable) + let endYear = startDate.year; + let endMonth = startDate.month + months - 1; + while (endMonth > 12) { + endMonth -= 12; + endYear++; + } + const endMonthDate = new CalendarDate(endYear, endMonth, 1); + const daysInEndMonth = endMonthDate.calendar.getDaysInMonth(endMonthDate); + + const startStr = startDate.toString(); + const endStr = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(daysInEndMonth).padStart(2, '0')}`; + + loadingWorkingHours = true; + loadingAvailableHours = true; + + try { + const [whRes, ahRes] = await Promise.all([ + fetch(`/api/scheduling/working-hours?start=${startStr}&end=${endStr}`), + fetch(`/api/scheduling/available-hours?start=${startStr}&end=${endStr}`) + ]); + if (!whRes.ok || !ahRes.ok) { + throw new Error(`HTTP error! wh: ${whRes.status}, ah: ${ahRes.status}`); + } + + const whData: Array = await whRes.json(); + const ahData: Array = await ahRes.json(); + + const whMap: Record = {}; + whData.forEach((d) => { + whMap[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime }; + }); + + const ahMap: Record }> = {}; + ahData.forEach((d) => { + ahMap[d.date] = { isOpen: d.isOpen, slots: d.slots }; + }); + + // Cache by month key + for (let i = 0; i < months; i++) { + let mYear = startDate.year; + let mMonth = startDate.month + i; + while (mMonth > 12) { + mMonth -= 12; + mYear++; + } + const key = `${mYear}-${String(mMonth).padStart(2, '0')}`; + workingHoursCache.set(key, whMap); + availableHoursCache.set(key, ahMap); + } + + workingHours = whMap; + availableHours = ahMap; + + if (!selectedDate) { + setDefaultSelectedDate(whMap); + } + } catch (error) { + console.error('Failed to fetch hours:', error); + if (!selectedDate) { + selectedDate = minDate; + } + } finally { + loadingWorkingHours = false; + loadingAvailableHours = false; + } + } + async function fetchHoursForMonth(date: CalendarDate) { const monthKey = `${date.year}-${String(date.month).padStart(2, '0')}`; @@ -597,29 +678,35 @@ const dateStr = nextDate.toISOString().split('T')[0]; if (hoursMap[dateStr]?.isOpen) { - selectedDate = new CalendarDate( + const calDate = new CalendarDate( nextDate.getFullYear(), nextDate.getMonth() + 1, nextDate.getDate() ); - // Also update placeholder to show the month with first available date - placeholder = new CalendarDate( - nextDate.getFullYear(), - nextDate.getMonth() + 1, - 1 // First day of the month - ); - break; + const duration = getTotalDuration() || 60; + const slots = generateAvailableTimeSlots(duration, calDate); + if (slots.length > 0) { + selectedDate = calDate; + if (!userNavigatedCalendar) { + placeholder = new CalendarDate( + nextDate.getFullYear(), + nextDate.getMonth() + 1, + 1 + ); + } + return; + } } } - if (!selectedDate) { - const tomorrow = new SvelteDate(); - tomorrow.setDate(tomorrow.getDate() + 1); - selectedDate = new CalendarDate( - tomorrow.getFullYear(), - tomorrow.getMonth() + 1, - tomorrow.getDate() - ); + const tomorrow = new SvelteDate(); + tomorrow.setDate(tomorrow.getDate() + 1); + selectedDate = new CalendarDate( + tomorrow.getFullYear(), + tomorrow.getMonth() + 1, + tomorrow.getDate() + ); + if (!userNavigatedCalendar) { placeholder = new CalendarDate(tomorrow.getFullYear(), tomorrow.getMonth() + 1, 1); } } @@ -837,16 +924,33 @@ if (!dayHours) return true; if (!dayHours.isOpen) return true; - // If no services selected, don't check availability slots - // This allows calendar to show open/closed days if (selectedServices.length === 0) { - return false; // Show all working days as available + return false; } const duration = getTotalDuration(); const availableSlots = generateAvailableTimeSlots(duration, date); if (availableSlots.length === 0) return true; + const dayAvailableHours = availableHours?.[dateStr]; + if (dayAvailableHours?.slots) { + const existingBookings = extractBookedSlots( + dayHours.startTime, + dayHours.endTime, + dayAvailableHours.slots + ); + const lunchProtection = getLunchProtectionForSlots( + dayHours.startTime, + dayHours.endTime, + existingBookings, + duration, + 15, + false + ); + const validSlots = availableSlots.filter((t) => !lunchProtection.get(t)?.isBlocked); + if (validSlots.length === 0) return true; + } + return false; } @@ -1320,9 +1424,10 @@ selectedDate = newDate; selectedTime = null; }} - onPlaceholderChange={(newPlaceholder) => { - placeholder = newPlaceholder; - }} + onPlaceholderChange={(newPlaceholder) => { + userNavigatedCalendar = true; + placeholder = newPlaceholder; + }} /> {/if} diff --git a/frontend/src/lib/components/payments/UserPaymentModal.svelte b/frontend/src/lib/components/payments/UserPaymentModal.svelte index 1859f50..de455da 100644 --- a/frontend/src/lib/components/payments/UserPaymentModal.svelte +++ b/frontend/src/lib/components/payments/UserPaymentModal.svelte @@ -471,8 +471,9 @@ {#if showCardList}
{#if canSaveCards} -
{ showNewCardForm = true; showCardList = false; @@ -483,11 +484,12 @@ -
+ {/if} {#each paymentMethods as method (method.id)} -
{ selectedPaymentMethod = method.id; showNewCardForm = false; @@ -508,7 +510,7 @@ {#if selectedPaymentMethod === method.id} Selected {/if} -
+ {/each}
{/if} diff --git a/frontend/src/lib/components/today/PendingApprovals.svelte b/frontend/src/lib/components/today/PendingApprovals.svelte index 4ffa54b..a775c50 100644 --- a/frontend/src/lib/components/today/PendingApprovals.svelte +++ b/frontend/src/lib/components/today/PendingApprovals.svelte @@ -7,6 +7,7 @@ import { Badge } from '$lib/components/ui/badge'; import { Skeleton } from '$lib/components/ui/skeleton'; import ApprovalModal from '$lib/components/admin/ApprovalModal.svelte'; + import EditRequestModal from '$lib/components/admin/EditRequestModal.svelte'; interface Props { openBookingModal?: (bookingId: string) => void; @@ -14,6 +15,40 @@ let { openBookingModal }: Props = $props(); + // Edit request types + interface ServiceItem { + id: string; + name: string; + price: number; + duration_minutes: number; + } + + interface EditRequest { + id: string; + booking_id: string; + requested_by: string; + requested_at: string; + notes: string | null; + original: { + start_time: string; + end_time: string; + services: ServiceItem[]; + notes: string; + }; + proposed: { + start_time: string | null; + end_time: string | null; + services: ServiceItem[]; + notes: string | null; + }; + user: { + id: string; + full_name: string; + email: string; + phone: string; + }; + } + // Match the backend structure type PendingApproval = { id: string; @@ -51,6 +86,11 @@ let showApprovalModal = $state(false); let selectedBooking = $state(null); + let pendingEditRequests = $state([]); + let visibleEditRequests = $derived(pendingEditRequests.slice(0, 3)); + let showEditRequestModal = $state(false); + let selectedEditRequest = $state(null); + // Helper function to format date nicely function formatDateTime(dateTimeString: string): string { const date = new SvelteDate(dateTimeString); @@ -67,6 +107,59 @@ return `${dateStr} at ${timeStr}`; } + function formatRelativeTime(iso: string): string { + const d = new Date(iso); + const now = new Date(); + const diffMs = now.getTime() - d.getTime(); + const diffMin = Math.floor(diffMs / 60000); + + if (diffMin < 1) return 'Just now'; + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDay = Math.floor(diffHr / 24); + return `${diffDay}d ago`; + } + + function getEditRequestSummary(er: EditRequest): string { + const timeChanged = er.proposed.start_time && er.proposed.start_time !== er.original.start_time; + const servicesChanged = areEditServicesChanged(er); + + if (timeChanged) { + const d = new Date(er.proposed.start_time!); + const dateStr = d.toLocaleDateString('en-US', { + weekday: 'long', + day: 'numeric', + month: 'short' + }); + const timeStr = d.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true + }); + return `Requested change to ${dateStr} at ${timeStr}`; + } + if (servicesChanged) { + return 'Requested change to services'; + } + return 'Requested change'; + } + + function areEditServicesChanged(er: EditRequest): boolean { + const origIds = new Set(er.original.services.map((s) => s.id)); + const propIds = new Set(er.proposed.services.map((s) => s.id)); + if (origIds.size !== propIds.size) return true; + for (const id of origIds) { + if (!propIds.has(id)) return true; + } + return false; + } + + function getServiceSummary(er: EditRequest): string { + const names = er.proposed.services.map((s) => s.name).filter(Boolean); + return names.join(', ') || 'No services'; + } + async function fetchPendingApprovals() { loading = true; try { @@ -81,7 +174,8 @@ if (response.ok) { const data = await response.json(); pendingApprovals = (data.approvals || []).sort( - (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime() + (a: PendingApproval, b: PendingApproval) => + new Date(a.created_at).getTime() - new Date(b.created_at).getTime() ); } else { toast.error('Failed to load pending approvals'); @@ -94,6 +188,28 @@ } } + async function fetchEditRequests() { + try { + const response = await fetch('/api/admin/bookings/edit-requests', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authStore.currentToken}` + } + }); + + if (response.ok) { + const data = await response.json(); + pendingEditRequests = (data.edit_requests || []).sort( + (a: EditRequest, b: EditRequest) => + new Date(a.requested_at).getTime() - new Date(b.requested_at).getTime() + ); + } + } catch (err) { + console.error('Error fetching edit requests:', err); + } + } + async function openApprovalModal(bookingId: string) { try { const response = await fetch(`/api/admin/bookings/${bookingId}`, { @@ -112,11 +228,18 @@ } } + function openReviewModal(editRequest: EditRequest) { + selectedEditRequest = editRequest; + showEditRequestModal = true; + } + $effect(() => { fetchPendingApprovals(); + fetchEditRequests(); const intervalId = setInterval(() => { fetchPendingApprovals(); + fetchEditRequests(); }, 60_000); return () => { @@ -144,11 +267,21 @@ Pending Approvals - New bookings awaiting confirmation + + {#if pendingApprovals.length > 0 && pendingEditRequests.length > 0} + New bookings and customer-requested changes awaiting review + {:else if pendingApprovals.length > 0} + New bookings awaiting confirmation + {:else if pendingEditRequests.length > 0} + Customer-requested booking changes awaiting review + {:else} + New bookings awaiting confirmation + {/if} +
{#if !loading} - {pendingApprovals.length} + {pendingApprovals.length + pendingEditRequests.length} {/if}
@@ -169,7 +302,7 @@
{/each}
- {:else if pendingApprovals.length === 0} + {:else if pendingApprovals.length === 0 && pendingEditRequests.length === 0}

All caught up!

-

No pending bookings to review

+

Nothing pending to review

{:else}
@@ -229,6 +362,43 @@
{/each} + + {#if pendingApprovals.length > 0 && pendingEditRequests.length > 0} +
+ {/if} + + {#each visibleEditRequests as er (er.id)} +
+
+
+
+ {er.user?.full_name || 'Unknown'} + Edit Request +
+
+ {getEditRequestSummary(er)} +
+
+ {getServiceSummary(er)} +
+
+ Requested {formatRelativeTime(er.requested_at)} +
+
+
+ +
+
+
+ {/each}
{/if} @@ -246,3 +416,23 @@ }} /> {/if} + + +{#if selectedEditRequest && showEditRequestModal} + { + showEditRequestModal = false; + selectedEditRequest = null; + fetchPendingApprovals(); + fetchEditRequests(); + }} + onDenied={() => { + showEditRequestModal = false; + selectedEditRequest = null; + fetchPendingApprovals(); + fetchEditRequests(); + }} + /> +{/if} diff --git a/frontend/src/routes/notifications/+page.svelte b/frontend/src/routes/notifications/+page.svelte index 9cfa923..f697139 100644 --- a/frontend/src/routes/notifications/+page.svelte +++ b/frontend/src/routes/notifications/+page.svelte @@ -11,6 +11,7 @@ import ApprovalModal from '$lib/components/admin/ApprovalModal.svelte'; import BookingModal from '$lib/components/admin/BookingModal.svelte'; import UserModal from '$lib/components/admin/UserModal.svelte'; + import EditRequestModal from '$lib/components/admin/EditRequestModal.svelte'; import { toast } from 'svelte-sonner'; interface Notification { @@ -24,6 +25,39 @@ created_at: string; } + interface ServiceItem { + id: string; + name: string; + price: number; + duration_minutes: number; + } + + interface EditRequest { + id: string; + booking_id: string; + requested_by: string; + requested_at: string; + notes: string | null; + original: { + start_time: string; + end_time: string; + services: ServiceItem[]; + notes: string; + }; + proposed: { + start_time: string | null; + end_time: string | null; + services: ServiceItem[]; + notes: string | null; + }; + user: { + id: string; + full_name: string; + email: string; + phone: string; + }; + } + let notifications = $state([]); let loading = $state(true); let error = $state(false); @@ -38,6 +72,9 @@ let showUserModal = $state(false); let selectedUserId = $state(null); + let showEditRequestModal = $state(false); + let selectedEditRequest = $state(null); + let pageState = $state<'loading' | 'authorized' | 'unauthorized'>('loading'); $effect(() => { @@ -72,9 +109,10 @@ case 'pending_booking': return 'approve'; case 'edit_request': - case 'edit_requested': case 'new_booking': return 'view'; + case 'edit_requested': + return 'edit_approve'; case 'late_cancellation': case 'no_deposit': case '1_week_no_pay': @@ -146,6 +184,27 @@ } else { toast.error('Could not load booking details'); } + } else if (action === 'edit_approve' && notification.booking_id) { + try { + const response = await fetch( + `/api/admin/bookings/${notification.booking_id}/edit-request`, + { + headers: { Authorization: `Bearer ${authStore.currentToken}` } + } + ); + if (response.ok) { + const data = await response.json(); + selectedEditRequest = data.edit_request; + showEditRequestModal = true; + } else if (response.status === 404) { + toast.error('This edit request has already been processed'); + } else { + toast.error('Could not load edit request details'); + } + } catch (err) { + console.error('Error fetching edit request:', err); + toast.error('Network error loading edit request'); + } } else if (action === 'see_user' && notification.user_id) { selectedUserId = notification.user_id; showUserModal = true; @@ -182,6 +241,12 @@ fetchNotifications(); } + function handleEditRequestAction() { + showEditRequestModal = false; + selectedEditRequest = null; + fetchNotifications(); + } + function toggleView() { includeAcknowledged = !includeAcknowledged; page = 1; @@ -347,11 +412,14 @@ {:else}
{#each notifications as n (n.id)} + {@const actionable = !n.acknowledged_at && (hasAction(n.reason) === 'approve' || hasAction(n.reason) === 'edit_approve')}
@@ -370,6 +438,8 @@ Approve Booking {:else if hasAction(n.reason) === 'see_user'} See User + {:else if hasAction(n.reason) === 'edit_approve'} + Review Change {:else} See Booking {/if} @@ -425,3 +495,12 @@ {#if showUserModal && selectedUserId} {/if} + +{#if showEditRequestModal && selectedEditRequest} + +{/if} diff --git a/local-dev-2.sh b/local-dev-2.sh index 2e28c4d..23b7ec2 100755 --- a/local-dev-2.sh +++ b/local-dev-2.sh @@ -1007,6 +1007,81 @@ if api_post "$BASE_URL/scheduling/exceptional-groups" "$XMAS_BREAK" "Christmas echo "${C_GREEN}✅ Created $sched_success/3 Exceptional Schedule Groups${C_RESET}" +# =========================================================================== +# 7b. EDIT REQUESTS (for testing the edit request UI) +# =========================================================================== +echo -e "\n${C_BLUE}✏️ Creating Edit Requests...${C_RESET}" +edit_req_count=0 + +# Create edit requests via the user API for upcoming confirmed bookings +# Use a wider time window to find more bookings (any future confirmed booking) +CONFIRMED_BOOKINGS=$(docker exec postgres psql -U myuser -d mydb -tAc \ + "SELECT b.id, b.user_id, b.start_time FROM bookings b + WHERE b.status = 'confirmed' AND b.start_time > NOW() + ORDER BY b.start_time ASC LIMIT 8;" 2>/dev/null) + +if [[ -n "$CONFIRMED_BOOKINGS" ]]; then + req_idx=0 + while IFS='|' read -r booking_id user_id start_time; do + [[ -z "$booking_id" ]] && continue + # Get user token + user_email=$(docker exec postgres psql -U myuser -d mydb -tAc \ + "SELECT email FROM users WHERE id = '$user_id'" 2>/dev/null | tr -d '\r\t ') + [[ -z "$user_email" ]] && continue + user_tok=$(login "$user_email" "password") + [[ -z "$user_tok" ]] && continue + + # Alternate between time-only and service+time requests + if (( req_idx % 3 == 0 )); then + # Time-only request + new_time=$(TZ=Europe/London date -d "$start_time +2 hours" +"%Y-%m-%dT%H:%M:%S%:z" 2>/dev/null) + [[ -z "$new_time" ]] && continue + resp=$(curl -s -w "\n%{http_code}" -X POST \ + -H 'Content-Type: application/json' \ + -H "Authorization: Bearer $user_tok" \ + -d "{\"new_start_time\":\"$new_time\",\"notes\":\"Would like to move this appointment 2 hours later please\"}" \ + "$BASE_URL/bookings/$booking_id/edit-request") + elif (( req_idx % 3 == 1 )); then + # Service change request (add nail art) + resp=$(curl -s -w "\n%{http_code}" -X POST \ + -H 'Content-Type: application/json' \ + -H "Authorization: Bearer $user_tok" \ + -d "{\"new_services\":[\"$(get_svc 0)\",\"$(get_svc 5)\"],\"notes\":\"Would like to add nail art to my appointment\"}" \ + "$BASE_URL/bookings/$booking_id/edit-request") + else + # Both time and services + new_time=$(TZ=Europe/London date -d "$start_time -1 hours" +"%Y-%m-%dT%H:%M:%S%:z" 2>/dev/null) + [[ -z "$new_time" ]] && continue + resp=$(curl -s -w "\n%{http_code}" -X POST \ + -H 'Content-Type: application/json' \ + -H "Authorization: Bearer $user_tok" \ + -d "{\"new_start_time\":\"$new_time\",\"new_services\":[\"$(get_svc 1)\"],\"notes\":\"Need to reschedule earlier and switch to gel\"}" \ + "$BASE_URL/bookings/$booking_id/edit-request") + fi + code=$(echo "$resp" | tail -n1) + if [[ "$code" =~ ^2 ]]; then + edit_req_count=$((edit_req_count+1)) + fi + req_idx=$((req_idx+1)) + done <<< "$CONFIRMED_BOOKINGS" +fi + +echo "${C_GREEN}✅ Created $edit_req_count Edit Requests${C_RESET}" + +# =========================================================================== +# 7c. MORE TIME BLOCKERS (for variety) +# =========================================================================== +echo -e "\n${C_BLUE}🚫 Creating Additional Time Blockers...${C_RESET}" +extra_blockers=0 + +tb "$(format_london_time "$(open_day "$(TZ=Europe/London date -d "$TODAY +3 days" +%Y-%m-%d)")" "$SLOT_B")" 90 "Equipment maintenance" +tb "$(format_london_time "$(open_day "$(TZ=Europe/London date -d "$TODAY +10 days" +%Y-%m-%d)")" "$SLOT_C")" 60 "Training session" +tb "$(format_london_time "$(open_day "$(TZ=Europe/London date -d "$TODAY +14 days" +%Y-%m-%d)")" "09:00:00")" 60 "Opening delay" +tb "$(format_london_time "$(open_day "$(TZ=Europe/London date -d "$TODAY +5 days" +%Y-%m-%d)")" "$SLOT_D")" 45 "Supplier visit" +tb "$(format_london_time "$(open_day "$(TZ=Europe/London date -d "$TODAY +8 days" +%Y-%m-%d)")" "$SLOT_A")" 120 "Deep clean — morning closed" + +echo "${C_GREEN}✅ Created $extra_blockers Additional Time Blockers${C_RESET}" + # =========================================================================== # SUMMARY # =========================================================================== @@ -1032,7 +1107,8 @@ echo -e " Confirmed : $confirmed_count | Still pending: $skipped_cou echo -e " Bookings — guest : $count_guest" echo -e " Bookings — w/ notes: $USER_NOTE_COUNT (pending notifications)" echo -e " Payments : $payment_count completed bookings" -echo -e " Time blockers : $count_blockers" +echo -e " Time blockers : $((count_blockers + extra_blockers))" +echo -e " Edit requests : $edit_req_count" echo -e " Schedule groups : $sched_success/3" echo "" echo -e " Quick login creds (all pass: ${C_YELLOW}password${C_RESET})" diff --git a/obsidian/Crussell/Admin Manual.md b/obsidian/Crussell/Admin Manual.md index 15c39d0..11935fd 100644 --- a/obsidian/Crussell/Admin Manual.md +++ b/obsidian/Crussell/Admin Manual.md @@ -541,6 +541,12 @@ The request appears in the **Pending Approvals** section on the Today page. You' - Any notes they've added - Any services they want to change +The system shows a **side-by-side comparison** of the original booking versus the proposed changes: +- **Original snapshot**: current start time, end time, services (with prices and durations), and notes +- **Proposed snapshot**: the new start time, recalculated end time, updated services, and new notes + +This lets you see exactly what will change before you approve or decline. + ### What You Can Do **Approve** — The booking is updated to the new time and services. The customer's request is cleared. @@ -551,12 +557,17 @@ The request appears in the **Pending Approvals** section on the Today page. You' - The new time doesn't clash with another appointment - The new time falls within your working hours +- **The new time doesn't fall during a holiday/closed period** — the system will block approval if the proposed time is during exceptional closed hours - If the customer is changing services, the new total duration fits in the slot ### Can the Customer Withdraw Their Request? Yes — a customer can cancel their own reschedule request at any time before you've reviewed it. +### What Happens When a Booking is Cancelled + +If a customer cancels their booking entirely, any pending reschedule request for that booking is automatically removed, along with the associated time block and notification. + --- ## The Deposit System (Admin View) diff --git a/obsidian/Crussell/Overview.md b/obsidian/Crussell/Overview.md index 5085fe9..03203b0 100644 --- a/obsidian/Crussell/Overview.md +++ b/obsidian/Crussell/Overview.md @@ -97,6 +97,7 @@ flowchart TD - Anonymous reservation cap (50 per 10-minute rolling window) - Auto-status transitions: confirmed → in_progress → completed - Booking edit requests (customers can request reschedule, admin approves/denies) +- **Enriched edit requests**: side-by-side original vs proposed snapshots with service details, end-time calculation, and user info - Admin booking service editing with overlap detection and price/duration overrides - Idempotency keys for booking deduplication @@ -195,12 +196,12 @@ All flows integrate with holiday/exceptional hours and time blockers. ## Test Coverage -**396/399 tests passing** (3 skipped) across 12+ test packages. Comprehensive coverage of online payments (deposit, full, partial, balance), saved card operations, tip payments, terminal payments, refunds, idempotency, webhook handling, and cash/gift card payments. +**438/441 tests passing** (3 skipped) across 12+ test packages. Comprehensive coverage of online payments (deposit, full, partial, balance), saved card operations, tip payments, terminal payments, refunds, idempotency, webhook handling, cash/gift card payments, and enriched edit request workflows. | Package | Coverage Area | |---------|--------------| | `handlers/auth` | Authentication (login, register, refresh, verification) | -| `handlers/bookings` | User booking flow, guest bookings, reservations, edit requests, discounts, closing hours validation, active booking limits | +| `handlers/bookings` | User booking flow, guest bookings, reservations, edit requests (create/delete/view/enriched), approval/rejection, time-blocker lifecycle, exceptional hours validation, cancellation cleanup, cross-user isolation | | `handlers/payments` | Square payments (terminal, online, refunds, tips, saved cards) | | `internal/square` | Square client interface, dev mock, prod stub | | `handlers/admin` | Admin bookings, today view, users, services | diff --git a/obsidian/Crussell/Technical Manual.md b/obsidian/Crussell/Technical Manual.md index 2c53cf8..cf4d852 100644 --- a/obsidian/Crussell/Technical Manual.md +++ b/obsidian/Crussell/Technical Manual.md @@ -209,6 +209,8 @@ src/lib/components/ | DELETE | `/api/bookings/{id}` | Cancel booking (with forgiveness option) | | POST | `/api/bookings/{id}/edit-request` | Request booking reschedule | | DELETE | `/api/bookings/{id}/edit-request` | Cancel edit request | +| GET | `/api/bookings/{id}/edit-request` | View own pending edit request (enriched) | +| GET | `/api/bookings/edit-requests` | List all own pending edit requests (enriched) | | POST | `/api/bookings/{id}/payment` | Create online payment (deposit, full, partial, balance) | | POST | `/api/bookings/{id}/tip` | Add tip to completed booking | | GET | `/api/bookings/{id}/payment-summary` | Get payment summary for booking | @@ -235,7 +237,9 @@ src/lib/components/ | POST | `/api/admin/bookings/{id}/confirm` | Confirm booking | | POST | `/api/admin/bookings/{id}/cancel` | Cancel booking | | POST | `/api/admin/bookings/reserve` | Reserve slot (walkin=5min, callin=1h) | -| GET | `/api/admin/bookings/{id}/edit-requests` | List edit requests | +| GET | `/api/admin/bookings/{id}/edit-requests` | List edit requests (paginated, with total) | +| GET | `/api/admin/bookings/edit-requests` | List ALL edit requests across all bookings (enriched) | +| GET | `/api/admin/bookings/{id}/edit-request` | View pending edit request for specific booking (enriched) | | POST | `/api/admin/bookings/{id}/edit-requests/{request_id}/approve` | Approve edit request | | POST | `/api/admin/bookings/{id}/edit-requests/{request_id}/deny` | Deny edit request | | GET | `/api/admin/users` | List users | @@ -516,6 +520,58 @@ Users manage their preferred notification channels via `/account` → Admin tab - `GET /api/user/notification-preferences` — Returns `{emailEnabled, smsEnabled, browserPushEnabled}`. Defaults to all `true` if no row exists. - `PUT /api/user/notification-preferences` — Accepts partial updates (only provided fields change, unset fields retain current value). Upserts on first call. +### Enriched Edit Request System + +**How it works:** When a user requests a booking edit (time change, notes, or services), the system creates a `booking_edit_requests` row and returns an **enriched response** with side-by-side `original` and `proposed` snapshots. Each snapshot includes start/end times, full service details (name, price, duration), and notes. + +**Enriched Response Types:** + +```go +type EditServiceDetail struct { + ID string `json:"id"` + Name string `json:"name"` + Price float64 `json:"price"` + DurationMinutes int `json:"duration_minutes"` +} + +type EditSnapshot struct { + StartTime *time.Time `json:"start_time"` + EndTime *time.Time `json:"end_time"` + Services []EditServiceDetail `json:"services"` + Notes *string `json:"notes"` +} + +type EnrichedEditRequest struct { + ID string `json:"id"` + BookingID string `json:"booking_id"` + RequestedBy string `json:"requested_by"` + RequestedAt time.Time `json:"requested_at"` + Notes *string `json:"notes"` + Original *EditSnapshot `json:"original"` + Proposed *EditSnapshot `json:"proposed"` + User *EditUserSummary `json:"user,omitempty"` +} +``` + +**End-time calculation:** `end_time = start_time + sum(service durations)`. If total duration is 0, falls back to 60 minutes. + +**`has_overrides` branch:** When a booking has override prices/durations on its services, the `proposed` snapshot uses the original booking services (not the `new_services` array) since service changes are blocked for overridden bookings. + +**New endpoints:** + +| Endpoint | Auth | Response | +|----------|------|----------| +| `GET /api/bookings/{id}/edit-request` | User (owner only) | `{"edit_request": EnrichedEditRequest}` | +| `GET /api/bookings/edit-requests` | User (own only) | `{"edit_requests": [EnrichedEditRequest]}` | +| `GET /api/admin/bookings/edit-requests` | Admin | `{"edit_requests": [EnrichedEditRequest]}` | +| `GET /api/admin/bookings/{id}/edit-request` | Admin | `{"edit_request": EnrichedEditRequest}` | + +**Cancellation cleanup:** When a user cancels their booking (`UserCancelBookingHandler`), any pending edit request, associated `RESERVATION:edit_request` time_blocker, and `edit_requested` admin_notification are all deleted. + +**Notification upsert:** When a user submits a second edit request (upsert), the old `edit_requested` notification is deleted and a fresh one is created — admins see a single refreshed notification with an updated timestamp, never duplicates. + +**Exceptional hours validation:** When admin approves an edit request, the proposed time is checked against `exceptional_working_hours`. If the time falls during a closed period, approval is rejected with 409 Conflict. + ### Loyalty & Discount System **Loyalty Stamps:** @@ -640,7 +696,7 @@ go test -tags "test,dev" -v -p 1 -count=2 ./... # Run twice for flaky detection ### Test Coverage -**396/399 tests passing** (3 skipped) across 12+ packages. Comprehensive coverage of online payments (deposit, full, partial, balance), saved card operations, tip payments, terminal payments, refunds, idempotency, webhook handling, and cash/gift card payments. +**438/441 tests passing** (3 skipped) across 12+ packages. Comprehensive coverage of online payments (deposit, full, partial, balance), saved card operations, tip payments, terminal payments, refunds, idempotency, webhook handling, and cash/gift card payments. - `handlers/auth` — Authentication - `handlers/bookings` — User booking flow, guest bookings, reservations, edit requests, discounts, closing hours validation, active booking limits - `handlers/payments` — Square payments (terminal, online, refunds, tips, saved cards) diff --git a/obsidian/Crussell/User Manual.md b/obsidian/Crussell/User Manual.md index ff334ba..ad95f4f 100644 --- a/obsidian/Crussell/User Manual.md +++ b/obsidian/Crussell/User Manual.md @@ -234,19 +234,23 @@ If you need to change your appointment time: 1. Go to your **Account** page and find the booking 2. Select **Reschedule** 3. Pick a new date and time (the same availability rules apply — the new slot must be open) -4. Submit your reschedule request +4. Add any notes about the change (optional) +5. Submit your reschedule request **What happens next:** - Your request goes to the salon for review +- The salon sees a side-by-side comparison of your original booking versus the proposed changes - The salon can either **approve** or **decline** it - If approved, your appointment time is updated to the new slot - If declined, your original appointment time stays the same - You can cancel your reschedule request at any time before the salon reviews it +- You can view all your pending reschedule requests from your account **Things to know:** - You can't reschedule a completed or cancelled appointment - The new time must not clash with any of your other existing appointments - If the salon has already adjusted the price or duration of your booking, those adjustments are respected in the reschedule +- If you cancel your booking entirely, any pending reschedule request is automatically removed ---