Add event report dashboard, slide-out drawer, in-app comp portal + creator tracking
All checks were successful
Build Android APK / build-apk (push) Successful in 56m36s
All checks were successful
Build Android APK / build-apk (push) Successful in 56m36s
- Reporting: GET /api/stats aggregates check-in progress, ice, ticket types, people breakdown, add-ons/donors, gate-crew leaderboard (from audit), comp tickets by creator, and a by-hour check-in timeline. New /stats screen. - Slide-out drawer (custom RN Animated, no new native deps) replaces per-screen header links; available on every main screen via a hamburger. - In-app comp portal (/comp), password-gated like /crush33, reusing the portal endpoints; records the issuing gate-staff name (Created By column) and reports comps per creator. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
251edfce42
commit
43fddec286
15 changed files with 979 additions and 21 deletions
|
|
@ -4,6 +4,7 @@ import { Stack, useRouter, useSegments } from "expo-router";
|
||||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||||
import { StatusBar } from "expo-status-bar";
|
import { StatusBar } from "expo-status-bar";
|
||||||
import { AuthProvider, useAuth } from "../lib/auth";
|
import { AuthProvider, useAuth } from "../lib/auth";
|
||||||
|
import { MenuProvider } from "../lib/menu";
|
||||||
import { theme } from "../lib/theme";
|
import { theme } from "../lib/theme";
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
|
|
@ -11,7 +12,9 @@ export default function RootLayout() {
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
<StatusBar style="light" />
|
<StatusBar style="light" />
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
<MenuProvider>
|
||||||
<AuthGate />
|
<AuthGate />
|
||||||
|
</MenuProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { router } from "expo-router";
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
import { searchTickets, redeem, getAudit, type TicketView, type AuditEntry } from "../lib/api";
|
import { searchTickets, redeem, getAudit, type TicketView, type AuditEntry } from "../lib/api";
|
||||||
import { feedbackSuccess, feedbackError } from "../lib/feedback";
|
import { feedbackSuccess, feedbackError } from "../lib/feedback";
|
||||||
|
import { useMenu } from "../lib/menu";
|
||||||
import { theme } from "../lib/theme";
|
import { theme } from "../lib/theme";
|
||||||
|
|
||||||
function fmtTime(iso: string): string {
|
function fmtTime(iso: string): string {
|
||||||
|
|
@ -43,6 +44,7 @@ function AuditList({ entries }: { entries: AuditEntry[] }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminScreen() {
|
export default function AdminScreen() {
|
||||||
|
const { open: openMenu } = useMenu();
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [results, setResults] = useState<TicketView[]>([]);
|
const [results, setResults] = useState<TicketView[]>([]);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
@ -119,10 +121,8 @@ export default function AdminScreen() {
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.root} edges={["top", "bottom"]}>
|
<SafeAreaView style={styles.root} edges={["top", "bottom"]}>
|
||||||
<View style={styles.topbar}>
|
<View style={styles.topbar}>
|
||||||
<Pressable onPress={() => router.replace("/")} hitSlop={10}>
|
<Pressable onPress={openMenu} hitSlop={12}>
|
||||||
<Text style={styles.link} numberOfLines={1}>
|
<Text style={styles.hamburger}>☰</Text>
|
||||||
‹ Scanner
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text style={styles.brand}>Admin lookup</Text>
|
<Text style={styles.brand}>Admin lookup</Text>
|
||||||
<View style={{ width: 72 }} />
|
<View style={{ width: 72 }} />
|
||||||
|
|
@ -276,6 +276,7 @@ const styles = StyleSheet.create({
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
},
|
},
|
||||||
brand: { color: theme.text, fontSize: 18, fontWeight: "700" },
|
brand: { color: theme.text, fontSize: 18, fontWeight: "700" },
|
||||||
|
hamburger: { color: theme.text, fontSize: 26, fontWeight: "700" },
|
||||||
link: { color: theme.textDim, fontSize: 16, fontWeight: "600" },
|
link: { color: theme.textDim, fontSize: 16, fontWeight: "600" },
|
||||||
searchRow: { flexDirection: "row", gap: 10, paddingHorizontal: 16, marginTop: 6 },
|
searchRow: { flexDirection: "row", gap: 10, paddingHorizontal: 16, marginTop: 6 },
|
||||||
input: {
|
input: {
|
||||||
|
|
|
||||||
239
app/app/comp.tsx
Normal file
239
app/app/comp.tsx
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
StyleSheet,
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Image,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
} from "react-native";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import { portalVerify, portalCreate, AuthError, type PortalTicket } from "../lib/api";
|
||||||
|
import { useAuth } from "../lib/auth";
|
||||||
|
import { useMenu } from "../lib/menu";
|
||||||
|
import { theme } from "../lib/theme";
|
||||||
|
|
||||||
|
const TYPES = ["Guest", "Worker", "Performer", "Volunteer", "Speaker"];
|
||||||
|
const TYPE_ICON: Record<string, string> = {
|
||||||
|
Guest: "🎫",
|
||||||
|
Worker: "🛠️",
|
||||||
|
Performer: "🎭",
|
||||||
|
Volunteer: "🙌",
|
||||||
|
Speaker: "🎤",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CompScreen() {
|
||||||
|
const { operator } = useAuth();
|
||||||
|
const { open: openMenu } = useMenu();
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [unlocked, setUnlocked] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const [type, setType] = useState("Guest");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [result, setResult] = useState<PortalTicket | null>(null);
|
||||||
|
|
||||||
|
async function unlock() {
|
||||||
|
if (!password || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await portalVerify(password);
|
||||||
|
setUnlocked(true);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e instanceof AuthError ? "Wrong password" : (e?.message ?? "Failed"));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
if (!name.trim() || !email.trim() || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const r = await portalCreate({ password, name: name.trim(), email: email.trim(), type, createdBy: operator });
|
||||||
|
setResult(r);
|
||||||
|
setName("");
|
||||||
|
setEmail("");
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e instanceof AuthError) {
|
||||||
|
setUnlocked(false); // password rotated — re-gate
|
||||||
|
setError("Password changed — unlock again.");
|
||||||
|
} else {
|
||||||
|
setError(e?.message ?? "Failed to create ticket");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.root} edges={["top", "bottom"]}>
|
||||||
|
<View style={styles.topbar}>
|
||||||
|
<Pressable onPress={openMenu} hitSlop={12}>
|
||||||
|
<Text style={styles.hamburger}>☰</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Text style={styles.brand}>Comp Tickets</Text>
|
||||||
|
<View style={{ width: 60 }} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === "ios" ? "padding" : undefined}>
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 20, paddingBottom: 48 }}>
|
||||||
|
{!unlocked ? (
|
||||||
|
<View>
|
||||||
|
<Text style={styles.lead}>Entry-only tickets for workers & guests. Enter the shared portal password.</Text>
|
||||||
|
<Text style={styles.label}>Portal password</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
secureTextEntry
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
placeholder="Shared admin password"
|
||||||
|
placeholderTextColor={theme.textDim}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
returnKeyType="go"
|
||||||
|
onSubmitEditing={unlock}
|
||||||
|
/>
|
||||||
|
{!!error && <Text style={styles.error}>{error}</Text>}
|
||||||
|
<Pressable style={[styles.btn, (busy || !password) && styles.btnOff]} onPress={unlock} disabled={busy || !password}>
|
||||||
|
<Text style={styles.btnText}>{busy ? "Checking…" : "Unlock"}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View>
|
||||||
|
<Text style={styles.label}>Ticket type</Text>
|
||||||
|
<View style={styles.types}>
|
||||||
|
{TYPES.map((t) => (
|
||||||
|
<Pressable
|
||||||
|
key={t}
|
||||||
|
style={[styles.typePill, type === t && styles.typePillOn]}
|
||||||
|
onPress={() => setType(t)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.typePillText, type === t && styles.typePillTextOn]}>
|
||||||
|
{(TYPE_ICON[t] ?? "🎫") + " " + t}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Full name</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={name}
|
||||||
|
onChangeText={setName}
|
||||||
|
placeholder="Attendee name"
|
||||||
|
placeholderTextColor={theme.textDim}
|
||||||
|
autoCapitalize="words"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Email</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
placeholder="Where to send the ticket"
|
||||||
|
placeholderTextColor={theme.textDim}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="email-address"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!!error && <Text style={styles.error}>{error}</Text>}
|
||||||
|
<Pressable
|
||||||
|
style={[styles.btn, (busy || !name.trim() || !email.trim()) && styles.btnOff]}
|
||||||
|
onPress={create}
|
||||||
|
disabled={busy || !name.trim() || !email.trim()}
|
||||||
|
>
|
||||||
|
<Text style={styles.btnText}>{busy ? "Creating…" : `Create ${type} ticket`}</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<View style={styles.result}>
|
||||||
|
<Image source={{ uri: result.qr }} style={styles.qr} />
|
||||||
|
<Text style={styles.rcode}>{result.code}</Text>
|
||||||
|
<Text style={styles.rwho}>
|
||||||
|
{result.type} · {result.name}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.rmail}>
|
||||||
|
{result.emailSent ? "✓ Emailed the ticket" : "Email not sent — screenshot this QR"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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" },
|
||||||
|
hamburger: { color: theme.text, fontSize: 26, fontWeight: "700" },
|
||||||
|
link: { color: theme.textDim, fontSize: 16, fontWeight: "600", width: 72 },
|
||||||
|
lead: { color: theme.textDim, fontSize: 15, lineHeight: 21, marginBottom: 8 },
|
||||||
|
label: { color: theme.textDim, fontSize: 13, marginTop: 16, marginBottom: 6 },
|
||||||
|
input: {
|
||||||
|
backgroundColor: theme.card,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.cardBorder,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 14,
|
||||||
|
color: theme.text,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
error: { color: theme.dangerBright, marginTop: 12, fontSize: 14, fontWeight: "600" },
|
||||||
|
btn: {
|
||||||
|
backgroundColor: theme.successBright,
|
||||||
|
borderRadius: 13,
|
||||||
|
paddingVertical: 15,
|
||||||
|
alignItems: "center",
|
||||||
|
marginTop: 20,
|
||||||
|
},
|
||||||
|
btnOff: { opacity: 0.4 },
|
||||||
|
btnText: { color: "#06210f", fontSize: 18, fontWeight: "800" },
|
||||||
|
|
||||||
|
types: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
|
||||||
|
typePill: {
|
||||||
|
backgroundColor: theme.card,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.cardBorder,
|
||||||
|
borderRadius: 999,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 9,
|
||||||
|
},
|
||||||
|
typePillOn: { backgroundColor: theme.primary, borderColor: theme.primary },
|
||||||
|
typePillText: { color: theme.textDim, fontSize: 14, fontWeight: "700" },
|
||||||
|
typePillTextOn: { color: "#fff" },
|
||||||
|
|
||||||
|
result: {
|
||||||
|
marginTop: 22,
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: theme.card,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.cardBorder,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
qr: { width: 220, height: 220, backgroundColor: "#fff", borderRadius: 10 },
|
||||||
|
rcode: { color: theme.successBright, fontSize: 22, fontWeight: "800", letterSpacing: 2, marginTop: 12 },
|
||||||
|
rwho: { color: theme.text, fontSize: 16, marginTop: 4 },
|
||||||
|
rmail: { color: theme.textDim, fontSize: 13, marginTop: 8 },
|
||||||
|
});
|
||||||
|
|
@ -6,6 +6,7 @@ import QRScanner from "../components/QRScanner";
|
||||||
import ResultOverlay from "../components/ResultOverlay";
|
import ResultOverlay from "../components/ResultOverlay";
|
||||||
import { lookup, redeem, banquet, type TicketView, type DonorLookup } from "../lib/api";
|
import { lookup, redeem, banquet, type TicketView, type DonorLookup } from "../lib/api";
|
||||||
import { useAuth } from "../lib/auth";
|
import { useAuth } from "../lib/auth";
|
||||||
|
import { useMenu } from "../lib/menu";
|
||||||
import { feedbackSuccess, feedbackError } from "../lib/feedback";
|
import { feedbackSuccess, feedbackError } from "../lib/feedback";
|
||||||
import { theme } from "../lib/theme";
|
import { theme } from "../lib/theme";
|
||||||
|
|
||||||
|
|
@ -19,7 +20,8 @@ const MODES: { key: Mode; label: string; icon: string }[] = [
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function ScannerScreen() {
|
export default function ScannerScreen() {
|
||||||
const { signOut, operator } = useAuth();
|
const { operator } = useAuth();
|
||||||
|
const { open: openMenu } = useMenu();
|
||||||
const [mode, setMode] = useState<Mode>("tickets");
|
const [mode, setMode] = useState<Mode>("tickets");
|
||||||
const [phase, setPhase] = useState<Phase>("scanning");
|
const [phase, setPhase] = useState<Phase>("scanning");
|
||||||
const [ticket, setTicket] = useState<TicketView | null>(null);
|
const [ticket, setTicket] = useState<TicketView | null>(null);
|
||||||
|
|
@ -147,29 +149,19 @@ export default function ScannerScreen() {
|
||||||
}
|
}
|
||||||
}, [ticket, count, mode, resume, showError]);
|
}, [ticket, count, mode, resume, showError]);
|
||||||
|
|
||||||
const doLogout = useCallback(async () => {
|
|
||||||
await signOut();
|
|
||||||
// The auth gate redirects to /login when signedIn flips to false.
|
|
||||||
}, [signOut]);
|
|
||||||
|
|
||||||
const isIce = mode === "ice";
|
const isIce = mode === "ice";
|
||||||
const successNoun = isIce ? (checkedIn === 1 ? "bag of ice" : "bags of ice") : "";
|
const successNoun = isIce ? (checkedIn === 1 ? "bag of ice" : "bags of ice") : "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.root} edges={["top", "bottom"]}>
|
<SafeAreaView style={styles.root} edges={["top", "bottom"]}>
|
||||||
<View style={styles.topbar}>
|
<View style={styles.topbar}>
|
||||||
<View>
|
<Pressable onPress={openMenu} hitSlop={12}>
|
||||||
|
<Text style={styles.hamburger}>☰</Text>
|
||||||
|
</Pressable>
|
||||||
|
<View style={styles.titleWrap}>
|
||||||
<Text style={styles.brand}>🐻 Camp Scan</Text>
|
<Text style={styles.brand}>🐻 Camp Scan</Text>
|
||||||
{!!operator && <Text style={styles.operator}>{operator}</Text>}
|
{!!operator && <Text style={styles.operator}>{operator}</Text>}
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.topActions}>
|
|
||||||
<Pressable onPress={() => router.push("/admin")} hitSlop={10}>
|
|
||||||
<Text style={styles.link}>Admin</Text>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable onPress={doLogout} hitSlop={10}>
|
|
||||||
<Text style={styles.link}>Sign out</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.modeBar}>
|
<View style={styles.modeBar}>
|
||||||
|
|
@ -474,6 +466,8 @@ const styles = StyleSheet.create({
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
},
|
},
|
||||||
|
hamburger: { color: theme.text, fontSize: 26, fontWeight: "700", paddingRight: 4 },
|
||||||
|
titleWrap: { flex: 1, marginLeft: 12 },
|
||||||
brand: { color: theme.text, fontSize: 18, fontWeight: "700" },
|
brand: { color: theme.text, fontSize: 18, fontWeight: "700" },
|
||||||
operator: { color: theme.textDim, fontSize: 13, marginTop: 1 },
|
operator: { color: theme.textDim, fontSize: 13, marginTop: 1 },
|
||||||
topActions: { flexDirection: "row", gap: 18, alignItems: "center" },
|
topActions: { flexDirection: "row", gap: 18, alignItems: "center" },
|
||||||
|
|
|
||||||
306
app/app/stats.tsx
Normal file
306
app/app/stats.tsx
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { StyleSheet, View, Text, Pressable, ScrollView, ActivityIndicator, RefreshControl } from "react-native";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import { getStats, type Stats } from "../lib/api";
|
||||||
|
import { useMenu } from "../lib/menu";
|
||||||
|
import { theme } from "../lib/theme";
|
||||||
|
|
||||||
|
const TYPE_ICON: Record<string, string> = {
|
||||||
|
Regular: "🎟️",
|
||||||
|
Guest: "🎫",
|
||||||
|
Worker: "🛠️",
|
||||||
|
Performer: "🎭",
|
||||||
|
Volunteer: "🙌",
|
||||||
|
Speaker: "🎤",
|
||||||
|
};
|
||||||
|
const MEDAL = ["🥇", "🥈", "🥉"];
|
||||||
|
|
||||||
|
function Bar({ pct, color }: { pct: number; color?: string }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.barTrack}>
|
||||||
|
<View style={[styles.barFill, { width: `${Math.min(100, Math.max(0, pct))}%`, backgroundColor: color ?? theme.successBright }]} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tile({ value, label, accent }: { value: string | number; label: string; accent?: boolean }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.tile}>
|
||||||
|
<Text style={[styles.tileValue, accent && { color: theme.successBright }]}>{value}</Text>
|
||||||
|
<Text style={styles.tileLabel}>{label}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatsScreen() {
|
||||||
|
const { open: openMenu } = useMenu();
|
||||||
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const load = useCallback(async (force = false) => {
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
setStats(await getStats(force));
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name === "AuthError") return router.replace("/login");
|
||||||
|
setError(e?.message ?? "Failed to load report");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const onRefresh = () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
load(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const peakHour = stats?.checkinsByHour.length
|
||||||
|
? stats.checkinsByHour.reduce((a, b) => (b.count > a.count ? b : a))
|
||||||
|
: null;
|
||||||
|
const maxHour = stats ? Math.max(1, ...stats.checkinsByHour.map((h) => h.count)) : 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.root} edges={["top", "bottom"]}>
|
||||||
|
<View style={styles.topbar}>
|
||||||
|
<Pressable onPress={openMenu} hitSlop={12}>
|
||||||
|
<Text style={styles.hamburger}>☰</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Text style={styles.brand}>Event Report</Text>
|
||||||
|
<Pressable onPress={onRefresh} hitSlop={10}>
|
||||||
|
<Text style={styles.link}>↻</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color={theme.successBright} size="large" style={{ marginTop: 40 }} />
|
||||||
|
) : error ? (
|
||||||
|
<Text style={styles.error}>{error}</Text>
|
||||||
|
) : stats ? (
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{ padding: 16, paddingBottom: 48 }}
|
||||||
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.successBright} />}
|
||||||
|
>
|
||||||
|
{/* Hero: check-in progress */}
|
||||||
|
<View style={styles.hero}>
|
||||||
|
<Text style={styles.heroPct}>{stats.tickets.pct}%</Text>
|
||||||
|
<Text style={styles.heroSub}>checked in</Text>
|
||||||
|
<Bar pct={stats.tickets.pct} />
|
||||||
|
<Text style={styles.heroCounts}>
|
||||||
|
{stats.tickets.redeemed} of {stats.tickets.total} tickets · {stats.tickets.remaining} to go
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Core tiles */}
|
||||||
|
<View style={styles.tileRow}>
|
||||||
|
<Tile value={stats.orders} label="orders" />
|
||||||
|
<Tile value={stats.tickets.total} label="tickets sold" />
|
||||||
|
<Tile value={stats.tickets.redeemed} label="checked in" accent />
|
||||||
|
<Tile value={stats.tickets.remaining} label="remaining" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Ice */}
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.cardTitle}>🧊 Ice</Text>
|
||||||
|
<Bar pct={stats.ice.pct} color="#4fc3f7" />
|
||||||
|
<Text style={styles.cardSub}>
|
||||||
|
{stats.ice.redeemed} of {stats.ice.total} bags handed out · {stats.ice.remaining} left · {stats.ice.ticketsSold} ice tickets sold
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Ticket types */}
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.cardTitle}>Ticket types</Text>
|
||||||
|
{stats.types.map((t) => (
|
||||||
|
<View key={t.type} style={styles.typeRow}>
|
||||||
|
<Text style={styles.typeName}>
|
||||||
|
{(TYPE_ICON[t.type] ?? "🎫") + " " + t.type}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.typeBarWrap}>
|
||||||
|
<Bar pct={t.total ? (t.redeemed / t.total) * 100 : 0} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.typeCount}>
|
||||||
|
{t.redeemed}/{t.total}
|
||||||
|
<Text style={styles.typeOrders}> · {t.count}×</Text>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* People breakdown */}
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.cardTitle}>Who's coming</Text>
|
||||||
|
<View style={styles.tileRow}>
|
||||||
|
<Tile value={stats.people.adults} label="adults" />
|
||||||
|
<Tile value={stats.people.youth} label="youth 13-16" />
|
||||||
|
<Tile value={stats.people.kids12 + stats.people.kids9} label="kids 5-12" />
|
||||||
|
<Tile value={stats.people.kids4Free} label="under 5 (free)" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Extras + donors */}
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.cardTitle}>Add-ons & donors</Text>
|
||||||
|
<View style={styles.chips}>
|
||||||
|
<Text style={styles.chip}>🚗 {stats.extras.carParking} parking</Text>
|
||||||
|
<Text style={styles.chip}>🚐 {stats.extras.rvParking} RV</Text>
|
||||||
|
<Text style={styles.chip}>🏍️ {stats.extras.utv} UTV</Text>
|
||||||
|
<Text style={styles.chip}>🐻 {stats.donors.members} members</Text>
|
||||||
|
<Text style={styles.chip}>⭐ {stats.donors.orders} donor orders</Text>
|
||||||
|
<Text style={styles.chip}>🎟️ {stats.donors.vouchers} vouchers</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Operator leaderboard */}
|
||||||
|
{stats.operators.length > 0 && (
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.cardTitle}>Gate crew leaderboard</Text>
|
||||||
|
{stats.operators.slice(0, 8).map((o, i) => (
|
||||||
|
<View key={o.name} style={styles.opRow}>
|
||||||
|
<Text style={styles.opRank}>{MEDAL[i] ?? `${i + 1}.`}</Text>
|
||||||
|
<Text style={styles.opName} numberOfLines={1}>
|
||||||
|
{o.name}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.opStat}>
|
||||||
|
{o.checkins} check-ins{o.ice ? ` · ${o.ice} ice` : ""}
|
||||||
|
{o.undos ? ` · ${o.undos} undo` : ""}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Comp tickets issued */}
|
||||||
|
{stats.comps.total > 0 && (
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.cardTitle}>🎟️ Comp tickets issued ({stats.comps.total})</Text>
|
||||||
|
{stats.comps.byCreator.map((c) => (
|
||||||
|
<View key={c.name} style={styles.opRow}>
|
||||||
|
<Text style={styles.opName} numberOfLines={1}>
|
||||||
|
{c.name}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.opStat}>{c.count} issued</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Check-in timeline */}
|
||||||
|
{stats.checkinsByHour.length > 0 && (
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.cardTitle}>Check-ins by hour</Text>
|
||||||
|
<View style={styles.spark}>
|
||||||
|
{stats.checkinsByHour.map((h) => (
|
||||||
|
<View key={h.hour} style={styles.sparkCol}>
|
||||||
|
<Text style={styles.sparkVal}>{h.count}</Text>
|
||||||
|
<View style={[styles.sparkBar, { height: 6 + (h.count / maxHour) * 80 }]} />
|
||||||
|
<Text style={styles.sparkLabel}>{h.hour.slice(11)}h</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
{peakHour && (
|
||||||
|
<Text style={styles.cardSub}>Busiest hour: {peakHour.count} checked in around {peakHour.hour.slice(11)}:00</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text style={styles.stamp}>Updated {new Date(stats.generatedAt).toLocaleTimeString()} · pull to refresh</Text>
|
||||||
|
</ScrollView>
|
||||||
|
) : null}
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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" },
|
||||||
|
hamburger: { color: theme.text, fontSize: 26, fontWeight: "700" },
|
||||||
|
link: { color: theme.textDim, fontSize: 16, fontWeight: "700" },
|
||||||
|
error: { color: theme.dangerBright, textAlign: "center", marginTop: 40, fontSize: 15 },
|
||||||
|
|
||||||
|
hero: {
|
||||||
|
backgroundColor: theme.card,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.cardBorder,
|
||||||
|
borderRadius: 18,
|
||||||
|
padding: 22,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
heroPct: { color: theme.successBright, fontSize: 64, fontWeight: "900", lineHeight: 66 },
|
||||||
|
heroSub: { color: theme.textDim, fontSize: 15, marginBottom: 14 },
|
||||||
|
heroCounts: { color: theme.text, fontSize: 15, marginTop: 10, textAlign: "center" },
|
||||||
|
|
||||||
|
barTrack: { width: "100%", height: 12, borderRadius: 6, backgroundColor: theme.cardBorder, overflow: "hidden" },
|
||||||
|
barFill: { height: "100%", borderRadius: 6 },
|
||||||
|
|
||||||
|
tileRow: { flexDirection: "row", flexWrap: "wrap", gap: 10, marginTop: 12 },
|
||||||
|
tile: {
|
||||||
|
flexGrow: 1,
|
||||||
|
flexBasis: "22%",
|
||||||
|
minWidth: 74,
|
||||||
|
backgroundColor: theme.card,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.cardBorder,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
tileValue: { color: theme.text, fontSize: 24, fontWeight: "800" },
|
||||||
|
tileLabel: { color: theme.textDim, fontSize: 11, marginTop: 2, textAlign: "center" },
|
||||||
|
|
||||||
|
card: {
|
||||||
|
backgroundColor: theme.card,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.cardBorder,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
marginTop: 14,
|
||||||
|
},
|
||||||
|
cardTitle: { color: theme.text, fontSize: 16, fontWeight: "800", marginBottom: 10 },
|
||||||
|
cardSub: { color: theme.textDim, fontSize: 13, marginTop: 8, lineHeight: 18 },
|
||||||
|
|
||||||
|
typeRow: { flexDirection: "row", alignItems: "center", gap: 10, marginVertical: 5 },
|
||||||
|
typeName: { color: theme.text, fontSize: 14, fontWeight: "600", width: 120 },
|
||||||
|
typeBarWrap: { flex: 1 },
|
||||||
|
typeCount: { color: theme.text, fontSize: 13, fontWeight: "700", minWidth: 66, textAlign: "right" },
|
||||||
|
typeOrders: { color: theme.textDim, fontWeight: "400" },
|
||||||
|
|
||||||
|
chips: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
|
||||||
|
chip: {
|
||||||
|
color: theme.text,
|
||||||
|
backgroundColor: theme.cardBorder,
|
||||||
|
borderRadius: 999,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 7,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: "600",
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
|
||||||
|
opRow: { flexDirection: "row", alignItems: "center", gap: 10, paddingVertical: 6 },
|
||||||
|
opRank: { fontSize: 16, width: 28, textAlign: "center", color: theme.textDim, fontWeight: "800" },
|
||||||
|
opName: { color: theme.text, fontSize: 15, fontWeight: "600", flex: 1 },
|
||||||
|
opStat: { color: theme.textDim, fontSize: 13 },
|
||||||
|
|
||||||
|
spark: { flexDirection: "row", alignItems: "flex-end", justifyContent: "space-between", gap: 4, height: 118, marginTop: 4 },
|
||||||
|
sparkCol: { flex: 1, alignItems: "center", justifyContent: "flex-end" },
|
||||||
|
sparkVal: { color: theme.textDim, fontSize: 10, marginBottom: 3 },
|
||||||
|
sparkBar: { width: "70%", minWidth: 8, backgroundColor: theme.successBright, borderRadius: 3 },
|
||||||
|
sparkLabel: { color: theme.textDim, fontSize: 9, marginTop: 3 },
|
||||||
|
|
||||||
|
stamp: { color: theme.textDim, fontSize: 12, textAlign: "center", marginTop: 20 },
|
||||||
|
});
|
||||||
112
app/components/SideMenu.tsx
Normal file
112
app/components/SideMenu.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
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: "Comp tickets", icon: "🎟️", route: "/comp", seg: "comp" },
|
||||||
|
{ label: "Admin 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" },
|
||||||
|
});
|
||||||
|
|
@ -22,6 +22,7 @@ export interface TicketView {
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
ticketType: string;
|
ticketType: string;
|
||||||
|
createdBy: string;
|
||||||
total: number;
|
total: number;
|
||||||
redeemed: number;
|
redeemed: number;
|
||||||
remaining: number;
|
remaining: number;
|
||||||
|
|
@ -194,6 +195,63 @@ export interface AuditEntry {
|
||||||
action: "check-in" | "undo" | "ice" | "ice-undo";
|
action: "check-in" | "undo" | "ice" | "ice-undo";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Stats {
|
||||||
|
orders: number;
|
||||||
|
tickets: { total: number; redeemed: number; remaining: number; pct: number };
|
||||||
|
people: { adults: number; youth: number; kids12: number; kids9: number; kids4Free: number };
|
||||||
|
ice: { total: number; redeemed: number; remaining: number; pct: number; ticketsSold: number };
|
||||||
|
types: { type: string; count: number; total: number; redeemed: number }[];
|
||||||
|
donors: { orders: number; members: number; vouchers: number };
|
||||||
|
extras: { carParking: number; rvParking: number; utv: number };
|
||||||
|
comps: { total: number; byCreator: { name: string; count: number }[] };
|
||||||
|
operators: { name: string; checkins: number; ice: number; undos: number }[];
|
||||||
|
checkinsByHour: { hour: string; count: number }[];
|
||||||
|
generatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStats(force = false): Promise<Stats> {
|
||||||
|
return authed<Stats>(`/api/stats${force ? "?force=1" : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comp-ticket portal (password-gated; separate from the staff PIN).
|
||||||
|
export async function portalVerify(password: string): Promise<{ ok: boolean; types: string[] }> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/portal/verify`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
if (res.status === 401) throw new AuthError("Wrong password");
|
||||||
|
if (!res.ok) throw new ApiError(`Verify failed (${res.status})`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PortalTicket {
|
||||||
|
ok: boolean;
|
||||||
|
code: string;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
emailSent: boolean;
|
||||||
|
qr: string; // data URL
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function portalCreate(input: {
|
||||||
|
password: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
type: string;
|
||||||
|
createdBy?: string;
|
||||||
|
}): Promise<PortalTicket> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/portal/create-ticket`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
if (res.status === 401) throw new AuthError("Wrong password");
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new ApiError(body?.detail ?? body?.error ?? `Create failed (${res.status})`);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
export function getAudit(opts: { code?: string; limit?: number } = {}): Promise<{
|
export function getAudit(opts: { code?: string; limit?: number } = {}): Promise<{
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
entries: AuditEntry[];
|
entries: AuditEntry[];
|
||||||
|
|
|
||||||
21
app/lib/menu.tsx
Normal file
21
app/lib/menu.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { createContext, useContext, useState, type ReactNode } from "react";
|
||||||
|
import SideMenu from "../components/SideMenu";
|
||||||
|
|
||||||
|
interface MenuState {
|
||||||
|
open: () => void;
|
||||||
|
close: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Ctx = createContext<MenuState>({ open: () => {}, close: () => {} });
|
||||||
|
|
||||||
|
export function MenuProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
return (
|
||||||
|
<Ctx.Provider value={{ open: () => setVisible(true), close: () => setVisible(false) }}>
|
||||||
|
{children}
|
||||||
|
<SideMenu visible={visible} onClose={() => setVisible(false)} />
|
||||||
|
</Ctx.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMenu = () => useContext(Ctx);
|
||||||
|
|
@ -25,6 +25,7 @@ export const COL = {
|
||||||
iceAccess: "Ice Access",
|
iceAccess: "Ice Access",
|
||||||
paymentMethod: "Payment Method",
|
paymentMethod: "Payment Method",
|
||||||
ticketType: "Ticket Type", // "" for regular; Guest/Worker/Performer/Volunteer/Speaker for portal comps
|
ticketType: "Ticket Type", // "" for regular; Guest/Worker/Performer/Volunteer/Speaker for portal comps
|
||||||
|
createdBy: "Created By", // gate-staff name who issued a comp ticket (portal)
|
||||||
|
|
||||||
// Columns this system manages:
|
// Columns this system manages:
|
||||||
code: "Ticket Code",
|
code: "Ticket Code",
|
||||||
|
|
@ -96,6 +97,7 @@ export interface TicketView {
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
ticketType: string; // "" for regular; Guest/Worker/... for special tickets
|
ticketType: string; // "" for regular; Guest/Worker/... for special tickets
|
||||||
|
createdBy: string; // who issued a comp ticket
|
||||||
total: number;
|
total: number;
|
||||||
redeemed: number;
|
redeemed: number;
|
||||||
remaining: number;
|
remaining: number;
|
||||||
|
|
@ -124,6 +126,7 @@ export function toView(rec: NocoRecord): TicketView {
|
||||||
name: String(rec[COL.name] ?? ""),
|
name: String(rec[COL.name] ?? ""),
|
||||||
email: String(rec[COL.email] ?? ""),
|
email: String(rec[COL.email] ?? ""),
|
||||||
ticketType: String(rec[COL.ticketType] ?? ""),
|
ticketType: String(rec[COL.ticketType] ?? ""),
|
||||||
|
createdBy: String(rec[COL.createdBy] ?? ""),
|
||||||
total,
|
total,
|
||||||
redeemed,
|
redeemed,
|
||||||
remaining: Math.max(0, total - redeemed),
|
remaining: Math.max(0, total - redeemed),
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,21 @@ export async function portalRoutes(app: FastifyInstance): Promise<void> {
|
||||||
reply.type("text/html").send(PAGE);
|
reply.type("text/html").send(PAGE);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Password check only (for the in-app portal to gate its form).
|
||||||
|
app.post(
|
||||||
|
"/api/portal/verify",
|
||||||
|
{ config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||||
|
async (req, reply) => {
|
||||||
|
const cfg = app.ctx.config;
|
||||||
|
if (!cfg.PORTAL_PASSWORD) return reply.code(404).send({ error: "portal_disabled" });
|
||||||
|
const b = (req.body ?? {}) as { password?: string };
|
||||||
|
if (!b.password || !safeEqual(b.password, cfg.PORTAL_PASSWORD)) {
|
||||||
|
return reply.code(401).send({ error: "bad_password" });
|
||||||
|
}
|
||||||
|
return { ok: true, types: TYPES };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
app.post(
|
app.post(
|
||||||
"/api/portal/create-ticket",
|
"/api/portal/create-ticket",
|
||||||
{ config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
{ config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||||
|
|
@ -29,13 +44,21 @@ export async function portalRoutes(app: FastifyInstance): Promise<void> {
|
||||||
const cfg = app.ctx.config;
|
const cfg = app.ctx.config;
|
||||||
if (!cfg.PORTAL_PASSWORD) return reply.code(404).send({ error: "portal_disabled" });
|
if (!cfg.PORTAL_PASSWORD) return reply.code(404).send({ error: "portal_disabled" });
|
||||||
|
|
||||||
const b = (req.body ?? {}) as { password?: string; name?: string; email?: string; type?: string };
|
const b = (req.body ?? {}) as {
|
||||||
|
password?: string;
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
type?: string;
|
||||||
|
createdBy?: string;
|
||||||
|
};
|
||||||
if (!b.password || !safeEqual(b.password, cfg.PORTAL_PASSWORD)) {
|
if (!b.password || !safeEqual(b.password, cfg.PORTAL_PASSWORD)) {
|
||||||
return reply.code(401).send({ error: "bad_password" });
|
return reply.code(401).send({ error: "bad_password" });
|
||||||
}
|
}
|
||||||
const name = String(b.name ?? "").trim();
|
const name = String(b.name ?? "").trim();
|
||||||
const email = String(b.email ?? "").trim();
|
const email = String(b.email ?? "").trim();
|
||||||
const type = TYPES.includes(String(b.type)) ? String(b.type) : "Guest";
|
const type = TYPES.includes(String(b.type)) ? String(b.type) : "Guest";
|
||||||
|
// Who issued it — from the in-app portal (signed-in gate staff) or header.
|
||||||
|
const createdBy = String(b.createdBy ?? req.headers["x-operator"] ?? "").slice(0, 80).trim();
|
||||||
if (!name || !email) {
|
if (!name || !email) {
|
||||||
return reply.code(400).send({ error: "missing_fields", detail: "name and email are required" });
|
return reply.code(400).send({ error: "missing_fields", detail: "name and email are required" });
|
||||||
}
|
}
|
||||||
|
|
@ -47,6 +70,7 @@ export async function portalRoutes(app: FastifyInstance): Promise<void> {
|
||||||
adultNames: [name],
|
adultNames: [name],
|
||||||
email,
|
email,
|
||||||
ticketType: type,
|
ticketType: type,
|
||||||
|
createdBy,
|
||||||
counts: { adults: 1, youth: 0, kids12: 0, kids9: 0, kids4: 0 },
|
counts: { adults: 1, youth: 0, kids12: 0, kids9: 0, kids4: 0 },
|
||||||
submissionKey: `portal:${Date.now()}:${Math.trunc(Math.random() * 1e9)}`,
|
submissionKey: `portal:${Date.now()}:${Math.trunc(Math.random() * 1e9)}`,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import { normalizeCode, looksLikeCode } from "../services/code.js";
|
import { normalizeCode, looksLikeCode } from "../services/code.js";
|
||||||
import { lookupByCode, redeem, search, createTicket } from "../ticketService.js";
|
import { lookupByCode, redeem, search, createTicket } from "../ticketService.js";
|
||||||
import { renderQrPng } from "../services/qrcode.js";
|
import { renderQrPng } from "../services/qrcode.js";
|
||||||
|
import { computeStats } from "../services/stats.js";
|
||||||
import { COL } from "../fields.js";
|
import { COL } from "../fields.js";
|
||||||
|
|
||||||
async function requireStaff(req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
async function requireStaff(req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||||
|
|
@ -42,6 +43,12 @@ export async function ticketRoutes(app: FastifyInstance): Promise<void> {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Aggregate event report (check-in progress, ice, types, extras, operators).
|
||||||
|
app.get("/api/stats", { preHandler: requireStaff }, async (req) => {
|
||||||
|
const force = String((req.query as any)?.force ?? "") === "1";
|
||||||
|
return computeStats(app.ctx, force);
|
||||||
|
});
|
||||||
|
|
||||||
// Recent check-in audit log (all, or filtered to one code via ?code=).
|
// Recent check-in audit log (all, or filtered to one code via ?code=).
|
||||||
app.get("/api/audit", { preHandler: requireStaff }, async (req) => {
|
app.get("/api/audit", { preHandler: requireStaff }, async (req) => {
|
||||||
const code = (req.query as any)?.code ? normalizeCode(String((req.query as any).code)) : undefined;
|
const code = (req.query as any)?.code ? normalizeCode(String((req.query as any).code)) : undefined;
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,43 @@ export class AuditLogger {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private mapRow(r: any): AuditRow {
|
||||||
|
return {
|
||||||
|
id: r.Id,
|
||||||
|
code: r[AUDIT_COL.code] ?? "",
|
||||||
|
people: Number(r[AUDIT_COL.people]) || 0,
|
||||||
|
name: r[AUDIT_COL.name] ?? "",
|
||||||
|
operator: r[AUDIT_COL.operator] ?? "",
|
||||||
|
remainingAfter: Number(r[AUDIT_COL.remainingAfter]) || 0,
|
||||||
|
at: r[AUDIT_COL.at] ?? r.CreatedAt ?? "",
|
||||||
|
action: (r[AUDIT_COL.action] ?? "check-in") as AuditEntry["action"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every audit row, paginated (for reporting/aggregation). */
|
||||||
|
async all(): Promise<AuditRow[]> {
|
||||||
|
if (!this.tableId) return [];
|
||||||
|
const out: AuditRow[] = [];
|
||||||
|
const pageSize = 1000;
|
||||||
|
let offset = 0;
|
||||||
|
for (;;) {
|
||||||
|
const url = new URL(this.url);
|
||||||
|
url.searchParams.set("limit", String(pageSize));
|
||||||
|
url.searchParams.set("offset", String(offset));
|
||||||
|
const res = await fetch(url.toString(), {
|
||||||
|
headers: { "xc-token": this.token, "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
if (!res.ok) break;
|
||||||
|
const body: any = await res.json().catch(() => ({}));
|
||||||
|
const list = body?.list ?? [];
|
||||||
|
out.push(...list.map((r: any) => this.mapRow(r)));
|
||||||
|
if (!list.length || body?.pageInfo?.isLastPage || list.length < pageSize) break;
|
||||||
|
offset += pageSize;
|
||||||
|
if (offset > 200000) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** Recent entries, newest first, optionally filtered to one code. */
|
/** Recent entries, newest first, optionally filtered to one code. */
|
||||||
async recent(opts: { code?: string; limit?: number } = {}): Promise<AuditRow[]> {
|
async recent(opts: { code?: string; limit?: number } = {}): Promise<AuditRow[]> {
|
||||||
if (!this.tableId) return [];
|
if (!this.tableId) return [];
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,26 @@ export class NocoDBClient {
|
||||||
return (Array.isArray(body) ? body[0] : body) as NocoRecord;
|
return (Array.isArray(body) ? body[0] : body) as NocoRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fetch every record in the table, paginating. */
|
||||||
|
async all(): Promise<NocoRecord[]> {
|
||||||
|
const out: NocoRecord[] = [];
|
||||||
|
const pageSize = 1000;
|
||||||
|
let offset = 0;
|
||||||
|
for (;;) {
|
||||||
|
const url = new URL(this.recordsUrl);
|
||||||
|
url.searchParams.set("limit", String(pageSize));
|
||||||
|
url.searchParams.set("offset", String(offset));
|
||||||
|
const body = await this.request(url.toString());
|
||||||
|
const list = (body?.list ?? []) as NocoRecord[];
|
||||||
|
out.push(...list);
|
||||||
|
const info = body?.pageInfo;
|
||||||
|
if (!list.length || info?.isLastPage || list.length < pageSize) break;
|
||||||
|
offset += pageSize;
|
||||||
|
if (offset > 200000) break; // safety
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** Cheap connectivity probe for healthchecks. */
|
/** Cheap connectivity probe for healthchecks. */
|
||||||
async ping(): Promise<boolean> {
|
async ping(): Promise<boolean> {
|
||||||
const url = new URL(this.recordsUrl);
|
const url = new URL(this.recordsUrl);
|
||||||
|
|
|
||||||
131
backend/src/services/stats.ts
Normal file
131
backend/src/services/stats.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import type { AppContext } from "../context.js";
|
||||||
|
import { COL, toView, toNumber, type NocoRecord } from "../fields.js";
|
||||||
|
|
||||||
|
export interface Stats {
|
||||||
|
orders: number;
|
||||||
|
tickets: { total: number; redeemed: number; remaining: number; pct: number };
|
||||||
|
people: { adults: number; youth: number; kids12: number; kids9: number; kids4Free: number };
|
||||||
|
ice: { total: number; redeemed: number; remaining: number; pct: number; ticketsSold: number };
|
||||||
|
types: { type: string; count: number; total: number; redeemed: number }[];
|
||||||
|
donors: { orders: number; members: number; vouchers: number };
|
||||||
|
extras: { carParking: number; rvParking: number; utv: number };
|
||||||
|
comps: { total: number; byCreator: { name: string; count: number }[] };
|
||||||
|
operators: { name: string; checkins: number; ice: number; undos: number }[];
|
||||||
|
checkinsByHour: { hour: string; count: number }[];
|
||||||
|
generatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cache: { at: number; data: Stats } | null = null;
|
||||||
|
const TTL_MS = 20_000;
|
||||||
|
|
||||||
|
export async function computeStats(ctx: AppContext, force = false): Promise<Stats> {
|
||||||
|
const now = Date.now();
|
||||||
|
if (!force && cache && now - cache.at < TTL_MS) return cache.data;
|
||||||
|
|
||||||
|
const records = await ctx.nocodb.all();
|
||||||
|
const bagsPerTicket = ctx.config.ICE_BAGS_PER_TICKET || 3;
|
||||||
|
|
||||||
|
let total = 0,
|
||||||
|
redeemed = 0,
|
||||||
|
iceTotal = 0,
|
||||||
|
iceRedeemed = 0;
|
||||||
|
let adults = 0,
|
||||||
|
youth = 0,
|
||||||
|
kids12 = 0,
|
||||||
|
kids9 = 0,
|
||||||
|
kids4 = 0;
|
||||||
|
let carParking = 0,
|
||||||
|
rvParking = 0,
|
||||||
|
utv = 0,
|
||||||
|
donorOrders = 0,
|
||||||
|
members = 0,
|
||||||
|
vouchers = 0;
|
||||||
|
const typeMap = new Map<string, { count: number; total: number; redeemed: number }>();
|
||||||
|
const compByCreator = new Map<string, number>();
|
||||||
|
let compTotal = 0;
|
||||||
|
|
||||||
|
for (const r of records as NocoRecord[]) {
|
||||||
|
const v = toView(r);
|
||||||
|
if (v.ticketType) {
|
||||||
|
compTotal += 1;
|
||||||
|
const who = v.createdBy || "(unknown)";
|
||||||
|
compByCreator.set(who, (compByCreator.get(who) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
total += v.total;
|
||||||
|
redeemed += v.redeemed;
|
||||||
|
iceTotal += v.ice.total;
|
||||||
|
iceRedeemed += v.ice.redeemed;
|
||||||
|
adults += toNumber(r[COL.adults]);
|
||||||
|
youth += toNumber(r[COL.youth]);
|
||||||
|
kids12 += toNumber(r[COL.kids12]);
|
||||||
|
kids9 += toNumber(r[COL.kids9]);
|
||||||
|
kids4 += toNumber(r[COL.kids4]);
|
||||||
|
|
||||||
|
const t = v.ticketType || "Regular";
|
||||||
|
const e = typeMap.get(t) ?? { count: 0, total: 0, redeemed: 0 };
|
||||||
|
e.count += 1;
|
||||||
|
e.total += v.total;
|
||||||
|
e.redeemed += v.redeemed;
|
||||||
|
typeMap.set(t, e);
|
||||||
|
|
||||||
|
if (v.extras.carParking) carParking += 1;
|
||||||
|
if (v.extras.rvParking) rvParking += 1;
|
||||||
|
if (v.extras.utv) utv += 1;
|
||||||
|
if (v.extras.isDonor) donorOrders += 1;
|
||||||
|
if (v.extras.donorTier === "member") members += 1;
|
||||||
|
vouchers += v.extras.vouchers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operator activity + check-in timeline from the audit log.
|
||||||
|
const audit = await ctx.audit.all().catch(() => []);
|
||||||
|
const opMap = new Map<string, { checkins: number; ice: number; undos: number }>();
|
||||||
|
const hourMap = new Map<string, number>();
|
||||||
|
for (const a of audit) {
|
||||||
|
if (a.operator) {
|
||||||
|
const o = opMap.get(a.operator) ?? { checkins: 0, ice: 0, undos: 0 };
|
||||||
|
if (a.action === "check-in") o.checkins += a.people;
|
||||||
|
else if (a.action === "undo") o.undos += -a.people;
|
||||||
|
else if (a.action === "ice") o.ice += a.people;
|
||||||
|
opMap.set(a.operator, o);
|
||||||
|
}
|
||||||
|
if (a.action === "check-in" && a.people > 0 && a.at) {
|
||||||
|
const hour = String(a.at).slice(0, 13); // YYYY-MM-DDTHH
|
||||||
|
hourMap.set(hour, (hourMap.get(hour) ?? 0) + a.people);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: Stats = {
|
||||||
|
orders: records.length,
|
||||||
|
tickets: { total, redeemed, remaining: Math.max(0, total - redeemed), pct: total ? Math.round((redeemed / total) * 100) : 0 },
|
||||||
|
people: { adults, youth, kids12, kids9, kids4Free: kids4 },
|
||||||
|
ice: {
|
||||||
|
total: iceTotal,
|
||||||
|
redeemed: iceRedeemed,
|
||||||
|
remaining: Math.max(0, iceTotal - iceRedeemed),
|
||||||
|
pct: iceTotal ? Math.round((iceRedeemed / iceTotal) * 100) : 0,
|
||||||
|
ticketsSold: Math.round(iceTotal / bagsPerTicket),
|
||||||
|
},
|
||||||
|
types: [...typeMap.entries()]
|
||||||
|
.map(([type, e]) => ({ type, ...e }))
|
||||||
|
.sort((a, b) => b.total - a.total),
|
||||||
|
donors: { orders: donorOrders, members, vouchers },
|
||||||
|
extras: { carParking, rvParking, utv },
|
||||||
|
comps: {
|
||||||
|
total: compTotal,
|
||||||
|
byCreator: [...compByCreator.entries()]
|
||||||
|
.map(([name, count]) => ({ name, count }))
|
||||||
|
.sort((a, b) => b.count - a.count),
|
||||||
|
},
|
||||||
|
operators: [...opMap.entries()]
|
||||||
|
.map(([name, o]) => ({ name, ...o }))
|
||||||
|
.sort((a, b) => b.checkins - a.checkins),
|
||||||
|
checkinsByHour: [...hourMap.entries()]
|
||||||
|
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
|
||||||
|
.slice(-12)
|
||||||
|
.map(([hour, count]) => ({ hour, count })),
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
cache = { at: now, data };
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
@ -131,6 +131,7 @@ export interface WebhookInput {
|
||||||
adultNames?: string[];
|
adultNames?: string[];
|
||||||
email: string;
|
email: string;
|
||||||
ticketType?: string; // Guest/Worker/Performer/Volunteer/Speaker for portal comps
|
ticketType?: string; // Guest/Worker/Performer/Volunteer/Speaker for portal comps
|
||||||
|
createdBy?: string; // gate-staff name who issued a comp
|
||||||
address?: string;
|
address?: string;
|
||||||
isDonor?: boolean;
|
isDonor?: boolean;
|
||||||
donorTier?: string;
|
donorTier?: string;
|
||||||
|
|
@ -180,6 +181,7 @@ export async function createTicket(
|
||||||
};
|
};
|
||||||
if (input.adultNames && input.adultNames.length) fields[COL.adultNames] = input.adultNames.join("\n");
|
if (input.adultNames && input.adultNames.length) fields[COL.adultNames] = input.adultNames.join("\n");
|
||||||
if (input.ticketType) fields[COL.ticketType] = input.ticketType;
|
if (input.ticketType) fields[COL.ticketType] = input.ticketType;
|
||||||
|
if (input.createdBy) fields[COL.createdBy] = input.createdBy;
|
||||||
if (input.address !== undefined) fields[COL.address] = input.address;
|
if (input.address !== undefined) fields[COL.address] = input.address;
|
||||||
if (input.isDonor !== undefined) fields[COL.isDonor] = input.isDonor;
|
if (input.isDonor !== undefined) fields[COL.isDonor] = input.isDonor;
|
||||||
if (input.donorTier !== undefined) fields[COL.donorTier] = input.donorTier;
|
if (input.donorTier !== undefined) fields[COL.donorTier] = input.donorTier;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue