Files
Crussell/frontend/src/lib/components/admin/RescheduleModal.svelte
T
popertots 6d4bc4d637 feat(loyalty-discount): implement loyalty and discount system
- Add discount campaign management and validation logic
- Update booking handlers with discount application flow
- Add customer relationship endpoints for loyalty tracking
- Update frontend modals (booking, approval, payment, reschedule)
- Add DiscountsManagement and loyalty reference documentation
- Update dev scripts and database init for discount tables
- Clean up completed plan files
2026-06-04 23:13:02 +01:00

517 lines
16 KiB
Svelte

<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { SvelteDate, SvelteSet } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
import * as Modal from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Separator } from '$lib/components/ui/separator';
import * as Card from '$lib/components/ui/card';
import DatePicker from '$lib/components/booking/DatePicker.svelte';
import TimeSlotList from '$lib/components/booking/TimeSlotList.svelte';
import SelectedTimeSummary from '$lib/components/booking/SelectedTimeSummary.svelte';
import {
buildLunchProtection,
generateAvailableTimeSlots,
generateGroupedTimeSlots,
formatTime,
calculateEndTime,
getDayWithOrdinal,
type DayHours,
type DayAvailability
} from '$lib/utils/timeSlots';
import type { Booking, WorkingHoursDay, AvailableHoursDay } from '$lib/types/booking';
interface Props {
open: boolean;
booking: Booking;
onSaved?: () => void;
}
let { open = $bindable(), booking, onSaved }: Props = $props();
let saving = $state(false);
let placeholder = $state<CalendarDate>(
new CalendarDate(
new SvelteDate().getFullYear(),
new SvelteDate().getMonth() + 1,
new SvelteDate().getDate()
)
);
let selectedDate = $state<CalendarDate | undefined>(undefined);
let selectedTime = $state<string | null>(null);
let workingHours = $state<Record<string, DayHours> | null>(null);
let availableHours = $state<Record<string, DayAvailability> | null>(null);
let loadingHours = $state(false);
let hoursRangeGeneration = $state(0);
let hoursMonthGeneration = $state(0);
let userNavigatedCalendar = $state(false);
let workingHoursCache: Record<string, Record<string, any>> = {};
let availableHoursCache: Record<string, Record<string, any>> = {};
let loadingMonths: Record<string, boolean> = {};
let loadingMonthKeys = new SvelteSet<string>();
let initialLoadDone = $state(false);
let rescheduleAutoSelectDone = $state(false);
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 bookingDuration = $derived(
booking.services?.reduce(
(sum, s) => sum + (s.override_duration_minutes ?? s.duration_minutes ?? 0),
0
) ??
booking.duration_minutes ??
0
);
const lunchProtection = $derived(
selectedDate
? buildLunchProtection(selectedDate, workingHours, availableHours, bookingDuration, true)
: new Map()
);
const groupedTimeSlots = $derived(
selectedDate
? generateGroupedTimeSlots(
selectedDate,
workingHours,
availableHours,
bookingDuration,
lunchProtection
)
: []
);
const formattedSelectedDate = $derived(
selectedDate ? getDayWithOrdinal(selectedDate) : undefined
);
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;
// No available hours data for this date = data not yet loaded = unavailable
if (!availableHours?.[dateStr]) return true;
// API returned empty slots = no availability at all
if (!availableHours[dateStr].slots || availableHours[dateStr].slots.length === 0) return true;
// If booking duration is 0 (data missing), no valid slots can exist — mark unavailable
if (bookingDuration <= 0) return true;
// Build lunch protection specifically for the date being checked (not the selected date)
const dayProtection = buildLunchProtection(
date as CalendarDate,
workingHours,
availableHours,
bookingDuration,
true
);
const slots = generateAvailableTimeSlots(
date as CalendarDate,
workingHours,
availableHours,
bookingDuration,
dayProtection
);
if (slots.length === 0) return true;
return false;
}
// =============== Auto-Select ===============
// Handled reactively via $effect below
async function fetchHoursRange(startDate: CalendarDate, months: number) {
let endYear = startDate.year;
let endMonth = startDate.month + months - 1;
while (endMonth > 12) {
endMonth -= 12;
endYear++;
}
const endMonthDate = new CalendarDate(endYear, endMonth, 1);
const daysInEndMonth = endMonthDate.calendar.getDaysInMonth(endMonthDate);
const startStr = startDate.toString();
const endStr = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(daysInEndMonth).padStart(2, '0')}`;
hoursRangeGeneration++;
const gen = hoursRangeGeneration;
loadingHours = true;
try {
const [whRes, ahRes] = await Promise.all([
fetch(`/api/scheduling/working-hours?start=${startStr}&end=${endStr}`, {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
}),
fetch(`/api/scheduling/available-hours?start=${startStr}&end=${endStr}`, {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
})
]);
if (whRes.ok && ahRes.ok) {
const whData: WorkingHoursDay[] = await whRes.json();
const ahData: AvailableHoursDay[] = await ahRes.json();
if (gen !== hoursRangeGeneration) return;
const whMap: Record<string, DayHours> = {};
const ahMap: Record<string, DayAvailability> = {};
whData.forEach(
(d) => (whMap[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime })
);
ahData.forEach((d) => (ahMap[d.date] = { isOpen: d.isOpen, slots: d.slots }));
// Cache by month key
for (let i = 0; i < months; i++) {
let mYear = startDate.year;
let mMonth = startDate.month + i;
while (mMonth > 12) {
mMonth -= 12;
mYear++;
}
const key = `${mYear}-${String(mMonth).padStart(2, '0')}`;
workingHoursCache[key] = whMap;
availableHoursCache[key] = ahMap;
delete loadingMonths[key];
}
// MERGE instead of replace — preserves data from previously loaded months
workingHours = { ...workingHours, ...whMap };
availableHours = { ...availableHours, ...ahMap };
}
} catch (err) {
console.error('Failed to fetch hours:', err);
toast.error('Failed to load availability');
for (let i = 0; i < months; i++) {
let mYear = startDate.year;
let mMonth = startDate.month + i;
while (mMonth > 12) {
mMonth -= 12;
mYear++;
}
const key = `${mYear}-${String(mMonth).padStart(2, '0')}`;
delete loadingMonths[key];
}
} finally {
loadingHours = false;
}
}
async function fetchHoursForMonth(date: CalendarDate) {
const monthKey = `${date.year}-${String(date.month).padStart(2, '0')}`;
if (monthKey in workingHoursCache && monthKey in availableHoursCache) {
// MERGE instead of replace — preserves data from other loaded months
workingHours = { ...workingHours, ...workingHoursCache[monthKey] };
availableHours = { ...availableHours, ...availableHoursCache[monthKey] };
return;
}
if (loadingMonths[monthKey]) return;
if (loadingMonthKeys.has(monthKey)) return;
loadingMonthKeys.add(monthKey);
loadingMonths[monthKey] = true;
hoursMonthGeneration++;
const gen = hoursMonthGeneration;
loadingHours = 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();
if (gen !== hoursMonthGeneration) return;
const whMap: Record<string, DayHours> = {};
const ahMap: Record<string, DayAvailability> = {};
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[monthKey] = whMap;
availableHoursCache[monthKey] = ahMap;
// MERGE instead of replace — preserves data from previously loaded months
workingHours = { ...workingHours, ...whMap };
availableHours = { ...availableHours, ...ahMap };
}
} catch (err) {
console.error('Failed to fetch hours:', err);
toast.error('Failed to load availability');
} finally {
delete loadingMonths[monthKey];
loadingMonthKeys.delete(monthKey);
loadingHours = false;
}
}
function resetForm() {
selectedDate = undefined;
selectedTime = null;
workingHours = null;
availableHours = null;
userNavigatedCalendar = false;
initialLoadDone = false;
workingHoursCache = {};
availableHoursCache = {};
loadingMonths = {};
loadingMonthKeys = new SvelteSet<string>();
rescheduleAutoSelectDone = false;
placeholder = new CalendarDate(
new SvelteDate().getFullYear(),
new SvelteDate().getMonth() + 1,
new SvelteDate().getDate()
);
}
async function reschedule() {
if (!selectedDate || !selectedTime) {
toast.error('Please select a date and time');
return;
}
const localDate = selectedDate.toDate(getLocalTimeZone());
const [hours, minutes] = selectedTime.split(':').map(Number);
localDate.setHours(hours, minutes, 0, 0);
const newStartTime = localDate.toISOString();
if (new Date(newStartTime).getTime() <= Date.now()) {
toast.error('Start time must be in the future');
return;
}
saving = true;
const loadingToast = toast.loading('Rescheduling booking...');
try {
const response = await fetch(`/api/admin/bookings/${booking.id}/reschedule`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify({ start_time: newStartTime })
});
if (response.ok) {
toast.success('Booking rescheduled!', { id: loadingToast });
open = false;
resetForm();
onSaved?.();
} else {
const text = await response.text();
toast.error('Failed to reschedule: ' + text, { id: loadingToast });
}
} catch (err) {
console.error('Error rescheduling booking:', err);
toast.error('Network error rescheduling booking', { id: loadingToast });
} finally {
saving = false;
}
}
let wasOpen = false;
$effect(() => {
if (open && !wasOpen) {
resetForm();
// Pre-seed cache for current + next month
for (let i = 0; i < 2; i++) {
let mYear = placeholder.year;
let mMonth = placeholder.month + i;
while (mMonth > 12) {
mMonth -= 12;
mYear++;
}
const key = `${mYear}-${String(mMonth).padStart(2, '0')}`;
if (!(key in workingHoursCache)) {
workingHoursCache[key] = null as unknown as Record<string, any>;
availableHoursCache[key] = null as unknown as Record<string, any>;
loadingMonths[key] = true;
}
}
fetchHoursRange(placeholder, 2);
initialLoadDone = true;
}
wasOpen = open;
});
// Fetch additional months when navigating beyond preloaded range
$effect(() => {
if (open && initialLoadDone) {
const monthKey = `${placeholder.year}-${String(placeholder.month).padStart(2, '0')}`;
if (!(monthKey in workingHoursCache) && !loadingMonthKeys.has(monthKey)) {
fetchHoursForMonth(placeholder);
}
}
});
// Auto-select first available date once data loads (timing-safe, data-driven)
$effect(() => {
if (
open &&
workingHours &&
availableHours &&
!selectedDate &&
!userNavigatedCalendar &&
!rescheduleAutoSelectDone
) {
rescheduleAutoSelectDone = 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 = 1; 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;
}
}
}
});
</script>
<Modal.Root bind:open>
<Modal.Content class="!z-[70] max-h-[90vh] max-w-4xl overflow-y-auto">
<Modal.Header>
<Modal.Title class="text-lg font-semibold">Reschedule Booking</Modal.Title>
<Modal.Description>
Move this booking to a new time. This change takes effect immediately.
</Modal.Description>
</Modal.Header>
<div class="px-6 pb-4">
<Card.Root class="mb-4">
<Card.Content class="pt-4">
<div class="text-sm font-medium">
Current: {new SvelteDate(booking.start_time).toLocaleDateString('en-GB', {
weekday: 'short',
day: 'numeric',
month: 'short',
year: 'numeric'
})} at {new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', {
hour: 'numeric',
minute: '2-digit',
hour12: true
})}
</div>
<div class="mt-1 text-sm text-gray-500">
{booking.user?.full_name || 'Unknown'} · {booking.services
?.map((s) => s.service_name)
.join(', ') || 'No services'} · {bookingDuration} min
</div>
</Card.Content>
</Card.Root>
<Separator class="mb-4" />
<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;
userNavigatedCalendar = true;
}}
/>
</div>
{#if !loadingHours}
{#if selectedDate}
<div class="border-t">
<div class="max-h-64 overflow-y-auto p-6">
{#if formattedSelectedDate}
<div class="mb-3 grid justify-center gap-2 text-sm font-medium">
{formattedSelectedDate}
</div>
{/if}
<TimeSlotList
slots={groupedTimeSlots}
{selectedTime}
duration={bookingDuration}
protection={lunchProtection}
onSelect={(time) => {
selectedTime = time;
}}
/>
</div>
</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}
{/if}
{#if selectedDate && selectedTime}
<SelectedTimeSummary
selectedDate={formattedSelectedDate || ''}
selectedTime={formatTime(selectedTime)}
endTime={formatTime(calculateEndTime(selectedTime, bookingDuration))}
duration={bookingDuration}
protection={lunchProtection.get(selectedTime)}
/>
{/if}
</div>
<Modal.Footer class="flex items-center justify-end gap-2">
<Button
variant="outline"
onclick={() => {
open = false;
resetForm();
}}
disabled={saving}
>
Cancel
</Button>
<Button onclick={reschedule} disabled={saving || !selectedDate || !selectedTime}>
{saving ? 'Rescheduling…' : 'Confirm Reschedule'}
</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>