- Fix <!-- svelte-ignore HTML comments in script sections (invalid JS) - Fix catch err -> _err references across all files after renames - Fix .writable (not in Svelte 5 stable) back to + - Fix NavBar dynamic href links with proper eslint-disable in template - Fix SvelteMap type params missing after Map->SvelteMap conversion - Fix required->_required and onclose->_onclose prop mismatches - Fix HolidayHours inline type mismatch, BookingCreateModal suppression - Fix remaining pre-existing no-unused-vars with eslint-disable-next-line - Revert fonts commit, run prettier format svelte-check: 0 errors, eslint: 0 errors, prettier: clean
807 lines
26 KiB
Svelte
807 lines
26 KiB
Svelte
<script lang="ts">
|
||
// ============================================================
|
||
// ADMIN SCHEDULE PAGE — Google Calendar-style week view
|
||
// ============================================================
|
||
import { goto } from '$app/navigation';
|
||
import { authStore } from '$lib/stores/auth.svelte';
|
||
import { browser } from '$app/environment';
|
||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||
import { Button } from '$lib/components/ui/button';
|
||
import { formatDuration } from '$lib/utils/format';
|
||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||
import { SvelteDate } from 'svelte/reactivity';
|
||
import BookingModal from '$lib/components/admin/BookingModal.svelte';
|
||
|
||
// -- Types --
|
||
type BookingService = { service_name?: string };
|
||
type ScheduleBooking = {
|
||
id: string;
|
||
start_time: string;
|
||
status: string;
|
||
duration_minutes: number;
|
||
user?: {
|
||
full_name: string;
|
||
previous_first_name?: string | null;
|
||
previous_last_name?: string | null;
|
||
};
|
||
services: BookingService[];
|
||
};
|
||
type TimeBlocker = {
|
||
id: string;
|
||
start_time: string;
|
||
duration_minutes: number;
|
||
description: string;
|
||
};
|
||
type WorkingDay = {
|
||
date: string;
|
||
isOpen: boolean;
|
||
startTime: string;
|
||
endTime: string;
|
||
};
|
||
|
||
// -- State --
|
||
let pageState = $state<'loading' | 'authorized' | 'unauthorized'>('loading');
|
||
let bookings = $state<ScheduleBooking[]>([]);
|
||
let blockers = $state<TimeBlocker[]>([]);
|
||
let workingHours = $state<WorkingDay[]>([]);
|
||
let loading = $state(true);
|
||
let prevBookingsJson = $state('');
|
||
let prevBlockersJson = $state('');
|
||
let prevHoursJson = $state('');
|
||
let initialized = $state(false);
|
||
let showBookingModal = $state(false);
|
||
let selectedBookingId = $state<string | null>(null);
|
||
let weekStart = $state<Date | undefined>(undefined);
|
||
|
||
// -- Drag-scroll state --
|
||
let scrollContainer = $state<HTMLElement | null>(null);
|
||
let isDragging = $state(false);
|
||
let dragStartX = $state(0);
|
||
let dragStartY = $state(0);
|
||
let dragScrollLeft = $state(0);
|
||
let dragScrollTop = $state(0);
|
||
|
||
// -- Auth & Init --
|
||
$effect(() => {
|
||
if (!browser) return;
|
||
if (authStore.isLoading) {
|
||
pageState = 'loading';
|
||
return;
|
||
}
|
||
if (!authStore.isAuthenticated || authStore.currentUser?.role !== 'admin') {
|
||
pageState = 'unauthorized';
|
||
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||
goto(authStore.isAuthenticated ? '/' : '/login', { replaceState: true });
|
||
return;
|
||
}
|
||
pageState = 'authorized';
|
||
});
|
||
|
||
$effect(() => {
|
||
if (pageState === 'authorized' && !weekStart) {
|
||
const today = getLondonToday();
|
||
const dayOfWeek = today.getDay();
|
||
const diff = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
||
const monday = new SvelteDate(today);
|
||
monday.setDate(monday.getDate() - diff);
|
||
monday.setHours(0, 0, 0, 0);
|
||
weekStart = monday;
|
||
}
|
||
});
|
||
|
||
// -- Helpers --
|
||
function getWeekDays(start: Date): Date[] {
|
||
return Array.from({ length: 7 }, (_, i) => {
|
||
const d = new SvelteDate(start);
|
||
d.setDate(d.getDate() + i);
|
||
return d;
|
||
});
|
||
}
|
||
|
||
function formatDate(d: Date): string {
|
||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||
}
|
||
|
||
/** Return the current London date as a Date set to midnight in the local timezone.
|
||
* Uses Intl.DateTimeFormat with Europe/London to handle BST/GMT correctly. */
|
||
function getLondonToday(): Date {
|
||
const dateStr = new SvelteDate().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||
return new SvelteDate(dateStr + 'T00:00:00');
|
||
}
|
||
|
||
/** Return the current time-of-day in London as minutes since midnight, using
|
||
* Intl.DateTimeFormat with Europe/London so the current-time blue line is
|
||
* positioned correctly regardless of the browser's system timezone. */
|
||
function getLondonNowMinutes(): number {
|
||
const timeStr = new SvelteDate().toLocaleTimeString('en-GB', {
|
||
timeZone: 'Europe/London',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
hour12: false
|
||
});
|
||
const [h, m] = timeStr.split(':').map(Number);
|
||
return h * 60 + m;
|
||
}
|
||
|
||
/** Convert a UTC ISO timestamp to a London-date YYYY-MM-DD key.
|
||
* Uses Intl.DateTimeFormat with Europe/London timezone so that bookings
|
||
* at 23:30 UTC (00:30 BST next day) are grouped under the correct
|
||
* London date column rather than the UTC date. */
|
||
function getLondonDateKey(iso: string): string {
|
||
return new SvelteDate(iso).toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
||
}
|
||
|
||
function formatWeekLabel(start: Date): string {
|
||
const end = new SvelteDate(start);
|
||
end.setDate(end.getDate() + 6);
|
||
const opts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' };
|
||
const endOpts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric' };
|
||
return `${start.toLocaleDateString('en-US', opts)} – ${end.toLocaleDateString('en-US', endOpts)}`;
|
||
}
|
||
|
||
function formatHour(h: number): string {
|
||
const period = h >= 12 ? 'PM' : 'AM';
|
||
const hour = h % 12 || 12;
|
||
return `${hour} ${period}`;
|
||
}
|
||
|
||
function formatTimeShort(iso: string): string {
|
||
return new SvelteDate(iso).toLocaleTimeString('en-US', {
|
||
hour: 'numeric',
|
||
minute: '2-digit',
|
||
hour12: true
|
||
});
|
||
}
|
||
|
||
function timeToMinutes(time: string): number {
|
||
const [h, m] = time.split(':').map(Number);
|
||
return h * 60 + m;
|
||
}
|
||
|
||
// Calculates pixel offset from the top of the grid.
|
||
// Each hour row is SLOT_HEIGHT (64px content + 1px border-b),
|
||
// so fractional hours are multiplied by SLOT_HEIGHT.
|
||
function gridPxFromTime(time: string, minStart: number): number {
|
||
const fractionalHours = timeToMinutes(time) / 60 - minStart;
|
||
return fractionalHours * SLOT_HEIGHT;
|
||
}
|
||
|
||
function getServiceNames(b: ScheduleBooking): string {
|
||
return (b.services ?? [])
|
||
.map((s) => s.service_name)
|
||
.filter(Boolean)
|
||
.join(', ');
|
||
}
|
||
|
||
function bookingStyle(status: string): string {
|
||
switch (status) {
|
||
case 'completed':
|
||
return 'background:#d1fae5;color:#065f46;border-color:#6ee7b7;text-decoration:line-through';
|
||
case 'confirmed':
|
||
return 'background:#d1fae5;color:#065f46;border-color:#6ee7b7;';
|
||
case 'in_progress':
|
||
return 'background:#dbeafe;color:#1e40af;border-color:#93c5fd';
|
||
case 'pending':
|
||
return 'background:#fef9c3;color:#854d0e;border-color:#fde047';
|
||
case 'client_cancelled':
|
||
return 'background:#fecaca;color:#7f1d1d;border-color:#f87171;text-decoration:line-through';
|
||
case 'we_cancelled':
|
||
return 'background:#fecaca;color:#7f1d1d;border-color:#f87171;text-decoration:line-through';
|
||
case 'no_show':
|
||
return 'background:#e5e7eb;color:#1f2937;border-color:#9ca3af;text-decoration:line-through';
|
||
default:
|
||
return 'background:#f3f4f6;color:#1f2937;border-color:#d1d5db';
|
||
}
|
||
}
|
||
|
||
function dotColor(status: string): string {
|
||
switch (status) {
|
||
case 'completed':
|
||
case 'confirmed':
|
||
return '#10b981';
|
||
case 'in_progress':
|
||
return '#3b82f6';
|
||
case 'pending':
|
||
return '#eab308';
|
||
case 'client_cancelled':
|
||
case 'we_cancelled':
|
||
return '#dc2626';
|
||
default:
|
||
return '#6b7280';
|
||
}
|
||
}
|
||
|
||
function bookingsOverlap(a: ScheduleBooking, b: ScheduleBooking): boolean {
|
||
const aStart = new SvelteDate(a.start_time).getTime();
|
||
const aEnd = aStart + a.duration_minutes * 60000;
|
||
const bStart = new SvelteDate(b.start_time).getTime();
|
||
const bEnd = bStart + b.duration_minutes * 60000;
|
||
return aStart < bEnd && bStart < aEnd;
|
||
}
|
||
|
||
// -- Data Fetching --
|
||
async function fetchWeekData() {
|
||
if (!weekStart) return;
|
||
if (!initialized) loading = true;
|
||
try {
|
||
const startStr = formatDate(weekStart);
|
||
const end = new SvelteDate(weekStart);
|
||
end.setDate(end.getDate() + 6);
|
||
const endStr = formatDate(end);
|
||
|
||
const [bookingsRes, whRes, blockersRes] = await Promise.all([
|
||
fetch(`/api/admin/bookings?start_date=${startStr}&end_date=${endStr}&per_page=500`, {
|
||
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||
}),
|
||
fetch(`/api/scheduling/working-hours?start=${startStr}&end=${endStr}`, {
|
||
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||
}),
|
||
fetch(`/api/admin/time-blockers?start=${startStr}&end=${endStr}`, {
|
||
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||
})
|
||
]);
|
||
|
||
if (bookingsRes.ok) {
|
||
const data = await bookingsRes.json();
|
||
const newBookings = data.bookings || [];
|
||
const newJson = JSON.stringify(newBookings);
|
||
if (newJson !== prevBookingsJson) {
|
||
bookings = newBookings;
|
||
prevBookingsJson = newJson;
|
||
}
|
||
}
|
||
|
||
if (blockersRes.ok) {
|
||
const data: TimeBlocker[] = await blockersRes.json();
|
||
const newBlockers = (data || []).filter((b) => !b.description?.startsWith('RESERVATION:'));
|
||
const newJson = JSON.stringify(newBlockers);
|
||
if (newJson !== prevBlockersJson) {
|
||
blockers = newBlockers;
|
||
prevBlockersJson = newJson;
|
||
}
|
||
}
|
||
|
||
if (whRes.ok) {
|
||
const data: WorkingDay[] = await whRes.json();
|
||
const newJson = JSON.stringify(data);
|
||
if (newJson !== prevHoursJson) {
|
||
workingHours = data;
|
||
prevHoursJson = newJson;
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('Error fetching schedule data:', err);
|
||
} finally {
|
||
if (!initialized) {
|
||
loading = false;
|
||
initialized = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
$effect(() => {
|
||
if (weekStart) fetchWeekData();
|
||
});
|
||
|
||
$effect(() => {
|
||
if (pageState !== 'authorized') return;
|
||
const intervalId = setInterval(fetchWeekData, 600_000);
|
||
const handleRefresh = () => fetchWeekData();
|
||
window.addEventListener('bookingApproved', handleRefresh);
|
||
return () => {
|
||
clearInterval(intervalId);
|
||
window.removeEventListener('bookingApproved', handleRefresh);
|
||
};
|
||
});
|
||
|
||
// -- Interaction Handlers --
|
||
function onMouseDown(e: MouseEvent) {
|
||
if (!scrollContainer || e.button !== 0) return;
|
||
isDragging = true;
|
||
dragStartX = e.pageX;
|
||
dragStartY = e.pageY;
|
||
dragScrollLeft = scrollContainer.scrollLeft;
|
||
dragScrollTop = scrollContainer.scrollTop;
|
||
}
|
||
|
||
function onMouseMove(e: MouseEvent) {
|
||
if (!isDragging || !scrollContainer) return;
|
||
e.preventDefault();
|
||
scrollContainer.scrollLeft = dragScrollLeft - (e.pageX - dragStartX);
|
||
scrollContainer.scrollTop = dragScrollTop - (e.pageY - dragStartY);
|
||
}
|
||
|
||
function onMouseUp() {
|
||
isDragging = false;
|
||
}
|
||
|
||
function navigateWeek(delta: number) {
|
||
if (!weekStart) return;
|
||
const d = new SvelteDate(weekStart);
|
||
d.setDate(d.getDate() + delta * 7);
|
||
weekStart = d;
|
||
}
|
||
|
||
function goToday() {
|
||
const today = getLondonToday();
|
||
const dayOfWeek = today.getDay();
|
||
const diff = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
||
const monday = new SvelteDate(today);
|
||
monday.setDate(monday.getDate() - diff);
|
||
monday.setHours(0, 0, 0, 0);
|
||
weekStart = monday;
|
||
}
|
||
|
||
function openBooking(id: string) {
|
||
selectedBookingId = id;
|
||
showBookingModal = true;
|
||
}
|
||
|
||
// -- Layout Constants --
|
||
const HOUR_HEIGHT = 64;
|
||
const SLOT_HEIGHT = HOUR_HEIGHT + 1; // each hour row has 1px border-b
|
||
const TIME_LABEL_WIDTH = 56;
|
||
const HEADER_HEIGHT = 48;
|
||
const dayHeaders = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||
|
||
const today = getLondonToday();
|
||
const todayStr = formatDate(today);
|
||
|
||
// -- Derived State --
|
||
|
||
// Group bookings by London-date key for O(1) lookup in the loop.
|
||
// Using London date (not UTC date) ensures that bookings crossing midnight
|
||
// during BST (e.g. 23:30 UTC → 00:30 BST next day) appear in the correct column.
|
||
const bookingsByDate = $derived(
|
||
bookings.reduce((acc, b) => {
|
||
const dateKey = getLondonDateKey(b.start_time);
|
||
if (!acc.has(dateKey)) acc.set(dateKey, []);
|
||
acc.get(dateKey)!.push(b);
|
||
return acc;
|
||
}, new Map<string, ScheduleBooking[]>())
|
||
);
|
||
|
||
const blockersByDate = $derived(
|
||
blockers.reduce((acc, b) => {
|
||
const dateKey = getLondonDateKey(b.start_time);
|
||
if (!acc.has(dateKey)) acc.set(dateKey, []);
|
||
acc.get(dateKey)!.push(b);
|
||
return acc;
|
||
}, new Map<string, TimeBlocker[]>())
|
||
);
|
||
|
||
const timeRange = $derived.by(() => {
|
||
if (!weekStart)
|
||
return { minStart: 8, maxEnd: 18, hours: Array.from({ length: 11 }, (_, i) => i + 8) };
|
||
|
||
// Find earliest start and latest end across ALL data (hours, bookings, blockers)
|
||
let earliest = 24,
|
||
latest = 0;
|
||
|
||
for (const day of getWeekDays(weekStart)) {
|
||
const dateStr = formatDate(day);
|
||
const wh = workingHours.find((w) => w.date === dateStr);
|
||
if (wh?.isOpen) {
|
||
const [sh, sm] = wh.startTime.split(':').map(Number);
|
||
const sf = sh + (sm || 0) / 60;
|
||
if (sf < earliest) earliest = sf;
|
||
const [eh, em] = wh.endTime.split(':').map(Number);
|
||
const ef = eh + (em || 0) / 60;
|
||
if (ef > latest) latest = ef;
|
||
}
|
||
for (const b of bookingsByDate.get(dateStr) || []) {
|
||
const d = new SvelteDate(b.start_time);
|
||
const sf = d.getHours() + d.getMinutes() / 60;
|
||
if (sf < earliest) earliest = sf;
|
||
const ef = sf + b.duration_minutes / 60;
|
||
if (ef > latest) latest = ef;
|
||
}
|
||
for (const b of blockersByDate.get(dateStr) || []) {
|
||
const d = new SvelteDate(b.start_time);
|
||
const sf = d.getHours() + d.getMinutes() / 60;
|
||
if (sf < earliest) earliest = sf;
|
||
const ef = sf + b.duration_minutes / 60;
|
||
if (ef > latest) latest = ef;
|
||
}
|
||
}
|
||
|
||
// No data: fall back to 8-18
|
||
if (earliest >= 24 || latest <= 0)
|
||
return { minStart: 8, maxEnd: 18, hours: [8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18] };
|
||
|
||
// Start: 30min buffer before earliest (to show the pre-booking gap)
|
||
// End: floor of latest (the row that CONTAINS the latest item, not the next row)
|
||
let minStart = Math.floor(earliest - 0.5);
|
||
let maxEnd = Math.floor(latest);
|
||
|
||
minStart = Math.max(0, minStart);
|
||
maxEnd = Math.min(24, maxEnd);
|
||
|
||
const hours: number[] = [];
|
||
for (let h = minStart; h <= maxEnd; h++) hours.push(h);
|
||
return { minStart, maxEnd, hours };
|
||
});
|
||
|
||
const isCurrentWeek = $derived(
|
||
weekStart !== undefined &&
|
||
(() => {
|
||
const end = new SvelteDate(weekStart);
|
||
end.setDate(end.getDate() + 6);
|
||
end.setHours(23, 59, 59, 999);
|
||
return today >= weekStart && today <= end;
|
||
})()
|
||
);
|
||
|
||
let nowMinutes = $state(getLondonNowMinutes());
|
||
$effect(() => {
|
||
if (pageState !== 'authorized') return;
|
||
const tickId = setInterval(() => {
|
||
nowMinutes = getLondonNowMinutes();
|
||
}, 30_000);
|
||
return () => clearInterval(tickId);
|
||
});
|
||
</script>
|
||
|
||
<svelte:head>
|
||
<script>
|
||
(function () {
|
||
try {
|
||
var token = localStorage.getItem('authToken');
|
||
if (!token) {
|
||
window.location.replace('/login');
|
||
return;
|
||
}
|
||
var payload = JSON.parse(atob(token.split('.')[1]));
|
||
if (payload.exp * 1000 <= Date.now()) {
|
||
window.location.replace('/login');
|
||
return;
|
||
}
|
||
if (payload.role !== 'admin') {
|
||
window.location.replace('/');
|
||
}
|
||
} catch (e) {
|
||
window.location.replace('/login');
|
||
}
|
||
})();
|
||
</script>
|
||
</svelte:head>
|
||
|
||
{#if pageState === 'loading'}
|
||
<div class="mx-auto max-w-7xl space-y-6 p-4 md:p-6">
|
||
<Skeleton class="h-8 w-64" />
|
||
<Skeleton class="h-[calc(100vh-12rem)] w-full" />
|
||
</div>
|
||
{:else if pageState === 'authorized' && weekStart}
|
||
<div class="mx-auto flex h-[calc(100vh-4rem)] max-w-7xl flex-col overflow-hidden p-4 md:p-6">
|
||
<!-- NAVIGATION BAR -->
|
||
<div class="mb-4 flex shrink-0 flex-wrap items-center justify-between gap-3">
|
||
<h1 class="font-['Playfair_Display'] text-3xl font-bold">Schedule</h1>
|
||
<div class="flex items-center gap-2">
|
||
<Button variant="outline" size="sm" onclick={() => navigateWeek(-1)}>
|
||
<svg
|
||
class="h-4 w-4"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
>
|
||
<polyline points="15 18 9 12 15 6" />
|
||
</svg>
|
||
<span class="hidden sm:inline">Prev</span>
|
||
</Button>
|
||
<Button variant="outline" size="sm" onclick={goToday}>Today</Button>
|
||
<Button variant="outline" size="sm" onclick={() => navigateWeek(1)}>
|
||
<span class="hidden sm:inline">Next</span>
|
||
<svg
|
||
class="h-4 w-4"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
>
|
||
<polyline points="9 18 15 12 9 6" />
|
||
</svg>
|
||
</Button>
|
||
<span class="ml-2 text-sm font-medium text-gray-600">{formatWeekLabel(weekStart)}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- GRID CONTAINER -->
|
||
{#if loading}
|
||
<div class="flex-1 overflow-hidden rounded-lg border bg-white">
|
||
<Skeleton class="h-full w-full" />
|
||
</div>
|
||
{:else}
|
||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||
<div
|
||
bind:this={scrollContainer}
|
||
role="application"
|
||
aria-label="Week schedule"
|
||
class="schedule-wrapper flex-1 overflow-auto rounded-lg border bg-white select-none"
|
||
class:cursor-grab={!isDragging}
|
||
class:cursor-grabbing={isDragging}
|
||
onmousedown={onMouseDown}
|
||
onmousemove={onMouseMove}
|
||
onmouseup={onMouseUp}
|
||
onmouseleave={onMouseUp}
|
||
>
|
||
<div class="relative isolate flex min-w-fit flex-col">
|
||
<!-- STICKY HEADER ROW -->
|
||
<div
|
||
class="sticky top-0 z-30 flex border-b bg-gray-50"
|
||
style="height: {HEADER_HEIGHT}px;"
|
||
>
|
||
<!-- Time Column Spacer -->
|
||
<div class="shrink-0 border-r bg-gray-50" style="width: {TIME_LABEL_WIDTH}px;"></div>
|
||
|
||
{#each getWeekDays(weekStart) as day, dayIdx (formatDate(day))}
|
||
{@const isToday = formatDate(day) === todayStr}
|
||
<div
|
||
class="flex min-w-35 flex-1 flex-col items-center justify-center border-l text-center
|
||
{isToday ? 'bg-blue-200' : ''}"
|
||
>
|
||
<div class="text-xs font-medium text-gray-500">{dayHeaders[dayIdx]}</div>
|
||
<div class="text-sm font-bold {isToday ? 'text-blue-600' : ''}">
|
||
{day.getDate()}
|
||
</div>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
|
||
<!-- BODY: TIME COLUMN + GRID -->
|
||
<div class="relative flex">
|
||
<!-- 1. SOLID TIME PANE (Left Column) -->
|
||
<div
|
||
class="sticky left-0 z-20 shrink-0 border-r border-gray-100 bg-white"
|
||
style="width: {TIME_LABEL_WIDTH}px;"
|
||
>
|
||
{#each timeRange.hours as h (h)}
|
||
<div
|
||
class="flex items-start justify-end border-b border-gray-100 pt-0.5 pr-1.5 text-right text-xs text-gray-400"
|
||
style="height: {SLOT_HEIGHT}px;"
|
||
>
|
||
{formatHour(h)}
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
|
||
<!-- 2. GRID CONTAINER (Day Columns) -->
|
||
<div class="relative flex flex-1 flex-col">
|
||
<!-- Grid Rows & Cells -->
|
||
<div class="relative">
|
||
{#each timeRange.hours as h (h)}
|
||
<div class="flex border-b border-gray-100">
|
||
{#each getWeekDays(weekStart) as day (formatDate(day))}
|
||
{@const dateStr = formatDate(day)}
|
||
{@const isToday = dateStr === todayStr}
|
||
{@const dayBookings = bookingsByDate.get(dateStr) || []}
|
||
{@const dayBlockers = blockersByDate.get(dateStr) || []}
|
||
|
||
<div
|
||
class="relative min-w-35 flex-1 border-r border-gray-100 {isToday
|
||
? 'bg-blue-50'
|
||
: ''}"
|
||
style="height: {HOUR_HEIGHT}px;"
|
||
>
|
||
<!-- Half-hour dashed line -->
|
||
<div class="half-hour-line absolute inset-x-0 h-px" style="top: 50%;"></div>
|
||
|
||
{#each dayBlockers.filter((b) => new SvelteDate(b.start_time).getHours() === h) as b (b.id)}
|
||
{@const startMinutes = new SvelteDate(b.start_time).getMinutes()}
|
||
{@const topOffset = (startMinutes / 60) * HOUR_HEIGHT}
|
||
{@const heightPx = Math.max((b.duration_minutes / 60) * HOUR_HEIGHT, 20)}
|
||
|
||
<div
|
||
class="absolute inset-x-0.5 z-[9] overflow-hidden rounded border border-amber-200 bg-amber-50/90 px-1.5 py-0.5 text-left text-xs text-amber-900 shadow-sm backdrop-blur-sm"
|
||
style="top: {topOffset}px; height: {heightPx}px;"
|
||
>
|
||
<div class="flex items-center gap-1">
|
||
<div class="h-2 w-2 shrink-0 rounded-full bg-amber-500"></div>
|
||
<span class="truncate font-medium">{b.description || 'Blocker'}</span>
|
||
</div>
|
||
{#if heightPx > 36}
|
||
<div class="mt-1 truncate text-[10px] opacity-75">
|
||
{formatTimeShort(b.start_time)} · {formatDuration(
|
||
b.duration_minutes
|
||
)}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/each}
|
||
|
||
<!-- BOOKING BLOCKS -->
|
||
{#each dayBookings.filter((b) => new SvelteDate(b.start_time).getHours() === h) as b (b.id)}
|
||
{@const startMinutes = new SvelteDate(b.start_time).getMinutes()}
|
||
{@const topOffset = (startMinutes / 60) * HOUR_HEIGHT}
|
||
{@const heightPx = Math.max((b.duration_minutes / 60) * HOUR_HEIGHT, 20)}
|
||
{@const isCancelled =
|
||
b.status === 'client_cancelled' ||
|
||
b.status === 'we_cancelled' ||
|
||
b.status === 'no_show'}
|
||
{@const hasOverlap =
|
||
isCancelled &&
|
||
dayBookings.some(
|
||
(other) =>
|
||
other.id !== b.id &&
|
||
!(
|
||
other.status === 'client_cancelled' ||
|
||
other.status === 'we_cancelled' ||
|
||
other.status === 'no_show'
|
||
) &&
|
||
bookingsOverlap(b, other)
|
||
)}
|
||
|
||
{#if !hasOverlap}
|
||
<button
|
||
type="button"
|
||
aria-label="View booking for {formatUserName(
|
||
b.user?.full_name || 'Guest',
|
||
b.user?.previous_first_name,
|
||
b.user?.previous_last_name
|
||
)}"
|
||
class="absolute inset-x-0.5 z-10 cursor-pointer overflow-hidden rounded border px-1.5 py-0.5 text-left text-xs transition-shadow hover:shadow-md focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-blue-500"
|
||
style="top: {topOffset}px; height: {heightPx}px; {bookingStyle(
|
||
b.status
|
||
)}"
|
||
onclick={(e) => {
|
||
e.stopPropagation();
|
||
openBooking(b.id);
|
||
}}
|
||
>
|
||
<div class="flex items-center gap-1">
|
||
<div
|
||
class="h-2 w-2 shrink-0 rounded-full"
|
||
style="background:{dotColor(b.status)}"
|
||
></div>
|
||
<span class="truncate font-medium"
|
||
>{formatUserName(
|
||
b.user?.full_name || 'Guest',
|
||
b.user?.previous_first_name,
|
||
b.user?.previous_last_name
|
||
)}</span
|
||
>
|
||
</div>
|
||
{#if heightPx > 36}
|
||
<div class="line-clamp-3 text-[10px] opacity-75">
|
||
{getServiceNames(b)}
|
||
</div>
|
||
{/if}
|
||
{#if heightPx > 52}
|
||
<div class="truncate text-[10px] opacity-75">
|
||
{formatTimeShort(b.start_time)} · {formatDuration(
|
||
b.duration_minutes
|
||
)}
|
||
</div>
|
||
{/if}
|
||
</button>
|
||
{/if}
|
||
{/each}
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
|
||
<!-- 3. OVERLAY LAYER (Lines & Closed Stripes) -->
|
||
<!-- z-5: below bookings (z-10) since grid no longer creates a stacking context -->
|
||
<div class="pointer-events-none absolute inset-0 z-5">
|
||
{#each getWeekDays(weekStart) as day, dayIdx (formatDate(day))}
|
||
{@const dateStr = formatDate(day)}
|
||
{@const wh = workingHours.find((w) => w.date === dateStr)}
|
||
{@const colLeft = `calc(${dayIdx} * (100% / 7))`}
|
||
{@const colWidth = `calc(100% / 7)`}
|
||
|
||
{#if !wh?.isOpen}
|
||
<!-- Closed Day Overlay -->
|
||
<div
|
||
class="stripe-bg absolute flex items-center justify-center"
|
||
style="left:{colLeft}; top:0; width:{colWidth}; height:100%;"
|
||
>
|
||
<span
|
||
class="pointer-events-auto rounded bg-black/40 px-3 py-1 text-sm font-semibold text-white"
|
||
>Closed</span
|
||
>
|
||
</div>
|
||
{:else}
|
||
{@const openPx = gridPxFromTime(wh.startTime, timeRange.minStart)}
|
||
{@const closePx = gridPxFromTime(wh.endTime, timeRange.minStart)}
|
||
|
||
<!-- Pre-opening gray stripe -->
|
||
{#if openPx > 0}
|
||
<div
|
||
class="stripe-bg absolute"
|
||
style="left:{colLeft}; top:0; width:{colWidth}; height:{openPx}px;"
|
||
></div>
|
||
{/if}
|
||
|
||
<!-- Post-closing gray stripe -->
|
||
{#if closePx < timeRange.hours.length * SLOT_HEIGHT}
|
||
<div
|
||
class="stripe-bg absolute"
|
||
style="left:{colLeft}; top:{closePx}px; width:{colWidth}; bottom:0;"
|
||
></div>
|
||
{/if}
|
||
|
||
<!-- Opening/Closing Red Lines -->
|
||
<div
|
||
class="absolute border-t-2 border-red-500"
|
||
style="left:{colLeft}; top:{openPx}px; width:{colWidth};"
|
||
></div>
|
||
<div
|
||
class="absolute border-t-2 border-red-500"
|
||
style="left:{colLeft}; top:{closePx}px; width:{colWidth};"
|
||
></div>
|
||
{/if}
|
||
{/each}
|
||
|
||
<!-- CURRENT TIME INDICATOR -->
|
||
{#if isCurrentWeek}
|
||
{@const nowTime = `${String(Math.floor(nowMinutes / 60)).padStart(2, '0')}:${String(nowMinutes % 60).padStart(2, '0')}`}
|
||
{@const nowPx = gridPxFromTime(nowTime, timeRange.minStart)}
|
||
{@const todayIdx = getWeekDays(weekStart!).findIndex(
|
||
(d) => formatDate(d) === todayStr
|
||
)}
|
||
{@const colLeft = `calc(${todayIdx} * (100% / 7))`}
|
||
{@const colWidth = `calc(100% / 7)`}
|
||
|
||
{#if nowPx >= 0 && nowPx <= timeRange.hours.length * SLOT_HEIGHT && todayIdx !== -1}
|
||
<!-- Dot -->
|
||
<div
|
||
class="absolute h-3 w-3 rounded-full bg-blue-500"
|
||
style="left: calc({colLeft} - 6px); top: {nowPx - 6}px;"
|
||
></div>
|
||
|
||
<!-- Line -->
|
||
<!-- FIXED: Restricted width to current column using colLeft/colWidth -->
|
||
<div
|
||
class="time-indicator-line absolute h-0.5"
|
||
style="left: {colLeft}; width: {colWidth}; top: {nowPx}px;"
|
||
></div>
|
||
{/if}
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<BookingModal bind:open={showBookingModal} bookingId={selectedBookingId ?? ''} />
|
||
{/if}
|
||
|
||
<style>
|
||
/* Diagonal stripe pattern for closed hours/days */
|
||
.stripe-bg {
|
||
background: repeating-linear-gradient(
|
||
45deg,
|
||
rgba(156, 163, 175, 0.4),
|
||
rgba(156, 163, 175, 0.4) 10px,
|
||
rgba(209, 213, 219, 0.15) 10px,
|
||
rgba(209, 213, 219, 0.15) 20px
|
||
);
|
||
}
|
||
|
||
/* Half-hour dashed line */
|
||
.half-hour-line {
|
||
background: repeating-linear-gradient(
|
||
to right,
|
||
#e5e7eb 0,
|
||
#e5e7eb 4px,
|
||
transparent 4px,
|
||
transparent 16px
|
||
);
|
||
}
|
||
|
||
/* Current time indicator line */
|
||
.time-indicator-line {
|
||
background: repeating-linear-gradient(
|
||
to right,
|
||
#3b82f6 0,
|
||
#3b82f6 8px,
|
||
transparent 8px,
|
||
transparent 16px
|
||
);
|
||
}
|
||
</style>
|