fix: full reschedule implementation with real available slots
- Fetch /api/scheduling/working-hours and /api/scheduling/available-hours for reschedule month - Generate grouped time slots using same logic as BookingFlow (available + unavailable with start-end times) - DatePicker uses isDateUnavailable based on real availability (no slots = unavailable) - Time slots show X - Y format (e.g. 9:30 AM - 10:00 AM) matching booking flow - Unavailable slots shown as disabled buttons - Today's slots respect 2-hour minimum notice buffer - Hours fetched on first reschedule open or calendar month change
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
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 type { Booking } from '$lib/types/booking';
|
||||
import type { Booking, WorkingHoursDay, AvailableHoursDay } from '$lib/types/booking';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -29,6 +29,10 @@
|
||||
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();
|
||||
@@ -103,6 +107,8 @@
|
||||
rescheduleTime = '';
|
||||
rescheduleNotes = '';
|
||||
reschedulePlaceholder = minDate;
|
||||
rescheduleWorkingHours = null;
|
||||
rescheduleAvailableHours = null;
|
||||
}, 200);
|
||||
} else if (bookingId && !selectedBooking) {
|
||||
fetchBookingDetails();
|
||||
@@ -138,12 +144,54 @@
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
return jsDate < todayStart;
|
||||
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 formatTime(time: string): string {
|
||||
@@ -157,18 +205,111 @@
|
||||
return `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`;
|
||||
}
|
||||
|
||||
function generateTimeSlots(): string[] {
|
||||
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[] = [];
|
||||
for (let h = 8; h <= 18; h++) {
|
||||
for (let m = 0; m < 60; m += 30) {
|
||||
if (h === 18 && m > 0) break;
|
||||
slots.push(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`);
|
||||
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;
|
||||
}
|
||||
|
||||
const timeSlots = generateTimeSlots();
|
||||
function generateGroupedTimeSlots(duration: number, date: CalendarDate): 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);
|
||||
|
||||
if (isAvailable) {
|
||||
if (currentUnavailableStart !== null) {
|
||||
const groupEnd = calculatePreviousTime(timeStr);
|
||||
grouped.push({ type: 'unavailable', startTime: lastAvailableEnd || currentUnavailableStart, 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;
|
||||
if (timeToMinutes(currentUnavailableStart) < 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;
|
||||
@@ -456,35 +597,71 @@
|
||||
Request Reschedule
|
||||
</h3>
|
||||
|
||||
<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; }}
|
||||
/>
|
||||
</div>
|
||||
{#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}
|
||||
<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' })}
|
||||
{#if loadingRescheduleHours}
|
||||
<div class="flex items-center justify-center border-t p-6">
|
||||
<p class="text-sm text-gray-500">Loading times...</p>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
{#each timeSlots as slot (slot)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => { rescheduleTime = slot; }}
|
||||
class="w-full hover:bg-fuchsia-50 {rescheduleTime === slot ? 'bg-fuchsia-100' : ''}"
|
||||
>
|
||||
{formatTime(slot)}
|
||||
</Button>
|
||||
{/each}
|
||||
{: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)}
|
||||
{#if grouped.length > 0}
|
||||
<div class="grid gap-2">
|
||||
{#each grouped as slot (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>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
@@ -544,6 +721,8 @@
|
||||
rescheduleDate = undefined;
|
||||
rescheduleTime = '';
|
||||
rescheduleNotes = '';
|
||||
} else if (!rescheduleWorkingHours) {
|
||||
fetchRescheduleHours(reschedulePlaceholder);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user