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:
2026-05-29 16:08:25 +01:00
co-authored by Sisyphus
parent a44972fbc6
commit 18d8822c47
3 changed files with 650 additions and 2 deletions
@@ -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>