feat: enriched edit request system with side-by-side snapshots, calendar preloading, and admin review UI

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 <button> instead of <div>

Dev & Docs:
- Seed edit requests in local-dev-2.sh
- Update all Obsidian manuals with enriched edit request documentation
- 42 new tests (438/441 passing)
This commit is contained in:
2026-05-26 11:59:07 +01:00
parent 3ccc017716
commit 8574bf2221
19 changed files with 5270 additions and 599 deletions
File diff suppressed because it is too large Load Diff
@@ -1,17 +1,13 @@
<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { SvelteDate } from 'svelte/reactivity';
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
import { toast } from 'svelte-sonner';
import * as Modal from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import * as Textarea from '$lib/components/ui/textarea';
import * as Label from '$lib/components/ui/label';
import DatePicker from '$lib/components/booking/DatePicker.svelte';
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
import type { Booking, WorkingHoursDay, AvailableHoursDay } from '$lib/types/booking';
import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection';
import EditRequestModal from '$lib/components/account/EditRequestModal.svelte';
import type { Booking } from '$lib/types/booking';
interface Props {
open: boolean;
@@ -22,76 +18,37 @@
let selectedBooking = $state<Booking | null>(null);
let loading = $state(false);
let hasPendingEditRequest = $state(false);
let showEditModal = $state(false);
let showCancelConfirm = $state(false);
let cancelling = $state(false);
let showRescheduleForm = $state(false);
let rescheduleDate = $state<CalendarDate | undefined>(undefined);
let rescheduleTime = $state('');
let rescheduleNotes = $state('');
let rescheduleSubmitting = $state(false);
let rescheduleWorkingHours = $state<Record<string, { isOpen: boolean; startTime: string; endTime: string }> | null>(null);
let rescheduleAvailableHours = $state<Record<string, { isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }> | null>(null);
let loadingRescheduleHours = $state(false);
const today = new Date();
const minDate = new CalendarDate(today.getFullYear(), today.getMonth() + 1, today.getDate());
const maxDate = new Date();
maxDate.setMonth(today.getMonth() + 6);
const maxCalendarDate = new CalendarDate(maxDate.getFullYear(), maxDate.getMonth() + 1, maxDate.getDate());
let reschedulePlaceholder = $state<CalendarDate>(minDate);
// IMPORTANT: Use override_duration_minutes when present — services may have been
// customised at booking time. Showing base values misleads users about what was booked.
let totalDuration = $derived(
selectedBooking?.services?.reduce((sum, service) => sum + (service.override_duration_minutes ?? service.duration_minutes ?? 0), 0) || 0
selectedBooking?.services?.reduce(
(sum, service) => sum + (service.override_duration_minutes ?? service.duration_minutes ?? 0),
0
) || 0
);
const rescheduleLunchProtection = $derived(() => {
if (!rescheduleDate || !rescheduleWorkingHours || !rescheduleAvailableHours || totalDuration === 0) {
return new Map();
}
const dateStr = rescheduleDate.toString();
const dayWorkingHours = rescheduleWorkingHours[dateStr];
const dayAvailableHours = rescheduleAvailableHours[dateStr];
if (!dayWorkingHours?.isOpen || !dayAvailableHours?.slots) {
return new Map();
}
const existingBookings = extractBookedSlots(
dayWorkingHours.startTime,
dayWorkingHours.endTime,
dayAvailableHours.slots
);
return getLunchProtectionForSlots(
dayWorkingHours.startTime,
dayWorkingHours.endTime,
existingBookings,
totalDuration,
15, // 15 minute slot intervals
false // User journey - requires 1h minimum
);
});
let isFutureBooking = $derived(
selectedBooking ? new Date(selectedBooking.start_time) > new Date() : false
);
let isCancellable = $derived(
selectedBooking &&
isFutureBooking &&
['pending', 'confirmed'].includes(selectedBooking.status)
);
let hasPayments = $derived(
selectedBooking && selectedBooking.payments && selectedBooking.payments.length > 0
);
let isCancellable = $derived(
selectedBooking && isFutureBooking && ['pending', 'confirmed'].includes(selectedBooking.status)
);
let canEditBooking = $derived(
isCancellable && !hasPayments
);
let totalPaid = $derived(
selectedBooking?.payments
?.filter((p) => p.status === 'completed')
@@ -104,9 +61,9 @@
let canPayEarly = $derived(
selectedBooking &&
!depositOutstanding &&
totalPaid < selectedBooking.total_amount &&
['confirmed', 'pending'].includes(selectedBooking.status)
!depositOutstanding &&
totalPaid < selectedBooking.total_amount &&
['confirmed', 'pending'].includes(selectedBooking.status)
);
let isCompleted = $derived(selectedBooking?.status === 'completed');
@@ -120,15 +77,18 @@
let tipProcessing = $state(false);
let canSaveCards = $derived(
authStore.currentUser?.role === 'verified_email' ||
authStore.currentUser?.role === 'affiliate'
authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate'
);
let tipPresets = $derived(selectedBooking ? [
{ pct: 10, amount: Math.round(selectedBooking.total_amount * 0.10 * 100) / 100 },
{ pct: 15, amount: Math.round(selectedBooking.total_amount * 0.15 * 100) / 100 },
{ pct: 20, amount: Math.round(selectedBooking.total_amount * 0.20 * 100) / 100 }
] : []);
let tipPresets = $derived(
selectedBooking
? [
{ pct: 10, amount: Math.round(selectedBooking.total_amount * 0.1 * 100) / 100 },
{ pct: 15, amount: Math.round(selectedBooking.total_amount * 0.15 * 100) / 100 },
{ pct: 20, amount: Math.round(selectedBooking.total_amount * 0.2 * 100) / 100 }
]
: []
);
function selectTipPreset(amount: number) {
selectedTipPreset = amount;
@@ -194,10 +154,6 @@
fetchBookingDetails();
}
let isRescheduleValid = $derived(
rescheduleDate && rescheduleTime && rescheduleTime.length >= 4
);
async function fetchBookingDetails() {
if (!bookingId) return;
loading = true;
@@ -213,6 +169,11 @@
if (response.ok) {
const data = await response.json();
selectedBooking = data as Booking;
const editResp = await fetch(`/api/bookings/${bookingId}/edit-request`, {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
});
hasPendingEditRequest = editResp.ok;
} else {
const text = await response.text();
toast.error('Failed to load booking: ' + text);
@@ -231,14 +192,9 @@
if (!open) {
setTimeout(() => {
selectedBooking = null;
hasPendingEditRequest = false;
showCancelConfirm = false;
showRescheduleForm = false;
rescheduleDate = undefined;
rescheduleTime = '';
rescheduleNotes = '';
reschedulePlaceholder = minDate;
rescheduleWorkingHours = null;
rescheduleAvailableHours = null;
showEditModal = false;
}, 200);
} else if (bookingId && !selectedBooking) {
fetchBookingDetails();
@@ -274,56 +230,6 @@
}
}
async function fetchRescheduleHours(date: CalendarDate) {
const monthKey = `${date.year}-${String(date.month).padStart(2, '0')}`;
loadingRescheduleHours = true;
try {
const startOfMonth = new CalendarDate(date.year, date.month, 1);
const endOfMonth = new CalendarDate(date.year, date.month, date.calendar.getDaysInMonth(date));
const startStr = startOfMonth.toString();
const endStr = endOfMonth.toString();
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) {
const whData: WorkingHoursDay[] = await whRes.json();
const ahData: AvailableHoursDay[] = await ahRes.json();
const whMap: Record<string, { isOpen: boolean; startTime: string; endTime: string }> = {};
whData.forEach((d) => { whMap[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime }; });
const ahMap: Record<string, { isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }> = {};
ahData.forEach((d) => { ahMap[d.date] = { isOpen: d.isOpen, slots: d.slots }; });
rescheduleWorkingHours = whMap;
rescheduleAvailableHours = ahMap;
}
} catch (err) {
console.error('Failed to fetch reschedule hours:', err);
} finally {
loadingRescheduleHours = false;
}
}
function isDateUnavailable(date: DateValue): boolean {
const d = date as CalendarDate;
const jsDate = d.toDate(getLocalTimeZone());
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
if (jsDate < todayStart) return true;
if (d.compare(minDate) < 0 || d.compare(maxCalendarDate) > 0) return true;
if (!rescheduleWorkingHours) return false;
const dateStr = d.toString();
const dayHours = rescheduleWorkingHours[dateStr];
if (!dayHours || !dayHours.isOpen) return true;
if (totalDuration === 0) return false;
const slots = generateAvailableTimeSlots(totalDuration, d);
return slots.length === 0;
}
function formatPaymentMethod(method: string): string {
switch (method) {
case 'in_person_card':
@@ -340,236 +246,6 @@
return method.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
}
function formatTime(time: string): string {
const parts = time.split(':').map(Number);
const hours = parts[0];
const minutes = parts.length > 1 ? parts[1] : 0;
if (hours === 12 && minutes === 0) return 'Noon';
if (hours === 0 && minutes === 0) return 'Midnight';
const period = hours >= 12 ? 'PM' : 'AM';
const displayHours = hours % 12 || 12;
return `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`;
}
function calculateEndTime(startTime: string, durationMinutes: number): string {
const [hours, minutes] = startTime.split(':').map(Number);
let total = hours * 60 + minutes + durationMinutes;
const h = Math.floor(total / 60);
const m = total % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}
function timeToMinutes(time: string): number {
const [h, m] = time.split(':').map(Number);
return h * 60 + m;
}
function calculatePreviousTime(time: string): string {
const [h, m] = time.split(':').map(Number);
let total = h * 60 + m - 15;
return `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`;
}
function generateAvailableTimeSlots(duration: number, date: CalendarDate): string[] {
if (!rescheduleWorkingHours || !rescheduleAvailableHours) return [];
const dateStr = date.toString();
const dayWH = rescheduleWorkingHours[dateStr];
const dayAH = rescheduleAvailableHours[dateStr];
if (!dayWH || !dayWH.isOpen || !dayAH || !dayAH.slots) return [];
const slots: string[] = [];
const now = new SvelteDate();
const todayCal = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
const isToday = date.compare(todayCal) === 0;
for (const slot of dayAH.slots) {
const [sh, sm] = slot.startTime.split(':').map(Number);
const [eh, em] = slot.endTime.split(':').map(Number);
let startMin = sh * 60 + sm;
const endMin = eh * 60 + em;
if (isToday) {
const currentMin = now.getHours() * 60 + now.getMinutes();
startMin = Math.max(startMin, currentMin + 120);
}
for (let m = startMin; m < endMin; m += 15) {
if (m + duration <= endMin) {
slots.push(`${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`);
}
}
}
return slots;
}
function generateGroupedTimeSlots(
duration: number,
date: CalendarDate,
lunchProtection: Map<string, { isBlocked: boolean; showWarning: boolean; warningMessage?: string }> = new Map()
): Array<{ type: 'available' | 'unavailable'; startTime: string; endTime: string; isGrouped?: boolean }> {
if (!rescheduleWorkingHours) return [];
const dateStr = date.toString();
const dayWH = rescheduleWorkingHours[dateStr];
if (!dayWH || !dayWH.isOpen) return [];
const grouped: Array<{ type: 'available' | 'unavailable'; startTime: string; endTime: string; isGrouped?: boolean }> = [];
const [sh, sm] = dayWH.startTime.split(':').map(Number);
const [eh, em] = dayWH.endTime.split(':').map(Number);
let startMin = sh * 60 + sm;
const endMin = eh * 60 + em;
const now = new SvelteDate();
const todayCal = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
const isToday = date.compare(todayCal) === 0;
if (isToday) {
const currentMin = now.getHours() * 60 + now.getMinutes();
startMin = Math.max(startMin, currentMin + 120);
}
const availableSlots = generateAvailableTimeSlots(duration, date);
let currentUnavailableStart: string | null = null;
let lastAvailableEnd: string | null = null;
for (let m = startMin; m < endMin; m += 15) {
const timeStr = `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
const isAvailable = availableSlots.includes(timeStr) && !lunchProtection.get(timeStr)?.isBlocked;
if (isAvailable) {
if (currentUnavailableStart !== null) {
const groupEnd = calculatePreviousTime(timeStr);
const unavailableStartTime = lastAvailableEnd || currentUnavailableStart;
if (unavailableStartTime && timeToMinutes(unavailableStartTime) < timeToMinutes(groupEnd)) {
grouped.push({ type: 'unavailable', startTime: unavailableStartTime, endTime: groupEnd, isGrouped: true });
}
currentUnavailableStart = null;
}
const slotEnd = calculateEndTime(timeStr, duration);
lastAvailableEnd = slotEnd;
grouped.push({ type: 'available', startTime: timeStr, endTime: slotEnd });
} else {
if (currentUnavailableStart === null) {
currentUnavailableStart = timeStr;
}
}
}
if (currentUnavailableStart !== null) {
const lastAvail = grouped.filter((s) => s.type === 'available').pop();
const lastAvailEnd = lastAvail ? timeToMinutes(lastAvail.endTime) : 0;
const unavailableStartMinutes = timeToMinutes(currentUnavailableStart);
if (unavailableStartMinutes < endMin && lastAvailEnd < endMin) {
grouped.push({ type: 'unavailable', startTime: lastAvail ? lastAvail.endTime : currentUnavailableStart, endTime: dayWH.endTime, isGrouped: true });
}
}
return grouped;
}
async function submitReschedule() {
if (!selectedBooking || !rescheduleDate || !rescheduleTime) return;
// Re-fetch available hours to confirm slot is still open
try {
const dateStr = rescheduleDate.toString();
const monthKey = `${rescheduleDate.year}-${String(rescheduleDate.month).padStart(2, '0')}`;
const startOfMonth = new CalendarDate(rescheduleDate.year, rescheduleDate.month, 1);
const endOfMonth = new CalendarDate(rescheduleDate.year, rescheduleDate.month, rescheduleDate.calendar.getDaysInMonth(rescheduleDate));
const [whRes, ahRes] = await Promise.all([
fetch(`/api/scheduling/working-hours?start=${startOfMonth}&end=${endOfMonth}`),
fetch(`/api/scheduling/available-hours?start=${startOfMonth}&end=${endOfMonth}`)
]);
if (whRes.ok && ahRes.ok) {
const whData: WorkingHoursDay[] = await whRes.json();
const ahData: AvailableHoursDay[] = await ahRes.json();
const freshWH: Record<string, { isOpen: boolean; startTime: string; endTime: string }> = {};
whData.forEach((d) => { freshWH[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime }; });
const freshAH: Record<string, { isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }> = {};
ahData.forEach((d) => { freshAH[d.date] = { isOpen: d.isOpen, slots: d.slots }; });
const dayWH = freshWH[dateStr];
const dayAH = freshAH[dateStr];
if (!dayWH?.isOpen || !dayAH?.slots) {
toast.error('This date is no longer available. Please select a different date.');
rescheduleDate = undefined;
rescheduleTime = '';
return;
}
// Check if the selected time is still available
const freshSlots: string[] = [];
for (const slot of dayAH.slots) {
const [sh, sm] = slot.startTime.split(':').map(Number);
const [eh, em] = slot.endTime.split(':').map(Number);
for (let m = sh * 60 + sm; m < eh * 60 + em; m += 15) {
if (m + totalDuration <= eh * 60 + em) {
freshSlots.push(`${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`);
}
}
}
if (!freshSlots.includes(rescheduleTime)) {
toast.error('This time slot is no longer available. Please choose a different time.');
rescheduleTime = '';
return;
}
// Check lunch protection
const existingBookings = extractBookedSlots(dayWH.startTime, dayWH.endTime, dayAH.slots);
const freshLunch = getLunchProtectionForSlots(dayWH.startTime, dayWH.endTime, existingBookings, totalDuration, 15, false);
if (freshLunch.get(rescheduleTime)?.isBlocked) {
toast.error('This time slot is no longer available. Please choose a different time.');
rescheduleTime = '';
return;
}
}
} catch {
toast.error('Could not verify slot availability. Please try again.');
return;
}
rescheduleSubmitting = true;
try {
const [hours, minutes] = rescheduleTime.split(':').map(Number);
const bookingDate = rescheduleDate.toDate(getLocalTimeZone());
bookingDate.setHours(hours || 0, minutes || 0, 0, 0);
const body: Record<string, unknown> = {
new_start_time: bookingDate.toISOString()
};
if (rescheduleNotes.trim()) {
body.notes = rescheduleNotes.trim();
}
const response = await fetch(`/api/bookings/${selectedBooking.id}/edit-request`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify(body)
});
if (response.ok) {
toast.success('Reschedule request sent — we\'ll confirm shortly');
showRescheduleForm = false;
rescheduleDate = undefined;
rescheduleTime = '';
rescheduleNotes = '';
} else {
const text = await response.text();
toast.error(text || 'Failed to submit reschedule request');
}
} catch {
toast.error('Network error');
} finally {
rescheduleSubmitting = false;
}
}
</script>
<Modal.Root bind:open>
@@ -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}
<div class="flex items-center gap-2">
@@ -614,9 +292,7 @@
{:else if isConfirmedOrLater}
<span
class="inline-flex items-center rounded-full px-3 py-1 text-sm font-medium
{selectedBooking.deposit_paid
? 'bg-green-100 text-green-800'
: 'bg-orange-100 text-orange-800'}"
{selectedBooking.deposit_paid ? 'bg-green-100 text-green-800' : 'bg-orange-100 text-orange-800'}"
>
{selectedBooking.deposit_paid ? 'Deposit Paid' : 'Deposit Due'}
</span>
@@ -684,8 +360,12 @@
<div class="mt-1 text-sm text-gray-600">{service.service_description}</div>
{/if}
<div class="mt-2 flex items-center justify-between text-sm">
<span class="text-gray-600">{service.override_duration_minutes ?? service.duration_minutes} min</span>
<span class="font-semibold">£{(service.override_price ?? service.price ?? 0).toFixed(2)}</span>
<span class="text-gray-600"
>{service.override_duration_minutes ?? service.duration_minutes} min</span
>
<span class="font-semibold"
>£{(service.override_price ?? service.price ?? 0).toFixed(2)}</span
>
</div>
</div>
{/each}
@@ -702,27 +382,33 @@
<div class="flex items-center justify-between border-b border-gray-200 pb-2">
<span class="text-sm text-gray-600">Deposit Required</span>
<div class="text-right">
<div class="font-semibold">£{selectedBooking.deposit_amount?.toFixed(2) || '0.00'}</div>
<div class="font-semibold">
£{selectedBooking.deposit_amount?.toFixed(2) || '0.00'}
</div>
<div class="text-xs">
<span
class="{selectedBooking.deposit_paid
? 'text-green-600'
: 'text-orange-600'}"
class={selectedBooking.deposit_paid ? 'text-green-600' : 'text-orange-600'}
>
{selectedBooking.deposit_paid ? 'Paid' : 'Outstanding'}
</span>
{#if !selectedBooking.deposit_paid && selectedBooking.deposit_deadline}
<span class="text-gray-500">
• 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
}
)}
</span>
{/if}
</div>
@@ -731,7 +417,11 @@
{/if}
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">{selectedBooking.amount_paid > selectedBooking.total_amount ? 'Pre-tip Subtotal' : 'Total Amount'}</span>
<span class="text-sm text-gray-600"
>{selectedBooking.amount_paid > selectedBooking.total_amount
? 'Pre-tip Subtotal'
: 'Total Amount'}</span
>
<span class="font-semibold">£{selectedBooking.total_amount.toFixed(2)}</span>
</div>
<div class="flex items-center justify-between">
@@ -745,9 +435,7 @@
<span class="font-medium text-gray-900">
{isFutureBooking ? 'Estimated Subtotal' : 'Amount Due'}
</span>
<span
class="text-lg font-bold text-red-600"
>
<span class="text-lg font-bold text-red-600">
£{selectedBooking.amount_due.toFixed(2)}
</span>
</div>
@@ -766,14 +454,16 @@
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="font-medium">{formatPaymentMethod(payment.payment_method)}</span>
<span class="font-medium"
>{formatPaymentMethod(payment.payment_method)}</span
>
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
{payment.status === 'completed'
? 'bg-green-100 text-green-800'
: payment.status === 'pending'
? 'bg-yellow-100 text-yellow-800'
: 'bg-gray-100 text-gray-800'}"
? 'bg-green-100 text-green-800'
: payment.status === 'pending'
? 'bg-yellow-100 text-yellow-800'
: 'bg-gray-100 text-gray-800'}"
>
{payment.status}
</span>
@@ -811,112 +501,6 @@
</div>
{/if}
{#if showRescheduleForm}
<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">
Request Reschedule
</h3>
{#if loadingRescheduleHours}
<div class="flex items-center justify-center p-6">
<p class="text-sm text-gray-500">Loading available dates...</p>
</div>
{:else}
<div class="flex items-center justify-center">
<DatePicker
date={rescheduleDate}
placeholder={reschedulePlaceholder}
minValue={minDate}
maxValue={maxCalendarDate}
isDateUnavailable={isDateUnavailable}
onchange={(d) => { rescheduleDate = d; rescheduleTime = ''; }}
onPlaceholderChange={(p) => {
reschedulePlaceholder = p;
if (!rescheduleWorkingHours) fetchRescheduleHours(p);
}}
/>
</div>
{/if}
{#if rescheduleDate}
{#if loadingRescheduleHours}
<div class="flex items-center justify-center border-t p-6">
<p class="text-sm text-gray-500">Loading times...</p>
</div>
{:else}
<div class="no-scrollbar mt-2 flex max-h-40 w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t pt-4">
<div class="grid justify-center gap-2 text-sm text-gray-600">
{rescheduleDate.toDate(getLocalTimeZone()).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'short' })}
</div>
{#if rescheduleWorkingHours && !rescheduleWorkingHours[rescheduleDate.toString()]?.isOpen}
<p class="text-center text-sm text-gray-500">We're closed on this day</p>
{:else}
{@const grouped = generateGroupedTimeSlots(totalDuration, rescheduleDate, rescheduleLunchProtection())}
{#if grouped.length > 0}
<div class="grid gap-2">
{#each grouped as slot (slot.type + '-' + slot.startTime + '-' + slot.endTime)}
{#if slot.type === 'available'}
<Button
variant="outline"
onclick={() => { rescheduleTime = slot.startTime; }}
class="w-full hover:bg-fuchsia-50 {rescheduleTime === slot.startTime ? 'bg-fuchsia-100' : ''}"
>
{formatTime(slot.startTime)}
<span class="text-gray-500">- {formatTime(slot.endTime)}</span>
</Button>
{:else}
<Button
variant="outline"
class="w-full cursor-not-allowed opacity-50 hover:bg-gray-100"
disabled
>
{formatTime(slot.startTime)}
<span class="text-gray-500">- {formatTime(slot.endTime)}</span>
</Button>
{/if}
{/each}
</div>
{:else}
<p class="text-center text-sm text-gray-500">No available slots</p>
{/if}
{/if}
</div>
{/if}
{/if}
<div class="mt-4 space-y-2">
<Label.Root for="reschedule-notes">Reason (optional)</Label.Root>
<Textarea.Root
id="reschedule-notes"
bind:value={rescheduleNotes}
placeholder="Tell us why you need to reschedule"
rows={2}
/>
</div>
<div class="mt-4 flex gap-2">
<Button
size="sm"
onclick={submitReschedule}
disabled={!isRescheduleValid || rescheduleSubmitting}
>
{rescheduleSubmitting ? 'Submitting...' : 'Submit Request'}
</Button>
<Button
size="sm"
variant="outline"
onclick={() => {
showRescheduleForm = false;
rescheduleDate = undefined;
rescheduleTime = '';
rescheduleNotes = '';
}}
>
Cancel
</Button>
</div>
</div>
{/if}
</div>
{/if}
@@ -931,23 +515,16 @@
>
Cancel Booking
</Button>
{#if canEditBooking}
<Button
variant="outline"
size="sm"
class="flex-1"
onclick={() => {
showRescheduleForm = !showRescheduleForm;
if (!showRescheduleForm) {
rescheduleDate = undefined;
rescheduleTime = '';
rescheduleNotes = '';
} else if (!rescheduleWorkingHours) {
fetchRescheduleHours(reschedulePlaceholder);
}
}}
onclick={() => { showEditModal = true; }}
>
{showRescheduleForm ? 'Hide Reschedule' : 'Reschedule'}
Edit Request
</Button>
{/if}
{/if}
</div>
<div class="flex gap-2">
@@ -964,16 +541,18 @@
{#if depositOutstanding}
<Button
size="sm"
class="flex-1 bg-amber-600 hover:bg-amber-700 text-white"
class="flex-1 bg-amber-600 text-white hover:bg-amber-700"
onclick={() => (showPaymentModal = true)}
disabled={hasPendingEditRequest}
>
Pay Deposit
</Button>
{:else if canPayEarly}
{:else if canPayEarly && !hasPendingEditRequest}
<Button
size="sm"
class="flex-1 bg-emerald-600 hover:bg-emerald-700 text-white"
class="flex-1 bg-emerald-600 text-white hover:bg-emerald-700"
onclick={() => (showPaymentModal = true)}
disabled={hasPendingEditRequest}
>
Pay Early
</Button>
@@ -984,12 +563,23 @@
</Modal.Content>
</Modal.Root>
{#if showEditModal && selectedBooking}
<EditRequestModal
bind:open={showEditModal}
booking={selectedBooking}
onSubmitted={() => {
showEditModal = false;
fetchBookingDetails();
}}
/>
{/if}
{#if showPaymentModal && selectedBooking}
<UserPaymentModal
booking={selectedBooking}
onClose={() => (showPaymentModal = false)}
onComplete={handlePaymentComplete}
canSaveCards={canSaveCards}
{canSaveCards}
/>
{/if}
@@ -1000,7 +590,9 @@
<Modal.Description>
Are you sure you want to cancel this booking?
{#if hasPayments}
<div class="mt-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
<div
class="mt-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800"
>
<p class="font-medium">Please note:</p>
<p class="mt-1">
The <span class="font-semibold">£{totalPaid.toFixed(2)}</span> already paid for this booking
@@ -1011,9 +603,7 @@
</Modal.Description>
</Modal.Header>
<Modal.Footer>
<Button variant="outline" onclick={() => (showCancelConfirm = false)}>
Keep Booking
</Button>
<Button variant="outline" onclick={() => (showCancelConfirm = false)}>Keep Booking</Button>
<Button variant="destructive" onclick={cancelBooking} disabled={cancelling}>
{cancelling ? 'Cancelling...' : 'Yes, Cancel'}
</Button>
@@ -1021,25 +611,31 @@
</Modal.Content>
</Modal.Root>
<Modal.Root open={showTipModal} onOpenChange={(v) => {
<Modal.Root
open={showTipModal}
onOpenChange={(v) => {
if (!v) {
showTipModal = false;
tipAmount = 0;
selectedTipPreset = null;
customTipInput = '';
}
}}>
}}
>
<Modal.Content class="max-w-sm">
<Modal.Header>
<Modal.Title>Leave a Tip</Modal.Title>
<Modal.Description>Show your appreciation for great service</Modal.Description>
</Modal.Header>
<div class="px-4 pb-4 space-y-4">
<div class="space-y-4 px-4 pb-4">
<div class="grid grid-cols-3 gap-3">
{#each tipPresets as preset (preset.pct)}
<button
class="rounded-lg border border-input bg-background py-3 text-center font-semibold transition-colors hover:bg-fuchsia-50 {selectedTipPreset === preset.amount ? 'bg-fuchsia-100' : ''}"
class="rounded-lg border border-input bg-background py-3 text-center font-semibold transition-colors hover:bg-fuchsia-50 {selectedTipPreset ===
preset.amount
? 'bg-fuchsia-100'
: ''}"
onclick={() => selectTipPreset(preset.amount)}
type="button"
>
@@ -1050,9 +646,11 @@
</div>
<div>
<label for="custom-tip" class="text-sm font-medium text-gray-700">Or enter custom amount</label>
<label for="custom-tip" class="text-sm font-medium text-gray-700"
>Or enter custom amount</label
>
<div class="relative mt-1">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">£</span>
<span class="absolute top-1/2 left-3 -translate-y-1/2 text-gray-500">£</span>
<Input
id="custom-tip"
type="text"
@@ -1069,9 +667,7 @@
</div>
<Modal.Footer>
<Button variant="outline" onclick={() => (showTipModal = false)}>
Cancel
</Button>
<Button variant="outline" onclick={() => (showTipModal = false)}>Cancel</Button>
<Button
class="hover:bg-fuchsia-50"
onclick={submitTip}
@@ -197,6 +197,7 @@
// =============== Effects ===============
let wasOpen = false;
let bookingCreateInitialLoadDone = $state(false);
$effect(() => {
if (open && !wasOpen) {
@@ -215,16 +216,21 @@
}
});
// Preload 3 months when entering step 4 to prevent snap-back
$effect(() => {
if (open && currentStep === 4) {
const dateToCheck = selectedDate || placeholder;
fetchHoursForMonth(dateToCheck);
if (open && currentStep === 4 && !bookingCreateInitialLoadDone) {
fetchHoursRange(placeholder, 3);
bookingCreateInitialLoadDone = true;
}
});
// Fetch additional months when navigating beyond preloaded range
$effect(() => {
if (open && currentStep === 4 && placeholder) {
fetchHoursForMonth(placeholder);
if (open && currentStep === 4 && bookingCreateInitialLoadDone && placeholder) {
const monthKey = `${placeholder.year}-${String(placeholder.month).padStart(2, '0')}`;
if (!workingHoursCache.has(monthKey)) {
fetchHoursForMonth(placeholder);
}
}
});
@@ -246,6 +252,7 @@
availableHoursCache.clear();
workingHours = null;
availableHours = null;
bookingCreateInitialLoadDone = false;
// Clear reservation state
reservationId = null;
reservationExpiresAt = null;
@@ -304,6 +311,68 @@
}
}
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}`, {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
}),
fetch(`/api/scheduling/available-hours?start=${startStr}&end=${endStr}`, {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
})
]);
if (whRes.ok && ahRes.ok) {
const whData: WorkingHoursDay[] = await whRes.json();
const ahData: AvailableHoursDay[] = await ahRes.json();
const whMap: Record<string, any> = {};
const ahMap: Record<string, any> = {};
whData.forEach((d) => (whMap[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime }));
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;
}
} catch (err) {
console.error('Failed to fetch hours', err);
toast.error('Failed to load availability');
} finally {
loadingWorkingHours = false;
loadingAvailableHours = false;
}
}
async function fetchHoursForMonth(date: CalendarDate) {
const monthKey = `${date.year}-${String(date.month).padStart(2, '0')}`;
@@ -712,12 +781,32 @@
if (!workingHours) return true;
const dateStr = date.toString();
if (!workingHours[dateStr]?.isOpen) return true;
const dayHours = workingHours[dateStr];
if (!dayHours?.isOpen) return true;
if (selectedServices.length > 0) {
const duration = getTotalDuration();
const slots = generateAvailableTimeSlots(duration, date);
return slots.length === 0;
if (slots.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,
true
);
const validSlots = slots.filter((t) => !lunchProtection.get(t)?.isBlocked);
if (validSlots.length === 0) return true;
}
}
return false;
@@ -1244,9 +1333,10 @@
selectedDate = newDate;
selectedTime = null;
}}
onPlaceholderChange={(newPlaceholder) => {
placeholder = newPlaceholder;
}}
onPlaceholderChange={(newPlaceholder) => {
placeholder = newPlaceholder;
fetchHoursForMonth(newPlaceholder);
}}
/>
</div>
{/if}
@@ -0,0 +1,377 @@
<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { toast } from 'svelte-sonner';
import * as Modal from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
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;
};
}
interface Props {
open: boolean;
editRequest: EditRequest;
onApproved: () => void;
onDenied: () => void;
}
let { open = $bindable(), editRequest, onApproved, onDenied }: Props = $props();
let submitting = $state(false);
let showDenyConfirm = $state(false);
function formatDateLine1(dateTimeString: string): string {
const d = new Date(dateTimeString);
const dateStr = d.toLocaleDateString('en-GB', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
});
const startTime = d.toLocaleTimeString('en-GB', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
return `${dateStr} at ${startTime}`;
}
function formatDateLine2(dateTimeString: string, durationMinutes: number): string {
const d = new Date(dateTimeString);
const endMinutes = d.getHours() * 60 + d.getMinutes() + durationMinutes;
const endH = Math.floor(endMinutes / 60);
const endM = endMinutes % 60;
const endPeriod = endH >= 12 ? 'pm' : 'am';
const endDisplayH = endH % 12 || 12;
const endTime = `${endDisplayH}:${String(endM).padStart(2, '0')} ${endPeriod}`;
return `${endTime}, ${durationMinutes} minutes`;
}
function getDuration(services: ServiceItem[]): number {
return services.reduce((sum, s) => sum + s.duration_minutes, 0);
}
function isTimeChanged(): boolean {
if (!editRequest.proposed.start_time) return false;
return editRequest.proposed.start_time !== editRequest.original.start_time;
}
function areServicesChanged(): boolean {
const origIds = new Set(editRequest.original.services.map((s) => s.id));
const propIds = new Set(editRequest.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;
}
let serviceDiff = $derived.by(() => {
const orig = editRequest.original.services;
const prop = editRequest.proposed.services;
const origIds = new Set(orig.map((s) => s.id));
const propIds = new Set(prop.map((s) => s.id));
return {
removed: orig.filter((s) => !propIds.has(s.id)),
added: prop.filter((s) => !origIds.has(s.id)),
same: orig.filter((s) => propIds.has(s.id))
};
});
async function handleApprove() {
submitting = true;
const loadingToast = toast.loading('Approving change request...');
try {
const response = await fetch(
`/api/admin/bookings/${editRequest.booking_id}/edit-requests/${editRequest.id}/approve`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
}
);
if (response.ok) {
toast.success('Change request approved!', { id: loadingToast });
open = false;
onApproved();
} else {
const text = await response.text();
toast.error('Failed to approve: ' + text, { id: loadingToast });
}
} catch (err) {
console.error('Error approving edit request:', err);
toast.error('Network error approving change request', { id: loadingToast });
} finally {
submitting = false;
}
}
async function handleDeny() {
submitting = true;
const loadingToast = toast.loading('Denying change request...');
try {
const response = await fetch(
`/api/admin/bookings/${editRequest.booking_id}/edit-requests/${editRequest.id}/deny`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
}
);
if (response.ok) {
toast.success('Change request denied', { id: loadingToast });
showDenyConfirm = false;
open = false;
onDenied();
} else {
const text = await response.text();
toast.error('Failed to deny: ' + text, { id: loadingToast });
}
} catch (err) {
console.error('Error denying edit request:', err);
toast.error('Network error denying change request', { id: loadingToast });
} finally {
submitting = false;
showDenyConfirm = false;
}
}
</script>
<Modal.Root bind:open>
<Modal.Content class="max-h-[90vh] max-w-2xl overflow-y-auto">
<Modal.Header>
<Modal.Title class="text-lg font-semibold">Booking Change Request</Modal.Title>
<Modal.Description>
Review the requested changes to {editRequest.user.full_name}'s booking.
</Modal.Description>
</Modal.Header>
<div class="space-y-6 px-4 pb-4">
<!-- Customer Contact 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 Contact
</h3>
<div class="space-y-2">
<div>
<div class="text-xs text-gray-500">Name</div>
<div class="font-medium">{editRequest.user.full_name}</div>
</div>
<div class="grid gap-3 md:grid-cols-2">
<div>
<div class="text-xs text-gray-500">Phone</div>
<div class="font-medium">{editRequest.user.phone || '—'}</div>
</div>
<div>
<div class="text-xs text-gray-500">Email</div>
<div class="font-medium break-all">{editRequest.user.email || '—'}</div>
</div>
</div>
</div>
</div>
<!-- Date & Time Change -->
<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">
Date & Time Change
</h3>
<div class="space-y-3">
<div>
<div class="mb-1 text-xs text-gray-500">Before</div>
<div class="font-medium">
{formatDateLine1(editRequest.original.start_time)}
</div>
<div class="text-sm text-gray-600">
{formatDateLine2(editRequest.original.start_time, getDuration(editRequest.original.services))}
</div>
</div>
{#if isTimeChanged()}
<div>
<div class="mb-1 text-xs text-gray-500">After</div>
<div class="font-medium text-emerald-700">
{formatDateLine1(editRequest.proposed.start_time!)}
</div>
<div class="text-sm text-gray-600">
{formatDateLine2(editRequest.proposed.start_time!, getDuration(editRequest.proposed.services))}
</div>
</div>
{:else}
<div class="text-sm italic text-gray-500">No change</div>
{/if}
</div>
</div>
<!-- Services Change -->
<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">
Services Change
</h3>
<div class="grid gap-4 md:grid-cols-2">
<div>
<div class="mb-1 text-xs text-gray-500">Original</div>
<div class="space-y-2">
{#each editRequest.original.services as service}
{#if serviceDiff.removed.some((s) => s.id === service.id)}
<div class="flex items-start gap-2 rounded border border-red-200 bg-red-50 p-2">
<span class="mt-0.5 text-red-600 font-mono text-sm"></span>
<div class="flex-1">
<div class="text-sm font-medium text-red-700 line-through">
{service.name}
</div>
<div class="text-xs text-red-600">
£{service.price.toFixed(2)} &middot; {service.duration_minutes} min
</div>
</div>
</div>
{:else}
<div class="flex items-start gap-2 rounded border border-gray-200 bg-white p-2">
<span class="mt-0.5 text-gray-400 font-mono text-sm"> </span>
<div class="flex-1">
<div class="text-sm font-medium">{service.name}</div>
<div class="text-xs text-gray-600">
£{service.price.toFixed(2)} &middot; {service.duration_minutes} min
</div>
</div>
</div>
{/if}
{/each}
</div>
</div>
<div>
<div class="mb-1 text-xs text-gray-500">Proposed</div>
<div class="space-y-2">
{#each editRequest.proposed.services as service}
{#if serviceDiff.added.some((s) => s.id === service.id)}
<div class="flex items-start gap-2 rounded border border-emerald-200 bg-emerald-50 p-2">
<span class="mt-0.5 text-emerald-600 font-mono text-sm">+</span>
<div class="flex-1">
<div class="text-sm font-medium text-emerald-700">{service.name}</div>
<div class="text-xs text-emerald-600">
£{service.price.toFixed(2)} &middot; {service.duration_minutes} min
</div>
</div>
</div>
{:else}
<div class="flex items-start gap-2 rounded border border-gray-200 bg-white p-2">
<span class="mt-0.5 text-gray-400 font-mono text-sm"> </span>
<div class="flex-1">
<div class="text-sm font-medium">{service.name}</div>
<div class="text-xs text-gray-600">
£{service.price.toFixed(2)} &middot; {service.duration_minutes} min
</div>
</div>
</div>
{/if}
{/each}
</div>
</div>
</div>
</div>
<!-- Notes Change -->
<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">
Booking Notes Change
</h3>
<div class="grid gap-4 md:grid-cols-2">
<div>
<div class="mb-1 text-xs text-gray-500">Original</div>
<div class="text-sm">{editRequest.original.notes || '—'}</div>
</div>
<div>
<div class="mb-1 text-xs text-gray-500">Proposed</div>
{#if editRequest.proposed.notes && editRequest.proposed.notes !== editRequest.original.notes}
<div class="text-sm">{editRequest.proposed.notes}</div>
{:else}
<div class="text-sm italic text-gray-500">No change</div>
{/if}
</div>
</div>
</div>
<!-- Request Notes -->
{#if editRequest.notes}
<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">
Reason for Change
</h3>
<p class="text-sm text-gray-700">{editRequest.notes}</p>
</div>
{/if}
</div>
<Modal.Footer class="flex items-center justify-end gap-2">
<Button
variant="destructive"
onclick={() => (showDenyConfirm = true)}
disabled={submitting}
>
Deny
</Button>
<Button
onclick={handleApprove}
disabled={submitting}
class="bg-emerald-600 hover:bg-emerald-700"
>
{submitting ? 'Approving...' : 'Approve'}
</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
<!-- Deny Confirmation Dialog -->
<AlertDialog.Root bind:open={showDenyConfirm}>
<AlertDialog.Content class="z-[60]">
<AlertDialog.Header>
<AlertDialog.Title>Deny this change request?</AlertDialog.Title>
<AlertDialog.Description>
This will reject the requested changes and notify the customer. This action cannot be
undone.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action onclick={handleDeny} class="bg-red-600 hover:bg-red-700">
Deny Request
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -479,17 +479,98 @@
);
let placeholder = $state<CalendarDate>(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<WorkingHoursDay> = await whRes.json();
const ahData: Array<AvailableHoursDay> = await ahRes.json();
const whMap: Record<string, { isOpen: boolean; startTime: string; endTime: string }> = {};
whData.forEach((d) => {
whMap[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime };
});
const ahMap: Record<string, { isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }> = {};
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}
@@ -471,8 +471,9 @@
{#if showCardList}
<div class="space-y-2">
{#if canSaveCards}
<div
class="flex items-center justify-between rounded-lg border p-3 cursor-pointer hover:border-gray-300 transition-colors"
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 hover:border-gray-300 transition-colors"
onclick={() => {
showNewCardForm = true;
showCardList = false;
@@ -483,11 +484,12 @@
<svg class="h-4 w-4 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 18l6-6-6-6" />
</svg>
</div>
</button>
{/if}
{#each paymentMethods as method (method.id)}
<div
class="flex items-center justify-between rounded-lg border p-3 cursor-pointer {selectedPaymentMethod === method.id ? 'border-input bg-fuchsia-100' : 'border-input hover:bg-fuchsia-50'}"
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 {selectedPaymentMethod === method.id ? 'border-input bg-fuchsia-100' : 'border-input hover:bg-fuchsia-50'}"
onclick={() => {
selectedPaymentMethod = method.id;
showNewCardForm = false;
@@ -508,7 +510,7 @@
{#if selectedPaymentMethod === method.id}
<span class="text-xs font-medium text-foreground">Selected</span>
{/if}
</div>
</button>
{/each}
</div>
{/if}
@@ -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<PendingBooking | null>(null);
let pendingEditRequests = $state<EditRequest[]>([]);
let visibleEditRequests = $derived(pendingEditRequests.slice(0, 3));
let showEditRequestModal = $state(false);
let selectedEditRequest = $state<EditRequest | null>(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 @@
</svg>
Pending Approvals
</Card.Title>
<Card.Description>New bookings awaiting confirmation</Card.Description>
<Card.Description>
{#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}
</Card.Description>
</div>
{#if !loading}
<Badge class="bg-amber-100 text-amber-800 hover:bg-amber-100">
{pendingApprovals.length}
{pendingApprovals.length + pendingEditRequests.length}
</Badge>
{/if}
</div>
@@ -169,7 +302,7 @@
</div>
{/each}
</div>
{:else if pendingApprovals.length === 0}
{:else if pendingApprovals.length === 0 && pendingEditRequests.length === 0}
<div class="py-8 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -186,7 +319,7 @@
/>
</svg>
<p class="text-sm font-medium text-gray-600">All caught up!</p>
<p class="text-xs text-gray-500">No pending bookings to review</p>
<p class="text-xs text-gray-500">Nothing pending to review</p>
</div>
{:else}
<div class="space-y-3">
@@ -229,6 +362,43 @@
</div>
</div>
{/each}
{#if pendingApprovals.length > 0 && pendingEditRequests.length > 0}
<div class="border-t border-gray-200 pt-3"></div>
{/if}
{#each visibleEditRequests as er (er.id)}
<div
class="rounded-lg border border-amber-200 bg-amber-50/30 p-3 transition-all hover:shadow-md"
>
<div class="flex items-start justify-between gap-3">
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="font-medium">{er.user?.full_name || 'Unknown'}</span>
<span class="text-xs text-amber-600 font-medium">Edit Request</span>
</div>
<div class="mt-1 text-sm text-gray-600">
{getEditRequestSummary(er)}
</div>
<div class="mt-1 text-xs text-gray-500">
{getServiceSummary(er)}
</div>
<div class="mt-1 text-xs text-gray-400">
Requested {formatRelativeTime(er.requested_at)}
</div>
</div>
<div class="flex flex-col gap-2">
<Button
size="sm"
onclick={() => openReviewModal(er)}
class="bg-amber-600 hover:bg-amber-700"
>
Review
</Button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</Card.Content>
@@ -246,3 +416,23 @@
}}
/>
{/if}
<!-- Edit Request Modal -->
{#if selectedEditRequest && showEditRequestModal}
<EditRequestModal
bind:open={showEditRequestModal}
editRequest={selectedEditRequest}
onApproved={() => {
showEditRequestModal = false;
selectedEditRequest = null;
fetchPendingApprovals();
fetchEditRequests();
}}
onDenied={() => {
showEditRequestModal = false;
selectedEditRequest = null;
fetchPendingApprovals();
fetchEditRequests();
}}
/>
{/if}