CampgroundTickets/backend/src/fields.ts
Hank 43fddec286
All checks were successful
Build Android APK / build-apk (push) Successful in 56m36s
Add event report dashboard, slide-out drawer, in-app comp portal + creator tracking
- Reporting: GET /api/stats aggregates check-in progress, ice, ticket types,
  people breakdown, add-ons/donors, gate-crew leaderboard (from audit),
  comp tickets by creator, and a by-hour check-in timeline. New /stats screen.
- Slide-out drawer (custom RN Animated, no new native deps) replaces per-screen
  header links; available on every main screen via a hamburger.
- In-app comp portal (/comp), password-gated like /crush33, reusing the portal
  endpoints; records the issuing gate-staff name (Created By column) and reports
  comps per creator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:30:18 +00:00

153 lines
4.5 KiB
TypeScript

/**
* Mapping for the "2026 Campground Tickets" NocoDB table, matching the 2026
* FluentForms "Tickets 2026" schema. Change titles here if the columns differ.
*/
export const COL = {
id: "Id",
name: "Title", // purchaser full name
adultNames: "Adult Names", // newline-separated list of adult attendee names
email: "Email Address",
address: "Address",
isDonor: "Is Donor",
donorTier: "Donor Tier", // member / donor / ""
vouchers: "Vouchers",
// Attendee counts by group:
adults: "Adults",
youth: "Youth 13-16",
kids12: "Kids 10-12",
kids9: "Kids 5-9",
kids4: "Kids 0-4", // free — NOT counted toward the scannable total
carParking: "Car Parking",
rvParking: "RV Parking",
utv: "UTV",
iceAccess: "Ice Access",
paymentMethod: "Payment Method",
ticketType: "Ticket Type", // "" for regular; Guest/Worker/Performer/Volunteer/Speaker for portal comps
createdBy: "Created By", // gate-staff name who issued a comp ticket (portal)
// Columns this system manages:
code: "Ticket Code",
redeemed: "Redeemed",
submissionKey: "SubmissionKey",
lastScanAt: "LastScanAt",
iceTotal: "Ice Total",
iceRedeemed: "Ice Redeemed",
} as const;
export type NocoRecord = Record<string, unknown> & { Id: number };
function num(v: unknown): number {
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
function bool(v: unknown): boolean {
if (typeof v === "boolean") return v;
if (typeof v === "number") return v !== 0;
if (typeof v === "string") return /^(1|true|yes|y|on)$/i.test(v.trim());
return false;
}
/**
* Total scannable tickets = everyone except kids 0-4 (who are free):
* adults + youth (13-16) + kids 10-12 + kids 5-9.
*/
export function computeTotal(rec: NocoRecord): number {
return num(rec[COL.adults]) + num(rec[COL.youth]) + num(rec[COL.kids12]) + num(rec[COL.kids9]);
}
export function computeIceTotal(rec: NocoRecord): number {
return num(rec[COL.iceTotal]);
}
/** Per-group breakdown for display. */
export function ageBreakdown(rec: NocoRecord): { bracket: string; count: number; free: boolean }[] {
return [
{ bracket: "Adults", count: num(rec[COL.adults]), free: false },
{ bracket: "Youth 13-16", count: num(rec[COL.youth]), free: false },
{ bracket: "Kids 10-12", count: num(rec[COL.kids12]), free: false },
{ bracket: "Kids 5-9", count: num(rec[COL.kids9]), free: false },
{ bracket: "Kids 0-4", count: num(rec[COL.kids4]), free: true },
].filter((b) => b.count > 0);
}
/** Adult names stored as a newline-separated list. */
export function parseAdultNames(rec: NocoRecord): string[] {
const raw = rec[COL.adultNames];
if (Array.isArray(raw)) return raw.map((x) => String(x)).filter(Boolean);
if (typeof raw === "string") {
return raw
.split(/\r?\n/)
.map((s) => s.trim())
.filter(Boolean);
}
return [];
}
export interface ResourceCount {
total: number;
redeemed: number;
remaining: number;
}
export interface TicketView {
code: string;
name: string;
email: string;
ticketType: string; // "" for regular; Guest/Worker/... for special tickets
createdBy: string; // who issued a comp ticket
total: number;
redeemed: number;
remaining: number;
ice: ResourceCount;
adultNames: string[];
extras: {
carParking: boolean;
rvParking: boolean;
utv: boolean;
iceAccess: boolean;
isDonor: boolean;
donorTier: string;
vouchers: number;
freeUnder5: number;
};
ages: { bracket: string; count: number; free: boolean }[];
}
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] ?? ""),
email: String(rec[COL.email] ?? ""),
ticketType: String(rec[COL.ticketType] ?? ""),
createdBy: String(rec[COL.createdBy] ?? ""),
total,
redeemed,
remaining: Math.max(0, total - redeemed),
ice: {
total: iceTotal,
redeemed: iceRedeemed,
remaining: Math.max(0, iceTotal - iceRedeemed),
},
adultNames: parseAdultNames(rec),
extras: {
carParking: bool(rec[COL.carParking]),
rvParking: bool(rec[COL.rvParking]),
utv: bool(rec[COL.utv]),
iceAccess: bool(rec[COL.iceAccess]),
isDonor: bool(rec[COL.isDonor]),
donorTier: String(rec[COL.donorTier] ?? ""),
vouchers: num(rec[COL.vouchers]),
freeUnder5: num(rec[COL.kids4]),
},
ages: ageBreakdown(rec),
};
}
export { num as toNumber, bool as toBool };