feat(frontend): add done-for-day summary card to CurrentAppointment, fix schedule z-index
Replace current/next card with done-for-day summary state when all appointments are finished. Shows daily stats (payments, tips, duration, customers) and week summary when tomorrow is closed. Fix schedule grid z-index to remove stacking context that caused overlay issues. Ultraworked with Sisyphus Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -8,20 +8,28 @@
|
|||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||||
import PaymentModal from '$lib/components/payments/PaymentModal.svelte';
|
import PaymentModal from '$lib/components/payments/PaymentModal.svelte';
|
||||||
|
import type { Booking as BookingType } from '$lib/types/booking';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
openBookingModal: (bookingId: string) => void;
|
_openBookingModal: (bookingId: string) => void;
|
||||||
openEditBookingModal: (bookingId: string, nextAppointmentStart?: string | null) => void;
|
openEditBookingModal: (bookingId: string, nextAppointmentStart?: string | null) => void;
|
||||||
openUserModal: (userId: string) => void;
|
openUserModal: (userId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { openBookingModal, openEditBookingModal, openUserModal }: Props = $props();
|
let { _openBookingModal, openEditBookingModal, openUserModal }: Props = $props();
|
||||||
|
|
||||||
type Booking = {
|
type Booking = {
|
||||||
id: string;
|
id: string;
|
||||||
start_time: string;
|
start_time: string;
|
||||||
status: string;
|
status: string;
|
||||||
notes?: 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?: {
|
user?: {
|
||||||
id: string;
|
id: string;
|
||||||
full_name: string;
|
full_name: string;
|
||||||
@@ -40,17 +48,60 @@
|
|||||||
total_amount: number;
|
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<Booking | null>(null);
|
let currentAppointment = $state<Booking | null>(null);
|
||||||
let nextAppointment = $state<Booking | null>(null);
|
let nextAppointment = $state<Booking | null>(null);
|
||||||
let closingTime = $state<string | null>(null); // "HH:MM" format
|
let closingTime = $state<string | null>(null);
|
||||||
let freeTimeAfter = $state(0); // minutes of free time after current/next appointment
|
let freeTimeAfter = $state(0);
|
||||||
let freeTimeCapped = $state(false); // true if free time extends to/past closing
|
let freeTimeCapped = $state(false);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let timeRemaining = $state(0); // minutes remaining in current appointment
|
let timeRemaining = $state(0);
|
||||||
let isInProgress = $state(false);
|
let isInProgress = $state(false);
|
||||||
let showPaymentModal = $state(false);
|
let showPaymentModal = $state(false);
|
||||||
|
|
||||||
// Calculate time remaining and free time
|
// Done-for-the-day state
|
||||||
|
let doneForDay = $state(false);
|
||||||
|
let summary = $state<DailySummary | null>(null);
|
||||||
|
let weekSummary = $state<DailySummary | null>(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<typeof setInterval> | null = null;
|
let interval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
function calculateTimes() {
|
function calculateTimes() {
|
||||||
@@ -77,7 +128,6 @@
|
|||||||
timeRemaining = Math.max(0, Math.floor(timeUntilMs / 60000));
|
timeRemaining = Math.max(0, Math.floor(timeUntilMs / 60000));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate free time after this appointment
|
|
||||||
let rawFreeMinutes = 0;
|
let rawFreeMinutes = 0;
|
||||||
if (nextAppointment) {
|
if (nextAppointment) {
|
||||||
const nextStart = new SvelteDate(nextAppointment.start_time);
|
const nextStart = new SvelteDate(nextAppointment.start_time);
|
||||||
@@ -85,7 +135,6 @@
|
|||||||
rawFreeMinutes = Math.max(0, Math.floor(gapMs / 60000));
|
rawFreeMinutes = Math.max(0, Math.floor(gapMs / 60000));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cap at closing time
|
|
||||||
if (closingTime) {
|
if (closingTime) {
|
||||||
const [ch, cm] = closingTime.split(':').map(Number);
|
const [ch, cm] = closingTime.split(':').map(Number);
|
||||||
const today = new SvelteDate();
|
const today = new SvelteDate();
|
||||||
@@ -103,11 +152,9 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!nextAppointment) {
|
if (!nextAppointment) {
|
||||||
// No next appointment — show time until closing
|
|
||||||
freeTimeAfter = minutesToClosing;
|
freeTimeAfter = minutesToClosing;
|
||||||
freeTimeCapped = true;
|
freeTimeCapped = true;
|
||||||
} else if (rawFreeMinutes > minutesToClosing) {
|
} else if (rawFreeMinutes > minutesToClosing) {
|
||||||
// Next appointment is after closing — cap at closing
|
|
||||||
freeTimeAfter = minutesToClosing;
|
freeTimeAfter = minutesToClosing;
|
||||||
freeTimeCapped = true;
|
freeTimeCapped = true;
|
||||||
} else {
|
} else {
|
||||||
@@ -137,6 +184,17 @@
|
|||||||
currentAppointment = data.current || null;
|
currentAppointment = data.current || null;
|
||||||
nextAppointment = data.next || null;
|
nextAppointment = data.next || null;
|
||||||
closingTime = data.closing_time || 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();
|
calculateTimes();
|
||||||
} else {
|
} else {
|
||||||
toast.error('Failed to load current appointment');
|
toast.error('Failed to load current appointment');
|
||||||
@@ -149,20 +207,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start interval for real-time countdown
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
fetchCurrentAndNext();
|
fetchCurrentAndNext();
|
||||||
|
|
||||||
interval = setInterval(() => {
|
interval = setInterval(() => {
|
||||||
calculateTimes();
|
calculateTimes();
|
||||||
}, 15000); // Update every 15 seconds
|
}, 15000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (interval) clearInterval(interval);
|
if (interval) clearInterval(interval);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// WIP Demo actions
|
|
||||||
function handleBegin() {
|
function handleBegin() {
|
||||||
toast.info('Begin appointment - Coming soon');
|
toast.info('Begin appointment - Coming soon');
|
||||||
}
|
}
|
||||||
@@ -181,7 +237,7 @@
|
|||||||
showPaymentModal = true;
|
showPaymentModal = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePaymentComplete(payment: unknown) {
|
function handlePaymentComplete(_payment: unknown) {
|
||||||
toast.success('Payment completed');
|
toast.success('Payment completed');
|
||||||
showPaymentModal = false;
|
showPaymentModal = false;
|
||||||
}
|
}
|
||||||
@@ -190,68 +246,104 @@
|
|||||||
toast.info('Cancel appointment - Coming soon');
|
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);
|
const activeAppointment = $derived(currentAppointment || nextAppointment);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card.Root class="border-2 border-blue-200 bg-blue-50">
|
<Card.Root
|
||||||
|
class="border-2 {doneForDay ? 'border-teal-200 bg-teal-50' : 'border-blue-200 bg-blue-50'}"
|
||||||
|
>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
{#if doneForDay}
|
||||||
<div class="min-w-0">
|
<div class="flex flex-col gap-1">
|
||||||
<Card.Title class="text-xl md:text-2xl">
|
<Card.Title class="flex items-center gap-2 text-xl md:text-2xl">
|
||||||
{isInProgress ? 'Current Appointment' : 'Next Appointment'}
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-7 w-7 text-teal-600"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{summary?.summary_scope === 'week' ? 'Closed today' : 'All done for today!'}
|
||||||
</Card.Title>
|
</Card.Title>
|
||||||
|
<Card.Description class="text-base">
|
||||||
|
{summary?.summary_scope === 'week'
|
||||||
|
? 'Showing summary from the last working period'
|
||||||
|
: 'No more customers booked for today'}
|
||||||
|
</Card.Description>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<Card.Title class="text-xl md:text-2xl">
|
||||||
|
{isInProgress ? 'Current Appointment' : 'Next Appointment'}
|
||||||
|
</Card.Title>
|
||||||
|
{#if activeAppointment}
|
||||||
|
<Card.Description class="text-base">
|
||||||
|
{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
|
||||||
|
})}
|
||||||
|
</Card.Description>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if activeAppointment}
|
{#if activeAppointment}
|
||||||
<Card.Description class="text-base">
|
{#if isInProgress}
|
||||||
{new SvelteDate(activeAppointment.start_time).toLocaleTimeString('en-US', {
|
<div class="flex flex-row flex-wrap gap-2 sm:flex-col sm:items-end">
|
||||||
hour: 'numeric',
|
<Badge class="bg-blue-100 px-3 py-1 text-sm whitespace-nowrap text-blue-800">
|
||||||
minute: '2-digit',
|
<span class="relative mr-2 flex h-2 w-2">
|
||||||
hour12: true
|
<span
|
||||||
})}
|
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-75"
|
||||||
-
|
></span>
|
||||||
{new SvelteDate(
|
<span class="relative inline-flex h-2 w-2 rounded-full bg-blue-600"></span>
|
||||||
new SvelteDate(activeAppointment.start_time).getTime() +
|
</span>
|
||||||
activeAppointment.duration_minutes * 60 * 1000
|
In Progress • {timeRemaining} min remaining
|
||||||
).toLocaleTimeString('en-US', {
|
</Badge>
|
||||||
hour: 'numeric',
|
{#if timeRemaining > 29}
|
||||||
minute: '2-digit',
|
{#if freeTimeCapped && freeTimeAfter === 0}
|
||||||
hour12: true
|
<Badge class="bg-blue-100 px-3 py-1 text-sm whitespace-nowrap text-blue-800">
|
||||||
})}
|
closing after
|
||||||
</Card.Description>
|
</Badge>
|
||||||
|
{:else if freeTimeAfter > 0}
|
||||||
|
<Badge class="bg-blue-100 px-3 py-1 text-sm whitespace-nowrap text-blue-800">
|
||||||
|
{freeTimeAfter} min free afterwards{#if freeTimeCapped}
|
||||||
|
(closing after){/if}
|
||||||
|
</Badge>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<Badge class="bg-amber-100 px-3 py-1 text-sm whitespace-nowrap text-amber-800">
|
||||||
|
Starts in {timeRemaining} min
|
||||||
|
</Badge>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
{#if activeAppointment}
|
|
||||||
{#if isInProgress}
|
|
||||||
<div class="flex flex-row flex-wrap gap-2 sm:flex-col sm:items-end">
|
|
||||||
<Badge class="bg-blue-100 px-3 py-1 text-sm whitespace-nowrap text-blue-800">
|
|
||||||
<span class="relative mr-2 flex h-2 w-2">
|
|
||||||
<span
|
|
||||||
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-75"
|
|
||||||
></span>
|
|
||||||
<span class="relative inline-flex h-2 w-2 rounded-full bg-blue-600"></span>
|
|
||||||
</span>
|
|
||||||
In Progress • {timeRemaining} min remaining
|
|
||||||
</Badge>
|
|
||||||
{#if timeRemaining > 29}
|
|
||||||
{#if freeTimeCapped && freeTimeAfter === 0}
|
|
||||||
<Badge class="bg-blue-100 px-3 py-1 text-sm whitespace-nowrap text-blue-800">
|
|
||||||
closing after
|
|
||||||
</Badge>
|
|
||||||
{:else if freeTimeAfter > 0}
|
|
||||||
<Badge class="bg-blue-100 px-3 py-1 text-sm whitespace-nowrap text-blue-800">
|
|
||||||
{freeTimeAfter} min free afterwards{#if freeTimeCapped}
|
|
||||||
(closing after){/if}
|
|
||||||
</Badge>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<Badge class="bg-amber-100 px-3 py-1 text-sm whitespace-nowrap text-amber-800">
|
|
||||||
Starts in {timeRemaining} min
|
|
||||||
</Badge>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
@@ -266,6 +358,216 @@
|
|||||||
</div>
|
</div>
|
||||||
<Skeleton class="h-32 w-full" />
|
<Skeleton class="h-32 w-full" />
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
|
{:else if doneForDay && summary}
|
||||||
|
<Card.Content>
|
||||||
|
<div class="space-y-5">
|
||||||
|
<!-- Conditional messages -->
|
||||||
|
{#if minutesUntilClosing >= 30}
|
||||||
|
<div class="rounded-md border border-teal-200 bg-white p-3 text-sm text-teal-800">
|
||||||
|
There is {formatRemainingTime(minutesUntilClosing)} left to accept walk-in bookings
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if minutesUntilClosing >= 75}
|
||||||
|
<div class="rounded-md border border-blue-200 bg-white p-3 text-sm text-blue-800">
|
||||||
|
Users booking online still have time to book in
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if summary.summary_scope === 'week'}
|
||||||
|
<div class="text-xs text-gray-500">
|
||||||
|
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'
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Stats grid -->
|
||||||
|
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
|
<div class="rounded-md border border-teal-100 bg-white p-3">
|
||||||
|
<div class="text-xs font-medium text-gray-500">Payments Taken</div>
|
||||||
|
<div class="mt-1 text-lg font-bold text-gray-900">
|
||||||
|
£{summary.total_payments_today.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-md border border-teal-100 bg-white p-3">
|
||||||
|
<div class="text-xs font-medium text-gray-500">Tips</div>
|
||||||
|
<div class="mt-1 text-lg font-bold text-gray-900">
|
||||||
|
£{summary.total_tips_today.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-md border border-teal-100 bg-white p-3">
|
||||||
|
<div class="text-xs font-medium text-gray-500">Time in Appointments</div>
|
||||||
|
<div class="mt-1 text-lg font-bold text-gray-900">
|
||||||
|
{formatDuration(summary.total_duration_spent)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if summary.amount_due_today > 0}
|
||||||
|
<div class="rounded-md border border-amber-200 bg-amber-50 p-3">
|
||||||
|
<div class="text-xs font-medium text-amber-700">Still Due</div>
|
||||||
|
<div class="mt-1 text-lg font-bold text-amber-900">
|
||||||
|
£{summary.amount_due_today.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="rounded-md border border-teal-100 bg-white p-3">
|
||||||
|
<div class="text-xs font-medium text-gray-500">Customers Served</div>
|
||||||
|
<div class="mt-1 text-lg font-bold text-gray-900">
|
||||||
|
{summary.customers_served}
|
||||||
|
{#if summary.total_bookings !== summary.customers_served}
|
||||||
|
<span class="text-sm font-normal text-gray-500">
|
||||||
|
across {summary.total_bookings} booking{summary.total_bookings !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-md border border-teal-100 bg-white p-3">
|
||||||
|
<div class="text-xs font-medium text-gray-500">Customers</div>
|
||||||
|
<div class="mt-1 space-y-0.5">
|
||||||
|
{#if summary.new_customers > 0}
|
||||||
|
<div class="flex items-baseline justify-between gap-2">
|
||||||
|
<span class="text-lg font-bold text-gray-900">{summary.new_customers}</span>
|
||||||
|
<span class="text-xs text-gray-500">new</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if summary.returning_customers > 0}
|
||||||
|
<div class="flex items-baseline justify-between gap-2">
|
||||||
|
<span class="text-lg font-bold text-gray-900">{summary.returning_customers}</span>
|
||||||
|
<span class="text-xs text-gray-500">returning</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if summary.guest_customers > 0}
|
||||||
|
<div class="flex items-baseline justify-between gap-2">
|
||||||
|
<span class="text-lg font-bold text-gray-900">{summary.guest_customers}</span>
|
||||||
|
<span class="text-xs text-gray-500">guest</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if summary.gift_cards_sold > 0}
|
||||||
|
<div class="rounded-md border border-teal-100 bg-white p-3">
|
||||||
|
<div class="text-xs font-medium text-gray-500">Gift Cards Sold</div>
|
||||||
|
<div class="mt-1 text-lg font-bold text-gray-900">{summary.gift_cards_sold}</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if summary.last_customer_name && isMilestone(summary.last_customer_visits ?? 0)}
|
||||||
|
<div class="rounded-md border border-gray-200 bg-white p-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div>
|
||||||
|
<span class="font-medium text-gray-900">{summary.last_customer_name}</span>
|
||||||
|
<span class="text-gray-700">
|
||||||
|
has visited {summary.last_customer_visits} times now!
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if summary.new_booking_services && summary.new_booking_services.length > 0}
|
||||||
|
<div>
|
||||||
|
<h4 class="mb-2 text-sm font-semibold text-gray-700">
|
||||||
|
New bookings made since{summary.summary_scope === 'week'
|
||||||
|
? ` opening ${new SvelteDate(summary.summary_start_date).toLocaleDateString('en-US', { weekday: 'long' })}`
|
||||||
|
: ' closing yesterday'}
|
||||||
|
</h4>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each summary.new_booking_services as svc (svc.service_name)}
|
||||||
|
<div class="rounded-md border border-gray-200 bg-white px-2.5 py-1.5 text-sm">
|
||||||
|
<span class="font-semibold text-gray-900">{svc.count}</span>
|
||||||
|
<span class="ml-1 text-gray-600">{svc.service_name}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Week summary (shown alongside daily summary when tomorrow is closed) -->
|
||||||
|
{#if weekSummary}
|
||||||
|
<hr class="border-gray-200" />
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-semibold text-gray-700">This week so far</h4>
|
||||||
|
<div class="text-xs text-gray-500">
|
||||||
|
{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'
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
|
<div class="rounded-md border border-gray-200 bg-white p-3">
|
||||||
|
<div class="text-xs font-medium text-gray-500">Payments Taken</div>
|
||||||
|
<div class="mt-1 text-lg font-bold text-gray-900">
|
||||||
|
£{weekSummary.total_payments_today.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-md border border-gray-200 bg-white p-3">
|
||||||
|
<div class="text-xs font-medium text-gray-500">Tips</div>
|
||||||
|
<div class="mt-1 text-lg font-bold text-gray-900">
|
||||||
|
£{weekSummary.total_tips_today.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if weekSummary.amount_due_today > 0}
|
||||||
|
<div class="rounded-md border border-amber-200 bg-amber-50 p-3">
|
||||||
|
<div class="text-xs font-medium text-amber-700">Still Due</div>
|
||||||
|
<div class="mt-1 text-lg font-bold text-amber-900">
|
||||||
|
£{weekSummary.amount_due_today.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="rounded-md border border-gray-200 bg-white p-3">
|
||||||
|
<div class="text-xs font-medium text-gray-500">Time in Appointments</div>
|
||||||
|
<div class="mt-1 text-lg font-bold text-gray-900">
|
||||||
|
{formatDuration(weekSummary.total_duration_spent)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-md border border-gray-200 bg-white p-3">
|
||||||
|
<div class="text-xs font-medium text-gray-500">Customers Served</div>
|
||||||
|
<div class="mt-1 text-lg font-bold text-gray-900">
|
||||||
|
{weekSummary.customers_served}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if weekSummary.gift_cards_sold > 0}
|
||||||
|
<div class="rounded-md border border-gray-200 bg-white p-3">
|
||||||
|
<div class="text-xs font-medium text-gray-500">Gift Cards Sold</div>
|
||||||
|
<div class="mt-1 text-lg font-bold text-gray-900">
|
||||||
|
{weekSummary.gift_cards_sold}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
{:else if !activeAppointment}
|
{:else if !activeAppointment}
|
||||||
<Card.Content>
|
<Card.Content>
|
||||||
<div class="py-12 text-center">
|
<div class="py-12 text-center">
|
||||||
@@ -461,7 +763,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
||||||
{#if showPaymentModal}
|
{#if showPaymentModal && activeAppointment}
|
||||||
<PaymentModal
|
<PaymentModal
|
||||||
booking={activeAppointment}
|
booking={activeAppointment}
|
||||||
onClose={() => (showPaymentModal = false)}
|
onClose={() => (showPaymentModal = false)}
|
||||||
|
|||||||
@@ -487,7 +487,7 @@
|
|||||||
<!-- 2. GRID CONTAINER (Day Columns) -->
|
<!-- 2. GRID CONTAINER (Day Columns) -->
|
||||||
<div class="relative flex flex-1 flex-col">
|
<div class="relative flex flex-1 flex-col">
|
||||||
<!-- Grid Rows & Cells -->
|
<!-- Grid Rows & Cells -->
|
||||||
<div class="relative z-0">
|
<div class="relative">
|
||||||
{#each timeRange.hours as h (h)}
|
{#each timeRange.hours as h (h)}
|
||||||
<div class="flex border-b border-gray-100">
|
<div class="flex border-b border-gray-100">
|
||||||
{#each getWeekDays(weekStart) as day (formatDate(day))}
|
{#each getWeekDays(weekStart) as day (formatDate(day))}
|
||||||
@@ -594,7 +594,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 3. OVERLAY LAYER (Lines & Closed Stripes) -->
|
<!-- 3. OVERLAY LAYER (Lines & Closed Stripes) -->
|
||||||
<!-- z-5 places it above grid backgrounds (z-0) but below bookings (z-10) -->
|
<!-- z-5: below bookings (z-10) since grid no longer creates a stacking context -->
|
||||||
<div class="pointer-events-none absolute inset-0 z-5">
|
<div class="pointer-events-none absolute inset-0 z-5">
|
||||||
{#each getWeekDays(weekStart) as day, dayIdx (formatDate(day))}
|
{#each getWeekDays(weekStart) as day, dayIdx (formatDate(day))}
|
||||||
{@const dateStr = formatDate(day)}
|
{@const dateStr = formatDate(day)}
|
||||||
|
|||||||
Reference in New Issue
Block a user