Files
Crussell/frontend/src/lib/components/today/TodayCalendar.svelte
T
popertots 52e2bfff55 Add dev-only frontend Square mock mode for as-if-live walkthroughs
VITE_SQUARE_ENVIRONMENT=mock renders a plain HTML card form (MockCardForm)
instead of the Square Web Payments SDK iframe, minting the same cnon: tokens
the backend dev mock accepts — all 8 payment flows run end-to-end locally with
zero credentials.

- isSquareMock() gated on import.meta.env.DEV: structurally impossible in a
  production build even if the env var is mis-set
- MockCardForm: Luhn/brand/expiry/CVC validation, Amex 15-digit + 4-digit CVC,
  error states, disabled propagation — mirrors the real form's onReady contract
  so CardSelection.isCardValid and submit guards behave identically
- tokenize() maps typed card -> deterministic cnon: token matching backend
  detectCardInfo (4242->test-card, 4111->visa, 5555->mastercard, 3782->amex)
- lazy-loaded via dynamic import: mock code ships in its own chunk, referenced
  only from the mock branch, never statically imported into the main bundle
- docs: .env.example (mock pairing with SQUARE_ENVIRONMENT=mock), P11 plan
  (mock opt-in + canonical-last4 caveat), Feature Catalog (2.1, 2.5)
- prettier formatting fixes in 10 unrelated files (line wrapping only)
2026-08-22 00:34:49 +01:00

1084 lines
33 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 { apiFetch } from '$lib/utils/api';
import { SvelteDate } from 'svelte/reactivity';
import { CalendarDate } from '@internationalized/date';
import { toast } from 'svelte-sonner';
import { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe';
import { formatUserName } from '$lib/utils/nameDisplay';
import { range } from '$lib/utils/format';
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';
import { formatLocalDateTime } from '$lib/utils/timeSlots';
interface Props {
openBookingModal: (bookingId: string) => void;
openUserModal: (userId: string) => void;
}
const { openBookingModal, openUserModal }: Props = $props();
type TodayAppointment = {
id: string;
start_time: string;
status: string;
user_name: string;
user_id: string;
services: string[];
duration_minutes: number;
previous_first_name?: string | null;
previous_last_name?: string | null;
};
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;
previous_first_name?: string | null;
previous_last_name?: 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(new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' }));
const weekStartStr = $derived.by(() => {
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const d = new SvelteDate(londonDateStr + 'T00:00:00Z');
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 + 'T00:00:00Z');
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;
}
const 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;
});
const 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;
});
const 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;
const 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 apiFetch('/api/admin/today/appointments', {
headers: { 'Content-Type': 'application/json' }
});
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([
apiFetch(
`/api/admin/time-blockers?start=${encodeURIComponent(dateParam)}&end=${encodeURIComponent(dateParam)}`,
{
headers: { 'Content-Type': 'application/json' }
}
),
apiFetch(
`/api/scheduling/working-hours?start=${encodeURIComponent(weekStartStr)}&end=${encodeURIComponent(weekEndStr)}`,
{
headers: { 'Content-Type': 'application/json' }
}
),
apiFetch(
`/api/scheduling/available-hours?start=${encodeURIComponent(weekStartStr)}&end=${encodeURIComponent(weekEndStr)}`,
{
headers: { 'Content-Type': 'application/json' }
}
)
]);
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 apiFetch(
`/api/admin/bookings/by-date-range?start=${encodeURIComponent(today)}&end=${encodeURIComponent(today)}`,
{
headers: { 'Content-Type': 'application/json' }
}
);
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);
const [y, m, d] = date.split('-').map(Number);
const cal = new CalendarDate(y, m, d);
const [h, min] = t24.split(':').map(Number);
const dt = cal.toDate('Europe/London');
dt.setHours(h, min, 0, 0);
return formatLocalDateTime(dt);
}
const 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 apiFetch('/api/admin/time-blockers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
start_time: startIso,
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(extractErrorMessage(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 apiFetch(`/api/admin/time-blockers/${blockerToDelete.id}`, {
method: 'DELETE'
});
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(extractErrorMessage(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 range(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)}
>
{formatUserName(
item.data.user_name,
item.data.previous_first_name,
item.data.previous_last_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-background px-3 py-1 text-sm shadow-xs ring-offset-background transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
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-background px-3 py-1 text-sm shadow-xs ring-offset-background transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
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">
{formatUserName(
booking.user?.full_name || 'Unknown',
booking.user?.previous_first_name,
booking.user?.previous_last_name
)}
</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>