Files
Crussell/frontend/src/lib/components/admin/TimeBlockers.svelte
T

963 lines
27 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 { SvelteDate } from 'svelte/reactivity';
import { apiFetch } from '$lib/utils/api';
import { CalendarDate } from '@internationalized/date';
import { toast } from 'svelte-sonner';
import { sanitizeText } from '$lib/utils/toast-safe';
import { formatDuration, range } from '$lib/utils/format';
import { formatUserName } from '$lib/utils/nameDisplay';
import { formatLocalDateTime, parseWallClockDate } from '$lib/utils/timeSlots';
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;
previous_first_name?: string | null;
previous_last_name?: 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;
defaultHours?: WorkingHourRow[];
}
const {
openUserModal,
openBookingModal,
rescheduleVersion = 0,
defaultHours: defaultHoursProp
}: 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);
const 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 SvelteDate(dateStr + 'T00:00:00Z');
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}`;
}
const selectedWorkingHours = $derived.by(() => getWorkingHoursForDate(newStartDate));
const 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;
});
const 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 formatRelativeTime(iso: string): string {
const d = new SvelteDate(iso);
const now = new SvelteDate();
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`;
}
const sortedBlockers = $derived.by(() => {
return [...blockers].sort(
(a, b) => new SvelteDate(a.start_time).getTime() - new SvelteDate(b.start_time).getTime()
);
});
const totalPages = $derived.by(() => Math.max(1, Math.ceil(sortedBlockers.length / PAGE_SIZE)));
const 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 apiFetch('/api/admin/time-blockers', {
headers: {
'Content-Type': 'application/json'
}
});
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 apiFetch('/api/scheduling/default-hours', {
headers: {
'Content-Type': 'application/json'
}
});
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);
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 formComplete = $derived.by(() => {
return (
newDescription.trim() !== '' && newStartDate !== '' && startHour !== '' && endHour !== ''
);
});
const 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 SvelteDate(startIso);
const blockerEnd = new SvelteDate(endIso);
if (blockerEnd.getTime() <= blockerStart.getTime()) {
overlappingBookings = [];
hasOverlap = false;
return;
}
checkingOverlap = true;
try {
const dateParam = newStartDate;
const response = await apiFetch(
`/api/admin/bookings/by-date-range?start=${encodeURIComponent(dateParam)}&end=${encodeURIComponent(dateParam)}`,
{
headers: {
'Content-Type': 'application/json'
}
}
);
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 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;
}
}
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 apiFetch('/api/admin/time-blockers', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
start_time: startIso,
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 apiFetch('/api/admin/time-blockers', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
start_time: startIso,
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: ' + 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 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 fetchBlockers();
} 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 = '';
newStartDate = '';
startHour = '9';
startMinute = '00';
startPeriod = 'AM';
endHour = '10';
endMinute = '00';
endPeriod = 'AM';
overlappingBookings = [];
hasOverlap = false;
prevWorkingHoursSig = '';
}
function openCreateModal() {
resetCreateForm();
showCreateModal = true;
}
$effect(() => {
fetchBlockers();
});
$effect(() => {
if (defaultHoursProp !== undefined) {
if (defaultHoursProp.length > 0) {
defaultHours = defaultHoursProp;
}
hoursLoading = false;
} else {
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 range(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 transition-all hover:shadow-sm sm:p-4"
>
<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>
{parseWallClockDate(b.start_time).toLocaleDateString('en-GB', {
weekday: 'short',
day: 'numeric',
month: 'short'
})}
</span>
<span class="text-gray-400">·</span>
<span>
{parseWallClockDate(b.start_time).toLocaleTimeString('en-GB', {
hour: 'numeric',
minute: '2-digit',
hour12: true
})}
</span>
<span class="text-gray-400"></span>
<span>
{(() => {
const end = parseWallClockDate(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 pt-2 sm:flex-row sm:items-center sm:justify-between"
>
<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 range(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}
</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="flex-1 text-xs"
onclick={() => {
newStartDate = new Date().toLocaleDateString('en-CA', {
timeZone: 'Europe/London'
});
onStartDateChange();
}}
>
Today
</Button>
<Button
type="button"
variant="outline"
size="sm"
class="flex-1 text-xs"
onclick={() => {
newStartDate = new Date(Date.now() + 86400000).toLocaleDateString('en-CA', {
timeZone: 'Europe/London'
});
onStartDateChange();
}}
>
Tomorrow
</Button>
</div>
<Input
id="blocker-start-date"
type="date"
bind:value={newStartDate}
onchange={onStartDateChange}
/>
</div>
<div class="space-y-2">
<div class="text-xs text-gray-600">Time</div>
{#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:00Z').toLocaleDateString('en-GB', {
weekday: 'long'
})}
</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;
onStartTimeChange();
}}
>
{#each availableStartOptions as opt (opt.totalMin)}
<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-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;
onEndTimeChange();
}}
>
{#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-4">
<div class="mb-3 flex items-center gap-2">
<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="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">
{parseWallClockDate(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-2 flex gap-2">
{#if openBookingModal}
<Button
variant="outline"
size="sm"
class="h-7 text-xs"
onclick={() => openBookingModal(booking.id)}
>
View Booking
</Button>
{/if}
{#if openUserModal && booking.user?.id}
<Button
variant="outline"
size="sm"
class="h-7 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}>
{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>