'use client'; import clsx from 'clsx'; import { XMarkIcon, ClockIcon, QrCodeIcon, MagnifyingGlassIcon, UserPlusIcon, ArrowPathIcon, } from '@heroicons/react/24/outline'; import type { DoorPaymentMethod, DoorSummary } from '@/lib/api'; import { DOOR_PAYMENT_METHODS } from '@/lib/api'; import { formatCurrency } from '@/lib/utils'; export interface SessionEntry { idempotencyKey: string; ticketId: string; name: string; at: string; entry: 'scan' | 'search' | 'walkin'; method: DoorPaymentMethod | null; amount: number; /** Epoch ms the action was fired; the undo window is measured from here. */ startedAt: number; undone: boolean; failed: boolean; } const ENTRY_ICONS = { scan: QrCodeIcon, search: MagnifyingGlassIcon, walkin: UserPlusIcon, }; const ENTRY_LABELS = { scan: 'Scanned', search: 'Search', walkin: 'Walk-in', }; const METHOD_LABELS: Record = { cash: 'Cash', bitcoin: 'Bitcoin', transfer: 'Transfer', guest: 'Guest', }; /** Totals for the current shift, computed from this session's own entries. */ function sessionTotals(entries: SessionEntry[]) { const totals: Record = { cash: { count: 0, total: 0 }, bitcoin: { count: 0, total: 0 }, transfer: { count: 0, total: 0 }, guest: { count: 0, total: 0 }, }; let grand = 0; for (const entry of entries) { if (entry.undone || entry.failed || !entry.method) continue; totals[entry.method].count += 1; totals[entry.method].total += entry.amount; grand += entry.amount; } return { totals, grand }; } function CashUpGrid({ totals, currency, }: { totals: Record; currency: string; }) { return (
{DOOR_PAYMENT_METHODS.map((method) => (

{METHOD_LABELS[method]}

{method === 'guest' ? `${totals[method].count} free` : formatCurrency(totals[method].total, currency)}

{method !== 'guest' && (

{totals[method].count} {totals[method].count === 1 ? 'payment' : 'payments'}

)}
))}
); } /** * The end-of-night view: what this shift took, what the whole event day took, * and the feed of who came in and how. */ export function SessionSheet({ entries, summary, summaryLoading, showEventTotals, currency, onRefresh, onClose, }: { entries: SessionEntry[]; summary: DoorSummary | null; summaryLoading: boolean; /** Whole-event takings are admin/organizer only; door staff see their own shift. */ showEventTotals: boolean; currency: string; onRefresh: () => void; onClose: () => void; }) { const { totals, grand } = sessionTotals(entries); const liveEntries = entries.filter((e) => !e.undone); return (

Session

{liveEntries.length} checked in from this device

{showEventTotals && ( )}
{/* This shift */}

This session

{formatCurrency(grand, currency)}

{/* Whole event, from the server — the number to reconcile the cash box against. Admin/organizer only; the API enforces the same split. */} {showEventTotals && (

Door total, whole event

{summary ? formatCurrency(summary.door.total, summary.currency) : '—'}

{summary ? ( <>

Pre-sale

{formatCurrency(summary.presale.total, summary.currency)}

Event total

{formatCurrency(summary.total, summary.currency)}

) : (

{summaryLoading ? 'Loading totals…' : 'Totals unavailable — pull to refresh.'}

)}
)} {/* Feed */}

Recent check-ins

{entries.length === 0 ? (

No check-ins yet

) : (
{entries.map((entry) => { const Icon = ENTRY_ICONS[entry.entry]; return (

{entry.name}

{ENTRY_LABELS[entry.entry]} {entry.method ? ` · ${METHOD_LABELS[entry.method]}` : ''} {entry.method && entry.method !== 'guest' ? ` ${formatCurrency(entry.amount, currency)}` : ''} {entry.failed ? ' · failed' : entry.undone ? ' · undone' : ''}

{entry.at}

); })}
)}
); }