CampgroundTickets/app/app/admin.tsx
Hank 4b78c82b35
Some checks failed
Build Android APK / build-apk (push) Failing after 1h11m4s
Add /crush33 comp-ticket portal + ticket-type badge on scan
Portal (/crush33): password-gated page (PORTAL_PASSWORD) for admins to create
entry-only tickets from just name + email, with a category
(Guest/Worker/Performer/Volunteer/Speaker). Creates a 1-admission ticket, emails
the QR, and shows the QR on-screen. New Ticket Type column.

Scanner: shows a prominent TYPE badge (🎭 PERFORMER, 🛠️ WORKER, …) on the
confirm + success screens and in admin, so staff can see it's a special ticket.
Added Worker + Performer personas to /test.

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

361 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useState } from "react";
import { StyleSheet, View, Text, Pressable, TextInput, ScrollView, ActivityIndicator } from "react-native";
import { router } from "expo-router";
import { SafeAreaView } from "react-native-safe-area-context";
import { searchTickets, redeem, getAudit, type TicketView, type AuditEntry } from "../lib/api";
import { feedbackSuccess, feedbackError } from "../lib/feedback";
import { theme } from "../lib/theme";
function fmtTime(iso: string): string {
if (!iso) return "";
const d = new Date(iso);
if (isNaN(d.getTime())) return iso.slice(0, 16).replace("T", " ");
return d.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
function AuditList({ entries }: { entries: AuditEntry[] }) {
if (!entries.length) return <Text style={styles.auditEmpty}>No check-ins recorded yet.</Text>;
return (
<View style={styles.auditList}>
{entries.map((e) => (
<View key={e.id} style={styles.auditRow}>
<Text style={[styles.auditPeople, e.people < 0 && styles.auditUndo]}>
{e.people > 0 ? `+${e.people}` : e.people}
</Text>
<View style={{ flex: 1 }}>
<Text style={styles.auditName} numberOfLines={1}>
{e.name || e.code}
</Text>
<Text style={styles.auditMeta}>
{fmtTime(e.at)} · {e.action} · {e.remainingAfter} left
{e.operator ? ` · ${e.operator}` : ""}
</Text>
</View>
</View>
))}
</View>
);
}
export default function AdminScreen() {
const [q, setQ] = useState("");
const [results, setResults] = useState<TicketView[]>([]);
const [busy, setBusy] = useState(false);
const [note, setNote] = useState("");
const [searched, setSearched] = useState(false);
const [showRecent, setShowRecent] = useState(false);
const [recent, setRecent] = useState<AuditEntry[]>([]);
const [recentLoading, setRecentLoading] = useState(false);
const loadRecent = useCallback(async () => {
setRecentLoading(true);
try {
const { entries } = await getAudit({ limit: 30 });
setRecent(entries);
} catch (e: any) {
if (e?.name === "AuthError") return router.replace("/login");
} finally {
setRecentLoading(false);
}
}, []);
const toggleRecent = useCallback(() => {
setShowRecent((v) => {
if (!v) loadRecent();
return !v;
});
}, [loadRecent]);
const doSearch = useCallback(async () => {
if (!q.trim()) return;
setBusy(true);
setNote("");
try {
const { results } = await searchTickets(q.trim());
setResults(results);
setSearched(true);
} catch (e: any) {
if (e?.name === "AuthError") return router.replace("/login");
setNote(e?.message ?? "Search failed");
} finally {
setBusy(false);
}
}, [q]);
const adjust = useCallback(async (t: TicketView, delta: number) => {
setNote("");
try {
const res = await redeem(t.code, delta);
if (!res.ok) {
feedbackError();
const msgs: Record<string, string> = {
insufficient: `Only ${res.ticket?.remaining ?? 0} remaining.`,
exhausted: "Already fully redeemed.",
not_found: "Ticket not found.",
db_error: `Database error: ${res.detail ?? ""}`,
};
setNote(msgs[res.reason] ?? "Update failed");
if (res.ticket) updateRow(res.ticket);
return;
}
feedbackSuccess();
updateRow(res.ticket);
setNote(`${delta > 0 ? "Checked in" : "Restored"} ${Math.abs(delta)} for ${res.ticket.name}. Database updated.`);
} catch (e: any) {
if (e?.name === "AuthError") return router.replace("/login");
feedbackError();
setNote(e?.message ?? "Update failed");
}
function updateRow(updated: TicketView) {
setResults((rows) => rows.map((r) => (r.code === updated.code ? updated : r)));
}
}, []);
return (
<SafeAreaView style={styles.root} edges={["top", "bottom"]}>
<View style={styles.topbar}>
<Pressable onPress={() => router.replace("/")} hitSlop={10}>
<Text style={styles.link} numberOfLines={1}>
Scanner
</Text>
</Pressable>
<Text style={styles.brand}>Admin lookup</Text>
<View style={{ width: 72 }} />
</View>
<View style={styles.searchRow}>
<TextInput
style={styles.input}
placeholder="Name, email, or ticket code"
placeholderTextColor={theme.textDim}
value={q}
onChangeText={setQ}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="search"
onSubmitEditing={doSearch}
/>
<Pressable style={styles.searchBtn} onPress={doSearch}>
<Text style={styles.searchBtnText}>Search</Text>
</Pressable>
</View>
<Pressable style={styles.recentToggle} onPress={toggleRecent}>
<Text style={styles.recentToggleText}>
{showRecent ? "▾ Recent check-ins" : "▸ Recent check-ins"}
</Text>
{showRecent && (
<Pressable onPress={loadRecent} hitSlop={8}>
<Text style={styles.refresh}> Refresh</Text>
</Pressable>
)}
</Pressable>
{showRecent && (
<View style={styles.recentBox}>
{recentLoading ? <ActivityIndicator color={theme.successBright} /> : <AuditList entries={recent} />}
</View>
)}
{!!note && <Text style={styles.note}>{note}</Text>}
{busy ? (
<ActivityIndicator color={theme.successBright} style={{ marginTop: 30 }} />
) : (
<ScrollView style={styles.list} contentContainerStyle={{ paddingBottom: 40 }}>
{searched && results.length === 0 && <Text style={styles.empty}>No matching tickets.</Text>}
{results.map((t) => (
<TicketCard key={t.code} ticket={t} onAdjust={adjust} />
))}
</ScrollView>
)}
</SafeAreaView>
);
}
function TicketCard({ ticket, onAdjust }: { ticket: TicketView; onAdjust: (t: TicketView, d: number) => void }) {
const [showHistory, setShowHistory] = useState(false);
const [history, setHistory] = useState<AuditEntry[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
const loadHistory = useCallback(async () => {
setHistoryLoading(true);
try {
const { entries } = await getAudit({ code: ticket.code, limit: 25 });
setHistory(entries);
} catch {
/* ignore */
} finally {
setHistoryLoading(false);
}
}, [ticket.code]);
// Refresh history after a check-in/undo changes the count while it's open.
useEffect(() => {
if (showHistory) loadHistory();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ticket.redeemed, showHistory]);
const tags: string[] = [];
const e = ticket.extras;
if (ticket.ticketType) tags.push(`🎫 ${ticket.ticketType}`);
if (e.donorTier === "member") tags.push("🐻 Member");
else if (e.isDonor) tags.push("⭐ Donor");
if (e.carParking) tags.push("🚗 Car");
if (e.rvParking) tags.push("🚐 RV");
if (e.utv) tags.push("🏍️ UTV");
if (e.iceAccess || ticket.ice.total > 0) tags.push(`🧊 ${ticket.ice.remaining}/${ticket.ice.total}`);
if (e.freeUnder5 > 0) tags.push(`👶 ${e.freeUnder5} free`);
return (
<View style={styles.card}>
<View style={styles.cardHead}>
<Text style={styles.cardName}>{ticket.name}</Text>
<Text style={styles.cardCode}>{ticket.code}</Text>
</View>
{!!ticket.email && <Text style={styles.cardEmail}>{ticket.email}</Text>}
<Text style={styles.cardCounts}>
<Text style={{ color: theme.successBright, fontWeight: "800" }}>{ticket.remaining}</Text> remaining ·{" "}
{ticket.redeemed}/{ticket.total} redeemed
</Text>
{tags.length > 0 && (
<View style={styles.tags}>
{tags.map((t) => (
<Text key={t} style={styles.tag}>
{t}
</Text>
))}
</View>
)}
<View style={styles.actions}>
<Pressable
style={[styles.actBtn, styles.actUndo]}
onPress={() => onAdjust(ticket, -1)}
disabled={ticket.redeemed <= 0}
>
<Text style={styles.actText}> Undo 1</Text>
</Pressable>
{[1, 2, 5].map((n) => (
<Pressable
key={n}
style={[styles.actBtn, styles.actRedeem, ticket.remaining < n && styles.actDisabled]}
onPress={() => onAdjust(ticket, n)}
disabled={ticket.remaining < n}
>
<Text style={styles.actText}>+ Check in {n}</Text>
</Pressable>
))}
</View>
<Pressable style={styles.historyToggle} onPress={() => setShowHistory((v) => !v)}>
<Text style={styles.historyToggleText}>
{showHistory ? "▾ Hide check-in history" : "▸ Check-in history"}
</Text>
</Pressable>
{showHistory &&
(historyLoading ? (
<ActivityIndicator color={theme.successBright} style={{ marginTop: 8 }} />
) : (
<AuditList entries={history} />
))}
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: theme.bg },
topbar: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 16,
paddingVertical: 10,
},
brand: { color: theme.text, fontSize: 18, fontWeight: "700" },
link: { color: theme.textDim, fontSize: 16, fontWeight: "600" },
searchRow: { flexDirection: "row", gap: 10, paddingHorizontal: 16, marginTop: 6 },
input: {
flex: 1,
backgroundColor: theme.card,
borderWidth: 1,
borderColor: theme.cardBorder,
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 12,
color: theme.text,
fontSize: 16,
},
searchBtn: { backgroundColor: theme.primary, borderRadius: 12, paddingHorizontal: 18, justifyContent: "center" },
searchBtnText: { color: "#fff", fontSize: 16, fontWeight: "700" },
note: { color: theme.text, backgroundColor: theme.card, marginHorizontal: 16, marginTop: 12, padding: 12, borderRadius: 10, fontSize: 14 },
list: { flex: 1, marginTop: 12, paddingHorizontal: 16 },
empty: { color: theme.textDim, textAlign: "center", marginTop: 30, fontSize: 16 },
card: {
backgroundColor: theme.card,
borderWidth: 1,
borderColor: theme.cardBorder,
borderRadius: 14,
padding: 16,
marginBottom: 14,
},
cardHead: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: 6 },
cardName: { color: theme.text, fontSize: 20, fontWeight: "700" },
cardCode: { color: theme.textDim, fontSize: 14, letterSpacing: 1 },
cardEmail: { color: theme.textDim, fontSize: 14, marginTop: 2 },
cardCounts: { color: theme.text, fontSize: 16, marginTop: 10 },
tags: { flexDirection: "row", flexWrap: "wrap", gap: 6, marginTop: 10 },
tag: {
color: theme.text,
backgroundColor: theme.cardBorder,
paddingHorizontal: 9,
paddingVertical: 4,
borderRadius: 999,
fontSize: 12,
overflow: "hidden",
},
actions: { flexDirection: "row", flexWrap: "wrap", gap: 8, marginTop: 14 },
actBtn: { paddingHorizontal: 14, paddingVertical: 10, borderRadius: 10 },
actRedeem: { backgroundColor: theme.primary },
actUndo: { backgroundColor: theme.warn },
actDisabled: { opacity: 0.35 },
actText: { color: "#fff", fontSize: 14, fontWeight: "700" },
recentToggle: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 16,
marginTop: 14,
},
recentToggleText: { color: theme.text, fontSize: 15, fontWeight: "700" },
refresh: { color: theme.textDim, fontSize: 14 },
recentBox: {
backgroundColor: theme.card,
borderWidth: 1,
borderColor: theme.cardBorder,
borderRadius: 12,
marginHorizontal: 16,
marginTop: 8,
padding: 12,
},
historyToggle: { marginTop: 14, paddingVertical: 4 },
historyToggleText: { color: theme.textDim, fontSize: 14, fontWeight: "600" },
auditList: { marginTop: 8, gap: 8 },
auditRow: { flexDirection: "row", alignItems: "center", gap: 12 },
auditPeople: {
color: theme.successBright,
fontSize: 18,
fontWeight: "800",
minWidth: 34,
textAlign: "center",
},
auditUndo: { color: theme.warn },
auditName: { color: theme.text, fontSize: 15, fontWeight: "600" },
auditMeta: { color: theme.textDim, fontSize: 12, marginTop: 1 },
auditEmpty: { color: theme.textDim, fontSize: 14, marginTop: 8, fontStyle: "italic" },
});