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>
357 lines
12 KiB
TypeScript
357 lines
12 KiB
TypeScript
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" },
|
||
});
|