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:
parent
60f0908299
commit
3a3119e324
15 changed files with 1042 additions and 172 deletions
630
app/app/comp.tsx
630
app/app/comp.tsx
|
|
@ -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 & 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 & 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" },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue