Add check-in audit logging + in-app history view

Every successful check-in/undo writes a row to the "2026 Ticket Audit Logs"
NocoDB table (timestamp, people, code, action, name, remaining-after).
Non-fatal: audit failures never block a gate check-in. New GET /api/audit
endpoint (global or per-code). Admin panel gains a global "Recent check-ins"
panel and per-ticket history. Audit table is optional via NOCODB_AUDIT_TABLE_ID.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-08 03:53:30 +00:00
parent 3397e3e3ec
commit b36d63a6a4
11 changed files with 342 additions and 4 deletions

View file

@ -1,17 +1,74 @@
import { useCallback, useState } from "react";
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, type TicketView } from "../lib/api";
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
</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;
@ -85,6 +142,22 @@ export default function AdminScreen() {
</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 ? (
@ -102,6 +175,28 @@ export default function AdminScreen() {
}
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");
@ -148,6 +243,18 @@ function TicketCard({ ticket, onAdjust }: { ticket: TicketView; onAdjust: (t: Ti
</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>
);
}
@ -209,4 +316,39 @@ const styles = StyleSheet.create({
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" },
});

View file

@ -118,3 +118,24 @@ export function redeem(code: string, count: number): Promise<RedeemResult> {
export function searchTickets(q: string): Promise<{ results: TicketView[] }> {
return authed<{ results: TicketView[] }>(`/api/tickets?q=${encodeURIComponent(q)}`);
}
export interface AuditEntry {
id: number;
code: string;
people: number;
name: string;
remainingAfter: number;
at: string;
action: "check-in" | "undo";
}
export function getAudit(opts: { code?: string; limit?: number } = {}): Promise<{
enabled: boolean;
entries: AuditEntry[];
}> {
const params = new URLSearchParams();
if (opts.code) params.set("code", opts.code);
if (opts.limit) params.set("limit", String(opts.limit));
const qs = params.toString();
return authed<{ enabled: boolean; entries: AuditEntry[] }>(`/api/audit${qs ? `?${qs}` : ""}`);
}