CampgroundTickets/app/app/admin.tsx
Hank 0e8fe3bb9a 4-digit auto-submit PIN + operator name in audit logs
Login: fixed-length 4-digit PIN that auto-submits on the 4th digit (no submit
button to scroll to on small iPhone screens) and clears on a wrong PIN.
Compact, vertically-centered keypad so it fits without scrolling.

Operator tracking: after PIN auth, staff enter their name (new /operator
screen, persisted per device). The name is sent as X-Operator on every authed
request and recorded on each check-in/undo/ice audit entry (new Operator
column), so logs show who did what. Shown in the scanner header and the admin
audit view.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 16:45:23 +00:00

357 lines
12 KiB
TypeScript
Raw Permalink 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[] = [];
if (ticket.extras.carParking) tags.push("🚗 Car");
if (ticket.extras.rvParking) tags.push("🚐 RV");
if (ticket.extras.iceAccess) tags.push("🧊 Ice");
if (ticket.extras.isDonor) tags.push("⭐ Donor");
if (ticket.extras.freeUnder4 > 0) tags.push(`👶 ${ticket.extras.freeUnder4} 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" },
});