From f69750da821873cd3c2acb3c6ce80ebdda6ec27e Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Thu, 28 May 2026 16:31:49 +0100 Subject: [PATCH] feat: today calendar with time blockers, stats, and pending approvals improvements Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../today/CurrentAppointment.svelte | 8 +- .../components/today/PendingApprovals.svelte | 31 +- .../lib/components/today/TodayCalendar.svelte | 687 ++++++++++++++++-- .../lib/components/today/TodayStats.svelte | 237 ++++++ frontend/src/routes/today/+page.svelte | 36 +- 5 files changed, 924 insertions(+), 75 deletions(-) create mode 100644 frontend/src/lib/components/today/TodayStats.svelte diff --git a/frontend/src/lib/components/today/CurrentAppointment.svelte b/frontend/src/lib/components/today/CurrentAppointment.svelte index 973524e..aa94ff3 100644 --- a/frontend/src/lib/components/today/CurrentAppointment.svelte +++ b/frontend/src/lib/components/today/CurrentAppointment.svelte @@ -186,7 +186,7 @@
- + {isInProgress ? 'Current Appointment' : 'Next Appointment'} {#if activeAppointment} @@ -245,7 +245,7 @@ {#if loading}
- +
@@ -284,12 +284,12 @@ {activeAppointment.user.full_name} {:else} {@const initials = activeAppointment.user?.full_name?.split(' ').map(n => n[0]).join('') || '?'}
{initials}
diff --git a/frontend/src/lib/components/today/PendingApprovals.svelte b/frontend/src/lib/components/today/PendingApprovals.svelte index a775c50..b8c87ff 100644 --- a/frontend/src/lib/components/today/PendingApprovals.svelte +++ b/frontend/src/lib/components/today/PendingApprovals.svelte @@ -11,9 +11,10 @@ interface Props { openBookingModal?: (bookingId: string) => void; + hasItems?: boolean; } - let { openBookingModal }: Props = $props(); + let { openBookingModal, hasItems = $bindable(false) }: Props = $props(); // Edit request types interface ServiceItem { @@ -91,6 +92,9 @@ let showEditRequestModal = $state(false); let selectedEditRequest = $state(null); + let _hasItems = $derived(pendingApprovals.length > 0 || pendingEditRequests.length > 0); + $effect(() => { hasItems = _hasItems; }); + // Helper function to format date nicely function formatDateTime(dateTimeString: string): string { const date = new SvelteDate(dateTimeString); @@ -160,8 +164,12 @@ return names.join(', ') || 'No services'; } + let prevApprovalsJson = $state(''); + let prevEditRequestsJson = $state(''); + let initialized = $state(false); + async function fetchPendingApprovals() { - loading = true; + if (!initialized) loading = true; try { const response = await fetch('/api/admin/today/pending-approvals', { method: 'GET', @@ -173,10 +181,15 @@ if (response.ok) { const data = await response.json(); - pendingApprovals = (data.approvals || []).sort( + const newApprovals = (data.approvals || []).sort( (a: PendingApproval, b: PendingApproval) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime() ); + const newJson = JSON.stringify(newApprovals); + if (newJson !== prevApprovalsJson) { + pendingApprovals = newApprovals; + prevApprovalsJson = newJson; + } } else { toast.error('Failed to load pending approvals'); } @@ -184,7 +197,10 @@ console.error('Error fetching pending approvals:', err); toast.error('Network error loading pending approvals'); } finally { - loading = false; + if (!initialized) { + loading = false; + initialized = true; + } } } @@ -200,10 +216,15 @@ if (response.ok) { const data = await response.json(); - pendingEditRequests = (data.edit_requests || []).sort( + const newEditRequests = (data.edit_requests || []).sort( (a: EditRequest, b: EditRequest) => new Date(a.requested_at).getTime() - new Date(b.requested_at).getTime() ); + const newJson = JSON.stringify(newEditRequests); + if (newJson !== prevEditRequestsJson) { + pendingEditRequests = newEditRequests; + prevEditRequestsJson = newJson; + } } } catch (err) { console.error('Error fetching edit requests:', err); diff --git a/frontend/src/lib/components/today/TodayCalendar.svelte b/frontend/src/lib/components/today/TodayCalendar.svelte index 7187c1c..3e38dda 100644 --- a/frontend/src/lib/components/today/TodayCalendar.svelte +++ b/frontend/src/lib/components/today/TodayCalendar.svelte @@ -6,6 +6,11 @@ import { Button } from '$lib/components/ui/button'; import { Badge } from '$lib/components/ui/badge'; import { Skeleton } from '$lib/components/ui/skeleton'; + 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 { calculateMiddleWindow, extractBookedSlots, findAllLunchGaps } from '$lib/lunchProtection'; interface Props { openBookingModal: (bookingId: string) => void; @@ -24,6 +29,38 @@ duration_minutes: number; }; + type TimeBlocker = { + id: string; + start_time: string; + duration_minutes: number; + description: string; + cron_expression: string | null; + created_at: string; + created_by: string | null; + }; + + type OverlappingBooking = { + id: string; + start_time: string; + duration_minutes: number; + status: string; + user: { id: string; full_name: string; email: string | null; phone: string | null } | null; + services: string[]; + }; + + type DayWorkingHours = { + date: string; + isOpen: boolean; + startTime: string; + endTime: string; + }; + + type DayAvailableHours = { + date: string; + isOpen: boolean; + slots: Array<{ startTime: string; endTime: string }>; + }; + function isPastAppointment(startTime: string, durationMinutes: number): boolean { const start = new Date(startTime); const end = new Date(start.getTime() + durationMinutes * 60_000); @@ -33,8 +70,209 @@ let appointments = $state([]); let loading = $state(true); + // ======== Blocker State ======== + let blockers = $state([]); + let workingHours = $state(null); + let whLoading = $state(true); + let availableHours = $state(null); + let showCreateModal = $state(false); + let showDeleteAlert = $state(false); + let blockerToDelete = $state(null); + let creating = $state(false); + let checkingOverlap = $state(false); + let newDescription = $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 overlappingBookings = $state([]); + let hasOverlap = $state(false); + + let startSelectValue = $derived(`${startHour}:${startMinute}:${startPeriod}`); + let endSelectValue = $derived(`${endHour}:${endMinute}:${endPeriod}`); + + const today = $derived.by(() => { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + }); + + 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; + if (h === 0) h = 12; + return { hour: String(h), minute: String(m).padStart(2, '0'), period }; + } + + 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 }; + } + + function formatDuration(minutes: number): string { + if (minutes < 60) return `${minutes}m`; + const h = Math.floor(minutes / 60); + const m = minutes % 60; + return m > 0 ? `${h}h ${m}m` : `${h}h`; + } + + function isAutoGenerated(blocker: TimeBlocker): boolean { + return blocker.description?.startsWith('RESERVATION:') ?? false; + } + + let availableStartOptions = $derived.by(() => { + const wh = workingHours; + 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); + options.push({ ...t, totalMin: m, label: `${t.hour}:${t.minute} ${t.period}` }); + } + return options; + }); + + let availableEndOptions = $derived.by(() => { + const wh = workingHours; + if (!wh || !wh.isOpen) return []; + 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(timeToMinutes(wh.startTime), currentStartMin + 15); m <= endMin; m += 15) { + const t = minutesTo12h(m); + options.push({ ...t, totalMin: m, label: `${t.hour}:${t.minute} ${t.period}` }); + } + return options; + }); + + let suggestedLunch = $derived.by(() => { + if (!workingHours || !availableHours || !workingHours.isOpen) return null; + const wh = workingHours; + const ah = availableHours; + const { windowStart, windowEnd } = calculateMiddleWindow(wh.startTime, wh.endTime); + const existingBookings = extractBookedSlots(wh.startTime, wh.endTime, ah.slots); + const gaps = findAllLunchGaps(windowStart, windowEnd, existingBookings); + if (gaps.length === 0) return null; + + const relevantBookings = existingBookings + .filter((b) => { + const s = timeToMinutes(b.startTime); + const e = timeToMinutes(b.endTime); + return s < windowEnd && e > windowStart; + }) + .map((b) => ({ + startTime: Math.max(timeToMinutes(b.startTime), windowStart), + endTime: Math.min(timeToMinutes(b.endTime), windowEnd) + })) + .sort((a, b) => a.startTime - b.startTime); + + const gapInfos: Array<{ start: number; end: number; duration: number }> = []; + let cursor = windowStart; + for (const b of relevantBookings) { + if (b.startTime > cursor) { + gapInfos.push({ start: cursor, end: b.startTime, duration: b.startTime - cursor }); + } + cursor = Math.max(cursor, b.endTime); + } + if (cursor < windowEnd) { + gapInfos.push({ start: cursor, end: windowEnd, duration: windowEnd - cursor }); + } + + gapInfos.sort((a, b) => b.duration - a.duration); + if (gapInfos.length === 0) return null; + const largestGap = gapInfos[0]; + const startTimeStr = `${minutesTo12h(largestGap.start).hour}:${minutesTo12h(largestGap.start).minute} ${minutesTo12h(largestGap.start).period}`; + const endTimeStr = `${minutesTo12h(largestGap.end).hour}:${minutesTo12h(largestGap.end).minute} ${minutesTo12h(largestGap.end).period}`; + return { startMin: largestGap.start, endMin: largestGap.end, duration: largestGap.duration, startLabel: startTimeStr, endLabel: endTimeStr }; + }); + + type AppointmentTimelineItem = { + id: string; + startMinutes: number; + endMinutes: number; + type: 'appointment'; + data: TodayAppointment; + }; + + type BlockerTimelineItem = { + id: string; + startMinutes: number; + endMinutes: number; + type: 'blocker'; + data: TimeBlocker; + }; + + type LunchTimelineItem = { + id: string; + startMinutes: number; + endMinutes: number; + type: 'lunch'; + data: { duration: number; startLabel: string; endLabel: string }; + }; + + type TimelineItem = AppointmentTimelineItem | BlockerTimelineItem | LunchTimelineItem; + + let timeline = $derived.by(() => { + const items: TimelineItem[] = []; + + for (const apt of appointments) { + const start = new Date(apt.start_time); + const startM = start.getHours() * 60 + start.getMinutes(); + items.push({ + id: `apt-${apt.id}`, + startMinutes: startM, + endMinutes: startM + apt.duration_minutes, + type: 'appointment', + data: apt + }); + } + + for (const b of blockers) { + const start = new Date(b.start_time); + const startM = start.getHours() * 60 + start.getMinutes(); + items.push({ + id: `blk-${b.id}`, + startMinutes: startM, + endMinutes: startM + b.duration_minutes, + type: 'blocker', + data: b + }); + } + + if (suggestedLunch) { + items.push({ + id: 'lunch', + startMinutes: suggestedLunch.startMin, + endMinutes: suggestedLunch.endMin, + type: 'lunch', + data: { duration: suggestedLunch.duration, startLabel: suggestedLunch.startLabel, endLabel: suggestedLunch.endLabel } + }); + } + + return items.sort((a, b) => a.startMinutes - b.startMinutes); + }); + + let prevAppointmentsJson = $state(''); + let apptsInitialized = $state(false); + async function fetchTodayAppointments() { - loading = true; + if (!apptsInitialized) loading = true; try { const response = await fetch('/api/admin/today/appointments', { method: 'GET', @@ -46,7 +284,12 @@ if (response.ok) { const data = await response.json(); - appointments = data.appointments || []; + const newAppointments = data.appointments || []; + const newJson = JSON.stringify(newAppointments); + if (newJson !== prevAppointmentsJson) { + appointments = newAppointments; + prevAppointmentsJson = newJson; + } } else { toast.error("Failed to load today's appointments"); } @@ -54,37 +297,175 @@ console.error("Error fetching today's appointments:", err); toast.error('Network error loading appointments'); } finally { - loading = false; + if (!apptsInitialized) { + loading = false; + apptsInitialized = true; + } } } - // šŸ” Refetch on mount, every minute, and on approval - $effect(() => { - // Initial fetch - fetchTodayAppointments(); + let prevBlockersJson = $state(''); + let blockersInitialized = $state(false); - // Timer: refetch every 60 seconds - const intervalId = setInterval(() => { - fetchTodayAppointments(); - }, 60_000); + async function fetchTodayBlockersData() { + if (!blockersInitialized) whLoading = true; + try { + const dateParam = today; + const [blockersRes, whRes, ahRes] = await Promise.all([ + fetch(`/api/admin/time-blockers?start=${encodeURIComponent(dateParam)}&end=${encodeURIComponent(dateParam)}`, { + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` } + }), + fetch(`/api/scheduling/working-hours?start=${encodeURIComponent(dateParam)}&end=${encodeURIComponent(dateParam)}`, { + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` } + }), + fetch(`/api/scheduling/available-hours?start=${encodeURIComponent(dateParam)}&end=${encodeURIComponent(dateParam)}`, { + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` } + }) + ]); - // Listener for approval events - function handleApproval() { - fetchTodayAppointments(); + if (blockersRes.ok) { + const data: TimeBlocker[] = await blockersRes.json(); + const newBlockers = (data || []).filter((b) => !isAutoGenerated(b)); + const newJson = JSON.stringify(newBlockers); + if (newJson !== prevBlockersJson) { + blockers = newBlockers; + prevBlockersJson = newJson; + } + } + + if (whRes.ok) { + const data: DayWorkingHours[] = await whRes.json(); + const newWh = (data || []).find((d) => d.date === dateParam) ?? null; + if (JSON.stringify(newWh) !== JSON.stringify(workingHours)) { + workingHours = newWh; + } + } + + if (ahRes.ok) { + const data: DayAvailableHours[] = await ahRes.json(); + const newAh = (data || []).find((d) => d.date === dateParam) ?? null; + if (JSON.stringify(newAh) !== JSON.stringify(availableHours)) { + availableHours = newAh; + } + } + } catch (err) { + console.error('Error fetching today data:', err); + } finally { + if (!blockersInitialized) { + whLoading = false; + blockersInitialized = true; + } } - window.addEventListener('bookingApproved', handleApproval); + } - // Cleanup - return () => { - clearInterval(intervalId); - window.removeEventListener('bookingApproved', handleApproval); - }; - }); + async function checkOverlappingBookings() { + const startIso = buildDateTime(today, startHour, startMinute, startPeriod); + const endIso = buildDateTime(today, endHour, endMinute, endPeriod); + if (!startIso || !endIso) { overlappingBookings = []; hasOverlap = false; return; } + const blockerStart = new Date(startIso); + const blockerEnd = new Date(endIso); + if (blockerEnd.getTime() <= blockerStart.getTime()) { overlappingBookings = []; hasOverlap = false; return; } + checkingOverlap = true; + try { + const response = await fetch(`/api/admin/bookings/by-date-range?start=${encodeURIComponent(today)}&end=${encodeURIComponent(today)}`, { + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` } + }); + if (response.ok) { + const data = await response.json(); + const allBookings: OverlappingBooking[] = data.bookings || []; + const filtered = allBookings.filter((b) => { + const bStart = new Date(b.start_time); + const bEnd = new Date(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; } + } - // Fetch on mount - $effect(() => { - fetchTodayAppointments(); - }); + function buildDateTime(date: string, hour: string, minute: string, period: 'AM' | 'PM'): string | null { + if (!date) return null; + const t24 = to24h(hour, minute, period); + return `${date}T${t24}:00`; + } + + let canCreate = $derived.by(() => !hasOverlap && !checkingOverlap); + + async function createBlocker() { + if (!canCreate) return; + const startIso = buildDateTime(today, startHour, startMinute, startPeriod); + const endIso = buildDateTime(today, 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 { + const response = await fetch('/api/admin/time-blockers', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` }, + body: JSON.stringify({ start_time: start.toISOString(), duration_minutes: durationMinutes, description: newDescription.trim() || 'break' }) + }); + if (response.ok) { + toast.success('Time blocker created!', { id: loadingToast }); + showCreateModal = false; + resetCreateForm(); + await fetchTodayBlockersData(); + } else { + const text = await response.text(); + toast.error('Failed to create: ' + text, { id: loadingToast }); + } + } catch (err) { + console.error('Error creating time blocker:', err); + toast.error('Network error creating time blocker', { id: loadingToast }); + } finally { creating = false; } + } + + async function confirmDeleteBlocker() { + if (!blockerToDelete) return; + const loadingToast = toast.loading('Deleting time blocker...'); + try { + const response = await fetch(`/api/admin/time-blockers/${blockerToDelete.id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${authStore.currentToken}` } + }); + if (response.ok || response.status === 204) { + toast.success('Time blocker deleted', { id: loadingToast }); + showDeleteAlert = false; + blockerToDelete = null; + await fetchTodayBlockersData(); + } else { + const text = await response.text(); + toast.error('Failed to delete: ' + text, { id: loadingToast }); + } + } catch (err) { + console.error('Error deleting time blocker:', err); + toast.error('Network error deleting time blocker', { id: loadingToast }); + } + } + + function resetCreateForm() { + newDescription = ''; + startHour = '9'; startMinute = '00'; startPeriod = 'AM'; + endHour = '10'; endMinute = '00'; endPeriod = 'AM'; + overlappingBookings = []; hasOverlap = false; + } + + function openCreateModal() { + resetCreateForm(); + if (workingHours && workingHours.isOpen) { + const startOpt = minutesTo12h(timeToMinutes(workingHours.startTime)); + const endOpt = minutesTo12h(timeToMinutes(workingHours.startTime) + 60); + startHour = startOpt.hour; startMinute = startOpt.minute; startPeriod = startOpt.period; + endHour = endOpt.hour; endMinute = endOpt.minute; endPeriod = endOpt.period; + } + showCreateModal = true; + } function getStatusColor(status: string): string { switch (status) { @@ -138,13 +519,49 @@ function formatStatus(status: string): string { return status.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()); } + + $effect(() => { + fetchTodayAppointments(); + fetchTodayBlockersData(); + + const intervalId = setInterval(() => { + fetchTodayAppointments(); + }, 600_000); + + function handleApproval() { + fetchTodayAppointments(); + } + window.addEventListener('bookingApproved', handleApproval); + + return () => { + clearInterval(intervalId); + window.removeEventListener('bookingApproved', handleApproval); + }; + }); + + $effect(() => { + if (showCreateModal) { + checkOverlappingBookings(); + } + });
- Today's Appointments - Timeline view of all bookings +
+
+ Today's Appointments + Timeline view of all bookings +
+ +
{#if loading} @@ -162,7 +579,7 @@
{/each}
- {:else if appointments.length === 0} + {:else if timeline.length === 0}
{:else}
- {#each appointments as apt (apt.id)} -
-
- {formatTime(apt.start_time)} + {#each timeline as item (item.id)} + {#if item.type === 'appointment'} +
+
+ {formatTime(item.data.start_time)} +
+
+
+ +
+ {item.data.services.join(', ')} + + {formatDuration(item.data.duration_minutes)} + +
+
+ + {formatStatus(item.data.status)} + +
-
-
- -
- {apt.services.join(', ')} • {apt.duration_minutes} min + + + + + +
+ {:else if item.type === 'lunch'} +
+
+ {item.data.startLabel} +
+
+ + + + + + + +
+
+

+ Suggested lunch: {formatDuration(item.data.duration)} available +

+

+ {item.data.startLabel} – {item.data.endLabel} +

- - {formatStatus(apt.status)} - - -
+ {/if} {/each}
{/if}
+ + + + + + Add Time Blocker + Block off a period of time today. + +
+
+ + +

Defaults to "break" if left empty

+
+ +
+

Start Time

+ {#if whLoading} + + {:else if !workingHours} +

Loading working hours…

+ {:else if !workingHours.isOpen} +

Closed today

+ {:else} + + {/if} +
+
+

End Time

+ {#if whLoading} + + {:else if !workingHours} +

Loading working hours…

+ {:else if !workingHours.isOpen} +

Closed today

+ {:else if availableEndOptions.length === 0} +

No available end time after selected start

+ {:else} + + {/if} +
+ {#if checkingOverlap} +
+ + + + + Checking for conflicting bookings… +
+ {:else if hasOverlap} +
+
+ + + + + + Booking exists in this timeslot +
+
+ {#each overlappingBookings as booking (booking.id)} +
+
+
{booking.user?.full_name || 'Unknown'}
+
+ {new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', { hour: 'numeric', minute: '2-digit', hour12: true })} + {' Ā· '}{formatDuration(booking.duration_minutes)} + {#if booking.services?.length} {' Ā· '}{booking.services.join(', ')}{/if} +
+
+
+ + {#if booking.user?.id} + + {/if} +
+
+ {/each} +
+
+ {/if} +
+ + + + +
+
+ + + + + Delete time blocker? + This will remove the "{blockerToDelete?.description}" time blocker. This action cannot be undone. + + + { showDeleteAlert = false; blockerToDelete = null; }}>Cancel + Delete + + + diff --git a/frontend/src/lib/components/today/TodayStats.svelte b/frontend/src/lib/components/today/TodayStats.svelte new file mode 100644 index 0000000..ba3dcb1 --- /dev/null +++ b/frontend/src/lib/components/today/TodayStats.svelte @@ -0,0 +1,237 @@ + + + + + Today's Stats + + + {#if loading && !stats} +
+ {#each Array(6) as _, i (i)} + + {/each} +
+ {:else if stats} +
+
+ Total Appointments + {stats.total} +
+
+ {#if stats.completed > 0} +
+ Completed + {stats.completed} +
+ {/if} + {#if stats.inProgress > 0} +
+ In Progress + {stats.inProgress} +
+ {/if} + {#if stats.upcoming > 0} +
+ Upcoming + {stats.upcoming} +
+ {/if} + {#if stats.cancelled > 0} +
+ Cancelled + {stats.cancelled} +
+ {/if} + {#if stats.noShow > 0} +
+ No Show + {stats.noShow} +
+ {/if} +
+ {#if stats.bookingsMade > 0} +
+ + Bookings Made{#if stats.bookingsMadeOverClosed}*{/if} + + {stats.bookingsMade} +
+ {/if} +
+ Occupancy + {stats.occupancyPercent}% +
+
+ {/if} +
+
diff --git a/frontend/src/routes/today/+page.svelte b/frontend/src/routes/today/+page.svelte index 221ac34..c809106 100644 --- a/frontend/src/routes/today/+page.svelte +++ b/frontend/src/routes/today/+page.svelte @@ -9,6 +9,7 @@ import CurrentAppointment from '$lib/components/today/CurrentAppointment.svelte'; import TodayCalendar from '$lib/components/today/TodayCalendar.svelte'; import PendingApprovals from '$lib/components/today/PendingApprovals.svelte'; + import TodayStats from '$lib/components/today/TodayStats.svelte'; import CallInBooking from '$lib/components/admin/CallInBooking.svelte'; import WalkInBooking from '$lib/components/admin/WalkInBooking.svelte'; import BookingModal from '$lib/components/admin/BookingModal.svelte'; @@ -48,6 +49,7 @@ let selectedBookingId = $state(null); let selectedUserId = $state(null); let editBookingNextStart = $state(null); + let hasItems = $state(false); function openBookingModal(bookingId: string) { selectedBookingId = bookingId; @@ -76,7 +78,7 @@ {#if pageState === 'loading'} -
+
@@ -105,10 +107,10 @@
{:else if pageState === 'authorized'} -
+
-

Today's Schedule

+

Today's Schedule

{dateString}

@@ -116,29 +118,35 @@ -
- -
+
+ +
- -
+ +
- -
+ +
- -
- - + +
+
+ +
+
+ + +
+