Admin hub in /crush33: sidebar, donor lookup, danger-zone actions

Rebuilt the password-gated /crush33 (in-app /comp) screen into an admin
hub with a left sidebar and three sections:

- Comp tickets — the existing entry-only comp creator.
- Donor lookup — admin-only free-text search across the donor master
  list + online/offline transaction tables by name / email / phone /
  address / bear name (columns discovered per table, deduped by email).
- Actions (danger zone) — heavy warnings, red buttons, and an
  "are you sure" modal that spells out exactly what will happen:
    • Wipe slate — delete ALL ticket + audit records in the active
      event table (donor data untouched, irreversible).
    • Switch event table — repoint the app at a different NocoDB
      tickets/audit table to start a new event while keeping the old
      one intact.

Backend:
- New /api/admin/{status,wipe,switch-table,donor-search}, all gated by
  PORTAL_PASSWORD (POST-only so it never lands in a URL/log).
- NocoDBClient + AuditLogger: runtime-switchable tableId, count(),
  deleteAll(), probeTable() (reachable + Id-PK check before switching).
- DonorService.search() with adaptive column discovery.
- Table switch persists across redeploys via a small state file on a
  new /data volume (Dockerfile creates it owned by node so it's
  writable); applied at startup in buildContext.

Also shipped equivalent CLI scripts: scripts/wipe-slate.sh and
scripts/switch-event.sh. Drawer: "Comp tickets" -> "Admin (crush33)".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-23 01:29:10 +00:00
parent 60f0908299
commit 3a3119e324
15 changed files with 1042 additions and 172 deletions

View file

@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useCallback, useEffect } from "react";
import {
StyleSheet,
View,
@ -9,10 +9,21 @@ import {
Image,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
} from "react-native";
import { router } from "expo-router";
import { SafeAreaView } from "react-native-safe-area-context";
import { portalVerify, portalCreate, AuthError, type PortalTicket } from "../lib/api";
import {
portalVerify,
portalCreate,
adminStatus,
adminWipe,
adminSwitchTable,
adminDonorSearch,
AuthError,
type PortalTicket,
type AdminStatus,
type DonorSearchResult,
} from "../lib/api";
import { useAuth } from "../lib/auth";
import { useMenu } from "../lib/menu";
import { theme } from "../lib/theme";
@ -26,18 +37,26 @@ const TYPE_ICON: Record<string, string> = {
Speaker: "🎤",
};
export default function CompScreen() {
type Section = "comp" | "donors" | "actions";
const NAV: { key: Section; icon: string; label: string }[] = [
{ key: "comp", icon: "🎟️", label: "Comp\ntickets" },
{ key: "donors", icon: "🔎", label: "Donor\nlookup" },
{ key: "actions", icon: "⚠️", label: "Actions" },
];
export default function AdminHub() {
const { operator } = useAuth();
const { open: openMenu } = useMenu();
const [password, setPassword] = useState("");
const [unlocked, setUnlocked] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [section, setSection] = useState<Section>("comp");
const [type, setType] = useState("Guest");
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [result, setResult] = useState<PortalTicket | null>(null);
const relock = useCallback(() => {
setUnlocked(false);
setError("Password changed — unlock again.");
}, []);
async function unlock() {
if (!password || busy) return;
@ -47,28 +66,7 @@ export default function CompScreen() {
await portalVerify(password);
setUnlocked(true);
} catch (e: any) {
setError(e instanceof AuthError ? "Wrong password" : (e?.message ?? "Failed"));
} finally {
setBusy(false);
}
}
async function create() {
if (!name.trim() || !email.trim() || busy) return;
setBusy(true);
setError("");
try {
const r = await portalCreate({ password, name: name.trim(), email: email.trim(), type, createdBy: operator });
setResult(r);
setName("");
setEmail("");
} catch (e: any) {
if (e instanceof AuthError) {
setUnlocked(false); // password rotated — re-gate
setError("Password changed — unlock again.");
} else {
setError(e?.message ?? "Failed to create ticket");
}
setError(e instanceof AuthError ? "Wrong password" : e?.message ?? "Failed");
} finally {
setBusy(false);
}
@ -80,160 +78,468 @@ export default function CompScreen() {
<Pressable onPress={openMenu} hitSlop={12}>
<Text style={styles.hamburger}></Text>
</Pressable>
<Text style={styles.brand}>Comp Tickets</Text>
<Text style={styles.brand}>Admin · crush33</Text>
<View style={{ width: 60 }} />
</View>
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === "ios" ? "padding" : undefined}>
<ScrollView contentContainerStyle={{ padding: 20, paddingBottom: 48 }}>
{!unlocked ? (
<View>
<Text style={styles.lead}>Entry-only tickets for workers &amp; guests. Enter the shared portal password.</Text>
<Text style={styles.label}>Portal password</Text>
<TextInput
style={styles.input}
secureTextEntry
value={password}
onChangeText={setPassword}
placeholder="Shared admin password"
placeholderTextColor={theme.textDim}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="go"
onSubmitEditing={unlock}
/>
{!!error && <Text style={styles.error}>{error}</Text>}
<Pressable style={[styles.btn, (busy || !password) && styles.btnOff]} onPress={unlock} disabled={busy || !password}>
<Text style={styles.btnText}>{busy ? "Checking…" : "Unlock"}</Text>
</Pressable>
</View>
) : (
<View>
<Text style={styles.label}>Ticket type</Text>
<View style={styles.types}>
{TYPES.map((t) => (
<Pressable
key={t}
style={[styles.typePill, type === t && styles.typePillOn]}
onPress={() => setType(t)}
>
<Text style={[styles.typePillText, type === t && styles.typePillTextOn]}>
{(TYPE_ICON[t] ?? "🎫") + " " + t}
</Text>
</Pressable>
))}
</View>
{!unlocked ? (
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === "ios" ? "padding" : undefined}>
<ScrollView contentContainerStyle={{ padding: 20 }}>
<Text style={styles.lead}>Admin-only area. Enter the shared portal password to unlock.</Text>
<Text style={styles.label}>Portal password</Text>
<TextInput
style={styles.input}
secureTextEntry
value={password}
onChangeText={setPassword}
placeholder="Shared admin password"
placeholderTextColor={theme.textDim}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="go"
onSubmitEditing={unlock}
/>
{!!error && <Text style={styles.error}>{error}</Text>}
<Pressable style={[styles.btn, (busy || !password) && styles.btnOff]} onPress={unlock} disabled={busy || !password}>
<Text style={styles.btnText}>{busy ? "Checking…" : "Unlock"}</Text>
</Pressable>
</ScrollView>
</KeyboardAvoidingView>
) : (
<View style={styles.body}>
<View style={styles.sidebar}>
{NAV.map((n) => {
const active = section === n.key;
return (
<Pressable key={n.key} style={[styles.navItem, active && styles.navItemOn]} onPress={() => setSection(n.key)}>
<Text style={styles.navIcon}>{n.icon}</Text>
<Text style={[styles.navLabel, active && styles.navLabelOn]}>{n.label}</Text>
</Pressable>
);
})}
</View>
<Text style={styles.label}>Full name</Text>
<TextInput
style={styles.input}
value={name}
onChangeText={setName}
placeholder="Attendee name"
placeholderTextColor={theme.textDim}
autoCapitalize="words"
/>
<Text style={styles.label}>Email</Text>
<TextInput
style={styles.input}
value={email}
onChangeText={setEmail}
placeholder="Where to send the ticket"
placeholderTextColor={theme.textDim}
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
/>
{!!error && <Text style={styles.error}>{error}</Text>}
<Pressable
style={[styles.btn, (busy || !name.trim() || !email.trim()) && styles.btnOff]}
onPress={create}
disabled={busy || !name.trim() || !email.trim()}
>
<Text style={styles.btnText}>{busy ? "Creating…" : `Create ${type} ticket`}</Text>
</Pressable>
{result && (
<View style={styles.result}>
<Image source={{ uri: result.qr }} style={styles.qr} />
<Text style={styles.rcode}>{result.code}</Text>
<Text style={styles.rwho}>
{result.type} · {result.name}
</Text>
<Text style={styles.rmail}>
{result.emailSent ? "✓ Emailed the ticket" : "Email not sent — screenshot this QR"}
</Text>
</View>
)}
</View>
)}
</ScrollView>
</KeyboardAvoidingView>
<View style={styles.content}>
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === "ios" ? "padding" : undefined}>
{section === "comp" && <CompSection password={password} operator={operator} onRelock={relock} />}
{section === "donors" && <DonorSection password={password} onRelock={relock} />}
{section === "actions" && <ActionsSection password={password} onRelock={relock} />}
</KeyboardAvoidingView>
</View>
</View>
)}
</SafeAreaView>
);
}
/* ---------------- Comp tickets ---------------- */
function CompSection({ password, operator, onRelock }: { password: string; operator: string | null; onRelock: () => void }) {
const [type, setType] = useState("Guest");
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [result, setResult] = useState<PortalTicket | null>(null);
async function create() {
if (!name.trim() || !email.trim() || busy) return;
setBusy(true);
setError("");
try {
const r = await portalCreate({ password, name: name.trim(), email: email.trim(), type, createdBy: operator ?? "" });
setResult(r);
setName("");
setEmail("");
} catch (e: any) {
if (e instanceof AuthError) onRelock();
else setError(e?.message ?? "Failed to create ticket");
} finally {
setBusy(false);
}
}
return (
<ScrollView contentContainerStyle={styles.pad}>
<Text style={styles.h1}>Comp tickets</Text>
<Text style={styles.sub}>Entry-only tickets for guests &amp; staff.</Text>
<Text style={styles.label}>Ticket type</Text>
<View style={styles.types}>
{TYPES.map((t) => (
<Pressable key={t} style={[styles.typePill, type === t && styles.typePillOn]} onPress={() => setType(t)}>
<Text style={[styles.typePillText, type === t && styles.typePillTextOn]}>{(TYPE_ICON[t] ?? "🎫") + " " + t}</Text>
</Pressable>
))}
</View>
<Text style={styles.label}>Full name</Text>
<TextInput style={styles.input} value={name} onChangeText={setName} placeholder="Attendee name" placeholderTextColor={theme.textDim} autoCapitalize="words" />
<Text style={styles.label}>Email</Text>
<TextInput
style={styles.input}
value={email}
onChangeText={setEmail}
placeholder="Where to send the ticket"
placeholderTextColor={theme.textDim}
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
/>
{!!error && <Text style={styles.error}>{error}</Text>}
<Pressable style={[styles.btn, (busy || !name.trim() || !email.trim()) && styles.btnOff]} onPress={create} disabled={busy || !name.trim() || !email.trim()}>
<Text style={styles.btnText}>{busy ? "Creating…" : `Create ${type} ticket`}</Text>
</Pressable>
{result && (
<View style={styles.result}>
<Image source={{ uri: result.qr }} style={styles.qr} />
<Text style={styles.rcode}>{result.code}</Text>
<Text style={styles.rwho}>{result.type} · {result.name}</Text>
<Text style={styles.rmail}>{result.emailSent ? "✓ Emailed the ticket" : "Email not sent — screenshot this QR"}</Text>
</View>
)}
</ScrollView>
);
}
/* ---------------- Donor lookup ---------------- */
function DonorSection({ password, onRelock }: { password: string; onRelock: () => void }) {
const [query, setQuery] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [results, setResults] = useState<DonorSearchResult[] | null>(null);
async function run() {
const q = query.trim();
if (q.length < 2 || busy) return;
setBusy(true);
setError("");
try {
const r = await adminDonorSearch(password, q);
setResults(r.results);
} catch (e: any) {
if (e instanceof AuthError) onRelock();
else setError(e?.message ?? "Search failed");
} finally {
setBusy(false);
}
}
return (
<ScrollView contentContainerStyle={styles.pad} keyboardShouldPersistTaps="handled">
<Text style={styles.h1}>Donor lookup</Text>
<Text style={styles.sub}>🔒 Admin only · private donor info. Search by name, email, phone, address, bear name</Text>
<View style={styles.searchRow}>
<TextInput
style={[styles.input, { flex: 1, marginBottom: 0 }]}
value={query}
onChangeText={setQuery}
placeholder="Search donors…"
placeholderTextColor={theme.textDim}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="search"
onSubmitEditing={run}
/>
<Pressable style={[styles.searchBtn, (busy || query.trim().length < 2) && styles.btnOff]} onPress={run} disabled={busy || query.trim().length < 2}>
<Text style={styles.searchBtnText}>{busy ? "…" : "Search"}</Text>
</Pressable>
</View>
{!!error && <Text style={styles.error}>{error}</Text>}
{results !== null && !busy && results.length === 0 && <Text style={styles.sub}>No donors match {query.trim()}.</Text>}
{results?.map((d, i) => (
<View key={i} style={styles.donorCard}>
<View style={styles.donorHead}>
<Text style={styles.donorName}>{d.name || d.email || "(unnamed)"}</Text>
{d.lifetime != null && <Text style={styles.donorAmt}>{money(d.lifetime)}</Text>}
</View>
{!!d.bearName && <Text style={styles.donorLine}>🐻 {d.bearName}</Text>}
{!!d.email && <Text style={styles.donorLine}> {d.email}</Text>}
{!!d.altEmail && <Text style={styles.donorLine}> {d.altEmail} (alt)</Text>}
{!!d.phone && <Text style={styles.donorLine}>📞 {d.phone}</Text>}
{!!d.address && <Text style={styles.donorLine}>🏠 {d.address}</Text>}
<View style={styles.donorTags}>
<Text style={styles.donorSource}>{d.source === "master" ? "directory" : "transactions"}</Text>
{d.tags.map((t) => (
<Text key={t} style={styles.donorTag}>{t}</Text>
))}
</View>
</View>
))}
</ScrollView>
);
}
/* ---------------- Actions (danger zone) ---------------- */
function ActionsSection({ password, onRelock }: { password: string; onRelock: () => void }) {
const [status, setStatus] = useState<AdminStatus | null>(null);
const [loading, setLoading] = useState(false);
const [msg, setMsg] = useState("");
const [confirm, setConfirm] = useState<null | "wipe" | "switch">(null);
const [busy, setBusy] = useState(false);
const [newTickets, setNewTickets] = useState("");
const [newAudit, setNewAudit] = useState("");
const refresh = useCallback(async () => {
setLoading(true);
try {
setStatus(await adminStatus(password));
} catch (e: any) {
if (e instanceof AuthError) onRelock();
} finally {
setLoading(false);
}
}, [password, onRelock]);
// Load status the first time this section renders.
useEffect(() => {
refresh();
}, [refresh]);
async function doWipe() {
setBusy(true);
setMsg("");
try {
const r = await adminWipe(password);
setMsg(`✓ Wiped ${r.ticketsDeleted} tickets and ${r.auditDeleted} audit rows.`);
setConfirm(null);
refresh();
} catch (e: any) {
if (e instanceof AuthError) onRelock();
else setMsg(e?.message ?? "Wipe failed");
} finally {
setBusy(false);
}
}
async function doSwitch() {
if (!newTickets.trim()) return;
setBusy(true);
setMsg("");
try {
const r = await adminSwitchTable(password, newTickets.trim(), newAudit.trim() || undefined);
setMsg(`✓ Now using tickets table ${r.tickets.tableId}.`);
setConfirm(null);
setNewTickets("");
setNewAudit("");
refresh();
} catch (e: any) {
if (e instanceof AuthError) onRelock();
else setMsg(e?.message ?? "Switch failed");
} finally {
setBusy(false);
}
}
return (
<ScrollView contentContainerStyle={styles.pad} keyboardShouldPersistTaps="handled">
<Text style={styles.h1}>Actions</Text>
<Text style={styles.sub}>Event-management tools. These change live data read the warnings.</Text>
{/* Current status */}
<View style={styles.statusBox}>
<View style={styles.statusRow}>
<Text style={styles.statusLabel}>Active event table</Text>
<Pressable onPress={refresh} hitSlop={8}>
<Text style={styles.refresh}>{loading ? "…" : "↻"}</Text>
</Pressable>
</View>
{status ? (
<>
<Text style={styles.statusVal}>tickets: {status.tickets.tableId} · {status.tickets.count} records</Text>
<Text style={styles.statusVal}>audit: {status.audit.tableId ?? "—"} · {status.audit.count} records</Text>
</>
) : (
<Text style={styles.statusVal}>{loading ? "loading…" : "—"}</Text>
)}
</View>
{!!msg && <Text style={styles.msg}>{msg}</Text>}
{/* Wipe slate */}
<View style={styles.dangerCard}>
<Text style={styles.dangerTitle}>🧹 Wipe the slate clean</Text>
<Text style={styles.dangerBody}>
Permanently deletes <Text style={styles.bold}>every ticket and every check-in</Text> in the active event
table. Use this to reset before a run-through or a fresh event.
</Text>
<Text style={styles.dangerBullet}> Does NOT affect donor data.</Text>
<Text style={styles.dangerBullet}> Cannot be undone.</Text>
<Pressable style={styles.redBtn} onPress={() => { setMsg(""); setConfirm("wipe"); }}>
<Text style={styles.redBtnText}>Wipe slate</Text>
</Pressable>
</View>
{/* Switch table */}
<View style={styles.dangerCard}>
<Text style={styles.dangerTitle}>🔀 Switch event table</Text>
<Text style={styles.dangerBody}>
Point the scanner at a <Text style={styles.bold}>different NocoDB table</Text> e.g. to start a new event on
a fresh table while keeping the current one intact.
</Text>
<Text style={styles.dangerBullet}> Create the new table first (duplicate the current one's structure in NocoDB keep the Id column).</Text>
<Text style={styles.dangerBullet}> The current event's data is NOT deleted, just no longer shown.</Text>
<Text style={styles.label}>New tickets table ID</Text>
<TextInput style={styles.input} value={newTickets} onChangeText={setNewTickets} placeholder="e.g. mv1a2b3c…" placeholderTextColor={theme.textDim} autoCapitalize="none" autoCorrect={false} />
<Text style={styles.label}>New audit table ID (optional)</Text>
<TextInput style={styles.input} value={newAudit} onChangeText={setNewAudit} placeholder="leave blank to keep current" placeholderTextColor={theme.textDim} autoCapitalize="none" autoCorrect={false} />
<Pressable style={[styles.redBtn, !newTickets.trim() && styles.btnOff]} onPress={() => { setMsg(""); setConfirm("switch"); }} disabled={!newTickets.trim()}>
<Text style={styles.redBtnText}>Switch table</Text>
</Pressable>
</View>
{confirm === "wipe" && (
<ConfirmModal
title="Wipe the slate clean?"
lines={[
`This will PERMANENTLY DELETE all data in the active event table:`,
`${status?.tickets.count ?? "?"} ticket records (${status?.tickets.tableId ?? "?"})`,
`${status?.audit.count ?? "?"} check-in / audit records`,
``,
`Donor data is not touched. This CANNOT be undone.`,
]}
confirmLabel={busy ? "Wiping…" : "Yes, delete everything"}
busy={busy}
onConfirm={doWipe}
onCancel={() => setConfirm(null)}
/>
)}
{confirm === "switch" && (
<ConfirmModal
title="Switch the active event table?"
lines={[
`The scanner will start using:`,
`• tickets → ${newTickets.trim()}`,
newAudit.trim() ? `• audit → ${newAudit.trim()}` : `• audit → unchanged`,
``,
`The current event (${status?.tickets.tableId ?? "?"}, ${status?.tickets.count ?? "?"} records) stays intact but will no longer be shown until you switch back. Purchases and scans will go to the new table.`,
]}
confirmLabel={busy ? "Switching…" : "Yes, switch table"}
busy={busy}
onConfirm={doSwitch}
onCancel={() => setConfirm(null)}
/>
)}
</ScrollView>
);
}
function ConfirmModal({
title,
lines,
confirmLabel,
busy,
onConfirm,
onCancel,
}: {
title: string;
lines: string[];
confirmLabel: string;
busy: boolean;
onConfirm: () => void;
onCancel: () => void;
}) {
return (
<View style={styles.modalScrim}>
<View style={styles.modalCard}>
<Text style={styles.modalWarn}></Text>
<Text style={styles.modalTitle}>{title}</Text>
{lines.map((l, i) => (
<Text key={i} style={styles.modalLine}>{l}</Text>
))}
<Pressable style={[styles.redBtn, busy && styles.btnOff]} onPress={onConfirm} disabled={busy}>
{busy ? <ActivityIndicator color="#fff" /> : <Text style={styles.redBtnText}>{confirmLabel}</Text>}
</Pressable>
<Pressable style={styles.cancelBtn} onPress={onCancel} disabled={busy}>
<Text style={styles.cancelText}>Cancel</Text>
</Pressable>
</View>
</View>
);
}
function money(n: number): string {
return "$" + Math.round(n).toLocaleString();
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: theme.bg },
topbar: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 16,
paddingVertical: 10,
},
topbar: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: 16, paddingVertical: 10 },
brand: { color: theme.text, fontSize: 18, fontWeight: "700" },
hamburger: { color: theme.text, fontSize: 26, fontWeight: "700" },
link: { color: theme.textDim, fontSize: 16, fontWeight: "600", width: 72 },
body: { flex: 1, flexDirection: "row" },
sidebar: { width: 84, backgroundColor: theme.card, borderRightWidth: 1, borderRightColor: theme.cardBorder, paddingTop: 8 },
navItem: { paddingVertical: 14, alignItems: "center", gap: 4, borderLeftWidth: 3, borderLeftColor: "transparent" },
navItemOn: { backgroundColor: theme.bg, borderLeftColor: theme.primary },
navIcon: { fontSize: 22 },
navLabel: { color: theme.textDim, fontSize: 11, fontWeight: "700", textAlign: "center", lineHeight: 13 },
navLabelOn: { color: theme.text },
content: { flex: 1 },
pad: { padding: 16, paddingBottom: 48 },
h1: { color: theme.text, fontSize: 22, fontWeight: "800", marginBottom: 2 },
sub: { color: theme.textDim, fontSize: 13, lineHeight: 19, marginBottom: 8 },
lead: { color: theme.textDim, fontSize: 15, lineHeight: 21, marginBottom: 8 },
label: { color: theme.textDim, fontSize: 13, marginTop: 16, marginBottom: 6 },
input: {
backgroundColor: theme.card,
borderWidth: 1,
borderColor: theme.cardBorder,
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 14,
color: theme.text,
fontSize: 16,
},
label: { color: theme.textDim, fontSize: 13, marginTop: 14, marginBottom: 6 },
input: { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.cardBorder, borderRadius: 12, paddingHorizontal: 14, paddingVertical: 12, color: theme.text, fontSize: 16, marginBottom: 2 },
error: { color: theme.dangerBright, marginTop: 12, fontSize: 14, fontWeight: "600" },
btn: {
backgroundColor: theme.successBright,
borderRadius: 13,
paddingVertical: 15,
alignItems: "center",
marginTop: 20,
},
msg: { color: theme.successBright, marginTop: 10, fontSize: 14, fontWeight: "700" },
bold: { fontWeight: "800", color: theme.text },
btn: { backgroundColor: theme.successBright, borderRadius: 13, paddingVertical: 15, alignItems: "center", marginTop: 18 },
btnOff: { opacity: 0.4 },
btnText: { color: "#06210f", fontSize: 18, fontWeight: "800" },
types: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
typePill: {
backgroundColor: theme.card,
borderWidth: 1,
borderColor: theme.cardBorder,
borderRadius: 999,
paddingHorizontal: 14,
paddingVertical: 9,
},
typePill: { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.cardBorder, borderRadius: 999, paddingHorizontal: 13, paddingVertical: 8 },
typePillOn: { backgroundColor: theme.primary, borderColor: theme.primary },
typePillText: { color: theme.textDim, fontSize: 14, fontWeight: "700" },
typePillText: { color: theme.textDim, fontSize: 13, fontWeight: "700" },
typePillTextOn: { color: "#fff" },
result: {
marginTop: 22,
alignItems: "center",
backgroundColor: theme.card,
borderWidth: 1,
borderColor: theme.cardBorder,
borderRadius: 16,
padding: 20,
},
qr: { width: 220, height: 220, backgroundColor: "#fff", borderRadius: 10 },
result: { marginTop: 22, alignItems: "center", backgroundColor: theme.card, borderWidth: 1, borderColor: theme.cardBorder, borderRadius: 16, padding: 20 },
qr: { width: 200, height: 200, backgroundColor: "#fff", borderRadius: 10 },
rcode: { color: theme.successBright, fontSize: 22, fontWeight: "800", letterSpacing: 2, marginTop: 12 },
rwho: { color: theme.text, fontSize: 16, marginTop: 4 },
rmail: { color: theme.textDim, fontSize: 13, marginTop: 8 },
searchRow: { flexDirection: "row", gap: 8, alignItems: "center", marginTop: 8 },
searchBtn: { backgroundColor: theme.primary, borderRadius: 12, paddingHorizontal: 16, paddingVertical: 13 },
searchBtnText: { color: "#fff", fontWeight: "800", fontSize: 15 },
donorCard: { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.cardBorder, borderRadius: 12, padding: 14, marginTop: 12 },
donorHead: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" },
donorName: { color: theme.text, fontSize: 17, fontWeight: "800", flex: 1 },
donorAmt: { color: theme.successBright, fontSize: 16, fontWeight: "800", marginLeft: 8 },
donorLine: { color: theme.textDim, fontSize: 14, marginTop: 3 },
donorTags: { flexDirection: "row", flexWrap: "wrap", gap: 6, marginTop: 8, alignItems: "center" },
donorSource: { color: theme.textDim, fontSize: 11, fontWeight: "700", textTransform: "uppercase", backgroundColor: theme.bg, borderWidth: 1, borderColor: theme.cardBorder, borderRadius: 6, paddingHorizontal: 6, paddingVertical: 2 },
donorTag: { color: theme.text, fontSize: 12, backgroundColor: theme.primaryDark, borderRadius: 6, paddingHorizontal: 7, paddingVertical: 2 },
statusBox: { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.cardBorder, borderRadius: 12, padding: 14, marginTop: 4 },
statusRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" },
statusLabel: { color: theme.textDim, fontSize: 12, fontWeight: "700", textTransform: "uppercase", letterSpacing: 0.5 },
refresh: { color: theme.text, fontSize: 20 },
statusVal: { color: theme.text, fontSize: 14, marginTop: 6, fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace" },
dangerCard: { backgroundColor: "#241717", borderWidth: 1, borderColor: theme.danger, borderRadius: 14, padding: 16, marginTop: 18 },
dangerTitle: { color: "#ff9a9a", fontSize: 17, fontWeight: "800", marginBottom: 6 },
dangerBody: { color: "#e9cfcf", fontSize: 14, lineHeight: 20 },
dangerBullet: { color: "#d9b8b8", fontSize: 13, lineHeight: 19, marginTop: 4 },
redBtn: { backgroundColor: theme.dangerBright, borderRadius: 12, paddingVertical: 14, alignItems: "center", marginTop: 16 },
redBtnText: { color: "#fff", fontSize: 16, fontWeight: "800" },
modalScrim: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, backgroundColor: "rgba(0,0,0,0.72)", alignItems: "center", justifyContent: "center", padding: 24 },
modalCard: { backgroundColor: "#1a1010", borderWidth: 2, borderColor: theme.dangerBright, borderRadius: 18, padding: 22, width: "100%", maxWidth: 380 },
modalWarn: { fontSize: 40, textAlign: "center" },
modalTitle: { color: "#fff", fontSize: 20, fontWeight: "900", textAlign: "center", marginTop: 4, marginBottom: 12 },
modalLine: { color: "#f0d9d9", fontSize: 14, lineHeight: 20 },
cancelBtn: { paddingVertical: 14, alignItems: "center", marginTop: 6 },
cancelText: { color: theme.textDim, fontSize: 16, fontWeight: "700" },
});

View file

@ -7,8 +7,8 @@ import { theme } from "../lib/theme";
const ITEMS: { label: string; icon: string; route: string; seg: string }[] = [
{ label: "Scanner", icon: "📷", route: "/", seg: "" },
{ label: "Event report", icon: "📊", route: "/stats", seg: "stats" },
{ label: "Comp tickets", icon: "🎟️", route: "/comp", seg: "comp" },
{ label: "Admin lookup", icon: "🔎", route: "/admin", seg: "admin" },
{ label: "Admin (crush33)", icon: "🔐", route: "/comp", seg: "comp" },
{ label: "Banquet lookup", icon: "🍽️", route: "/admin", seg: "admin" },
];
export default function SideMenu({ visible, onClose }: { visible: boolean; onClose: () => void }) {

View file

@ -252,6 +252,56 @@ export async function portalCreate(input: {
return body;
}
// ---- Admin actions (all gated by the portal password) ----
async function adminPost<T>(path: string, password: string, extra: Record<string, unknown> = {}): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password, ...extra }),
});
if (res.status === 401) throw new AuthError("Wrong password");
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new ApiError(body?.detail ?? body?.error ?? `Request failed (${res.status})`);
return body as T;
}
export interface AdminStatus {
tickets: { tableId: string; count: number };
audit: { tableId: string | null; count: number; enabled: boolean };
defaults: { ticketsTableId: string; auditTableId: string | null };
}
export function adminStatus(password: string): Promise<AdminStatus> {
return adminPost<AdminStatus>("/api/admin/status", password);
}
export function adminWipe(password: string): Promise<{ ok: boolean; ticketsDeleted: number; auditDeleted: number }> {
return adminPost("/api/admin/wipe", password);
}
export function adminSwitchTable(
password: string,
ticketsTableId: string,
auditTableId?: string,
): Promise<{ ok: boolean; tickets: { tableId: string }; audit: { tableId: string | null } }> {
return adminPost("/api/admin/switch-table", password, { ticketsTableId, auditTableId });
}
export interface DonorSearchResult {
name: string;
bearName: string;
email: string;
altEmail: string;
phone: string;
address: string;
lifetime: number | null;
tags: string[];
source: "master" | "transactions";
}
export function adminDonorSearch(password: string, query: string): Promise<{ results: DonorSearchResult[]; query: string }> {
return adminPost("/api/admin/donor-search", password, { query });
}
export function getAudit(opts: { code?: string; limit?: number } = {}): Promise<{
enabled: boolean;
entries: AuditEntry[];