diff --git a/frontend/src/lib/components/today/CurrentAppointment.svelte b/frontend/src/lib/components/today/CurrentAppointment.svelte index 6d99433..d652d0a 100644 --- a/frontend/src/lib/components/today/CurrentAppointment.svelte +++ b/frontend/src/lib/components/today/CurrentAppointment.svelte @@ -8,20 +8,28 @@ import { Badge } from '$lib/components/ui/badge'; import { Skeleton } from '$lib/components/ui/skeleton'; import PaymentModal from '$lib/components/payments/PaymentModal.svelte'; + import type { Booking as BookingType } from '$lib/types/booking'; interface Props { - openBookingModal: (bookingId: string) => void; + _openBookingModal: (bookingId: string) => void; openEditBookingModal: (bookingId: string, nextAppointmentStart?: string | null) => void; openUserModal: (userId: string) => void; } - let { openBookingModal, openEditBookingModal, openUserModal }: Props = $props(); + let { _openBookingModal, openEditBookingModal, openUserModal }: Props = $props(); type Booking = { id: string; start_time: string; status: string; notes?: string; + user_id?: string; + created_at?: string; + updated_at?: string; + deposit_required?: boolean; + deposit_paid?: boolean; + amount_paid?: number; + amount_due?: number; user?: { id: string; full_name: string; @@ -40,17 +48,60 @@ total_amount: number; }; + type DailySummary = { + total_payments_today: number; + total_tips_today: number; + amount_due_today: number; + total_duration_spent: number; + customers_served: number; + total_bookings: number; + new_customers: number; + returning_customers: number; + guest_customers: number; + gift_cards_sold: number; + last_customer_name?: string; + last_customer_visits?: number; + summary_scope: 'day' | 'week'; + summary_start_date: string; + summary_end_date: string; + new_booking_services?: Array<{ + service_name: string; + count: number; + }>; + }; + let currentAppointment = $state(null); let nextAppointment = $state(null); - let closingTime = $state(null); // "HH:MM" format - let freeTimeAfter = $state(0); // minutes of free time after current/next appointment - let freeTimeCapped = $state(false); // true if free time extends to/past closing + let closingTime = $state(null); + let freeTimeAfter = $state(0); + let freeTimeCapped = $state(false); let loading = $state(true); - let timeRemaining = $state(0); // minutes remaining in current appointment + let timeRemaining = $state(0); let isInProgress = $state(false); let showPaymentModal = $state(false); - // Calculate time remaining and free time + // Done-for-the-day state + let doneForDay = $state(false); + let summary = $state(null); + let weekSummary = $state(null); + + function formatRemainingTime(minutes: number): string { + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + if (hours === 0) return `${mins} minutes`; + if (mins === 0) return `${hours} hour`; + if (hours === 1) return `1 hour ${mins} minutes`; + return `${hours} hours ${mins} minutes`; + } + + const minutesUntilClosing = $derived.by(() => { + if (!closingTime) return 0; + const [ch, cm] = closingTime.split(':').map(Number); + const now = new SvelteDate(); + const closing = new SvelteDate(now.getFullYear(), now.getMonth(), now.getDate(), ch, cm); + return Math.max(0, Math.floor((closing.getTime() - now.getTime()) / 60000)); + }); + let interval: ReturnType | null = null; function calculateTimes() { @@ -77,7 +128,6 @@ timeRemaining = Math.max(0, Math.floor(timeUntilMs / 60000)); } - // Calculate free time after this appointment let rawFreeMinutes = 0; if (nextAppointment) { const nextStart = new SvelteDate(nextAppointment.start_time); @@ -85,7 +135,6 @@ rawFreeMinutes = Math.max(0, Math.floor(gapMs / 60000)); } - // Cap at closing time if (closingTime) { const [ch, cm] = closingTime.split(':').map(Number); const today = new SvelteDate(); @@ -103,11 +152,9 @@ ); if (!nextAppointment) { - // No next appointment — show time until closing freeTimeAfter = minutesToClosing; freeTimeCapped = true; } else if (rawFreeMinutes > minutesToClosing) { - // Next appointment is after closing — cap at closing freeTimeAfter = minutesToClosing; freeTimeCapped = true; } else { @@ -137,6 +184,17 @@ currentAppointment = data.current || null; nextAppointment = data.next || null; closingTime = data.closing_time || null; + + if (data.done_for_day) { + doneForDay = true; + summary = data.summary || null; + weekSummary = data.week_summary || null; + } else { + doneForDay = false; + summary = null; + weekSummary = null; + } + calculateTimes(); } else { toast.error('Failed to load current appointment'); @@ -149,20 +207,18 @@ } } - // Start interval for real-time countdown $effect(() => { fetchCurrentAndNext(); interval = setInterval(() => { calculateTimes(); - }, 15000); // Update every 15 seconds + }, 15000); return () => { if (interval) clearInterval(interval); }; }); - // WIP Demo actions function handleBegin() { toast.info('Begin appointment - Coming soon'); } @@ -181,7 +237,7 @@ showPaymentModal = true; } - function handlePaymentComplete(payment: unknown) { + function handlePaymentComplete(_payment: unknown) { toast.success('Payment completed'); showPaymentModal = false; } @@ -190,68 +246,104 @@ toast.info('Cancel appointment - Coming soon'); } + function isMilestone(visits: number): boolean { + if (visits < 5) return false; + const early = [5, 10, 15, 20, 25, 50, 75, 100, 150]; + if (visits <= 150) return early.includes(visits); + return visits % 50 === 0; + } + const activeAppointment = $derived(currentAppointment || nextAppointment); - + -
-
- - {isInProgress ? 'Current Appointment' : 'Next Appointment'} + {#if doneForDay} +
+ + + + + {summary?.summary_scope === 'week' ? 'Closed today' : 'All done for today!'} + + {summary?.summary_scope === 'week' + ? 'Showing summary from the last working period' + : 'No more customers booked for today'} + +
+ {:else} +
+
+ + {isInProgress ? 'Current Appointment' : 'Next Appointment'} + + {#if activeAppointment} + + {new SvelteDate(activeAppointment.start_time).toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true + })} + - + {new SvelteDate( + new SvelteDate(activeAppointment.start_time).getTime() + + activeAppointment.duration_minutes * 60 * 1000 + ).toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true + })} + + {/if} +
+ {#if activeAppointment} - - {new SvelteDate(activeAppointment.start_time).toLocaleTimeString('en-US', { - hour: 'numeric', - minute: '2-digit', - hour12: true - })} - - - {new SvelteDate( - new SvelteDate(activeAppointment.start_time).getTime() + - activeAppointment.duration_minutes * 60 * 1000 - ).toLocaleTimeString('en-US', { - hour: 'numeric', - minute: '2-digit', - hour12: true - })} - + {#if isInProgress} +
+ + + + + + In Progress • {timeRemaining} min remaining + + {#if timeRemaining > 29} + {#if freeTimeCapped && freeTimeAfter === 0} + + closing after + + {:else if freeTimeAfter > 0} + + {freeTimeAfter} min free afterwards{#if freeTimeCapped} + (closing after){/if} + + {/if} + {/if} +
+ {:else} + + Starts in {timeRemaining} min + + {/if} {/if}
- - {#if activeAppointment} - {#if isInProgress} -
- - - - - - In Progress • {timeRemaining} min remaining - - {#if timeRemaining > 29} - {#if freeTimeCapped && freeTimeAfter === 0} - - closing after - - {:else if freeTimeAfter > 0} - - {freeTimeAfter} min free afterwards{#if freeTimeCapped} - (closing after){/if} - - {/if} - {/if} -
- {:else} - - Starts in {timeRemaining} min - - {/if} - {/if} -
+ {/if} {#if loading} @@ -266,6 +358,216 @@
+ {:else if doneForDay && summary} + +
+ + {#if minutesUntilClosing >= 30} +
+ There is {formatRemainingTime(minutesUntilClosing)} left to accept walk-in bookings +
+ {/if} + {#if minutesUntilClosing >= 75} +
+ Users booking online still have time to book in +
+ {/if} + + {#if summary.summary_scope === 'week'} +
+ Summary: {new SvelteDate(summary.summary_start_date).toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric' + })} + – + {new SvelteDate(summary.summary_end_date).toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric' + })} +
+ {/if} + + +
+
+
Payments Taken
+
+ £{summary.total_payments_today.toFixed(2)} +
+
+ +
+
Tips
+
+ £{summary.total_tips_today.toFixed(2)} +
+
+ +
+
Time in Appointments
+
+ {formatDuration(summary.total_duration_spent)} +
+
+ + {#if summary.amount_due_today > 0} +
+
Still Due
+
+ £{summary.amount_due_today.toFixed(2)} +
+
+ {/if} + +
+
Customers Served
+
+ {summary.customers_served} + {#if summary.total_bookings !== summary.customers_served} + + across {summary.total_bookings} booking{summary.total_bookings !== 1 ? 's' : ''} + + {/if} +
+
+ +
+
Customers
+
+ {#if summary.new_customers > 0} +
+ {summary.new_customers} + new +
+ {/if} + {#if summary.returning_customers > 0} +
+ {summary.returning_customers} + returning +
+ {/if} + {#if summary.guest_customers > 0} +
+ {summary.guest_customers} + guest +
+ {/if} +
+
+ + {#if summary.gift_cards_sold > 0} +
+
Gift Cards Sold
+
{summary.gift_cards_sold}
+
+ {/if} +
+ + {#if summary.last_customer_name && isMilestone(summary.last_customer_visits ?? 0)} +
+
+
+ {summary.last_customer_name} + + has visited {summary.last_customer_visits} times now! + +
+
+
+ {/if} + + {#if summary.new_booking_services && summary.new_booking_services.length > 0} +
+

+ New bookings made since{summary.summary_scope === 'week' + ? ` opening ${new SvelteDate(summary.summary_start_date).toLocaleDateString('en-US', { weekday: 'long' })}` + : ' closing yesterday'} +

+
+ {#each summary.new_booking_services as svc (svc.service_name)} +
+ {svc.count} + {svc.service_name} +
+ {/each} +
+
+ {/if} + + + {#if weekSummary} +
+
+
+

This week so far

+
+ {new SvelteDate(weekSummary.summary_start_date).toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric' + })} + – + {new SvelteDate(weekSummary.summary_end_date).toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric' + })} +
+
+ +
+
+
Payments Taken
+
+ £{weekSummary.total_payments_today.toFixed(2)} +
+
+ +
+
Tips
+
+ £{weekSummary.total_tips_today.toFixed(2)} +
+
+ + {#if weekSummary.amount_due_today > 0} +
+
Still Due
+
+ £{weekSummary.amount_due_today.toFixed(2)} +
+
+ {/if} + +
+
Time in Appointments
+
+ {formatDuration(weekSummary.total_duration_spent)} +
+
+ +
+
Customers Served
+
+ {weekSummary.customers_served} +
+
+ + {#if weekSummary.gift_cards_sold > 0} +
+
Gift Cards Sold
+
+ {weekSummary.gift_cards_sold} +
+
+ {/if} +
+
+ {/if} +
+
{:else if !activeAppointment}
@@ -461,7 +763,7 @@ {/if} -{#if showPaymentModal} +{#if showPaymentModal && activeAppointment} (showPaymentModal = false)} diff --git a/frontend/src/routes/admin/schedule/+page.svelte b/frontend/src/routes/admin/schedule/+page.svelte index 8d18256..d689a5c 100644 --- a/frontend/src/routes/admin/schedule/+page.svelte +++ b/frontend/src/routes/admin/schedule/+page.svelte @@ -487,7 +487,7 @@
-
+
{#each timeRange.hours as h (h)}
{#each getWeekDays(weekStart) as day (formatDate(day))} @@ -594,7 +594,7 @@
- +
{#each getWeekDays(weekStart) as day, dayIdx (formatDate(day))} {@const dateStr = formatDate(day)}