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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user