Add Check-in / Ice / Banquet scan modes

- Ice mode: prepaid ice bags (Ice Total / Ice Redeemed columns) redeemed
  independently of ticket check-ins; grab all bags at once or some now.
- Banquet mode: donor total (online + offline) looked up by the ticket's
  email via the Donors Master List, with a manual email override. New
  DonorService + POST /api/banquet.
- Redeem generalized over a resource (tickets|ice); audit records ice actions.
- App gains a mode selector; webhook maps ice_bags (defaults to
  ICE_BAGS_DEFAULT when only a boolean ice option is present).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-08 04:07:51 +00:00
parent b36d63a6a4
commit d293e53ee6
15 changed files with 587 additions and 125 deletions

View file

@ -24,6 +24,8 @@ export const COL = {
redeemed: "Redeemed",
submissionKey: "SubmissionKey",
lastScanAt: "LastScanAt",
iceTotal: "Ice Total", // prepaid ice bags
iceRedeemed: "Ice Redeemed", // bags picked up
} as const;
/** Age-bracket columns, in order. */
@ -74,6 +76,12 @@ export function ageBreakdown(rec: NocoRecord): { bracket: string; count: number;
})).filter((b) => b.count > 0);
}
export interface ResourceCount {
total: number;
redeemed: number;
remaining: number;
}
export interface TicketView {
code: string;
name: string;
@ -81,6 +89,7 @@ export interface TicketView {
total: number;
redeemed: number;
remaining: number;
ice: ResourceCount;
extras: {
carParking: boolean;
rvParking: boolean;
@ -91,9 +100,15 @@ export interface TicketView {
ages: { bracket: string; count: number; free: boolean }[];
}
export function computeIceTotal(rec: NocoRecord): number {
return num(rec[COL.iceTotal]);
}
export function toView(rec: NocoRecord): TicketView {
const total = computeTotal(rec);
const redeemed = num(rec[COL.redeemed]);
const iceTotal = computeIceTotal(rec);
const iceRedeemed = num(rec[COL.iceRedeemed]);
return {
code: String(rec[COL.code] ?? ""),
name: String(rec[COL.name] ?? ""),
@ -101,6 +116,11 @@ export function toView(rec: NocoRecord): TicketView {
total,
redeemed,
remaining: Math.max(0, total - redeemed),
ice: {
total: iceTotal,
redeemed: iceRedeemed,
remaining: Math.max(0, iceTotal - iceRedeemed),
},
extras: {
carParking: bool(rec[COL.carParking]),
rvParking: bool(rec[COL.rvParking]),