963 lines
27 KiB
Svelte
963 lines
27 KiB
Svelte
<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>
|