refactor: admin user modal, image upload, and new schedule page
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -825,7 +825,7 @@
|
||||
{tags.length === 1 ? 'tag' : 'tags'}.
|
||||
{#if tags.length > 0}
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
{#each tags as tag}
|
||||
{#each tags as tag (tag)}
|
||||
<span
|
||||
class="inline-block rounded-full bg-gray-200 px-2.5 py-1 text-xs text-gray-800"
|
||||
>
|
||||
|
||||
@@ -287,7 +287,7 @@
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
const today = new Date();
|
||||
const today = new SvelteDate();
|
||||
let age = today.getFullYear() - dob.getFullYear();
|
||||
const m = today.getMonth() - dob.getMonth();
|
||||
if (m < 0 || (m === 0 && today.getDate() < dob.getDate())) age--;
|
||||
|
||||
@@ -0,0 +1,648 @@
|
||||
<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 { 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 };
|
||||
services: BookingService[];
|
||||
};
|
||||
type WorkingDay = {
|
||||
date: string;
|
||||
isOpen: boolean;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
};
|
||||
|
||||
// -- State --
|
||||
let pageState = $state<'loading' | 'authorized' | 'unauthorized'>('loading');
|
||||
let bookings = $state<ScheduleBooking[]>([]);
|
||||
let workingHours = $state<WorkingDay[]>([]);
|
||||
let loading = $state(true);
|
||||
let prevBookingsJson = $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';
|
||||
goto(authStore.isAuthenticated ? '/' : '/login', { replaceState: true });
|
||||
return;
|
||||
}
|
||||
pageState = 'authorized';
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (pageState === 'authorized' && !weekStart) {
|
||||
const today = new SvelteDate();
|
||||
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')}`;
|
||||
}
|
||||
|
||||
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.
|
||||
// Adds 1px per hour to account for border heights.
|
||||
function gridPxFromTime(time: string, minStart: number): number {
|
||||
const fractionalHours = timeToMinutes(time) / 60 - minStart;
|
||||
return fractionalHours * HOUR_HEIGHT + Math.floor(fractionalHours);
|
||||
}
|
||||
|
||||
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] = 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}` }
|
||||
})
|
||||
]);
|
||||
|
||||
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 (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 Date(weekStart);
|
||||
d.setDate(d.getDate() + delta * 7);
|
||||
weekStart = d;
|
||||
}
|
||||
|
||||
function goToday() {
|
||||
const today = new Date();
|
||||
const dayOfWeek = today.getDay();
|
||||
const diff = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
||||
const monday = new Date(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 TIME_LABEL_WIDTH = 56;
|
||||
const HEADER_HEIGHT = 48;
|
||||
const dayHeaders = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
|
||||
const today = new SvelteDate();
|
||||
const todayStr = formatDate(today);
|
||||
|
||||
// -- Derived State --
|
||||
|
||||
// Group bookings by date string for O(1) lookup in the loop
|
||||
const bookingsByDate = $derived(
|
||||
bookings.reduce((acc, b) => {
|
||||
const dateKey = b.start_time.slice(0, 10); // YYYY-MM-DD
|
||||
if (!acc.has(dateKey)) acc.set(dateKey, []);
|
||||
acc.get(dateKey)!.push(b);
|
||||
return acc;
|
||||
}, new Map<string, ScheduleBooking[]>())
|
||||
);
|
||||
|
||||
const timeRange = $derived.by(() => {
|
||||
if (!weekStart)
|
||||
return { minStart: 8, maxEnd: 18, hours: [8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18] };
|
||||
|
||||
let minStart = 8,
|
||||
maxEnd = 18;
|
||||
for (const day of getWeekDays(weekStart)) {
|
||||
const wh = workingHours.find((w) => w.date === formatDate(day));
|
||||
if (wh?.isOpen) {
|
||||
const [sh] = wh.startTime.split(':').map(Number);
|
||||
const [eh] = wh.endTime.split(':').map(Number);
|
||||
if (sh < minStart) minStart = sh;
|
||||
if (eh > maxEnd) maxEnd = eh;
|
||||
}
|
||||
}
|
||||
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(today.getHours() * 60 + today.getMinutes());
|
||||
$effect(() => {
|
||||
if (pageState !== 'authorized') return;
|
||||
const tickId = setInterval(() => {
|
||||
const n = new Date();
|
||||
nowMinutes = n.getHours() * 60 + n.getMinutes();
|
||||
}, 30_000);
|
||||
return () => clearInterval(tickId);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#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="text-xl font-bold md:text-2xl">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}
|
||||
<div
|
||||
bind:this={scrollContainer}
|
||||
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: {HOUR_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 z-0">
|
||||
{#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) || []}
|
||||
|
||||
<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>
|
||||
|
||||
<!-- 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 {b.user?.full_name || 'Guest'}"
|
||||
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"
|
||||
>{b.user?.full_name || 'Guest'}</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 places it above grid backgrounds (z-0) but below bookings (z-10) -->
|
||||
<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 * HOUR_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 * HOUR_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>
|
||||
Reference in New Issue
Block a user