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

854 lines
26 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 { formatDuration } from '$lib/utils/format';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Separator } from '$lib/components/ui/separator';
import * as Modal from '$lib/components/ui/dialog';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Skeleton } from '$lib/components/ui/skeleton';
type TimeBlocker = {
id: string;
start_time: string;
duration_minutes: number;
description: string;
cron_expression: string | null;
created_at: string;
created_by: string | null;
};
type OverlappingBooking = {
id: string;
start_time: string;
duration_minutes: number;
status: string;
user: {
id: string;
full_name: string;
email: string | null;
phone: string | null;
} | null;
services: string[];
};
type WorkingHourRow = {
weekday: number;
startTime: string;
endTime: string;
isOpen: boolean;
};
interface Props {
openUserModal?: (userId: string) => void;
openBookingModal?: (bookingId: string) => void;
rescheduleVersion?: number;
}
let { openUserModal, openBookingModal, rescheduleVersion = 0 }: Props = $props();
const PAGE_SIZE = 5;
let blockers = $state<TimeBlocker[]>([]);
let loading = $state(true);
let creating = $state(false);
let checkingOverlap = $state(false);
let showCreateModal = $state(false);
let newDescription = $state('');
let newStartDate = $state('');
let startHour = $state('9');
let startMinute = $state('00');
let startPeriod = $state<'AM' | 'PM'>('AM');
let endHour = $state('10');
let endMinute = $state('00');
let endPeriod = $state<'AM' | 'PM'>('AM');
let startSelectValue = $derived(`${startHour}:${startMinute}:${startPeriod}`);
let endSelectValue = $derived(`${endHour}:${endMinute}:${endPeriod}`);
function parseSelectValue(val: string): { hour: string; minute: string; period: 'AM' | 'PM' } {
const [hour, minute, period] = val.split(':') as [string, string, 'AM' | 'PM'];
return { hour, minute, period };
}
let overlappingBookings = $state<OverlappingBooking[]>([]);
let hasOverlap = $state(false);
let hasDayConflicts = $derived(overlappingBookings.length > 0);
let showDeleteAlert = $state(false);
let blockerToDelete = $state<TimeBlocker | null>(null);
let currentPage = $state(1);
let defaultHours = $state<WorkingHourRow[]>([]);
let hoursLoading = $state(true);
function to24h(hour: string, minute: string, period: 'AM' | 'PM'): string {
let h = parseInt(hour);
if (period === 'PM' && h !== 12) h += 12;
if (period === 'AM' && h === 12) h = 0;
return `${String(h).padStart(2, '0')}:${minute}`;
}
function timeToMinutes(time: string): number {
const [h, m] = time.split(':').map(Number);
return h * 60 + m;
}
function minutesTo12h(totalMin: number): { hour: string; minute: string; period: 'AM' | 'PM' } {
let h = Math.floor(totalMin / 60);
const m = totalMin % 60;
const period: 'AM' | 'PM' = h >= 12 ? 'PM' : 'AM';
if (h >= 12 && h !== 12) h -= 12;
if (h === 0) h = 12;
return { hour: String(h), minute: String(m).padStart(2, '0'), period };
}
function getWorkingHoursForDate(dateStr: string): WorkingHourRow | null {
if (!dateStr || defaultHours.length === 0) return null;
const d = new SvelteDate(dateStr + 'T00:00:00');
const jsDay = d.getDay();
const weekday = jsDay === 0 ? 6 : jsDay - 1;
return defaultHours.find((h) => h.weekday === weekday) ?? null;
}
function workingHoursSignature(row: WorkingHourRow | null): string {
if (!row) return '';
return `${row.startTime}-${row.endTime}-${row.isOpen}`;
}
let selectedWorkingHours = $derived.by(() => getWorkingHoursForDate(newStartDate));
let availableStartOptions = $derived.by(() => {
const wh = selectedWorkingHours;
if (!wh || !wh.isOpen) return [];
const startMin = timeToMinutes(wh.startTime);
const endMin = timeToMinutes(wh.endTime);
const options: Array<{ hour: string; minute: string; period: 'AM' | 'PM'; totalMin: number; label: string }> = [];
for (let m = startMin; m < endMin; m += 15) {
const t = minutesTo12h(m);
const isStart = m === startMin;
options.push({ ...t, totalMin: m, label: `${t.hour}:${t.minute} ${t.period}${t.period === 'PM' && t.hour === '12' && t.minute === '00' ? ' (noon)' : ''}${isStart ? ' (start of day)' : ''}` });
}
return options;
});
let availableEndOptions = $derived.by(() => {
const wh = selectedWorkingHours;
if (!wh || !wh.isOpen) return [];
const startMin = timeToMinutes(wh.startTime);
const endMin = timeToMinutes(wh.endTime);
const currentStartMin = timeToMinutes(to24h(startHour, startMinute, startPeriod));
const options: Array<{ hour: string; minute: string; period: 'AM' | 'PM'; totalMin: number; label: string }> = [];
for (let m = Math.max(startMin, currentStartMin + 15); m <= endMin; m += 15) {
const t = minutesTo12h(m);
const isEnd = m === endMin;
options.push({ ...t, totalMin: m, label: `${t.hour}:${t.minute} ${t.period}${t.period === 'PM' && t.hour === '12' && t.minute === '00' ? ' (noon)' : ''}${isEnd ? ' (end of day)' : ''}` });
}
return options;
});
function 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`;
}
let sortedBlockers = $derived.by(() => {
return [...blockers].sort((a, b) => new SvelteDate(a.start_time).getTime() - new SvelteDate(b.start_time).getTime());
});
let totalPages = $derived.by(() => Math.max(1, Math.ceil(sortedBlockers.length / PAGE_SIZE)));
let pagedBlockers = $derived.by(() => {
const start = (currentPage - 1) * PAGE_SIZE;
return sortedBlockers.slice(start, start + PAGE_SIZE);
});
function isAutoGenerated(blocker: TimeBlocker): boolean {
return blocker.description?.startsWith('RESERVATION:') ?? false;
}
async function fetchBlockers() {
loading = true;
try {
const response = await fetch('/api/admin/time-blockers', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (response.ok) {
const data: TimeBlocker[] = await response.json();
blockers = (data || []).filter((b) => !isAutoGenerated(b));
} else {
toast.error('Failed to load time blockers');
}
} catch (err) {
console.error('Error fetching time blockers:', err);
toast.error('Network error loading time blockers');
} finally {
loading = false;
}
}
async function fetchDefaultHours() {
hoursLoading = true;
try {
const response = await fetch('/api/scheduling/default-hours', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (response.ok) {
defaultHours = await response.json();
}
} catch (err) {
console.error('Error fetching default hours:', err);
} finally {
hoursLoading = false;
}
}
function buildDateTime(date: string, hour: string, minute: string, period: 'AM' | 'PM'): string | null {
if (!date) return null;
const t24 = to24h(hour, minute, period);
return `${date}T${t24}:00`;
}
let formComplete = $derived.by(() => {
return newDescription.trim() !== '' && newStartDate !== '' && startHour !== '' && endHour !== '';
});
let canCreate = $derived.by(() => {
return formComplete && !hasOverlap && !hasDayConflicts && !checkingOverlap;
});
async function checkOverlappingBookings() {
const startIso = buildDateTime(newStartDate, startHour, startMinute, startPeriod);
const endIso = buildDateTime(newStartDate, endHour, endMinute, endPeriod);
if (!startIso || !endIso) {
overlappingBookings = [];
hasOverlap = false;
return;
}
const blockerStart = new 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 fetch(
`/api/admin/bookings/by-date-range?start=${encodeURIComponent(dateParam)}&end=${encodeURIComponent(dateParam)}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
}
);
if (response.ok) {
const data = await response.json();
const allBookings: OverlappingBooking[] = data.bookings || [];
// Client-side filter: only bookings that overlap with the proposed blocker timespan
const filtered = allBookings.filter((b) => {
const bStart = new 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 fetch('/api/admin/time-blockers', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify({
start_time: start.toISOString(),
duration_minutes: 60,
description: `RESERVATION:placeholder:${booking.id}`
})
});
} catch (placeholderErr) {
console.error('Failed to create placeholder for booking', booking.id, placeholderErr);
}
}
}
// Create the actual time blocker
const response = await fetch('/api/admin/time-blockers', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify({
start_time: start.toISOString(),
duration_minutes: durationMinutes,
description: newDescription.trim()
})
});
if (response.ok) {
toast.success('Time blocker created!', { id: loadingToast });
showCreateModal = false;
resetCreateForm();
await fetchBlockers();
} else {
const text = await response.text();
toast.error('Failed to create: ' + text, { id: loadingToast });
}
} catch (err) {
console.error('Error creating time blocker:', err);
toast.error('Network error creating time blocker', { id: loadingToast });
} finally {
creating = false;
}
}
async function confirmDeleteBlocker() {
if (!blockerToDelete) return;
const loadingToast = toast.loading('Deleting time blocker...');
try {
const response = await fetch(`/api/admin/time-blockers/${blockerToDelete.id}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (response.ok || response.status === 204) {
toast.success('Time blocker deleted', { id: loadingToast });
showDeleteAlert = false;
blockerToDelete = null;
await fetchBlockers();
} else {
const text = await response.text();
toast.error('Failed to delete: ' + text, { id: loadingToast });
}
} catch (err) {
console.error('Error deleting time blocker:', err);
toast.error('Network error deleting time blocker', { id: loadingToast });
}
}
function resetCreateForm() {
newDescription = '';
newStartDate = '';
startHour = '9';
startMinute = '00';
startPeriod = 'AM';
endHour = '10';
endMinute = '00';
endPeriod = 'AM';
overlappingBookings = [];
hasOverlap = false;
prevWorkingHoursSig = '';
}
function openCreateModal() {
resetCreateForm();
showCreateModal = true;
}
$effect(() => {
fetchBlockers();
fetchDefaultHours();
});
$effect(() => {
if (showCreateModal) {
currentPage = 1;
}
});
$effect(() => {
void rescheduleVersion;
if (showCreateModal) {
checkOverlappingBookings();
}
});
</script>
<Card.Root>
<Card.Header>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<Card.Title>Time Blockers</Card.Title>
<Card.Description>
Manage one-off blocked periods like appointments, breaks, or closures.
</Card.Description>
</div>
<Button variant="default" onclick={openCreateModal}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
New Blocker
</Button>
</div>
</Card.Header>
<Card.Content class="space-y-4">
{#if loading}
<div class="space-y-3">
{#each Array(3) as _, i (i)}
<Skeleton class="h-16 w-full" />
{/each}
</div>
{:else}
{#if blockers.length === 0}
<p class="text-sm text-gray-500">No time blockers found.</p>
{:else}
<div class="space-y-3">
{#each pagedBlockers as b (b.id)}
<div class="flex items-center justify-between gap-3 rounded-lg border p-3 sm:p-4 transition-all hover:shadow-sm">
<div class="flex min-w-0 flex-1 items-center gap-3">
<div class="hidden shrink-0 rounded-lg bg-gray-50 p-1.5 sm:block">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 text-gray-600"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</div>
<div class="min-w-0">
<div class="truncate font-medium text-gray-900">{b.description || 'Untitled'}</div>
<div class="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-gray-600">
<span>
{new SvelteDate(b.start_time).toLocaleDateString('en-GB', {
weekday: 'short',
day: 'numeric',
month: 'short'
})}
</span>
<span class="text-gray-400">·</span>
<span>
{new SvelteDate(b.start_time).toLocaleTimeString('en-GB', {
hour: 'numeric',
minute: '2-digit',
hour12: true
})}
</span>
<span class="text-gray-400"></span>
<span>
{(() => {
const end = new SvelteDate(b.start_time);
end.setMinutes(end.getMinutes() + b.duration_minutes);
return end.toLocaleTimeString('en-GB', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
})()}
</span>
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium">
{formatDuration(b.duration_minutes)}
</span>
</div>
<div class="text-xs text-gray-400">
{formatRelativeTime(b.start_time)}
</div>
</div>
</div>
<Button
variant="destructive"
size="sm"
class="shrink-0"
onclick={() => {
blockerToDelete = b;
showDeleteAlert = true;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-3 w-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</Button>
</div>
{/each}
</div>
{#if totalPages > 1}
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between pt-2">
<p class="text-sm text-gray-500">
{(currentPage - 1) * PAGE_SIZE + 1}{Math.min(currentPage * PAGE_SIZE, sortedBlockers.length)} of {sortedBlockers.length}
</p>
<div class="flex items-center gap-1">
<Button
variant="outline"
size="sm"
disabled={currentPage <= 1}
onclick={() => (currentPage -= 1)}
>
Prev
</Button>
{#each Array(totalPages) as _, i (i)}
<Button
variant={currentPage === i + 1 ? 'default' : 'outline'}
size="sm"
class="h-8 w-8 p-0"
onclick={() => (currentPage = i + 1)}
>
{i + 1}
</Button>
{/each}
<Button
variant="outline"
size="sm"
disabled={currentPage >= totalPages}
onclick={() => (currentPage += 1)}
>
Next
</Button>
</div>
</div>
{/if}
{/if}
{/if}
</Card.Content>
</Card.Root>
<Modal.Root bind:open={showCreateModal}>
<Modal.Content class="max-h-[90vh] max-w-lg overflow-y-auto">
<Modal.Header>
<Modal.Title class="text-lg font-semibold">Create Time Blocker</Modal.Title>
<Modal.Description>
Block off a period of time so no bookings can be made during it.
</Modal.Description>
</Modal.Header>
<div class="space-y-6 px-4 pb-4">
<div class="space-y-2">
<label for="blocker-description" class="text-sm font-medium">Description *</label>
<Input
id="blocker-description"
type="text"
placeholder="e.g., Extended lunch, Doctor's appointment, Illness"
bind:value={newDescription}
/>
</div>
<Separator />
<div class="space-y-4">
<h3 class="text-sm font-medium">Start</h3>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="space-y-2">
<label for="blocker-start-date" class="text-xs text-gray-600">Date</label>
<div class="flex gap-2">
<Button type="button" variant="outline" size="sm" class="text-xs flex-1" onclick={() => { newStartDate = new Date().toISOString().slice(0, 10); onStartDateChange(); }}>
Today
</Button>
<Button type="button" variant="outline" size="sm" class="text-xs flex-1" onclick={() => { newStartDate = new Date(Date.now() + 86400000).toISOString().slice(0, 10); onStartDateChange(); }}>
Tomorrow
</Button>
</div>
<Input id="blocker-start-date" type="date" bind:value={newStartDate} onchange={onStartDateChange} />
</div>
<div class="space-y-2">
<label class="text-xs text-gray-600">Time</label>
{#if hoursLoading}
<Skeleton class="h-9 w-full" />
{:else if !selectedWorkingHours}
<p class="text-sm text-gray-400">Select a date first</p>
{:else if !selectedWorkingHours.isOpen}
<p class="text-sm text-red-500">Closed on {new SvelteDate(newStartDate + 'T00:00:00').toLocaleDateString('en-GB', { weekday: 'long' })}</p>
{:else}
<select
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm"
bind:value={startSelectValue}
onchange={() => {
const p = parseSelectValue(startSelectValue);
startHour = p.hour;
startMinute = p.minute;
startPeriod = p.period;
onStartTimeChange();
}}
>
{#each availableStartOptions as opt (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-transparent px-3 text-sm"
bind:value={endSelectValue}
onchange={() => {
const p = parseSelectValue(endSelectValue);
endHour = p.hour;
endMinute = p.minute;
endPeriod = p.period;
onEndTimeChange();
}}
>
{#each availableEndOptions as opt (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="flex items-center gap-2 mb-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-amber-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
<span class="text-sm font-medium text-amber-800">
{overlappingBookings.length} booking{overlappingBookings.length > 1 ? 's' : ''} conflict{overlappingBookings.length > 1 ? '' : 's'} with this slot
</span>
</div>
<div class="space-y-3">
{#each overlappingBookings as booking (booking.id)}
<div class="rounded-md border border-amber-200 bg-white p-3">
<div class="min-w-0">
<div class="font-medium text-sm">{booking.user?.full_name || 'Unknown'}</div>
<div class="text-xs text-gray-500">
{new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', {
hour: 'numeric',
minute: '2-digit',
hour12: true
})}}
{' · '}
{formatDuration(booking.duration_minutes)}
{#if booking.services?.length}
{' · '}
{booking.services.join(', ')}
{/if}
</div>
</div>
<div class="flex gap-2 mt-2">
{#if openBookingModal}
<Button
variant="outline"
size="sm"
class="text-xs h-7"
onclick={() => openBookingModal(booking.id)}
>
View Booking
</Button>
{/if}
{#if openUserModal && booking.user?.id}
<Button
variant="outline"
size="sm"
class="text-xs h-7"
onclick={() => openUserModal(booking.user!.id)}
>
View Client
</Button>
{/if}
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
<Modal.Footer class="flex items-center justify-end gap-2">
<Button
variant="outline"
onclick={() => {
showCreateModal = false;
resetCreateForm();
}}
disabled={creating}
>
Cancel
</Button>
<Button onclick={createBlocker} disabled={!canCreate || creating}>
{creating ? 'Creating…' : 'Create Blocker'}
</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
<AlertDialog.Root bind:open={showDeleteAlert}>
<AlertDialog.Content class="z-[60]">
<AlertDialog.Header>
<AlertDialog.Title>Delete time blocker?</AlertDialog.Title>
<AlertDialog.Description>
This will remove the "{blockerToDelete?.description}" time blocker. Bookings may become available
during this period. This action cannot be undone.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel
onclick={() => {
showDeleteAlert = false;
blockerToDelete = null;
}}
>
Cancel
</AlertDialog.Cancel>
<AlertDialog.Action onclick={confirmDeleteBlocker} class="bg-red-600 hover:bg-red-700">
Delete
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>