feat: admin reschedule modal and time blockers for schedule management
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -0,0 +1,434 @@
|
||||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { SvelteDate } 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 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, 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: Set<string> = new Set();
|
||||
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 = new Set(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 = new Set([...loadingMonthKeys].filter(k => k !== monthKey));
|
||||
loadingHours = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
selectedDate = undefined;
|
||||
selectedTime = null;
|
||||
workingHours = null;
|
||||
availableHours = null;
|
||||
userNavigatedCalendar = false;
|
||||
initialLoadDone = false;
|
||||
workingHoursCache = {};
|
||||
availableHoursCache = {};
|
||||
loadingMonths = {};
|
||||
loadingMonthKeys = new Set();
|
||||
rescheduleAutoSelectDone = false;
|
||||
placeholder = new CalendarDate(new Date().getFullYear(), new Date().getMonth() + 1, new Date().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="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="text-sm text-gray-500 mt-1">
|
||||
{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>
|
||||
@@ -0,0 +1,857 @@
|
||||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
|
||||
type TimeBlocker = {
|
||||
id: string;
|
||||
start_time: string;
|
||||
duration_minutes: number;
|
||||
description: string;
|
||||
cron_expression: string | null;
|
||||
created_at: string;
|
||||
created_by: string | null;
|
||||
};
|
||||
|
||||
type OverlappingBooking = {
|
||||
id: string;
|
||||
start_time: string;
|
||||
duration_minutes: number;
|
||||
status: string;
|
||||
user: {
|
||||
id: string;
|
||||
full_name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
} | null;
|
||||
services: string[];
|
||||
};
|
||||
|
||||
type WorkingHourRow = {
|
||||
weekday: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
isOpen: boolean;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
openUserModal?: (userId: string) => void;
|
||||
openBookingModal?: (bookingId: string) => void;
|
||||
rescheduleVersion?: number;
|
||||
}
|
||||
|
||||
let { openUserModal, openBookingModal, rescheduleVersion = 0 }: Props = $props();
|
||||
|
||||
const PAGE_SIZE = 5;
|
||||
|
||||
let blockers = $state<TimeBlocker[]>([]);
|
||||
let loading = $state(true);
|
||||
let creating = $state(false);
|
||||
let checkingOverlap = $state(false);
|
||||
|
||||
let showCreateModal = $state(false);
|
||||
let newDescription = $state('');
|
||||
let newStartDate = $state('');
|
||||
let startHour = $state('9');
|
||||
let startMinute = $state('00');
|
||||
let startPeriod = $state<'AM' | 'PM'>('AM');
|
||||
let endHour = $state('10');
|
||||
let endMinute = $state('00');
|
||||
let endPeriod = $state<'AM' | 'PM'>('AM');
|
||||
|
||||
let startSelectValue = $derived(`${startHour}:${startMinute}:${startPeriod}`);
|
||||
let endSelectValue = $derived(`${endHour}:${endMinute}:${endPeriod}`);
|
||||
|
||||
function parseSelectValue(val: string): { hour: string; minute: string; period: 'AM' | 'PM' } {
|
||||
const [hour, minute, period] = val.split(':') as [string, string, 'AM' | 'PM'];
|
||||
return { hour, minute, period };
|
||||
}
|
||||
|
||||
let overlappingBookings = $state<OverlappingBooking[]>([]);
|
||||
let hasOverlap = $state(false);
|
||||
let hasDayConflicts = $derived(overlappingBookings.length > 0);
|
||||
|
||||
let showDeleteAlert = $state(false);
|
||||
let blockerToDelete = $state<TimeBlocker | null>(null);
|
||||
|
||||
let currentPage = $state(1);
|
||||
|
||||
let defaultHours = $state<WorkingHourRow[]>([]);
|
||||
let hoursLoading = $state(true);
|
||||
|
||||
function to24h(hour: string, minute: string, period: 'AM' | 'PM'): string {
|
||||
let h = parseInt(hour);
|
||||
if (period === 'PM' && h !== 12) h += 12;
|
||||
if (period === 'AM' && h === 12) h = 0;
|
||||
return `${String(h).padStart(2, '0')}:${minute}`;
|
||||
}
|
||||
|
||||
function timeToMinutes(time: string): number {
|
||||
const [h, m] = time.split(':').map(Number);
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
function minutesTo12h(totalMin: number): { hour: string; minute: string; period: 'AM' | 'PM' } {
|
||||
let h = Math.floor(totalMin / 60);
|
||||
const m = totalMin % 60;
|
||||
const period: 'AM' | 'PM' = h >= 12 ? 'PM' : 'AM';
|
||||
if (h >= 12 && h !== 12) h -= 12;
|
||||
if (h === 0) h = 12;
|
||||
return { hour: String(h), minute: String(m).padStart(2, '0'), period };
|
||||
}
|
||||
|
||||
function getWorkingHoursForDate(dateStr: string): WorkingHourRow | null {
|
||||
if (!dateStr || defaultHours.length === 0) return null;
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
const jsDay = d.getDay();
|
||||
const weekday = jsDay === 0 ? 6 : jsDay - 1;
|
||||
return defaultHours.find((h) => h.weekday === weekday) ?? null;
|
||||
}
|
||||
|
||||
function workingHoursSignature(row: WorkingHourRow | null): string {
|
||||
if (!row) return '';
|
||||
return `${row.startTime}-${row.endTime}-${row.isOpen}`;
|
||||
}
|
||||
|
||||
let selectedWorkingHours = $derived.by(() => getWorkingHoursForDate(newStartDate));
|
||||
|
||||
let availableStartOptions = $derived.by(() => {
|
||||
const wh = selectedWorkingHours;
|
||||
if (!wh || !wh.isOpen) return [];
|
||||
const startMin = timeToMinutes(wh.startTime);
|
||||
const endMin = timeToMinutes(wh.endTime);
|
||||
const options: Array<{ hour: string; minute: string; period: 'AM' | 'PM'; totalMin: number; label: string }> = [];
|
||||
for (let m = startMin; m < endMin; m += 15) {
|
||||
const t = minutesTo12h(m);
|
||||
const isStart = m === startMin;
|
||||
options.push({ ...t, totalMin: m, label: `${t.hour}:${t.minute} ${t.period}${t.period === 'PM' && t.hour === '12' && t.minute === '00' ? ' (noon)' : ''}${isStart ? ' (start of day)' : ''}` });
|
||||
}
|
||||
return options;
|
||||
});
|
||||
|
||||
let availableEndOptions = $derived.by(() => {
|
||||
const wh = selectedWorkingHours;
|
||||
if (!wh || !wh.isOpen) return [];
|
||||
const startMin = timeToMinutes(wh.startTime);
|
||||
const endMin = timeToMinutes(wh.endTime);
|
||||
const currentStartMin = timeToMinutes(to24h(startHour, startMinute, startPeriod));
|
||||
const options: Array<{ hour: string; minute: string; period: 'AM' | 'PM'; totalMin: number; label: string }> = [];
|
||||
for (let m = Math.max(startMin, currentStartMin + 15); m <= endMin; m += 15) {
|
||||
const t = minutesTo12h(m);
|
||||
const isEnd = m === endMin;
|
||||
options.push({ ...t, totalMin: m, label: `${t.hour}:${t.minute} ${t.period}${t.period === 'PM' && t.hour === '12' && t.minute === '00' ? ' (noon)' : ''}${isEnd ? ' (end of day)' : ''}` });
|
||||
}
|
||||
return options;
|
||||
});
|
||||
|
||||
function formatDuration(minutes: number): string {
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
||||
}
|
||||
|
||||
function formatRelativeTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
const diffMs = d.getTime() - now.getTime();
|
||||
if (diffMs < 0) {
|
||||
const absMin = Math.floor(Math.abs(diffMs) / 60000);
|
||||
if (absMin < 60) return `${absMin}m ago`;
|
||||
const absHr = Math.floor(absMin / 60);
|
||||
if (absHr < 24) return `${absHr}h ago`;
|
||||
return `${Math.floor(absHr / 24)}d ago`;
|
||||
}
|
||||
const min = Math.floor(diffMs / 60000);
|
||||
if (min < 60) return `in ${min}m`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `in ${hr}h`;
|
||||
return `in ${Math.floor(hr / 24)}d`;
|
||||
}
|
||||
|
||||
let sortedBlockers = $derived.by(() => {
|
||||
return [...blockers].sort((a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime());
|
||||
});
|
||||
|
||||
let totalPages = $derived.by(() => Math.max(1, Math.ceil(sortedBlockers.length / PAGE_SIZE)));
|
||||
let pagedBlockers = $derived.by(() => {
|
||||
const start = (currentPage - 1) * PAGE_SIZE;
|
||||
return sortedBlockers.slice(start, start + PAGE_SIZE);
|
||||
});
|
||||
|
||||
function isAutoGenerated(blocker: TimeBlocker): boolean {
|
||||
return blocker.description?.startsWith('RESERVATION:') ?? false;
|
||||
}
|
||||
|
||||
async function fetchBlockers() {
|
||||
loading = true;
|
||||
try {
|
||||
const response = await fetch('/api/admin/time-blockers', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data: TimeBlocker[] = await response.json();
|
||||
blockers = (data || []).filter((b) => !isAutoGenerated(b));
|
||||
} else {
|
||||
toast.error('Failed to load time blockers');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching time blockers:', err);
|
||||
toast.error('Network error loading time blockers');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDefaultHours() {
|
||||
hoursLoading = true;
|
||||
try {
|
||||
const response = await fetch('/api/scheduling/default-hours', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
defaultHours = await response.json();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching default hours:', err);
|
||||
} finally {
|
||||
hoursLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildDateTime(date: string, hour: string, minute: string, period: 'AM' | 'PM'): string | null {
|
||||
if (!date) return null;
|
||||
const t24 = to24h(hour, minute, period);
|
||||
return `${date}T${t24}:00`;
|
||||
}
|
||||
|
||||
let formComplete = $derived.by(() => {
|
||||
return newDescription.trim() !== '' && newStartDate !== '' && startHour !== '' && endHour !== '';
|
||||
});
|
||||
|
||||
let canCreate = $derived.by(() => {
|
||||
return formComplete && !hasOverlap && !hasDayConflicts && !checkingOverlap;
|
||||
});
|
||||
|
||||
async function checkOverlappingBookings() {
|
||||
const startIso = buildDateTime(newStartDate, startHour, startMinute, startPeriod);
|
||||
const endIso = buildDateTime(newStartDate, endHour, endMinute, endPeriod);
|
||||
|
||||
if (!startIso || !endIso) {
|
||||
overlappingBookings = [];
|
||||
hasOverlap = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const blockerStart = new Date(startIso);
|
||||
const blockerEnd = new Date(endIso);
|
||||
if (blockerEnd.getTime() <= blockerStart.getTime()) {
|
||||
overlappingBookings = [];
|
||||
hasOverlap = false;
|
||||
return;
|
||||
}
|
||||
|
||||
checkingOverlap = true;
|
||||
try {
|
||||
const dateParam = newStartDate;
|
||||
const response = await fetch(
|
||||
`/api/admin/bookings/by-date-range?start=${encodeURIComponent(dateParam)}&end=${encodeURIComponent(dateParam)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const allBookings: OverlappingBooking[] = data.bookings || [];
|
||||
// Client-side filter: only bookings that overlap with the proposed blocker timespan
|
||||
const filtered = allBookings.filter((b) => {
|
||||
const bStart = new Date(b.start_time);
|
||||
const bEnd = new Date(bStart.getTime() + b.duration_minutes * 60000);
|
||||
return bStart < blockerEnd && bEnd > blockerStart;
|
||||
});
|
||||
overlappingBookings = filtered;
|
||||
hasOverlap = overlappingBookings.length > 0;
|
||||
} else {
|
||||
overlappingBookings = [];
|
||||
hasOverlap = false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error checking overlapping bookings:', err);
|
||||
overlappingBookings = [];
|
||||
hasOverlap = false;
|
||||
} finally {
|
||||
checkingOverlap = false;
|
||||
}
|
||||
}
|
||||
|
||||
let prevWorkingHoursSig = $state('');
|
||||
|
||||
function onStartTimeChange() {
|
||||
checkOverlappingBookings();
|
||||
}
|
||||
|
||||
function onEndTimeChange() {
|
||||
checkOverlappingBookings();
|
||||
}
|
||||
|
||||
function onStartDateChange() {
|
||||
const wh = getWorkingHoursForDate(newStartDate);
|
||||
const sig = workingHoursSignature(wh);
|
||||
|
||||
if (sig !== prevWorkingHoursSig && prevWorkingHoursSig !== '') {
|
||||
resetTimeToDefaults(wh);
|
||||
}
|
||||
prevWorkingHoursSig = sig;
|
||||
checkOverlappingBookings();
|
||||
}
|
||||
|
||||
function resetTimeToDefaults(wh: WorkingHourRow | null) {
|
||||
if (!wh || !wh.isOpen) {
|
||||
startHour = '9';
|
||||
startMinute = '00';
|
||||
startPeriod = 'AM';
|
||||
endHour = '10';
|
||||
endMinute = '00';
|
||||
endPeriod = 'AM';
|
||||
return;
|
||||
}
|
||||
|
||||
const startOpt = minutesTo12h(timeToMinutes(wh.startTime));
|
||||
const endOpt = minutesTo12h(timeToMinutes(wh.startTime) + 60);
|
||||
|
||||
startHour = startOpt.hour;
|
||||
startMinute = startOpt.minute;
|
||||
startPeriod = startOpt.period;
|
||||
endHour = endOpt.hour;
|
||||
endMinute = endOpt.minute;
|
||||
endPeriod = endOpt.period;
|
||||
}
|
||||
|
||||
async function createBlocker() {
|
||||
if (!canCreate) return;
|
||||
|
||||
const startIso = buildDateTime(newStartDate, startHour, startMinute, startPeriod);
|
||||
const endIso = buildDateTime(newStartDate, endHour, endMinute, endPeriod);
|
||||
if (!startIso || !endIso) return;
|
||||
|
||||
const start = new Date(startIso);
|
||||
const end = new Date(endIso);
|
||||
const durationMinutes = Math.round((end.getTime() - start.getTime()) / 60000);
|
||||
|
||||
creating = true;
|
||||
const loadingToast = toast.loading('Creating time blocker...');
|
||||
|
||||
try {
|
||||
// Create placeholder blockers for conflicting bookings (1-hour TTL)
|
||||
if (overlappingBookings.length > 0) {
|
||||
for (const booking of overlappingBookings) {
|
||||
try {
|
||||
await fetch('/api/admin/time-blockers', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
start_time: start.toISOString(),
|
||||
duration_minutes: 60,
|
||||
description: `RESERVATION:placeholder:${booking.id}`
|
||||
})
|
||||
});
|
||||
} catch (placeholderErr) {
|
||||
console.error('Failed to create placeholder for booking', booking.id, placeholderErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create the actual time blocker
|
||||
const response = await fetch('/api/admin/time-blockers', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
start_time: start.toISOString(),
|
||||
duration_minutes: durationMinutes,
|
||||
description: newDescription.trim()
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Time blocker created!', { id: loadingToast });
|
||||
showCreateModal = false;
|
||||
resetCreateForm();
|
||||
await fetchBlockers();
|
||||
} else {
|
||||
const text = await response.text();
|
||||
toast.error('Failed to create: ' + text, { id: loadingToast });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error creating time blocker:', err);
|
||||
toast.error('Network error creating time blocker', { id: loadingToast });
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteBlocker() {
|
||||
if (!blockerToDelete) return;
|
||||
|
||||
const loadingToast = toast.loading('Deleting time blocker...');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/time-blockers/${blockerToDelete.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok || response.status === 204) {
|
||||
toast.success('Time blocker deleted', { id: loadingToast });
|
||||
showDeleteAlert = false;
|
||||
blockerToDelete = null;
|
||||
await fetchBlockers();
|
||||
} else {
|
||||
const text = await response.text();
|
||||
toast.error('Failed to delete: ' + text, { id: loadingToast });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error deleting time blocker:', err);
|
||||
toast.error('Network error deleting time blocker', { id: loadingToast });
|
||||
}
|
||||
}
|
||||
|
||||
function resetCreateForm() {
|
||||
newDescription = '';
|
||||
newStartDate = '';
|
||||
startHour = '9';
|
||||
startMinute = '00';
|
||||
startPeriod = 'AM';
|
||||
endHour = '10';
|
||||
endMinute = '00';
|
||||
endPeriod = 'AM';
|
||||
overlappingBookings = [];
|
||||
hasOverlap = false;
|
||||
prevWorkingHoursSig = '';
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
resetCreateForm();
|
||||
showCreateModal = true;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
fetchBlockers();
|
||||
fetchDefaultHours();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (showCreateModal) {
|
||||
currentPage = 1;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
void rescheduleVersion;
|
||||
if (showCreateModal) {
|
||||
checkOverlappingBookings();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<Card.Title>Time Blockers</Card.Title>
|
||||
<Card.Description>
|
||||
Manage one-off blocked periods like appointments, breaks, or closures.
|
||||
</Card.Description>
|
||||
</div>
|
||||
<Button variant="default" onclick={openCreateModal}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="mr-2 h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
New Blocker
|
||||
</Button>
|
||||
</div>
|
||||
</Card.Header>
|
||||
|
||||
<Card.Content class="space-y-4">
|
||||
{#if loading}
|
||||
<div class="space-y-3">
|
||||
{#each Array(3) as _, i (i)}
|
||||
<Skeleton class="h-16 w-full" />
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
{#if blockers.length === 0}
|
||||
<p class="text-sm text-gray-500">No time blockers found.</p>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each pagedBlockers as b (b.id)}
|
||||
<div class="flex items-center justify-between gap-3 rounded-lg border p-3 sm:p-4 transition-all hover:shadow-sm">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div class="hidden shrink-0 rounded-lg bg-gray-50 p-1.5 sm:block">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 text-gray-600"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate font-medium text-gray-900">{b.description || 'Untitled'}</div>
|
||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-gray-600">
|
||||
<span>
|
||||
{new SvelteDate(b.start_time).toLocaleDateString('en-GB', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short'
|
||||
})}
|
||||
</span>
|
||||
<span class="text-gray-400">·</span>
|
||||
<span>
|
||||
{new SvelteDate(b.start_time).toLocaleTimeString('en-GB', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
})}
|
||||
</span>
|
||||
<span class="text-gray-400">–</span>
|
||||
<span>
|
||||
{(() => {
|
||||
const end = new SvelteDate(b.start_time);
|
||||
end.setMinutes(end.getMinutes() + b.duration_minutes);
|
||||
return end.toLocaleTimeString('en-GB', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
})()}
|
||||
</span>
|
||||
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium">
|
||||
{formatDuration(b.duration_minutes)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">
|
||||
{formatRelativeTime(b.start_time)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
onclick={() => {
|
||||
blockerToDelete = b;
|
||||
showDeleteAlert = true;
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-3 w-3"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if totalPages > 1}
|
||||
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between pt-2">
|
||||
<p class="text-sm text-gray-500">
|
||||
{(currentPage - 1) * PAGE_SIZE + 1}–{Math.min(currentPage * PAGE_SIZE, sortedBlockers.length)} of {sortedBlockers.length}
|
||||
</p>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage <= 1}
|
||||
onclick={() => (currentPage -= 1)}
|
||||
>
|
||||
Prev
|
||||
</Button>
|
||||
{#each Array(totalPages) as _, i (i)}
|
||||
<Button
|
||||
variant={currentPage === i + 1 ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
class="h-8 w-8 p-0"
|
||||
onclick={() => (currentPage = i + 1)}
|
||||
>
|
||||
{i + 1}
|
||||
</Button>
|
||||
{/each}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage >= totalPages}
|
||||
onclick={() => (currentPage += 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Modal.Root bind:open={showCreateModal}>
|
||||
<Modal.Content class="max-h-[90vh] max-w-lg overflow-y-auto">
|
||||
<Modal.Header>
|
||||
<Modal.Title class="text-lg font-semibold">Create Time Blocker</Modal.Title>
|
||||
<Modal.Description>
|
||||
Block off a period of time so no bookings can be made during it.
|
||||
</Modal.Description>
|
||||
</Modal.Header>
|
||||
|
||||
<div class="space-y-6 px-4 pb-4">
|
||||
<div class="space-y-2">
|
||||
<label for="blocker-description" class="text-sm font-medium">Description *</label>
|
||||
<Input
|
||||
id="blocker-description"
|
||||
type="text"
|
||||
placeholder="e.g., Extended lunch, Doctor's appointment, Illness"
|
||||
bind:value={newDescription}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium">Start</h3>
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<label for="blocker-start-date" class="text-xs text-gray-600">Date</label>
|
||||
<div class="flex gap-2">
|
||||
<Button type="button" variant="outline" size="sm" class="text-xs flex-1" onclick={() => { newStartDate = new Date().toISOString().slice(0, 10); onStartDateChange(); }}>
|
||||
Today
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" class="text-xs flex-1" onclick={() => { newStartDate = new Date(Date.now() + 86400000).toISOString().slice(0, 10); onStartDateChange(); }}>
|
||||
Tomorrow
|
||||
</Button>
|
||||
</div>
|
||||
<Input id="blocker-start-date" type="date" bind:value={newStartDate} onchange={onStartDateChange} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs text-gray-600">Time</label>
|
||||
{#if hoursLoading}
|
||||
<Skeleton class="h-9 w-full" />
|
||||
{:else if !selectedWorkingHours}
|
||||
<p class="text-sm text-gray-400">Select a date first</p>
|
||||
{:else if !selectedWorkingHours.isOpen}
|
||||
<p class="text-sm text-red-500">Closed on {new SvelteDate(newStartDate + 'T00:00:00').toLocaleDateString('en-GB', { weekday: 'long' })}</p>
|
||||
{:else}
|
||||
<select
|
||||
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm"
|
||||
bind:value={startSelectValue}
|
||||
onchange={() => {
|
||||
const p = parseSelectValue(startSelectValue);
|
||||
startHour = p.hour;
|
||||
startMinute = p.minute;
|
||||
startPeriod = p.period;
|
||||
onStartTimeChange();
|
||||
}}
|
||||
>
|
||||
{#each availableStartOptions as opt}
|
||||
<option value={`${opt.hour}:${opt.minute}:${opt.period}`}>
|
||||
{opt.label}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium">End Time</h3>
|
||||
{#if hoursLoading}
|
||||
<Skeleton class="h-9 w-full" />
|
||||
{:else if !selectedWorkingHours}
|
||||
<p class="text-sm text-gray-400">Select a date first</p>
|
||||
{:else if !selectedWorkingHours.isOpen}
|
||||
<p class="text-sm text-red-500">Closed on this day</p>
|
||||
{:else if availableEndOptions.length === 0}
|
||||
<p class="text-sm text-gray-400">No available end time after selected start</p>
|
||||
{:else}
|
||||
<select
|
||||
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm"
|
||||
bind:value={endSelectValue}
|
||||
onchange={() => {
|
||||
const p = parseSelectValue(endSelectValue);
|
||||
endHour = p.hour;
|
||||
endMinute = p.minute;
|
||||
endPeriod = p.period;
|
||||
onEndTimeChange();
|
||||
}}
|
||||
>
|
||||
{#each availableEndOptions as opt}
|
||||
<option value={`${opt.hour}:${opt.minute}:${opt.period}`}>
|
||||
{opt.label}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if checkingOverlap}
|
||||
<div class="flex items-center gap-2 text-sm text-gray-500">
|
||||
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Checking for conflicting bookings…
|
||||
</div>
|
||||
{:else if hasOverlap}
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-amber-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-amber-800">
|
||||
{overlappingBookings.length} booking{overlappingBookings.length > 1 ? 's' : ''} conflict{overlappingBookings.length > 1 ? '' : 's'} with this slot
|
||||
</span>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
{#each overlappingBookings as booking (booking.id)}
|
||||
<div class="rounded-md border border-amber-200 bg-white p-3">
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium text-sm">{booking.user?.full_name || 'Unknown'}</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
})}}
|
||||
{' · '}
|
||||
{formatDuration(booking.duration_minutes)}
|
||||
{#if booking.services?.length}
|
||||
{' · '}
|
||||
{booking.services.join(', ')}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-2">
|
||||
{#if openBookingModal}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="text-xs h-7"
|
||||
onclick={() => openBookingModal(booking.id)}
|
||||
>
|
||||
View Booking
|
||||
</Button>
|
||||
{/if}
|
||||
{#if openUserModal && booking.user?.id}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="text-xs h-7"
|
||||
onclick={() => openUserModal(booking.user!.id)}
|
||||
>
|
||||
View Client
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Modal.Footer class="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
showCreateModal = false;
|
||||
resetCreateForm();
|
||||
}}
|
||||
disabled={creating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onclick={createBlocker} disabled={!canCreate || creating}>
|
||||
{creating ? 'Creating…' : 'Create Blocker'}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
|
||||
<AlertDialog.Root bind:open={showDeleteAlert}>
|
||||
<AlertDialog.Content class="z-[60]">
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Delete time blocker?</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
This will remove the "{blockerToDelete?.description}" time blocker. Bookings may become available
|
||||
during this period. This action cannot be undone.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel
|
||||
onclick={() => {
|
||||
showDeleteAlert = false;
|
||||
blockerToDelete = null;
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</AlertDialog.Cancel>
|
||||
<AlertDialog.Action onclick={confirmDeleteBlocker} class="bg-red-600 hover:bg-red-700">
|
||||
Delete
|
||||
</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
@@ -9,6 +9,7 @@
|
||||
import UsersCard from '$lib/components/admin/UsersCard.svelte';
|
||||
import BookingsCard from '$lib/components/admin/BookingsCard.svelte';
|
||||
import HolidayHours from '$lib/components/admin/HolidayHours.svelte';
|
||||
import TimeBlockers from '$lib/components/admin/TimeBlockers.svelte';
|
||||
import WeeklySchedule from '$lib/components/admin/WeeklySchedule.svelte';
|
||||
import ServicesManagement from '$lib/components/admin/ServicesManagement.svelte';
|
||||
import DiscountsManagement from '$lib/components/admin/DiscountsManagement.svelte';
|
||||
@@ -47,6 +48,7 @@
|
||||
let showBookingModal = $state(false);
|
||||
let selectedUserId = $state<string | null>(null);
|
||||
let selectedBookingId = $state<string | null>(null);
|
||||
let rescheduleVersion = $state(0);
|
||||
|
||||
function openUserModal(userId: string) {
|
||||
selectedUserId = userId;
|
||||
@@ -57,6 +59,10 @@
|
||||
selectedBookingId = bookingId;
|
||||
showBookingModal = true;
|
||||
}
|
||||
|
||||
function handleReschedule() {
|
||||
rescheduleVersion++;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if pageState === 'loading'}
|
||||
@@ -119,22 +125,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Holiday Hours Card Skeleton -->
|
||||
<!-- Time Blockers Card Skeleton -->
|
||||
<div class="rounded-lg border p-6">
|
||||
<div class="mb-4 space-y-2">
|
||||
<Skeleton class="h-6 w-32" />
|
||||
<Skeleton class="h-4 w-64" />
|
||||
<div class="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="space-y-2">
|
||||
<Skeleton class="h-6 w-32" />
|
||||
<Skeleton class="h-4 w-80" />
|
||||
</div>
|
||||
<Skeleton class="h-10 w-28" />
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<Skeleton class="h-10 w-32" />
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{#each Array(2) as _, i (i)}
|
||||
<Skeleton class="h-32 w-full" />
|
||||
<div class="mt-4 space-y-3">
|
||||
{#each Array(3) as _, i (i)}
|
||||
<Skeleton class="h-16 w-full" />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Holiday Hours Card Skeleton -->
|
||||
|
||||
<!-- Working Hours Card Skeleton -->
|
||||
<div class="rounded-lg border p-6">
|
||||
<div class="mb-4 space-y-2">
|
||||
@@ -267,6 +275,7 @@
|
||||
<UsersCard {openUserModal} />
|
||||
<BookingsCard {openBookingModal} />
|
||||
</div>
|
||||
<TimeBlockers {openUserModal} {openBookingModal} onReschedule={handleReschedule} rescheduleVersion={rescheduleVersion} />
|
||||
<HolidayHours />
|
||||
<WeeklySchedule />
|
||||
<ServicesManagement />
|
||||
@@ -279,6 +288,6 @@
|
||||
{/if}
|
||||
|
||||
{#if showBookingModal && selectedBookingId}
|
||||
<BookingModal bind:open={showBookingModal} bookingId={selectedBookingId} />
|
||||
<BookingModal bind:open={showBookingModal} bookingId={selectedBookingId} onReschedule={handleReschedule} />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user