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

1072 lines
32 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 { sanitizeText } from '$lib/utils/toast-safe';
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,
shouldApplyLunchProtection,
timeToMinutes
} from '$lib/lunchProtection';
import { formatDuration, formatDateISO } from '$lib/utils/format';
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 SvelteDate(startTime);
const end = new SvelteDate(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(formatDateISO(new SvelteDate()));
const weekStartStr = $derived.by(() => {
const d = new SvelteDate();
const day = d.getDay();
const diff = day === 0 ? 6 : day - 1;
d.setDate(d.getDate() - diff);
return formatDateISO(d);
});
const weekEndStr = $derived.by(() => {
const d = new SvelteDate(weekStartStr);
d.setDate(d.getDate() + 6);
return formatDateISO(d);
});
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 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 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;
if (!shouldApplyLunchProtection(wh.startTime, wh.endTime)) return null;
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 ClosingTimeTimelineItem = {
id: string;
startMinutes: number;
type: 'closing';
data: { label: string };
};
type TimelineItem = AppointmentTimelineItem | BlockerTimelineItem | LunchTimelineItem | ClosingTimeTimelineItem;
let timeline = $derived.by(() => {
const items: TimelineItem[] = [];
for (const apt of appointments) {
const start = new SvelteDate(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 SvelteDate(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
}
});
}
if (workingHours?.isOpen && workingHours.endTime) {
const closeMin = timeToMinutes(workingHours.endTime);
const t = minutesTo12h(closeMin);
items.push({
id: 'closing',
startMinutes: closeMin,
type: 'closing',
data: { label: `${t.hour}:${t.minute} ${t.period}` }
});
}
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(weekStartStr)}&end=${encodeURIComponent(weekEndStr)}`,
{
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
}
),
fetch(
`/api/scheduling/available-hours?start=${encodeURIComponent(weekStartStr)}&end=${encodeURIComponent(weekEndStr)}`,
{
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 SvelteDate(startIso);
const blockerEnd = new SvelteDate(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 SvelteDate(b.start_time);
const bEnd = new SvelteDate(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: ' + sanitizeText(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: ' + sanitizeText(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-3 rounded-lg border p-3 sm:gap-4">
<Skeleton class="h-4 w-[45px] sm:w-[60px] lg:w-[80px]" />
<Skeleton class="h-10 w-1 shrink-0" />
<div class="min-w-0 flex-1 space-y-2">
<Skeleton class="h-4 w-32" />
<Skeleton class="h-3 w-48" />
</div>
<Skeleton class="hidden h-6 w-20 shrink-0 sm:block" />
<Skeleton class="h-8 w-9 shrink-0 sm: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-3 rounded-lg border p-3 transition-all hover:shadow-md sm:gap-4
{isPastAppointment(item.data.start_time, item.data.duration_minutes)
? 'line-through opacity-50'
: ''}"
>
<div
class="min-w-[45px] shrink-0 text-xs font-semibold text-gray-700 sm:min-w-[60px] sm:text-sm lg:min-w-[80px]"
>
{formatTime(item.data.start_time)}
</div>
<div class="h-10 w-1 shrink-0 rounded {getStatusBarColor(item.data.status)}"></div>
<div class="min-w-0 flex-1">
<button
type="button"
class="block max-w-full truncate 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 class="truncate">{(item.data.services ?? []).join(', ')}</span>
<span
class="shrink-0 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)} hidden shrink-0 sm:inline-flex">
{formatStatus(item.data.status)}
</Badge>
<Button
size="sm"
variant="outline"
class="h-9 w-9 shrink-0 sm:w-auto sm:px-3"
onclick={() => openBookingModal(item.data.id)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 sm:mr-1.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
<span class="hidden sm:inline">View</span>
</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] text-sm font-semibold text-gray-700 sm:min-w-[80px]">
{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="h-9 w-9 shrink-0 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] text-sm font-semibold text-green-700 sm:min-w-[80px]">
{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>
{:else if item.type === 'closing'}
<div class="flex items-center gap-3 border-t border-gray-100 px-1 pt-3">
<div
class="text-xs font-medium text-gray-400"
>
{item.data.label}
</div>
<div class="h-px flex-1 bg-gray-100"></div>
<span class="text-[11px] text-gray-400">Close</span>
</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 (opt.totalMin)}
<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 (opt.totalMin)}
<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="mb-2 flex items-center gap-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="text-sm font-medium">{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="mt-1.5 flex gap-2">
<Button
variant="outline"
size="sm"
class="h-6 text-xs"
onclick={() => openBookingModal(booking.id)}>View Booking</Button
>
{#if booking.user?.id}
<Button
variant="outline"
size="sm"
class="h-6 text-xs"
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>