Backend: - Enriched GetAllUserBookings response with calculated total_amount, amount_paid, and duration_minutes. - Refactored GetBookingHandler to return a flat booking object matching frontend expectations. - Added account_role to admin user list response and sorted users by booking activity. - Corrected function name oo to AdminCreateBookingForUserHandler. Frontend: - Rebuilt BookingCreateModal into a 4-step wizard supporting guest bookings, service overrides, and real-time availability checks. - Fixed account dashboard logic to correctly identify upcoming vs past bookings and sort unpaid items to the top. - Extracted booking flow into a shared BookingFlow component. - Redirected admin users from home page to /today.
1187 lines
39 KiB
Svelte
1187 lines
39 KiB
Svelte
<script lang="ts">
|
|
import { authStore } from '$lib/stores/auth.svelte';
|
|
import { toast } from 'svelte-sonner';
|
|
import { SvelteMap, SvelteDate } from 'svelte/reactivity';
|
|
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
|
|
|
|
// UI Components
|
|
import * as Modal from '$lib/components/ui/dialog';
|
|
import { Button } from '$lib/components/ui/button';
|
|
import * as Card from '$lib/components/ui/card';
|
|
// Note: We are using native inputs for Steps 1 and 3 to fix reactivity bugs
|
|
// but keeping the Label and other components.
|
|
import { Label } from '$lib/components/ui/label';
|
|
import { Separator } from '$lib/components/ui/separator';
|
|
import { Skeleton } from '$lib/components/ui/skeleton';
|
|
|
|
// Booking Components
|
|
import BookingActions from '$lib/components/booking/BookingActions.svelte';
|
|
import ServiceSelector from '$lib/components/booking/ServiceSelector.svelte';
|
|
import DatePicker from '$lib/components/booking/DatePicker.svelte';
|
|
|
|
// Types
|
|
import type { Service, WorkingHoursDay, AvailableHoursDay } from '$lib/types/booking';
|
|
|
|
import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection';
|
|
|
|
// =============== Props ===============
|
|
interface Props {
|
|
open: boolean;
|
|
onBookingCreated?: () => void;
|
|
}
|
|
|
|
let { open = $bindable(), onBookingCreated }: Props = $props();
|
|
|
|
// =============== State ===============
|
|
let currentStep = $state(1);
|
|
|
|
// Step 1: Customer Selection
|
|
let userType = $state<'member' | 'guest'>('member');
|
|
let userQuery = $state('');
|
|
// Updated type to include account_role for filtering
|
|
let users = $state<
|
|
Array<{ id: string; fullName: string; email?: string; phone?: string; account_role: string }>
|
|
>([]);
|
|
let selectedUserId = $state<string | null>(null);
|
|
let guestName = $state('');
|
|
let guestPhone = $state('');
|
|
let loadingUsers = $state(false);
|
|
|
|
// Step 2: Services
|
|
let services = $state<Service[]>([]);
|
|
let selectedServices = $state<Service[]>([]);
|
|
let loadingServices = $state(true);
|
|
|
|
// Step 3: Service Overrides & Notes
|
|
let notes = $state('');
|
|
let serviceOverrides = $state<
|
|
Record<
|
|
string,
|
|
{ price: string; duration: string; originalPrice: number; originalDuration: number }
|
|
>
|
|
>({});
|
|
|
|
// Step 4: Date & Time
|
|
let placeholder = $state<CalendarDate>(
|
|
new CalendarDate(new Date().getFullYear(), new Date().getMonth() + 1, new Date().getDate())
|
|
);
|
|
let selectedDate = $state<CalendarDate | undefined>(undefined);
|
|
let selectedTime = $state<string | null>(null);
|
|
let workingHours = $state<Record<
|
|
string,
|
|
{ isOpen: boolean; startTime: string; endTime: string }
|
|
> | null>(null);
|
|
let availableHours = $state<Record<
|
|
string,
|
|
{ isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }
|
|
> | null>(null);
|
|
let loadingWorkingHours = $state(false);
|
|
let loadingAvailableHours = $state(false);
|
|
|
|
// Date Boundaries
|
|
const today = new SvelteDate();
|
|
const minDate = new CalendarDate(today.getFullYear(), today.getMonth() + 1, today.getDate());
|
|
const maxDate = new SvelteDate();
|
|
maxDate.setMonth(today.getMonth() + 6);
|
|
const maxCalendarDate = new CalendarDate(
|
|
maxDate.getFullYear(),
|
|
maxDate.getMonth() + 1,
|
|
maxDate.getDate()
|
|
);
|
|
|
|
let submitting = $state(false);
|
|
|
|
// =============== Cache ===============
|
|
const workingHoursCache = new SvelteMap<string, Record<string, any>>();
|
|
const availableHoursCache = new SvelteMap<string, Record<string, any>>();
|
|
|
|
// =============== Derived Helpers ===============
|
|
function getTotalDuration() {
|
|
return selectedServices.reduce((total, service) => {
|
|
const override = serviceOverrides[service.id];
|
|
const duration =
|
|
override && override.duration ? parseInt(override.duration) : service.duration_minutes;
|
|
return total + (isNaN(duration) ? 0 : duration);
|
|
}, 0);
|
|
}
|
|
|
|
function getTotalPrice() {
|
|
return selectedServices.reduce((total, service) => {
|
|
const override = serviceOverrides[service.id];
|
|
const price = override && override.price ? parseFloat(override.price) : service.price;
|
|
return total + (isNaN(price) ? 0 : price);
|
|
}, 0);
|
|
}
|
|
|
|
// =============== Lunch Protection ===============
|
|
const lunchProtectionStatus = $derived(() => {
|
|
if (!selectedDate || !workingHours || !availableHours || selectedServices.length === 0) {
|
|
return new Map();
|
|
}
|
|
|
|
const dateStr = selectedDate.toString();
|
|
const dayWorkingHours = workingHours[dateStr];
|
|
const dayAvailableHours = availableHours[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,
|
|
getTotalDuration(),
|
|
15,
|
|
true // Admin journey - 30min minimum, warn if <1h
|
|
);
|
|
});
|
|
|
|
function formatDuration(minutes: number): string {
|
|
const hours = Math.floor(minutes / 60);
|
|
const remainingMinutes = minutes % 60;
|
|
|
|
if (hours === 0) {
|
|
return `${remainingMinutes} minutes`;
|
|
} else if (remainingMinutes === 0) {
|
|
return `${hours} ${hours === 1 ? 'hour' : 'hours'}`;
|
|
} else {
|
|
return `${hours} ${hours === 1 ? 'hour' : 'hours'} ${remainingMinutes} minutes`;
|
|
}
|
|
}
|
|
|
|
function getDayWithOrdinal(date: CalendarDate): string {
|
|
const monthName = new SvelteDate(date.year, date.month - 1, date.day).toLocaleDateString(
|
|
'en-GB',
|
|
{
|
|
month: 'long'
|
|
}
|
|
);
|
|
const day = date.day;
|
|
if (day > 3 && day < 21) return monthName + ' ' + day + 'th';
|
|
switch (day % 10) {
|
|
case 1:
|
|
return monthName + ' ' + day + 'st';
|
|
case 2:
|
|
return monthName + ' ' + day + 'nd';
|
|
case 3:
|
|
return monthName + ' ' + day + 'rd';
|
|
default:
|
|
return monthName + ' ' + day + 'th';
|
|
}
|
|
}
|
|
|
|
const formattedTotalDuration = $derived(formatDuration(getTotalDuration()));
|
|
const formattedSelectedDate = $derived(
|
|
selectedDate ? getDayWithOrdinal(selectedDate) : undefined
|
|
);
|
|
|
|
const canProceedStep1 = $derived(
|
|
userType === 'member' ? !!selectedUserId : !!(guestName.trim() && guestPhone.trim())
|
|
);
|
|
const canProceedStep2 = $derived(selectedServices.length > 0);
|
|
const canProceedStep3 = $derived(true); // Overrides are optional
|
|
const canProceedStep4 = $derived(!!(selectedDate && selectedTime));
|
|
|
|
// =============== Effects ===============
|
|
let wasOpen = false;
|
|
|
|
$effect(() => {
|
|
if (open && !wasOpen) {
|
|
resetState();
|
|
fetchServices();
|
|
fetchUsers();
|
|
}
|
|
|
|
wasOpen = open;
|
|
});
|
|
|
|
$effect(() => {
|
|
if (open && currentStep === 4) {
|
|
const dateToCheck = selectedDate || placeholder;
|
|
fetchHoursForMonth(dateToCheck);
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
if (open && currentStep === 4 && placeholder) {
|
|
fetchHoursForMonth(placeholder);
|
|
}
|
|
});
|
|
|
|
// =============== Reset State ===============
|
|
function resetState() {
|
|
currentStep = 1;
|
|
userType = 'member';
|
|
userQuery = '';
|
|
users = [];
|
|
selectedUserId = null;
|
|
guestName = '';
|
|
guestPhone = '';
|
|
selectedServices = [];
|
|
selectedDate = undefined;
|
|
selectedTime = null;
|
|
notes = '';
|
|
serviceOverrides = {};
|
|
workingHoursCache.clear();
|
|
availableHoursCache.clear();
|
|
workingHours = null;
|
|
availableHours = null;
|
|
}
|
|
|
|
// =============== Data Fetching ===============
|
|
async function fetchUsers() {
|
|
loadingUsers = true;
|
|
try {
|
|
const response = await fetch(
|
|
`/api/admin/users?page=1&per_page=10&q=${encodeURIComponent(userQuery)}`,
|
|
{
|
|
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
|
}
|
|
);
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
|
|
// Filter out specific roles
|
|
const excludedRoles = ['admin', 'guest', 'affiliate'];
|
|
users = (data.users || []).filter(
|
|
(user: { account_role: string }) => !excludedRoles.includes(user.account_role)
|
|
);
|
|
console.log(users);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to fetch users', err);
|
|
toast.error('Failed to load users');
|
|
} finally {
|
|
loadingUsers = false;
|
|
}
|
|
}
|
|
|
|
async function fetchServices() {
|
|
loadingServices = true;
|
|
try {
|
|
const response = await fetch('/api/services', {
|
|
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
|
});
|
|
if (response.ok) {
|
|
services = await response.json();
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to fetch services', err);
|
|
toast.error('Failed to load services');
|
|
} finally {
|
|
loadingServices = false;
|
|
}
|
|
}
|
|
|
|
async function fetchHoursForMonth(date: CalendarDate) {
|
|
const monthKey = `${date.year}-${String(date.month).padStart(2, '0')}`;
|
|
|
|
if (workingHoursCache.has(monthKey) && availableHoursCache.has(monthKey)) {
|
|
workingHours = workingHoursCache.get(monthKey)!;
|
|
availableHours = availableHoursCache.get(monthKey)!;
|
|
return;
|
|
}
|
|
|
|
loadingWorkingHours = true;
|
|
loadingAvailableHours = true;
|
|
|
|
try {
|
|
const startOfMonth = new CalendarDate(date.year, date.month, 1);
|
|
const endOfMonth = new CalendarDate(
|
|
date.year,
|
|
date.month,
|
|
date.calendar.getDaysInMonth(date)
|
|
);
|
|
|
|
const [whRes, ahRes] = await Promise.all([
|
|
fetch(`/api/scheduling/working-hours?start=${startOfMonth}&end=${endOfMonth}`, {
|
|
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
|
}),
|
|
fetch(`/api/scheduling/available-hours?start=${startOfMonth}&end=${endOfMonth}`, {
|
|
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 }));
|
|
|
|
workingHoursCache.set(monthKey, whMap);
|
|
availableHoursCache.set(monthKey, 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;
|
|
}
|
|
}
|
|
|
|
// =============== Logic ===============
|
|
function toggleService(service: Service) {
|
|
const index = selectedServices.findIndex((s) => s.id === service.id);
|
|
if (index >= 0) {
|
|
selectedServices = selectedServices.filter((s) => s.id !== service.id);
|
|
const newOverrides = { ...serviceOverrides };
|
|
delete newOverrides[service.id];
|
|
serviceOverrides = newOverrides;
|
|
} else {
|
|
selectedServices = [...selectedServices, service];
|
|
serviceOverrides = {
|
|
...serviceOverrides,
|
|
[service.id]: {
|
|
price: service.price.toFixed(2),
|
|
duration: service.duration_minutes.toString(),
|
|
originalPrice: service.price,
|
|
originalDuration: service.duration_minutes
|
|
}
|
|
};
|
|
}
|
|
|
|
selectedTime = null;
|
|
}
|
|
|
|
// Time Slot Generation
|
|
function calculateEndTime(startTime: string, durationMinutes: number): string {
|
|
const [hours, minutes] = startTime.split(':').map(Number);
|
|
const totalMinutes = hours * 60 + minutes + durationMinutes;
|
|
const endHours = Math.floor(totalMinutes / 60);
|
|
const endMinutes = totalMinutes % 60;
|
|
return `${String(endHours).padStart(2, '0')}:${String(endMinutes).padStart(2, '0')}`;
|
|
}
|
|
|
|
function timeToMinutes(time: string): number {
|
|
const [hours, minutes] = time.split(':').map(Number);
|
|
return hours * 60 + minutes;
|
|
}
|
|
|
|
function calculatePreviousTime(time: string): string {
|
|
const [hours, minutes] = time.split(':').map(Number);
|
|
let totalMinutes = hours * 60 + minutes;
|
|
totalMinutes -= 15;
|
|
const prevHours = Math.floor(totalMinutes / 60);
|
|
const prevMinutes = totalMinutes % 60;
|
|
return `${String(prevHours).padStart(2, '0')}:${String(prevMinutes).padStart(2, '0')}`;
|
|
}
|
|
|
|
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';
|
|
} else 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 normalizeTime(time: string): string {
|
|
// Strip seconds if present (convert HH:MM:SS to HH:MM)
|
|
const parts = time.split(':');
|
|
return `${parts[0]}:${parts[1]}`;
|
|
}
|
|
|
|
function generateAvailableTimeSlots(duration: number, date: CalendarDate | undefined): string[] {
|
|
if (!date || !workingHours || !availableHours) return [];
|
|
|
|
const dateStr = date.toString();
|
|
const dayWorkingHours = workingHours[dateStr];
|
|
const dayAvailableHours = availableHours[dateStr];
|
|
|
|
if (!dayWorkingHours?.isOpen || !dayAvailableHours?.slots) return [];
|
|
|
|
const slots: string[] = [];
|
|
const now = new SvelteDate();
|
|
const today = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
|
|
const isToday = date.compare(today) === 0;
|
|
const protection = lunchProtectionStatus();
|
|
|
|
for (const slot of dayAvailableHours.slots) {
|
|
const [startHour, startMinute] = slot.startTime.split(':').map(Number);
|
|
const [endHour, endMinute] = slot.endTime.split(':').map(Number);
|
|
let startTotalMinutes = startHour * 60 + startMinute;
|
|
const endTotalMinutes = endHour * 60 + endMinute;
|
|
|
|
if (isToday) {
|
|
// Calculate current time + buffer
|
|
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
|
let minimumStart = currentMinutes + 15;
|
|
|
|
// FIX: Snap to the NEXT 15-minute interval
|
|
// Math.ceil(x / 15) * 15 rounds up to the nearest 15
|
|
minimumStart = Math.ceil(minimumStart / 15) * 15;
|
|
|
|
startTotalMinutes = Math.max(startTotalMinutes, minimumStart);
|
|
}
|
|
|
|
for (let minutes = startTotalMinutes; minutes < endTotalMinutes; minutes += 15) {
|
|
const slotEndMinutes = minutes + duration;
|
|
|
|
if (slotEndMinutes <= endTotalMinutes) {
|
|
const hour = Math.floor(minutes / 60);
|
|
const minute = minutes % 60;
|
|
const timeStr = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
|
|
|
const protectionResult = protection.get(timeStr);
|
|
if (!protectionResult || !protectionResult.isBlocked) {
|
|
slots.push(timeStr);
|
|
}
|
|
// If blocked, skip adding this slot
|
|
}
|
|
}
|
|
}
|
|
return slots;
|
|
}
|
|
|
|
function generateGroupedTimeSlots(
|
|
duration: number,
|
|
date: CalendarDate | undefined
|
|
): Array<{
|
|
type: 'available' | 'unavailable';
|
|
startTime: string;
|
|
endTime: string;
|
|
isGrouped?: boolean;
|
|
}> {
|
|
if (!date || !workingHours) return [];
|
|
|
|
const dateStr = date.toString();
|
|
const dayWorkingHours = workingHours[dateStr];
|
|
|
|
if (!dayWorkingHours || !dayWorkingHours.isOpen) return [];
|
|
|
|
const groupedSlots: Array<{
|
|
type: 'available' | 'unavailable';
|
|
startTime: string;
|
|
endTime: string;
|
|
isGrouped?: boolean;
|
|
}> = [];
|
|
|
|
const [startHour, startMinute] = dayWorkingHours.startTime.split(':').map(Number);
|
|
const [endHour, endMinute] = dayWorkingHours.endTime.split(':').map(Number);
|
|
|
|
let startTotalMinutes = startHour * 60 + startMinute;
|
|
const endTotalMinutes = endHour * 60 + endMinute;
|
|
|
|
const now = new SvelteDate();
|
|
const today = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
|
|
const isToday = date.compare(today) === 0;
|
|
|
|
if (isToday) {
|
|
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
|
let minimumStart = currentMinutes + 15;
|
|
|
|
// FIX: Also snap here so the visual blocks start at 00, 15, 30, 45
|
|
minimumStart = Math.ceil(minimumStart / 15) * 15;
|
|
|
|
startTotalMinutes = Math.max(startTotalMinutes, minimumStart);
|
|
}
|
|
|
|
const availableSlots = generateAvailableTimeSlots(duration, date);
|
|
let currentUnavailableStart: string | null = null;
|
|
let lastAvailableEndTime: string | null = null;
|
|
const protection = lunchProtectionStatus();
|
|
|
|
for (let minutes = startTotalMinutes; minutes < endTotalMinutes; minutes += 15) {
|
|
const hour = Math.floor(minutes / 60);
|
|
const minute = minutes % 60;
|
|
const timeStr = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
|
|
|
const isInAvailableSlots = availableSlots.includes(timeStr);
|
|
const protectionResult = protection.get(timeStr);
|
|
const isLunchBlocked = protectionResult?.isBlocked || false;
|
|
const isAvailable = isInAvailableSlots && !isLunchBlocked;
|
|
|
|
if (isAvailable) {
|
|
if (currentUnavailableStart !== null) {
|
|
const unavailableStartTime = lastAvailableEndTime || currentUnavailableStart;
|
|
const groupEndTime = calculatePreviousTime(timeStr);
|
|
groupedSlots.push({
|
|
type: 'unavailable',
|
|
startTime: unavailableStartTime,
|
|
endTime: groupEndTime,
|
|
isGrouped: true
|
|
});
|
|
currentUnavailableStart = null;
|
|
}
|
|
|
|
const slotEndTime = calculateEndTime(timeStr, duration);
|
|
lastAvailableEndTime = slotEndTime;
|
|
groupedSlots.push({
|
|
type: 'available',
|
|
startTime: timeStr,
|
|
endTime: slotEndTime
|
|
});
|
|
|
|
if (timeToMinutes(slotEndTime) >= endTotalMinutes) break;
|
|
} else {
|
|
if (currentUnavailableStart === null) {
|
|
currentUnavailableStart = timeStr;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (currentUnavailableStart !== null) {
|
|
const lastAvailableSlot = groupedSlots.filter((s) => s.type === 'available').pop();
|
|
const lastAvailableEnd = lastAvailableSlot ? timeToMinutes(lastAvailableSlot.endTime) : 0;
|
|
const unavailableStartMinutes = timeToMinutes(currentUnavailableStart);
|
|
|
|
if (unavailableStartMinutes < endTotalMinutes && lastAvailableEnd < endTotalMinutes) {
|
|
const unavailableStartTime = lastAvailableSlot
|
|
? lastAvailableSlot.endTime
|
|
: currentUnavailableStart;
|
|
groupedSlots.push({
|
|
type: 'unavailable',
|
|
startTime: unavailableStartTime,
|
|
endTime: normalizeTime(dayWorkingHours.endTime),
|
|
isGrouped: true
|
|
});
|
|
}
|
|
}
|
|
|
|
return groupedSlots;
|
|
}
|
|
|
|
const groupedTimeSlots = $derived(
|
|
currentStep === 4 && selectedServices.length > 0 && selectedDate
|
|
? generateGroupedTimeSlots(getTotalDuration(), selectedDate)
|
|
: []
|
|
);
|
|
|
|
function isDateUnavailable(date: DateValue): boolean {
|
|
if (!(date instanceof CalendarDate)) return true;
|
|
if (date.compare(minDate) < 0 || date.compare(maxCalendarDate) > 0) return true;
|
|
if (!workingHours) return true;
|
|
|
|
const dateStr = date.toString();
|
|
if (!workingHours[dateStr]?.isOpen) return true;
|
|
|
|
if (selectedServices.length > 0) {
|
|
const duration = getTotalDuration();
|
|
const slots = generateAvailableTimeSlots(duration, date);
|
|
return slots.length === 0;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// =============== Submission ===============
|
|
async function submitBooking() {
|
|
submitting = true;
|
|
|
|
try {
|
|
let finalUserId = selectedUserId;
|
|
|
|
if (userType === 'guest') {
|
|
const createRes = await fetch('/api/users/guest', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${authStore.currentToken}`
|
|
},
|
|
body: JSON.stringify({
|
|
name: guestName,
|
|
phone: guestPhone
|
|
})
|
|
});
|
|
|
|
if (!createRes.ok) {
|
|
toast.error('Failed to create guest user');
|
|
return;
|
|
}
|
|
|
|
const guestUser = await createRes.json();
|
|
finalUserId = guestUser.id;
|
|
}
|
|
|
|
if (!finalUserId) throw new Error('User ID required');
|
|
if (!selectedDate || !selectedTime) throw new Error('Date and time required');
|
|
|
|
const localDate = selectedDate.toDate(getLocalTimeZone());
|
|
const [hours, minutes] = selectedTime.split(':').map(Number);
|
|
localDate.setHours(hours, minutes, 0, 0);
|
|
const dateTimeStr = localDate.toISOString();
|
|
|
|
const overrides = [];
|
|
for (const [serviceId, data] of Object.entries(serviceOverrides)) {
|
|
const priceChanged = Math.abs(parseFloat(data.price) - data.originalPrice) > 0.01;
|
|
const durationChanged = parseInt(data.duration) !== data.originalDuration;
|
|
|
|
if (priceChanged || durationChanged) {
|
|
overrides.push({
|
|
service_id: serviceId,
|
|
override_price: priceChanged ? parseFloat(data.price) : null,
|
|
override_duration_minutes: durationChanged ? parseInt(data.duration) : null
|
|
});
|
|
}
|
|
}
|
|
|
|
const payload = {
|
|
user_id: finalUserId,
|
|
start_time: dateTimeStr,
|
|
service_ids: selectedServices.map((s) => s.id),
|
|
service_overrides: overrides.length > 0 ? overrides : undefined,
|
|
notes: notes.trim() || null
|
|
};
|
|
|
|
const res = await fetch('/api/admin/bookings', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${authStore.currentToken}`
|
|
},
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
if (res.ok) {
|
|
toast.success('Booking created successfully!');
|
|
open = false;
|
|
onBookingCreated?.();
|
|
} else {
|
|
const errorText = await res.text();
|
|
console.error('Booking creation failed:', errorText);
|
|
toast.error(`Failed to create booking: ${errorText}`);
|
|
}
|
|
} catch (err) {
|
|
console.error('Booking submission error:', err);
|
|
toast.error('An error occurred while creating booking');
|
|
} finally {
|
|
submitting = false;
|
|
}
|
|
}
|
|
|
|
// Input handlers
|
|
function handlePriceInput(serviceId: string, value: string) {
|
|
const override = serviceOverrides[serviceId];
|
|
if (!override) return;
|
|
|
|
let cleaned = value.replace(/[^\d.]/g, '');
|
|
const parts = cleaned.split('.');
|
|
if (parts.length > 2) cleaned = parts[0] + '.' + parts.slice(1).join('');
|
|
if (cleaned.includes('.')) {
|
|
const [int, dec] = cleaned.split('.');
|
|
cleaned = int + '.' + dec.substring(0, 2);
|
|
}
|
|
|
|
serviceOverrides = { ...serviceOverrides, [serviceId]: { ...override, price: cleaned } };
|
|
}
|
|
|
|
function handleDurationInput(serviceId: string, value: string) {
|
|
const override = serviceOverrides[serviceId];
|
|
if (!override) return;
|
|
|
|
const cleaned = value.replace(/\D/g, '');
|
|
serviceOverrides = { ...serviceOverrides, [serviceId]: { ...override, duration: cleaned } };
|
|
|
|
// Clear date/time and cache when duration changes
|
|
selectedDate = undefined;
|
|
selectedTime = null;
|
|
availableHoursCache.clear();
|
|
}
|
|
</script>
|
|
|
|
<Modal.Root bind:open>
|
|
<Modal.Content class="max-h-[90vh] max-w-4xl overflow-y-auto">
|
|
<Modal.Header>
|
|
<Modal.Title>Create Admin Booking</Modal.Title>
|
|
<Modal.Description>
|
|
Book an appointment for a member or guest with flexible pricing and timing
|
|
</Modal.Description>
|
|
</Modal.Header>
|
|
|
|
<div class="px-6 pb-4">
|
|
<!-- Step 1: Customer Selection -->
|
|
{#if currentStep === 1}
|
|
<Card.Root>
|
|
<Card.Header>
|
|
<Card.Title>Select Customer</Card.Title>
|
|
<Card.Description>Choose an existing member or create a guest booking</Card.Description>
|
|
</Card.Header>
|
|
<Card.Content class="space-y-4">
|
|
<!-- Tabs -->
|
|
<div class="flex gap-6 border-b border-gray-200">
|
|
<button
|
|
class="pb-2 text-sm font-medium transition-colors {userType === 'member'
|
|
? 'border-b-2 border-primary text-primary'
|
|
: 'text-gray-500 hover:text-gray-700'}"
|
|
onclick={() => {
|
|
userType = 'member';
|
|
selectedUserId = null;
|
|
}}
|
|
>
|
|
Member
|
|
</button>
|
|
<button
|
|
class="pb-2 text-sm font-medium transition-colors {userType === 'guest'
|
|
? 'border-b-2 border-primary text-primary'
|
|
: 'text-gray-500 hover:text-gray-700'}"
|
|
onclick={() => {
|
|
userType = 'guest';
|
|
guestName = '';
|
|
guestPhone = '';
|
|
}}
|
|
>
|
|
Guest / Non-Member
|
|
</button>
|
|
</div>
|
|
|
|
{#if userType === 'member'}
|
|
<!-- Native Input using oninput to prevent reactivity bugs -->
|
|
<div class="flex items-center space-x-2">
|
|
<div class="relative flex-1">
|
|
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
|
<svg
|
|
class="h-4 w-4 text-gray-400"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
|
></path>
|
|
</svg>
|
|
</div>
|
|
<input
|
|
type="text"
|
|
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pl-9 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
|
placeholder="Search by name, email or phone..."
|
|
value={userQuery}
|
|
oninput={(e) => {
|
|
userQuery = e.currentTarget.value;
|
|
}}
|
|
onkeydown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
fetchUsers();
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
<Button onclick={fetchUsers} disabled={loadingUsers}>
|
|
{loadingUsers ? '...' : 'Search'}
|
|
</Button>
|
|
</div>
|
|
|
|
<!-- Compact Results List -->
|
|
<div class="max-h-[300px] overflow-y-auto rounded-md border border-gray-200">
|
|
{#if loadingUsers}
|
|
<div class="space-y-2 p-2">
|
|
{#each Array(3) as _}
|
|
<Skeleton class="h-10 w-full" />
|
|
{/each}
|
|
</div>
|
|
{:else if users.length === 0}
|
|
<div class="flex items-center justify-center p-8 text-sm text-gray-500">
|
|
{userQuery
|
|
? 'No users found. Try a different search.'
|
|
: 'Search for a user above to get started.'}
|
|
</div>
|
|
{:else}
|
|
<ul class="divide-y divide-gray-200">
|
|
{#each users.slice(0, 4) as user (user.id)}
|
|
<li>
|
|
<button
|
|
type="button"
|
|
class="flex w-full cursor-pointer items-center justify-between px-4 py-3 text-left transition-colors hover:bg-fuchsia-50 {selectedUserId ===
|
|
user.id
|
|
? 'bg-fuchsia-100 font-medium'
|
|
: ''}"
|
|
onclick={() => (selectedUserId = user.id)}
|
|
>
|
|
<div>
|
|
<div class="text-base font-medium">{user.fullName}</div>
|
|
<div class="text-xs text-gray-500">
|
|
{#if user.email && user.phone}
|
|
{user.email} • {user.phone}
|
|
{:else if user.email}
|
|
{user.email}
|
|
{:else if user.phone}
|
|
{user.phone}
|
|
{:else}
|
|
No contact info
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{#if selectedUserId === user.id}
|
|
<svg
|
|
class="h-5 w-5 text-primary"
|
|
fill="currentColor"
|
|
viewBox="0 0 20 20"
|
|
>
|
|
<path
|
|
fill-rule="evenodd"
|
|
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
|
clip-rule="evenodd"
|
|
></path>
|
|
</svg>
|
|
{/if}
|
|
</button>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<!-- Guest Form - Using Native Input -->
|
|
<div class="space-y-4">
|
|
<div class="space-y-2">
|
|
<Label for="guest-name">Guest Name *</Label>
|
|
<input
|
|
id="guest-name"
|
|
type="text"
|
|
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
|
placeholder="Jane Doe"
|
|
value={guestName}
|
|
oninput={(e) => (guestName = e.currentTarget.value)}
|
|
/>
|
|
</div>
|
|
<div class="space-y-2">
|
|
<Label for="guest-phone">Phone Number *</Label>
|
|
<input
|
|
id="guest-phone"
|
|
type="tel"
|
|
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
|
placeholder="07700 900000"
|
|
value={guestPhone}
|
|
oninput={(e) => (guestPhone = e.currentTarget.value)}
|
|
/>
|
|
</div>
|
|
<p class="rounded-lg bg-yellow-50 p-3 text-sm text-yellow-800">
|
|
Booking as a guest creates a temporary record. Encourage them to sign up for
|
|
loyalty benefits.
|
|
</p>
|
|
</div>
|
|
{/if}
|
|
</Card.Content>
|
|
<Card.Footer class="flex justify-end">
|
|
<BookingActions
|
|
canBack={false}
|
|
canNext={canProceedStep1}
|
|
nextLabel="Next: Choose Services"
|
|
on:next={() => currentStep++}
|
|
/>
|
|
</Card.Footer>
|
|
</Card.Root>
|
|
{/if}
|
|
|
|
<!-- Step 2: Service Selection -->
|
|
{#if currentStep === 2}
|
|
<Card.Root>
|
|
<Card.Header>
|
|
<Card.Title>Choose Services</Card.Title>
|
|
<Card.Description>Select one or more treatments for this appointment</Card.Description>
|
|
</Card.Header>
|
|
<Card.Content class="space-y-4">
|
|
{#if loadingServices}
|
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
{#each Array(4) as _}
|
|
<Skeleton class="h-28 w-full" />
|
|
{/each}
|
|
</div>
|
|
{:else if services.length === 0}
|
|
<p class="py-8 text-center text-gray-500">No services available.</p>
|
|
{:else}
|
|
<ServiceSelector
|
|
{services}
|
|
selected={selectedServices}
|
|
loading={loadingServices}
|
|
ontoggle={toggleService}
|
|
/>
|
|
{/if}
|
|
|
|
{#if selectedServices.length > 0}
|
|
<div class="rounded-lg bg-gray-50 p-4">
|
|
<h4 class="mb-2 font-semibold">Selected Services</h4>
|
|
<div class="space-y-2">
|
|
{#each selectedServices as service (service.id)}
|
|
<div class="flex justify-between text-sm">
|
|
<span>{service.name}</span>
|
|
<span>{service.duration_minutes} mins • £{service.price}</span>
|
|
</div>
|
|
{/each}
|
|
<Separator class="my-2" />
|
|
<div class="flex justify-between text-sm font-semibold">
|
|
<span>Estimated Duration:</span>
|
|
<span>{formattedTotalDuration}</span>
|
|
</div>
|
|
<div class="flex justify-between text-sm font-semibold">
|
|
<span>Total Cost:</span>
|
|
<span>£{getTotalPrice()}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</Card.Content>
|
|
<Card.Footer class="flex justify-between">
|
|
<Button variant="outline" onclick={() => currentStep--}>Back</Button>
|
|
<Button disabled={!canProceedStep2} onclick={() => currentStep++}>
|
|
Next: Customize Services
|
|
</Button>
|
|
</Card.Footer>
|
|
</Card.Root>
|
|
{/if}
|
|
|
|
<!-- Step 3: Service Overrides & Notes -->
|
|
{#if currentStep === 3}
|
|
<Card.Root>
|
|
<Card.Header>
|
|
<Card.Title>Customize Services</Card.Title>
|
|
<Card.Description>
|
|
Adjust pricing or duration if needed, and add appointment notes
|
|
</Card.Description>
|
|
</Card.Header>
|
|
<Card.Content class="space-y-6">
|
|
<div>
|
|
<h4 class="mb-3 font-semibold">Service Details</h4>
|
|
<p class="mb-4 text-sm text-gray-600">
|
|
Override default pricing or duration for special cases (discounts, extended
|
|
sessions, etc.)
|
|
</p>
|
|
<div class="space-y-3">
|
|
{#each selectedServices as service (service.id)}
|
|
<!-- Safety check to ensure override exists -->
|
|
{#if serviceOverrides[service.id]}
|
|
<div class="rounded-lg border bg-white p-4">
|
|
<div class="mb-3 font-medium">{service.name}</div>
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div class="space-y-2">
|
|
<Label for="price-{service.id}" class="text-xs text-gray-600"
|
|
>Price (£)</Label
|
|
>
|
|
<!-- Native Input with oninput -->
|
|
<input
|
|
id="price-{service.id}"
|
|
type="text"
|
|
inputmode="decimal"
|
|
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
|
value={serviceOverrides[service.id]?.price || service.price.toFixed(2)}
|
|
oninput={(e) => handlePriceInput(service.id, e.currentTarget.value)}
|
|
/>
|
|
</div>
|
|
<div class="space-y-2">
|
|
<Label for="duration-{service.id}" class="text-xs text-gray-600"
|
|
>Duration (min)</Label
|
|
>
|
|
<!-- Native Input with oninput -->
|
|
<input
|
|
id="duration-{service.id}"
|
|
type="number"
|
|
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
|
value={serviceOverrides[service.id]?.duration ||
|
|
service.duration_minutes}
|
|
oninput={(e) => handleDurationInput(service.id, e.currentTarget.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{#if serviceOverrides[service.id] && (Math.abs(parseFloat(serviceOverrides[service.id].price) - serviceOverrides[service.id].originalPrice) > 0.01 || parseInt(serviceOverrides[service.id].duration) !== serviceOverrides[service.id].originalDuration)}
|
|
<div class="mt-2 text-xs text-amber-600">
|
|
{#if Math.abs(parseFloat(serviceOverrides[service.id].price) - serviceOverrides[service.id].originalPrice) > 0.01}
|
|
Price modified from £{serviceOverrides[
|
|
service.id
|
|
].originalPrice.toFixed(2)}
|
|
{/if}
|
|
|
|
{#if Math.abs(parseFloat(serviceOverrides[service.id].price) - serviceOverrides[service.id].originalPrice) > 0.01 && parseInt(serviceOverrides[service.id].duration) !== serviceOverrides[service.id].originalDuration}
|
|
•
|
|
{/if}
|
|
|
|
{#if parseInt(serviceOverrides[service.id].duration) !== serviceOverrides[service.id].originalDuration}
|
|
Duration modified from {serviceOverrides[service.id].originalDuration} mins
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="rounded-lg bg-gray-50 p-4">
|
|
<div class="flex justify-between text-sm font-semibold">
|
|
<span>Total Duration:</span>
|
|
<span>{formattedTotalDuration}</span>
|
|
</div>
|
|
<div class="mt-1 flex justify-between text-sm font-semibold">
|
|
<span>Total Cost:</span>
|
|
<span>£{getTotalPrice().toFixed(2)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<Label for="notes">Appointment Notes (extras only, client will see this)</Label>
|
|
<!-- Native Textarea -->
|
|
<textarea
|
|
id="notes"
|
|
class="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
|
bind:value={notes}
|
|
placeholder="Any special requirements, preferences, or notes about this booking..."
|
|
></textarea>
|
|
</div>
|
|
</Card.Content>
|
|
<Card.Footer class="flex justify-between">
|
|
<Button variant="outline" onclick={() => currentStep--}>Back</Button>
|
|
<Button onclick={() => currentStep++}>Next: Select Date & Time</Button>
|
|
</Card.Footer>
|
|
</Card.Root>
|
|
{/if}
|
|
|
|
<!-- Step 4: Date & Time Selection -->
|
|
{#if currentStep === 4}
|
|
<Card.Root>
|
|
<Card.Header>
|
|
<Card.Title>Choose Date & Time</Card.Title>
|
|
<Card.Description>
|
|
{selectedServices.map((s) => s.name).join(', ')} • {formattedTotalDuration} total • £{getTotalPrice().toFixed(
|
|
2
|
|
)}
|
|
</Card.Description>
|
|
</Card.Header>
|
|
<Card.Content class="space-y-4 p-0">
|
|
{#if loadingWorkingHours}
|
|
<div class="flex items-center justify-center p-6">
|
|
<p>Loading available dates...</p>
|
|
</div>
|
|
{:else}
|
|
<div class="flex items-center justify-center p-6">
|
|
<DatePicker
|
|
date={selectedDate}
|
|
{placeholder}
|
|
minValue={minDate}
|
|
maxValue={maxCalendarDate}
|
|
{isDateUnavailable}
|
|
onchange={(newDate) => {
|
|
selectedDate = newDate;
|
|
selectedTime = null;
|
|
}}
|
|
onPlaceholderChange={(newPlaceholder) => {
|
|
placeholder = newPlaceholder;
|
|
}}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if loadingAvailableHours}
|
|
<div class="flex items-center justify-center border-t p-6">
|
|
<p class="text-sm text-gray-500">Loading times...</p>
|
|
</div>
|
|
{:else if selectedDate}
|
|
<div
|
|
class="no-scrollbar flex max-h-40 w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t p-6"
|
|
>
|
|
{#if formattedSelectedDate}
|
|
<div class="grid justify-center gap-2">{formattedSelectedDate}</div>
|
|
{/if}
|
|
|
|
{#if groupedTimeSlots.length > 0}
|
|
<div class="grid gap-2">
|
|
{#each groupedTimeSlots as slot (slot.startTime)}
|
|
{#if slot.type === 'available'}
|
|
{@const protection = lunchProtectionStatus().get(slot.startTime)}
|
|
{#if protection?.isBlocked}
|
|
<!-- Blocked by lunch protection -->
|
|
<Button
|
|
variant="outline"
|
|
class="w-full cursor-not-allowed opacity-50 hover:bg-gray-100"
|
|
disabled
|
|
title={protection.warningMessage || 'Lunch protection'}
|
|
>
|
|
{formatTime(slot.startTime)}
|
|
<span class="text-gray-500">- {formatTime(slot.endTime)}</span>
|
|
</Button>
|
|
{:else}
|
|
<Button
|
|
variant="outline"
|
|
onclick={() => {
|
|
selectedTime = slot.startTime;
|
|
}}
|
|
class={`w-full hover:bg-fuchsia-50 ${
|
|
slot.startTime === selectedTime ? 'bg-fuchsia-100' : ''
|
|
} ${protection?.showWarning ? 'border-amber-400 bg-amber-50' : ''}`}
|
|
title={protection?.warningMessage}
|
|
>
|
|
{#if protection?.showWarning}
|
|
<span class="mr-1 text-amber-500">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="h-4 w-4"
|
|
viewBox="0 0 20 20"
|
|
fill="currentColor"
|
|
>
|
|
<path
|
|
fill-rule="evenodd"
|
|
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
|
|
clip-rule="evenodd"
|
|
/>
|
|
</svg>
|
|
</span>
|
|
{/if}
|
|
{formatTime(slot.startTime)}
|
|
<span class="text-gray-500">- {formatTime(slot.endTime)}</span>
|
|
</Button>
|
|
{/if}
|
|
{: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}
|
|
</div>
|
|
{:else}
|
|
<div class="flex items-center justify-center border-t p-6">
|
|
<p class="text-center text-sm text-gray-500">
|
|
Select a date to see available times
|
|
</p>
|
|
</div>
|
|
{/if}
|
|
</Card.Content>
|
|
|
|
<Card.Footer class="flex justify-between">
|
|
<Button variant="outline" onclick={() => currentStep--}>Back</Button>
|
|
<Button
|
|
disabled={!canProceedStep4 || submitting}
|
|
onclick={submitBooking}
|
|
class="bg-primary text-primary-foreground"
|
|
>
|
|
{submitting ? 'Creating Booking...' : 'Create Booking'}
|
|
</Button>
|
|
</Card.Footer>
|
|
</Card.Root>
|
|
{/if}
|
|
</div>
|
|
</Modal.Content>
|
|
</Modal.Root>
|