Add extractErrorMessage helper for JSON error body parsing and apply sanitizeText across all toast displays. Add time_blockers test coverage for new holiday placeholder cleanup and overlapping scenarios.
589 lines
19 KiB
Svelte
589 lines
19 KiB
Svelte
<script lang="ts">
|
|
import { SvelteDate, SvelteSet } from 'svelte/reactivity';
|
|
import { toast } from 'svelte-sonner';
|
|
import { extractErrorMessage } from '$lib/utils/toast-safe';
|
|
import { apiFetch } from '$lib/utils/api';
|
|
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
|
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
|
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
|
import { formatUserName } from '$lib/utils/nameDisplay';
|
|
|
|
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,
|
|
formatLocalDateTime,
|
|
calculateEndTime,
|
|
getDayWithOrdinal,
|
|
getLondonTodayCalendarDate,
|
|
parseWallClockDate,
|
|
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>(getLondonTodayCalendarDate());
|
|
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, DayHours>> = {};
|
|
let availableHoursCache: Record<string, Record<string, DayAvailability>> = {};
|
|
let loadingMonths: Record<string, boolean> = {};
|
|
let loadingMonthKeys = new SvelteSet<string>();
|
|
let initialLoadDone = $state(false);
|
|
let rescheduleAutoSelectDone = $state(false);
|
|
|
|
const today = getLondonTodayCalendarDate();
|
|
const minDate = today;
|
|
const maxDate = new SvelteDate(today.year, today.month - 1, today.day);
|
|
maxDate.setMonth(today.month - 1 + 6);
|
|
const maxCalendarDate = new CalendarDate(
|
|
maxDate.getFullYear(),
|
|
maxDate.getMonth() + 1,
|
|
maxDate.getDate()
|
|
);
|
|
|
|
const hoursUntilAppointment = $derived(
|
|
(new SvelteDate(booking.start_time).getTime() - new SvelteDate().getTime()) / (1000 * 60 * 60)
|
|
);
|
|
const hasPayments = $derived((booking.amount_paid ?? 0) > 0);
|
|
const showNoticeWarning = $derived(
|
|
hasPayments ? hoursUntilAppointment < 72 : hoursUntilAppointment < 24
|
|
);
|
|
const showNoShowWarning = $derived(
|
|
!hasPayments && hoursUntilAppointment < 24 && hoursUntilAppointment >= 0
|
|
);
|
|
let forgiveFees = $state(false);
|
|
let forgiveNoShow = $state(false);
|
|
|
|
const 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([
|
|
apiFetch(`/api/scheduling/working-hours?start=${startStr}&end=${endStr}`),
|
|
apiFetch(`/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();
|
|
|
|
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([
|
|
apiFetch(`/api/scheduling/working-hours?start=${startOfMonth}&end=${endOfMonth}`),
|
|
apiFetch(`/api/scheduling/available-hours?start=${startOfMonth}&end=${endOfMonth}`)
|
|
]);
|
|
if (whRes.ok && ahRes.ok) {
|
|
const whData: WorkingHoursDay[] = await whRes.json();
|
|
const ahData: AvailableHoursDay[] = await ahRes.json();
|
|
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 = getLondonTodayCalendarDate();
|
|
}
|
|
|
|
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 = formatLocalDateTime(localDate);
|
|
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 body: Record<string, unknown> = { start_time: newStartTime };
|
|
if (forgiveFees) body.forgive_fees = true;
|
|
if (forgiveNoShow) body.forgive_noshow = true;
|
|
const response = await apiFetch(`/api/admin/bookings/${booking.id}/reschedule`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(body)
|
|
});
|
|
if (response.ok) {
|
|
toast.success('Booking rescheduled!', { id: loadingToast });
|
|
open = false;
|
|
resetForm();
|
|
onSaved?.();
|
|
} else {
|
|
const text = await response.text();
|
|
toast.error('Failed to reschedule: ' + extractErrorMessage(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, DayHours>;
|
|
availableHoursCache[key] = null as unknown as Record<string, DayAvailability>;
|
|
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(getLondonTodayCalendarDate().toString() + 'T00:00:00');
|
|
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.toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
|
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-[calc(100%-2rem)] overflow-y-auto sm:max-w-4xl">
|
|
<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">
|
|
{#if showNoticeWarning || showNoShowWarning}
|
|
<div class="mb-4 rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
|
<p class="font-medium text-amber-900">Short Notice Reschedule</p>
|
|
|
|
{#if hasPayments}
|
|
<p class="mt-1">Rescheduling may forfeit deposit protection on payments made.</p>
|
|
<details class="mt-1 text-xs text-gray-500">
|
|
<summary class="cursor-pointer hover:text-gray-700">What this means</summary>
|
|
<p class="mt-1">
|
|
"Rescheduling within {Math.round(hoursUntilAppointment)}h of the original time with
|
|
payments present means deposit protection applies — up to 50% of the total (up to £{(
|
|
booking.total_amount * 0.5
|
|
).toFixed(2)}) could be retained depending on notice period."
|
|
</p>
|
|
</details>
|
|
<label class="mt-2 flex cursor-pointer items-center gap-2">
|
|
<Checkbox bind:checked={forgiveFees} />
|
|
<span class="text-xs">Forgive fees — refund fully (overrides deposit protection)</span
|
|
>
|
|
</label>
|
|
<details class="ml-6 text-xs text-gray-500">
|
|
<summary class="cursor-pointer hover:text-gray-700"
|
|
>What happens with forgiveness</summary
|
|
>
|
|
<p class="mt-1">
|
|
"We've waived deposit protection on this reschedule as a goodwill gesture. The full
|
|
amount moves to the new appointment instead of having up to 50% retained as
|
|
deposit."
|
|
</p>
|
|
</details>
|
|
{/if}
|
|
|
|
<p class="mt-1">This time change counts as a no-show toward deposit obligations.</p>
|
|
<details class="mt-1 text-xs text-gray-500">
|
|
<summary class="cursor-pointer hover:text-gray-700">What this means</summary>
|
|
<p class="mt-1">
|
|
"This time change will count as a no-show toward your booking history. Two no-shows
|
|
within 6 months would require a deposit on future bookings."
|
|
</p>
|
|
</details>
|
|
<label class="mt-2 flex cursor-pointer items-center gap-2">
|
|
<Checkbox bind:checked={forgiveNoShow} />
|
|
<span class="text-xs"
|
|
>Forgive no-show — this reschedule will <strong>not</strong> count toward deposit obligations</span
|
|
>
|
|
</label>
|
|
<details class="ml-6 text-xs text-gray-500">
|
|
<summary class="cursor-pointer hover:text-gray-700"
|
|
>What happens with forgiveness</summary
|
|
>
|
|
<p class="mt-1">
|
|
"We've waived the no-show record for this reschedule so your deposit obligations are
|
|
unaffected."
|
|
</p>
|
|
</details>
|
|
</div>
|
|
<p class="mt-2 text-xs text-gray-500">
|
|
<PolicyPopover>
|
|
{#snippet trigger()}
|
|
<span class="underline">View full cancellation policy →</span>
|
|
{/snippet}
|
|
</PolicyPopover>
|
|
</p>
|
|
{/if}
|
|
|
|
<Card.Root class="mb-4">
|
|
<Card.Content class="pt-4">
|
|
<div class="text-sm font-medium">
|
|
Current: {parseWallClockDate(booking.start_time).toLocaleDateString('en-GB', {
|
|
weekday: 'short',
|
|
day: 'numeric',
|
|
month: 'short',
|
|
year: 'numeric'
|
|
})} at {parseWallClockDate(booking.start_time).toLocaleTimeString('en-GB', {
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
hour12: true
|
|
})}
|
|
</div>
|
|
<div class="mt-1 text-sm text-gray-500">
|
|
{formatUserName(
|
|
booking.user?.full_name || 'Unknown',
|
|
booking.user?.previous_first_name,
|
|
booking.user?.previous_last_name
|
|
)} · {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>
|