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:
parent
3397e3e3ec
commit
b36d63a6a4
11 changed files with 342 additions and 4 deletions
|
|
@ -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" },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}` : ""}`);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue