Events can now set an optional walk-in price (events.walk_in_price): null falls back to the ticket price, 0 is a free walk-in. It is set in the Create/Edit Event modal (EN/ES) and carried through create, update and duplicate, but it is internal: public event responses and the attendee-facing ticket/dashboard endpoints drop it, and only admin, organizer and staff callers receive it. The door screen no longer trusts the amount the client sends. It sends a quantity and the server prices the charge from the event record: walk-ins pay the walk-in price, existing tickets the ticket price. A typed amount is only honoured with amountOverride from admin/organizer and is written to audit_logs in the same transaction. The charged amount stays snapshotted on the payment row. POS is a new door tender for the physical card terminal. Staff pick POS, see the amount to key in, charge the card, then confirm with "Mark as paid"; it is then recorded like any other door payment (provider and method 'pos'). It can be switched off globally or per event via payment options, and shows up in the payments filters, bookings, revenue summaries and door takings. PosChargePanel is where an automatic terminal push would go later; nothing calls a terminal today. tickets.booking_source (online | walk_in | admin) records how a booking was made. The migration backfills walk-ins from the door screen's idempotency records; everything else stays online. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
245 lines
8.7 KiB
TypeScript
245 lines
8.7 KiB
TypeScript
'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<DoorPaymentMethod, string> = {
|
|
cash: 'Cash',
|
|
bitcoin: 'Bitcoin',
|
|
transfer: 'Transfer',
|
|
pos: 'POS',
|
|
guest: 'Guest',
|
|
};
|
|
|
|
/** Totals for the current shift, computed from this session's own entries. */
|
|
function sessionTotals(entries: SessionEntry[]) {
|
|
const totals: Record<DoorPaymentMethod, { count: number; total: number }> = {
|
|
cash: { count: 0, total: 0 },
|
|
bitcoin: { count: 0, total: 0 },
|
|
transfer: { count: 0, total: 0 },
|
|
pos: { 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<DoorPaymentMethod, { count: number; total: number }>;
|
|
currency: string;
|
|
}) {
|
|
return (
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{DOOR_PAYMENT_METHODS.map((method) => (
|
|
<div key={method} className="bg-gray-800 border border-gray-700 rounded-xl px-3 py-2.5">
|
|
<p className="text-[11px] uppercase tracking-wide text-gray-500">{METHOD_LABELS[method]}</p>
|
|
<p className="font-bold text-white text-base leading-tight">
|
|
{method === 'guest' ? `${totals[method].count} free` : formatCurrency(totals[method].total, currency)}
|
|
</p>
|
|
{method !== 'guest' && (
|
|
<p className="text-[11px] text-gray-500">
|
|
{totals[method].count} {totals[method].count === 1 ? 'payment' : 'payments'}
|
|
</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 (
|
|
<div className="fixed inset-0 z-50 bg-gray-950 flex flex-col" style={{ height: '100dvh' }}>
|
|
<header className="flex-shrink-0 bg-gray-900 border-b border-gray-800 px-4 py-3 safe-area-top flex items-center justify-between">
|
|
<div>
|
|
<p className="font-bold text-white text-lg">Session</p>
|
|
<p className="text-xs text-gray-500">{liveEntries.length} checked in from this device</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{showEventTotals && (
|
|
<button
|
|
onClick={onRefresh}
|
|
className="min-w-[48px] min-h-[48px] flex items-center justify-center rounded-full text-gray-400 active:text-white active:scale-95 transition-all"
|
|
aria-label="Refresh totals"
|
|
>
|
|
<ArrowPathIcon className={clsx('w-5 h-5', summaryLoading && 'animate-spin')} />
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={onClose}
|
|
className="min-w-[48px] min-h-[48px] flex items-center justify-center rounded-full text-gray-400 active:text-white active:scale-95 transition-all"
|
|
aria-label="Close session view"
|
|
>
|
|
<XMarkIcon className="w-6 h-6" />
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-4 space-y-5 pb-safe">
|
|
{/* This shift */}
|
|
<section className="space-y-2">
|
|
<div className="flex items-baseline justify-between">
|
|
<h2 className="text-sm font-bold text-white uppercase tracking-wide">This session</h2>
|
|
<p className="text-primary-yellow font-bold">{formatCurrency(grand, currency)}</p>
|
|
</div>
|
|
<CashUpGrid totals={totals} currency={currency} />
|
|
</section>
|
|
|
|
{/* Whole event, from the server — the number to reconcile the cash box
|
|
against. Admin/organizer only; the API enforces the same split. */}
|
|
{showEventTotals && (
|
|
<section className="space-y-2">
|
|
<div className="flex items-baseline justify-between">
|
|
<h2 className="text-sm font-bold text-white uppercase tracking-wide">Door total, whole event</h2>
|
|
<p className="text-primary-yellow font-bold">
|
|
{summary ? formatCurrency(summary.door.total, summary.currency) : '—'}
|
|
</p>
|
|
</div>
|
|
{summary ? (
|
|
<>
|
|
<CashUpGrid totals={summary.door.byMethod} currency={summary.currency} />
|
|
<div className="flex items-center justify-between bg-gray-800 border border-gray-700 rounded-xl px-3 py-2.5">
|
|
<div>
|
|
<p className="text-[11px] uppercase tracking-wide text-gray-500">Pre-sale</p>
|
|
<p className="font-bold text-white">
|
|
{formatCurrency(summary.presale.total, summary.currency)}
|
|
</p>
|
|
</div>
|
|
<div className="text-right">
|
|
<p className="text-[11px] uppercase tracking-wide text-gray-500">Event total</p>
|
|
<p className="font-bold text-white">{formatCurrency(summary.total, summary.currency)}</p>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<p className="text-sm text-gray-500">
|
|
{summaryLoading ? 'Loading totals…' : 'Totals unavailable — pull to refresh.'}
|
|
</p>
|
|
)}
|
|
</section>
|
|
)}
|
|
|
|
{/* Feed */}
|
|
<section className="space-y-2">
|
|
<h2 className="text-sm font-bold text-white uppercase tracking-wide">Recent check-ins</h2>
|
|
{entries.length === 0 ? (
|
|
<div className="text-center text-gray-500 py-10">
|
|
<ClockIcon className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
|
<p className="text-sm">No check-ins yet</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{entries.map((entry) => {
|
|
const Icon = ENTRY_ICONS[entry.entry];
|
|
return (
|
|
<div
|
|
key={entry.idempotencyKey}
|
|
className={clsx(
|
|
'rounded-xl border px-3 py-2.5 flex items-center gap-3',
|
|
entry.failed
|
|
? 'bg-red-950/40 border-red-900'
|
|
: entry.undone
|
|
? 'bg-gray-900 border-gray-800 opacity-50'
|
|
: 'bg-gray-800 border-gray-700',
|
|
)}
|
|
>
|
|
<Icon className="w-5 h-5 text-gray-500 flex-shrink-0" />
|
|
<div className="flex-1 min-w-0">
|
|
<p
|
|
className={clsx(
|
|
'font-medium truncate',
|
|
entry.undone ? 'text-gray-500 line-through' : 'text-white',
|
|
)}
|
|
>
|
|
{entry.name}
|
|
</p>
|
|
<p className="text-xs text-gray-500 truncate">
|
|
{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' : ''}
|
|
</p>
|
|
</div>
|
|
<p className="text-sm text-gray-400 flex-shrink-0">{entry.at}</p>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|