refactor: admin BookingCreateModal to use shared time slot utilities
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { authStore } from '$lib/stores/auth.svelte';
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { SvelteMap, SvelteDate } from 'svelte/reactivity';
|
import { SvelteDate } from 'svelte/reactivity';
|
||||||
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
|
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
|
||||||
|
|
||||||
// UI Components
|
// UI Components
|
||||||
@@ -18,11 +18,22 @@
|
|||||||
import BookingActions from '$lib/components/booking/BookingActions.svelte';
|
import BookingActions from '$lib/components/booking/BookingActions.svelte';
|
||||||
import ServiceSelector from '$lib/components/booking/ServiceSelector.svelte';
|
import ServiceSelector from '$lib/components/booking/ServiceSelector.svelte';
|
||||||
import DatePicker from '$lib/components/booking/DatePicker.svelte';
|
import DatePicker from '$lib/components/booking/DatePicker.svelte';
|
||||||
|
import TimeSlotList from '$lib/components/booking/TimeSlotList.svelte';
|
||||||
|
import SelectedTimeSummary from '$lib/components/booking/SelectedTimeSummary.svelte';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
import type { Service, WorkingHoursDay, AvailableHoursDay } from '$lib/types/booking';
|
import type { Service, WorkingHoursDay, AvailableHoursDay } from '$lib/types/booking';
|
||||||
|
|
||||||
import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection';
|
import {
|
||||||
|
buildLunchProtection,
|
||||||
|
generateAvailableTimeSlots,
|
||||||
|
generateGroupedTimeSlots,
|
||||||
|
formatTime,
|
||||||
|
calculateEndTime,
|
||||||
|
getDayWithOrdinal,
|
||||||
|
type DayHours,
|
||||||
|
type DayAvailability
|
||||||
|
} from '$lib/utils/timeSlots';
|
||||||
|
|
||||||
// =============== Props ===============
|
// =============== Props ===============
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -67,16 +78,12 @@
|
|||||||
);
|
);
|
||||||
let selectedDate = $state<CalendarDate | undefined>(undefined);
|
let selectedDate = $state<CalendarDate | undefined>(undefined);
|
||||||
let selectedTime = $state<string | null>(null);
|
let selectedTime = $state<string | null>(null);
|
||||||
let workingHours = $state<Record<
|
let workingHours = $state<Record<string, DayHours> | null>(null);
|
||||||
string,
|
let availableHours = $state<Record<string, DayAvailability> | null>(null);
|
||||||
{ 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 loadingWorkingHours = $state(false);
|
||||||
let loadingAvailableHours = $state(false);
|
let loadingAvailableHours = $state(false);
|
||||||
|
let hoursRangeGeneration = $state(0);
|
||||||
|
let hoursMonthGeneration = $state(0);
|
||||||
|
|
||||||
// Date Boundaries
|
// Date Boundaries
|
||||||
const today = new SvelteDate();
|
const today = new SvelteDate();
|
||||||
@@ -98,8 +105,9 @@
|
|||||||
let isReserving = $state(false);
|
let isReserving = $state(false);
|
||||||
|
|
||||||
// =============== Cache ===============
|
// =============== Cache ===============
|
||||||
const workingHoursCache = new SvelteMap<string, Record<string, any>>();
|
let workingHoursCache: Record<string, Record<string, any>> = {};
|
||||||
const availableHoursCache = new SvelteMap<string, Record<string, any>>();
|
let availableHoursCache: Record<string, Record<string, any>> = {};
|
||||||
|
let loadingMonthKeys: Set<string> = new Set();
|
||||||
|
|
||||||
// =============== Derived Helpers ===============
|
// =============== Derived Helpers ===============
|
||||||
function getTotalDuration() {
|
function getTotalDuration() {
|
||||||
@@ -120,67 +128,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// =============== Lunch Protection ===============
|
// =============== Lunch Protection ===============
|
||||||
const lunchProtectionStatus = $derived(() => {
|
const lunchProtection = $derived(
|
||||||
if (!selectedDate || !workingHours || !availableHours || selectedServices.length === 0) {
|
(selectedDate && selectedServices.length > 0)
|
||||||
return new Map();
|
? buildLunchProtection(selectedDate, workingHours, availableHours, getTotalDuration(), true)
|
||||||
}
|
: 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 {
|
function formatDuration(minutes: number): string {
|
||||||
const hours = Math.floor(minutes / 60);
|
const hours = Math.floor(minutes / 60);
|
||||||
const remainingMinutes = minutes % 60;
|
const remainingMinutes = minutes % 60;
|
||||||
|
if (hours === 0) return `${remainingMinutes} minutes`;
|
||||||
if (hours === 0) {
|
if (remainingMinutes === 0) return `${hours} ${hours === 1 ? 'hour' : 'hours'}`;
|
||||||
return `${remainingMinutes} minutes`;
|
return `${hours} ${hours === 1 ? 'hour' : 'hours'} ${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 formattedTotalDuration = $derived(formatDuration(getTotalDuration()));
|
||||||
@@ -197,7 +156,7 @@
|
|||||||
|
|
||||||
// =============== Effects ===============
|
// =============== Effects ===============
|
||||||
let wasOpen = false;
|
let wasOpen = false;
|
||||||
let bookingCreateInitialLoadDone = $state(false);
|
let userNavigatedCalendar = $state(false);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (open && !wasOpen) {
|
if (open && !wasOpen) {
|
||||||
@@ -216,24 +175,56 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Preload 3 months when entering step 4 to prevent snap-back
|
// Preload current + next month on entering step 4; individual months fetched on navigation
|
||||||
|
let bookingCreateInitialLoadDone = $state(false);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (open && currentStep === 4 && !bookingCreateInitialLoadDone) {
|
if (open && currentStep === 4 && !bookingCreateInitialLoadDone) {
|
||||||
fetchHoursRange(placeholder, 3);
|
fetchHoursRange(placeholder, 2);
|
||||||
bookingCreateInitialLoadDone = true;
|
bookingCreateInitialLoadDone = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch additional months when navigating beyond preloaded range
|
// Safety net: fetch when navigating to an uncached month
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (open && currentStep === 4 && bookingCreateInitialLoadDone && placeholder) {
|
if (open && currentStep === 4 && bookingCreateInitialLoadDone) {
|
||||||
const monthKey = `${placeholder.year}-${String(placeholder.month).padStart(2, '0')}`;
|
const monthKey = `${placeholder.year}-${String(placeholder.month).padStart(2, '0')}`;
|
||||||
if (!workingHoursCache.has(monthKey)) {
|
if (!(monthKey in workingHoursCache) && !loadingMonthKeys.has(monthKey)) {
|
||||||
fetchHoursForMonth(placeholder);
|
fetchHoursForMonth(placeholder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-select first available date once data loads (timing-safe, data-driven)
|
||||||
|
let bookingCreateAutoSelectDone = $state(false);
|
||||||
|
$effect(() => {
|
||||||
|
if (open && currentStep === 4 && workingHours && availableHours && !selectedDate && selectedServices.length > 0 && !userNavigatedCalendar && !bookingCreateAutoSelectDone) {
|
||||||
|
bookingCreateAutoSelectDone = true;
|
||||||
|
const now = new SvelteDate();
|
||||||
|
const maxDateJs = new SvelteDate(
|
||||||
|
maxCalendarDate.year,
|
||||||
|
maxCalendarDate.month - 1,
|
||||||
|
maxCalendarDate.day
|
||||||
|
);
|
||||||
|
const daysDifference = Math.floor(
|
||||||
|
(maxDateJs.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
|
||||||
|
);
|
||||||
|
const daysToCheck = Math.min(daysDifference, 180);
|
||||||
|
for (let i = 0; i <= daysToCheck; i++) {
|
||||||
|
const checkDate = new SvelteDate(now);
|
||||||
|
checkDate.setDate(now.getDate() + i);
|
||||||
|
const dateStr = checkDate.toISOString().split('T')[0];
|
||||||
|
const calDate = new CalendarDate(checkDate.getFullYear(), checkDate.getMonth() + 1, checkDate.getDate());
|
||||||
|
if (workingHours[dateStr]?.isOpen && !isDateUnavailable(calDate)) {
|
||||||
|
selectedDate = calDate;
|
||||||
|
if (!userNavigatedCalendar) {
|
||||||
|
placeholder = new CalendarDate(checkDate.getFullYear(), checkDate.getMonth() + 1, 1);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// =============== Reset State ===============
|
// =============== Reset State ===============
|
||||||
function resetState() {
|
function resetState() {
|
||||||
currentStep = 1;
|
currentStep = 1;
|
||||||
@@ -248,11 +239,14 @@
|
|||||||
selectedTime = null;
|
selectedTime = null;
|
||||||
notes = '';
|
notes = '';
|
||||||
serviceOverrides = {};
|
serviceOverrides = {};
|
||||||
workingHoursCache.clear();
|
workingHoursCache = {};
|
||||||
availableHoursCache.clear();
|
availableHoursCache = {};
|
||||||
workingHours = null;
|
workingHours = null;
|
||||||
availableHours = null;
|
availableHours = null;
|
||||||
bookingCreateInitialLoadDone = false;
|
bookingCreateInitialLoadDone = false;
|
||||||
|
userNavigatedCalendar = false;
|
||||||
|
bookingCreateAutoSelectDone = false;
|
||||||
|
loadingMonthKeys = new Set();
|
||||||
// Clear reservation state
|
// Clear reservation state
|
||||||
reservationId = null;
|
reservationId = null;
|
||||||
reservationExpiresAt = null;
|
reservationExpiresAt = null;
|
||||||
@@ -325,6 +319,9 @@
|
|||||||
const startStr = startDate.toString();
|
const startStr = startDate.toString();
|
||||||
const endStr = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(daysInEndMonth).padStart(2, '0')}`;
|
const endStr = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(daysInEndMonth).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
hoursRangeGeneration++;
|
||||||
|
const gen = hoursRangeGeneration;
|
||||||
|
|
||||||
loadingWorkingHours = true;
|
loadingWorkingHours = true;
|
||||||
loadingAvailableHours = true;
|
loadingAvailableHours = true;
|
||||||
|
|
||||||
@@ -348,6 +345,8 @@
|
|||||||
whData.forEach((d) => (whMap[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime }));
|
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 }));
|
ahData.forEach((d) => (ahMap[d.date] = { isOpen: d.isOpen, slots: d.slots }));
|
||||||
|
|
||||||
|
if (gen !== hoursRangeGeneration) return; // Stale response, discard
|
||||||
|
|
||||||
// Cache by month key
|
// Cache by month key
|
||||||
for (let i = 0; i < months; i++) {
|
for (let i = 0; i < months; i++) {
|
||||||
let mYear = startDate.year;
|
let mYear = startDate.year;
|
||||||
@@ -357,12 +356,13 @@
|
|||||||
mYear++;
|
mYear++;
|
||||||
}
|
}
|
||||||
const key = `${mYear}-${String(mMonth).padStart(2, '0')}`;
|
const key = `${mYear}-${String(mMonth).padStart(2, '0')}`;
|
||||||
workingHoursCache.set(key, whMap);
|
workingHoursCache[key] = whMap;
|
||||||
availableHoursCache.set(key, ahMap);
|
availableHoursCache[key] = ahMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
workingHours = whMap;
|
// MERGE instead of replace — preserves data from previously loaded months
|
||||||
availableHours = ahMap;
|
workingHours = { ...workingHours, ...whMap };
|
||||||
|
availableHours = { ...availableHours, ...ahMap };
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch hours', err);
|
console.error('Failed to fetch hours', err);
|
||||||
@@ -376,12 +376,20 @@
|
|||||||
async function fetchHoursForMonth(date: CalendarDate) {
|
async function fetchHoursForMonth(date: CalendarDate) {
|
||||||
const monthKey = `${date.year}-${String(date.month).padStart(2, '0')}`;
|
const monthKey = `${date.year}-${String(date.month).padStart(2, '0')}`;
|
||||||
|
|
||||||
if (workingHoursCache.has(monthKey) && availableHoursCache.has(monthKey)) {
|
if (monthKey in workingHoursCache && monthKey in availableHoursCache) {
|
||||||
workingHours = workingHoursCache.get(monthKey)!;
|
// MERGE instead of replace — preserves data from other loaded months
|
||||||
availableHours = availableHoursCache.get(monthKey)!;
|
workingHours = { ...workingHours, ...workingHoursCache[monthKey] };
|
||||||
|
availableHours = { ...availableHours, ...availableHoursCache[monthKey] };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prevent re-entrant calls for the same month
|
||||||
|
if (loadingMonthKeys.has(monthKey)) return;
|
||||||
|
loadingMonthKeys = new Set(loadingMonthKeys).add(monthKey);
|
||||||
|
|
||||||
|
hoursMonthGeneration++;
|
||||||
|
const gen = hoursMonthGeneration;
|
||||||
|
|
||||||
loadingWorkingHours = true;
|
loadingWorkingHours = true;
|
||||||
loadingAvailableHours = true;
|
loadingAvailableHours = true;
|
||||||
|
|
||||||
@@ -414,10 +422,12 @@
|
|||||||
);
|
);
|
||||||
ahData.forEach((d) => (ahMap[d.date] = { isOpen: d.isOpen, slots: d.slots }));
|
ahData.forEach((d) => (ahMap[d.date] = { isOpen: d.isOpen, slots: d.slots }));
|
||||||
|
|
||||||
workingHoursCache.set(monthKey, whMap);
|
if (gen !== hoursMonthGeneration) return; // Stale response, discard
|
||||||
availableHoursCache.set(monthKey, ahMap);
|
|
||||||
workingHours = whMap;
|
workingHoursCache[monthKey] = whMap;
|
||||||
availableHours = ahMap;
|
availableHoursCache[monthKey] = ahMap;
|
||||||
|
workingHours = { ...workingHours, ...whMap };
|
||||||
|
availableHours = { ...availableHours, ...ahMap };
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch hours', err);
|
console.error('Failed to fetch hours', err);
|
||||||
@@ -425,6 +435,7 @@
|
|||||||
} finally {
|
} finally {
|
||||||
loadingWorkingHours = false;
|
loadingWorkingHours = false;
|
||||||
loadingAvailableHours = false;
|
loadingAvailableHours = false;
|
||||||
|
loadingMonthKeys = new Set([...loadingMonthKeys].filter(k => k !== monthKey));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -562,216 +573,9 @@
|
|||||||
selectedTime = null;
|
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);
|
|
||||||
if (timeToMinutes(unavailableStartTime) < timeToMinutes(groupEndTime)) {
|
|
||||||
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(
|
const groupedTimeSlots = $derived(
|
||||||
currentStep === 4 && selectedServices.length > 0 && selectedDate
|
currentStep === 4 && selectedServices.length > 0 && selectedDate
|
||||||
? generateGroupedTimeSlots(getTotalDuration(), selectedDate)
|
? generateGroupedTimeSlots(selectedDate, workingHours, availableHours, getTotalDuration(), lunchProtection)
|
||||||
: []
|
: []
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -784,30 +588,20 @@
|
|||||||
const dayHours = workingHours[dateStr];
|
const dayHours = workingHours[dateStr];
|
||||||
if (!dayHours?.isOpen) return true;
|
if (!dayHours?.isOpen) return true;
|
||||||
|
|
||||||
if (selectedServices.length > 0) {
|
// No available hours data for this date = data not loaded = unavailable
|
||||||
const duration = getTotalDuration();
|
if (!availableHours?.[dateStr]) return true;
|
||||||
const slots = generateAvailableTimeSlots(duration, date);
|
// API returned empty slots = no availability at all
|
||||||
if (slots.length === 0) return true;
|
if (!availableHours[dateStr].slots || availableHours[dateStr].slots.length === 0) return true;
|
||||||
|
|
||||||
const dayAvailableHours = availableHours?.[dateStr];
|
// Determine the duration to check: if services are selected use their total,
|
||||||
if (dayAvailableHours?.slots) {
|
// otherwise use a minimum of 15 minutes (any meaningful booking needs at least this)
|
||||||
const existingBookings = extractBookedSlots(
|
const duration = selectedServices.length > 0 ? getTotalDuration() : 15;
|
||||||
dayHours.startTime,
|
if (duration <= 0) return true;
|
||||||
dayHours.endTime,
|
|
||||||
dayAvailableHours.slots
|
// Build lunch protection specifically for the date being checked
|
||||||
);
|
const dayProtection = buildLunchProtection(date as CalendarDate, workingHours, availableHours, duration, true);
|
||||||
const lunchProtection = getLunchProtectionForSlots(
|
const slots = generateAvailableTimeSlots(date as CalendarDate, workingHours, availableHours, duration, dayProtection);
|
||||||
dayHours.startTime,
|
if (slots.length === 0) return true;
|
||||||
dayHours.endTime,
|
|
||||||
existingBookings,
|
|
||||||
duration,
|
|
||||||
15,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
const validSlots = slots.filter((t) => !lunchProtection.get(t)?.isBlocked);
|
|
||||||
if (validSlots.length === 0) return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -894,6 +688,7 @@
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
toast.success('Booking created successfully!');
|
toast.success('Booking created successfully!');
|
||||||
open = false;
|
open = false;
|
||||||
|
window.dispatchEvent(new CustomEvent('bookingApproved'));
|
||||||
onBookingCreated?.();
|
onBookingCreated?.();
|
||||||
} else {
|
} else {
|
||||||
const errorText = await res.text();
|
const errorText = await res.text();
|
||||||
@@ -934,12 +729,12 @@
|
|||||||
// Clear date/time and cache when duration changes
|
// Clear date/time and cache when duration changes
|
||||||
selectedDate = undefined;
|
selectedDate = undefined;
|
||||||
selectedTime = null;
|
selectedTime = null;
|
||||||
availableHoursCache.clear();
|
availableHoursCache = {};
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal.Root bind:open>
|
<Modal.Root bind:open>
|
||||||
<Modal.Content class="max-h-[90vh] max-w-4xl overflow-y-auto">
|
<Modal.Content class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-2xl md:max-w-4xl">
|
||||||
<Modal.Header>
|
<Modal.Header>
|
||||||
<Modal.Title>Create Admin Booking</Modal.Title>
|
<Modal.Title>Create Admin Booking</Modal.Title>
|
||||||
<Modal.Description>
|
<Modal.Description>
|
||||||
@@ -1205,7 +1000,7 @@
|
|||||||
{#if serviceOverrides[service.id]}
|
{#if serviceOverrides[service.id]}
|
||||||
<div class="rounded-lg border bg-white p-4">
|
<div class="rounded-lg border bg-white p-4">
|
||||||
<div class="mb-3 font-medium">{service.name}</div>
|
<div class="mb-3 font-medium">{service.name}</div>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="price-{service.id}" class="text-xs text-gray-600"
|
<Label for="price-{service.id}" class="text-xs text-gray-600"
|
||||||
>Price (£)</Label
|
>Price (£)</Label
|
||||||
@@ -1317,105 +1112,55 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if loadingWorkingHours}
|
<div class="flex items-center justify-center p-6">
|
||||||
<div class="flex items-center justify-center p-6">
|
<DatePicker
|
||||||
<p>Loading available dates...</p>
|
date={selectedDate}
|
||||||
</div>
|
{placeholder}
|
||||||
{:else}
|
minValue={minDate}
|
||||||
<div class="flex items-center justify-center p-6">
|
maxValue={maxCalendarDate}
|
||||||
<DatePicker
|
{isDateUnavailable}
|
||||||
date={selectedDate}
|
onchange={(newDate) => {
|
||||||
{placeholder}
|
selectedDate = newDate;
|
||||||
minValue={minDate}
|
selectedTime = null;
|
||||||
maxValue={maxCalendarDate}
|
|
||||||
{isDateUnavailable}
|
|
||||||
onchange={(newDate) => {
|
|
||||||
selectedDate = newDate;
|
|
||||||
selectedTime = null;
|
|
||||||
}}
|
|
||||||
onPlaceholderChange={(newPlaceholder) => {
|
|
||||||
placeholder = newPlaceholder;
|
|
||||||
fetchHoursForMonth(newPlaceholder);
|
|
||||||
}}
|
}}
|
||||||
/>
|
onPlaceholderChange={(newPlaceholder) => {
|
||||||
</div>
|
placeholder = newPlaceholder;
|
||||||
{/if}
|
userNavigatedCalendar = true;
|
||||||
|
fetchHoursForMonth(newPlaceholder);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if loadingAvailableHours}
|
{#if loadingAvailableHours && selectedDate}
|
||||||
<div class="flex items-center justify-center border-t p-6">
|
<div class="flex items-center justify-center border-t p-6">
|
||||||
<p class="text-sm text-gray-500">Loading times...</p>
|
<p class="text-sm text-gray-500">Loading times...</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if selectedDate}
|
{:else if selectedDate}
|
||||||
<div
|
<div class="border-t">
|
||||||
class="no-scrollbar flex max-h-40 w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t p-6"
|
<div class="max-h-64 overflow-y-auto p-6">
|
||||||
>
|
{#if formattedSelectedDate}
|
||||||
{#if formattedSelectedDate}
|
<div class="mb-3 grid justify-center gap-2 text-sm font-medium">{formattedSelectedDate}</div>
|
||||||
<div class="grid justify-center gap-2">{formattedSelectedDate}</div>
|
{/if}
|
||||||
{/if}
|
<TimeSlotList
|
||||||
|
slots={groupedTimeSlots}
|
||||||
{#if groupedTimeSlots.length > 0}
|
{selectedTime}
|
||||||
<div class="grid gap-2">
|
duration={getTotalDuration()}
|
||||||
{#each groupedTimeSlots as slot (slot.type + '-' + slot.startTime + '-' + slot.endTime)}
|
protection={lunchProtection}
|
||||||
{#if slot.type === 'available'}
|
onSelect={(time) => { selectedTime = time; }}
|
||||||
{@const protection = lunchProtectionStatus().get(slot.startTime)}
|
/>
|
||||||
{#if protection?.isBlocked}
|
</div>
|
||||||
<!-- 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>
|
</div>
|
||||||
|
{#if selectedDate && selectedTime}
|
||||||
|
<div class="px-6 pb-4">
|
||||||
|
<SelectedTimeSummary
|
||||||
|
selectedDate={formattedSelectedDate || ''}
|
||||||
|
selectedTime={formatTime(selectedTime)}
|
||||||
|
endTime={formatTime(calculateEndTime(selectedTime, getTotalDuration()))}
|
||||||
|
duration={getTotalDuration()}
|
||||||
|
protection={lunchProtection.get(selectedTime)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex items-center justify-center border-t p-6">
|
<div class="flex items-center justify-center border-t p-6">
|
||||||
<p class="text-center text-sm text-gray-500">
|
<p class="text-center text-sm text-gray-500">
|
||||||
|
|||||||
Reference in New Issue
Block a user