Rebuilt the password-gated /crush33 (in-app /comp) screen into an admin
hub with a left sidebar and three sections:
- Comp tickets — the existing entry-only comp creator.
- Donor lookup — admin-only free-text search across the donor master
list + online/offline transaction tables by name / email / phone /
address / bear name (columns discovered per table, deduped by email).
- Actions (danger zone) — heavy warnings, red buttons, and an
"are you sure" modal that spells out exactly what will happen:
• Wipe slate — delete ALL ticket + audit records in the active
event table (donor data untouched, irreversible).
• Switch event table — repoint the app at a different NocoDB
tickets/audit table to start a new event while keeping the old
one intact.
Backend:
- New /api/admin/{status,wipe,switch-table,donor-search}, all gated by
PORTAL_PASSWORD (POST-only so it never lands in a URL/log).
- NocoDBClient + AuditLogger: runtime-switchable tableId, count(),
deleteAll(), probeTable() (reachable + Id-PK check before switching).
- DonorService.search() with adaptive column discovery.
- Table switch persists across redeploys via a small state file on a
new /data volume (Dockerfile creates it owned by node so it's
writable); applied at startup in buildContext.
Also shipped equivalent CLI scripts: scripts/wipe-slate.sh and
scripts/switch-event.sh. Drawer: "Comp tickets" -> "Admin (crush33)".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
112 lines
4.2 KiB
TypeScript
112 lines
4.2 KiB
TypeScript
import { useEffect, useRef } from "react";
|
|
import { Animated, StyleSheet, View, Text, Pressable, Easing, useWindowDimensions } from "react-native";
|
|
import { router, useSegments } from "expo-router";
|
|
import { useAuth } from "../lib/auth";
|
|
import { theme } from "../lib/theme";
|
|
|
|
const ITEMS: { label: string; icon: string; route: string; seg: string }[] = [
|
|
{ label: "Scanner", icon: "📷", route: "/", seg: "" },
|
|
{ label: "Event report", icon: "📊", route: "/stats", seg: "stats" },
|
|
{ label: "Admin (crush33)", icon: "🔐", route: "/comp", seg: "comp" },
|
|
{ label: "Banquet lookup", icon: "🍽️", route: "/admin", seg: "admin" },
|
|
];
|
|
|
|
export default function SideMenu({ visible, onClose }: { visible: boolean; onClose: () => void }) {
|
|
const { operator, signOut } = useAuth();
|
|
const segments = useSegments();
|
|
const current = segments[0] ?? "";
|
|
const { width } = useWindowDimensions();
|
|
const panelW = Math.min(320, width * 0.84);
|
|
const tx = useRef(new Animated.Value(-panelW)).current;
|
|
const fade = useRef(new Animated.Value(0)).current;
|
|
|
|
useEffect(() => {
|
|
Animated.parallel([
|
|
Animated.timing(tx, {
|
|
toValue: visible ? 0 : -panelW,
|
|
duration: 220,
|
|
easing: Easing.out(Easing.cubic),
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(fade, { toValue: visible ? 1 : 0, duration: 220, useNativeDriver: true }),
|
|
]).start();
|
|
}, [visible, panelW, tx, fade]);
|
|
|
|
const go = (item: { route: string; seg: string }) => {
|
|
onClose();
|
|
if (item.seg !== current) router.replace(item.route as any);
|
|
};
|
|
|
|
return (
|
|
<View pointerEvents={visible ? "auto" : "none"} style={StyleSheet.absoluteFill}>
|
|
<Animated.View style={[styles.scrim, { opacity: fade }]}>
|
|
<Pressable style={StyleSheet.absoluteFill} onPress={onClose} />
|
|
</Animated.View>
|
|
<Animated.View style={[styles.panel, { width: panelW, transform: [{ translateX: tx }] }]}>
|
|
<View style={styles.header}>
|
|
<Text style={styles.logo}>🐻 Camp Scan</Text>
|
|
{!!operator && <Text style={styles.operator}>{operator}</Text>}
|
|
</View>
|
|
<View style={styles.items}>
|
|
{ITEMS.map((it) => {
|
|
const active = it.seg === current;
|
|
return (
|
|
<Pressable key={it.route} style={[styles.item, active && styles.itemActive]} onPress={() => go(it)}>
|
|
<Text style={styles.itemIcon}>{it.icon}</Text>
|
|
<Text style={[styles.itemText, active && styles.itemTextActive]}>{it.label}</Text>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</View>
|
|
<View style={styles.spacer} />
|
|
<Pressable
|
|
style={styles.signout}
|
|
onPress={() => {
|
|
onClose();
|
|
signOut();
|
|
}}
|
|
>
|
|
<Text style={styles.itemIcon}>🚪</Text>
|
|
<Text style={styles.signoutText}>Sign out</Text>
|
|
</Pressable>
|
|
</Animated.View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
scrim: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, backgroundColor: "rgba(0,0,0,0.55)" },
|
|
panel: {
|
|
position: "absolute",
|
|
top: 0,
|
|
bottom: 0,
|
|
left: 0,
|
|
backgroundColor: theme.card,
|
|
borderRightWidth: 1,
|
|
borderRightColor: theme.cardBorder,
|
|
paddingTop: 54,
|
|
paddingHorizontal: 14,
|
|
paddingBottom: 28,
|
|
},
|
|
header: { paddingHorizontal: 8, paddingBottom: 14, borderBottomWidth: 1, borderBottomColor: theme.cardBorder },
|
|
logo: { color: theme.text, fontSize: 20, fontWeight: "800" },
|
|
operator: { color: theme.textDim, fontSize: 14, marginTop: 3 },
|
|
items: { marginTop: 14, gap: 4 },
|
|
item: { flexDirection: "row", alignItems: "center", gap: 14, paddingVertical: 14, paddingHorizontal: 12, borderRadius: 12 },
|
|
itemActive: { backgroundColor: theme.primary },
|
|
itemIcon: { fontSize: 20, width: 26, textAlign: "center" },
|
|
itemText: { color: theme.text, fontSize: 17, fontWeight: "600" },
|
|
itemTextActive: { color: "#fff", fontWeight: "800" },
|
|
spacer: { flex: 1 },
|
|
signout: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 14,
|
|
paddingVertical: 14,
|
|
paddingHorizontal: 12,
|
|
borderRadius: 12,
|
|
borderTopWidth: 1,
|
|
borderTopColor: theme.cardBorder,
|
|
},
|
|
signoutText: { color: theme.dangerBright, fontSize: 17, fontWeight: "700" },
|
|
});
|