Files
Crussell/frontend/src/lib/components/today/TodayCalendar.svelte
T

803 lines
29 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { Skeleton } from '$lib/components/ui/skeleton';
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 { calculateMiddleWindow, extractBookedSlots, findAllLunchGaps } from '$lib/lunchProtection';
interface Props {
openBookingModal: (bookingId: string) => void;
openUserModal: (userId: string) => void;
}
let { openBookingModal, openUserModal }: Props = $props();
type TodayAppointment = {
id: string;
start_time: string;
status: string;
user_name: string;
user_id: string;
services: string[];
duration_minutes: number;
};
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 DayWorkingHours = {
date: string;
isOpen: boolean;
startTime: string;
endTime: string;
};
type DayAvailableHours = {
date: string;
isOpen: boolean;
slots: Array<{ startTime: string; endTime: string }>;
};
function isPastAppointment(startTime: string, durationMinutes: number): boolean {
const start = new Date(startTime);
const end = new Date(start.getTime() + durationMinutes * 60_000);
return end.getTime() < Date.now();
}
let appointments = $state<TodayAppointment[]>([]);
let loading = $state(true);
// ======== Blocker State ========
let blockers = $state<TimeBlocker[]>([]);
let workingHours = $state<DayWorkingHours | null>(null);
let whLoading = $state(true);
let availableHours = $state<DayAvailableHours | null>(null);
let showCreateModal = $state(false);
let showDeleteAlert = $state(false);
let blockerToDelete = $state<TimeBlocker | null>(null);
let creating = $state(false);
let checkingOverlap = $state(false);
let newDescription = $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 overlappingBookings = $state<OverlappingBooking[]>([]);
let hasOverlap = $state(false);
let startSelectValue = $derived(`${startHour}:${startMinute}:${startPeriod}`);
let endSelectValue = $derived(`${endHour}:${endMinute}:${endPeriod}`);
const today = $derived.by(() => {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
});
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;
if (h === 0) h = 12;
return { hour: String(h), minute: String(m).padStart(2, '0'), period };
}
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 };
}
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 isAutoGenerated(blocker: TimeBlocker): boolean {
return blocker.description?.startsWith('RESERVATION:') ?? false;
}
let availableStartOptions = $derived.by(() => {
const wh = workingHours;
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);
options.push({ ...t, totalMin: m, label: `${t.hour}:${t.minute} ${t.period}` });
}
return options;
});
let availableEndOptions = $derived.by(() => {
const wh = workingHours;
if (!wh || !wh.isOpen) return [];
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(timeToMinutes(wh.startTime), currentStartMin + 15); m <= endMin; m += 15) {
const t = minutesTo12h(m);
options.push({ ...t, totalMin: m, label: `${t.hour}:${t.minute} ${t.period}` });
}
return options;
});
let suggestedLunch = $derived.by(() => {
if (!workingHours || !availableHours || !workingHours.isOpen) return null;
const wh = workingHours;
const ah = availableHours;
const { windowStart, windowEnd } = calculateMiddleWindow(wh.startTime, wh.endTime);
const existingBookings = extractBookedSlots(wh.startTime, wh.endTime, ah.slots);
const gaps = findAllLunchGaps(windowStart, windowEnd, existingBookings);
if (gaps.length === 0) return null;
const relevantBookings = existingBookings
.filter((b) => {
const s = timeToMinutes(b.startTime);
const e = timeToMinutes(b.endTime);
return s < windowEnd && e > windowStart;
})
.map((b) => ({
startTime: Math.max(timeToMinutes(b.startTime), windowStart),
endTime: Math.min(timeToMinutes(b.endTime), windowEnd)
}))
.sort((a, b) => a.startTime - b.startTime);
const gapInfos: Array<{ start: number; end: number; duration: number }> = [];
let cursor = windowStart;
for (const b of relevantBookings) {
if (b.startTime > cursor) {
gapInfos.push({ start: cursor, end: b.startTime, duration: b.startTime - cursor });
}
cursor = Math.max(cursor, b.endTime);
}
if (cursor < windowEnd) {
gapInfos.push({ start: cursor, end: windowEnd, duration: windowEnd - cursor });
}
gapInfos.sort((a, b) => b.duration - a.duration);
if (gapInfos.length === 0) return null;
const largestGap = gapInfos[0];
const startTimeStr = `${minutesTo12h(largestGap.start).hour}:${minutesTo12h(largestGap.start).minute} ${minutesTo12h(largestGap.start).period}`;
const endTimeStr = `${minutesTo12h(largestGap.end).hour}:${minutesTo12h(largestGap.end).minute} ${minutesTo12h(largestGap.end).period}`;
return { startMin: largestGap.start, endMin: largestGap.end, duration: largestGap.duration, startLabel: startTimeStr, endLabel: endTimeStr };
});
type AppointmentTimelineItem = {
id: string;
startMinutes: number;
endMinutes: number;
type: 'appointment';
data: TodayAppointment;
};
type BlockerTimelineItem = {
id: string;
startMinutes: number;
endMinutes: number;
type: 'blocker';
data: TimeBlocker;
};
type LunchTimelineItem = {
id: string;
startMinutes: number;
endMinutes: number;
type: 'lunch';
data: { duration: number; startLabel: string; endLabel: string };
};
type TimelineItem = AppointmentTimelineItem | BlockerTimelineItem | LunchTimelineItem;
let timeline = $derived.by(() => {
const items: TimelineItem[] = [];
for (const apt of appointments) {
const start = new Date(apt.start_time);
const startM = start.getHours() * 60 + start.getMinutes();
items.push({
id: `apt-${apt.id}`,
startMinutes: startM,
endMinutes: startM + apt.duration_minutes,
type: 'appointment',
data: apt
});
}
for (const b of blockers) {
const start = new Date(b.start_time);
const startM = start.getHours() * 60 + start.getMinutes();
items.push({
id: `blk-${b.id}`,
startMinutes: startM,
endMinutes: startM + b.duration_minutes,
type: 'blocker',
data: b
});
}
if (suggestedLunch) {
items.push({
id: 'lunch',
startMinutes: suggestedLunch.startMin,
endMinutes: suggestedLunch.endMin,
type: 'lunch',
data: { duration: suggestedLunch.duration, startLabel: suggestedLunch.startLabel, endLabel: suggestedLunch.endLabel }
});
}
return items.sort((a, b) => a.startMinutes - b.startMinutes);
});
let prevAppointmentsJson = $state('');
let apptsInitialized = $state(false);
async function fetchTodayAppointments() {
if (!apptsInitialized) loading = true;
try {
const response = await fetch('/api/admin/today/appointments', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (response.ok) {
const data = await response.json();
const newAppointments = data.appointments || [];
const newJson = JSON.stringify(newAppointments);
if (newJson !== prevAppointmentsJson) {
appointments = newAppointments;
prevAppointmentsJson = newJson;
}
} else {
toast.error("Failed to load today's appointments");
}
} catch (err) {
console.error("Error fetching today's appointments:", err);
toast.error('Network error loading appointments');
} finally {
if (!apptsInitialized) {
loading = false;
apptsInitialized = true;
}
}
}
let prevBlockersJson = $state('');
let blockersInitialized = $state(false);
async function fetchTodayBlockersData() {
if (!blockersInitialized) whLoading = true;
try {
const dateParam = today;
const [blockersRes, whRes, ahRes] = await Promise.all([
fetch(`/api/admin/time-blockers?start=${encodeURIComponent(dateParam)}&end=${encodeURIComponent(dateParam)}`, {
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` }
}),
fetch(`/api/scheduling/working-hours?start=${encodeURIComponent(dateParam)}&end=${encodeURIComponent(dateParam)}`, {
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` }
}),
fetch(`/api/scheduling/available-hours?start=${encodeURIComponent(dateParam)}&end=${encodeURIComponent(dateParam)}`, {
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` }
})
]);
if (blockersRes.ok) {
const data: TimeBlocker[] = await blockersRes.json();
const newBlockers = (data || []).filter((b) => !isAutoGenerated(b));
const newJson = JSON.stringify(newBlockers);
if (newJson !== prevBlockersJson) {
blockers = newBlockers;
prevBlockersJson = newJson;
}
}
if (whRes.ok) {
const data: DayWorkingHours[] = await whRes.json();
const newWh = (data || []).find((d) => d.date === dateParam) ?? null;
if (JSON.stringify(newWh) !== JSON.stringify(workingHours)) {
workingHours = newWh;
}
}
if (ahRes.ok) {
const data: DayAvailableHours[] = await ahRes.json();
const newAh = (data || []).find((d) => d.date === dateParam) ?? null;
if (JSON.stringify(newAh) !== JSON.stringify(availableHours)) {
availableHours = newAh;
}
}
} catch (err) {
console.error('Error fetching today data:', err);
} finally {
if (!blockersInitialized) {
whLoading = false;
blockersInitialized = true;
}
}
}
async function checkOverlappingBookings() {
const startIso = buildDateTime(today, startHour, startMinute, startPeriod);
const endIso = buildDateTime(today, 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 response = await fetch(`/api/admin/bookings/by-date-range?start=${encodeURIComponent(today)}&end=${encodeURIComponent(today)}`, {
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` }
});
if (response.ok) {
const data = await response.json();
const allBookings: OverlappingBooking[] = data.bookings || [];
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; }
}
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 canCreate = $derived.by(() => !hasOverlap && !checkingOverlap);
async function createBlocker() {
if (!canCreate) return;
const startIso = buildDateTime(today, startHour, startMinute, startPeriod);
const endIso = buildDateTime(today, 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 {
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() || 'break' })
});
if (response.ok) {
toast.success('Time blocker created!', { id: loadingToast });
showCreateModal = false;
resetCreateForm();
await fetchTodayBlockersData();
} 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 fetchTodayBlockersData();
} 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 = '';
startHour = '9'; startMinute = '00'; startPeriod = 'AM';
endHour = '10'; endMinute = '00'; endPeriod = 'AM';
overlappingBookings = []; hasOverlap = false;
}
function openCreateModal() {
resetCreateForm();
if (workingHours && workingHours.isOpen) {
const startOpt = minutesTo12h(timeToMinutes(workingHours.startTime));
const endOpt = minutesTo12h(timeToMinutes(workingHours.startTime) + 60);
startHour = startOpt.hour; startMinute = startOpt.minute; startPeriod = startOpt.period;
endHour = endOpt.hour; endMinute = endOpt.minute; endPeriod = endOpt.period;
}
showCreateModal = true;
}
function getStatusColor(status: string): string {
switch (status) {
case 'completed':
return 'bg-green-100 text-green-800 hover:bg-green-100';
case 'in_progress':
return 'bg-blue-100 text-blue-800 hover:bg-blue-100';
case 'confirmed':
return 'bg-emerald-100 text-emerald-800 hover:bg-emerald-100';
case 'pending':
return 'bg-yellow-100 text-yellow-800 hover:bg-yellow-100';
case 'client_cancelled':
case 'we_cancelled':
return 'bg-red-100 text-red-800 hover:bg-red-100';
case 'no_show':
return 'bg-gray-100 text-gray-800 hover:bg-gray-100';
default:
return 'bg-gray-100 text-gray-800 hover:bg-gray-100';
}
}
function getStatusBarColor(status: string): string {
switch (status) {
case 'completed':
return 'bg-green-500';
case 'in_progress':
return 'bg-blue-500';
case 'confirmed':
return 'bg-emerald-500';
case 'pending':
return 'bg-yellow-500';
case 'client_cancelled':
case 'we_cancelled':
return 'bg-red-500';
case 'no_show':
return 'bg-gray-500';
default:
return 'bg-gray-500';
}
}
function formatTime(dateString: string): string {
const date = new SvelteDate(dateString);
return date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
}
function formatStatus(status: string): string {
return status.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase());
}
$effect(() => {
fetchTodayAppointments();
fetchTodayBlockersData();
const intervalId = setInterval(() => {
fetchTodayAppointments();
}, 600_000);
function handleApproval() {
fetchTodayAppointments();
}
window.addEventListener('bookingApproved', handleApproval);
return () => {
clearInterval(intervalId);
window.removeEventListener('bookingApproved', handleApproval);
};
});
$effect(() => {
if (showCreateModal) {
checkOverlappingBookings();
}
});
</script>
<div class="lg:col-span-2">
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Today's Appointments</Card.Title>
<Card.Description>Timeline view of all bookings</Card.Description>
</div>
<Button variant="outline" size="sm" onclick={openCreateModal}>
<svg xmlns="http://www.w3.org/2000/svg" class="mr-1 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>
Add Blocker
</Button>
</div>
</Card.Header>
<Card.Content>
{#if loading}
<div class="space-y-3">
{#each Array(5) as _, i (i)}
<div class="flex items-center gap-4 rounded-lg border p-3">
<Skeleton class="h-4 w-20" />
<Skeleton class="h-10 w-1" />
<div class="flex-1 space-y-2">
<Skeleton class="h-4 w-32" />
<Skeleton class="h-3 w-48" />
</div>
<Skeleton class="h-6 w-20" />
<Skeleton class="h-8 w-16" />
</div>
{/each}
</div>
{:else if timeline.length === 0}
<div class="py-12 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
class="mx-auto mb-4 h-16 w-16 text-gray-300"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<p class="text-lg font-medium text-gray-600">No appointments today</p>
<p class="text-sm text-gray-500">Looks like you have a quiet day</p>
</div>
{:else}
<div class="space-y-3">
{#each timeline as item (item.id)}
{#if item.type === 'appointment'}
<div
class="flex items-center gap-4 rounded-lg border p-3 transition-all hover:shadow-md
{isPastAppointment(item.data.start_time, item.data.duration_minutes) ? 'line-through opacity-50' : ''}"
>
<div class="min-w-[60px] sm:min-w-[80px] text-sm font-semibold text-gray-700">
{formatTime(item.data.start_time)}
</div>
<div class="h-10 w-1 rounded {getStatusBarColor(item.data.status)}"></div>
<div class="flex-1">
<button
type="button"
class="font-medium hover:text-blue-600 hover:underline"
onclick={() => openUserModal(item.data.user_id)}
>
{item.data.user_name}
</button>
<div class="flex flex-wrap items-center gap-x-1 text-sm text-gray-600">
<span>{item.data.services.join(', ')}</span>
<span class="rounded-full bg-gray-100 px-1.5 py-0.5 text-xs font-medium">
{formatDuration(item.data.duration_minutes)}
</span>
</div>
</div>
<Badge class={getStatusColor(item.data.status)}>
{formatStatus(item.data.status)}
</Badge>
<Button size="sm" variant="outline" onclick={() => openBookingModal(item.data.id)}>
View
</Button>
</div>
{:else if item.type === 'blocker'}
<div class="flex items-center gap-4 rounded-lg border border-amber-200 bg-amber-50/40 p-3 transition-all hover:shadow-sm">
<div class="min-w-[60px] sm:min-w-[80px] text-sm font-semibold text-gray-700">
{formatTime(item.data.start_time)}
</div>
<div class="flex h-10 w-6 shrink-0 items-center justify-center rounded bg-amber-100">
<svg class="h-4 w-4 text-amber-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-x-1">
<div class="truncate text-sm font-medium text-gray-900">{item.data.description || 'Untitled'}</div>
<span class="rounded-full bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-600">
{formatDuration(item.data.duration_minutes)}
</span>
</div>
<Button
variant="destructive" size="sm" class="shrink-0 h-9 w-9 p-0"
onclick={() => { blockerToDelete = item.data; 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>
{:else if item.type === 'lunch'}
<div class="flex items-center gap-4 rounded-lg border border-green-200 bg-green-50 p-3">
<div class="min-w-[60px] sm:min-w-[80px] text-sm font-semibold text-green-700">
{item.data.startLabel}
</div>
<div class="flex h-10 w-6 shrink-0 items-center justify-center rounded bg-green-100">
<svg class="h-4 w-4 text-green-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 8h1a4 4 0 0 1 0 8h-1" />
<path d="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z" />
<line x1="6" y1="1" x2="6" y2="4" />
<line x1="10" y1="1" x2="10" y2="4" />
<line x1="14" y1="1" x2="14" y2="4" />
</svg>
</div>
<div class="flex-1">
<p class="text-sm font-medium text-green-800">
Suggested lunch: {formatDuration(item.data.duration)} available
</p>
<p class="text-xs text-green-700">
{item.data.startLabel} {item.data.endLabel}
</p>
</div>
</div>
{/if}
{/each}
</div>
{/if}
</Card.Content>
</Card.Root>
</div>
<!-- Create Blocker Modal -->
<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">Add Time Blocker</Modal.Title>
<Modal.Description>Block off a period of time today.</Modal.Description>
</Modal.Header>
<div class="space-y-5 px-4 pb-4">
<div class="space-y-2">
<label for="blocker-desc" class="text-sm font-medium">Description</label>
<Input id="blocker-desc" type="text" placeholder="e.g., Lunch, Appointment, Break" bind:value={newDescription} />
<p class="text-xs text-gray-400">Defaults to "break" if left empty</p>
</div>
<Separator />
<div class="space-y-3">
<h3 class="text-sm font-medium">Start Time</h3>
{#if whLoading}
<Skeleton class="h-9 w-full" />
{:else if !workingHours}
<p class="text-sm text-gray-400">Loading working hours…</p>
{:else if !workingHours.isOpen}
<p class="text-sm text-red-500">Closed today</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; checkOverlappingBookings(); }}>
{#each availableStartOptions as opt}
<option value={`${opt.hour}:${opt.minute}:${opt.period}`}>{opt.label}</option>
{/each}
</select>
{/if}
</div>
<div class="space-y-3">
<h3 class="text-sm font-medium">End Time</h3>
{#if whLoading}
<Skeleton class="h-9 w-full" />
{:else if !workingHours}
<p class="text-sm text-gray-400">Loading working hours…</p>
{:else if !workingHours.isOpen}
<p class="text-sm text-red-500">Closed today</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; checkOverlappingBookings(); }}>
{#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-3">
<div class="flex items-center gap-2 mb-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0 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">Booking exists in this timeslot</span>
</div>
<div class="space-y-2">
{#each overlappingBookings as booking (booking.id)}
<div class="rounded-md border border-amber-200 bg-white p-2">
<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-1.5">
<Button variant="outline" size="sm" class="text-xs h-6" onclick={() => openBookingModal(booking.id)}>View Booking</Button>
{#if booking.user?.id}
<Button variant="outline" size="sm" class="text-xs h-6" 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 || checkingOverlap}>
{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. 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>