Type more frontend pages and fix their hook dependencies
Cashup, users, profile, consent, allocations, event options, forms, event tickets, ticket scanning and manual registration now use real types. Data loaders are wrapped in useCallback and listed as effect dependencies. Also adds shared Attendee, Section and form response types. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import type { Event } from "@/types";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
@@ -15,11 +16,11 @@ const VALID_ALIAS = /^[a-zA-Z0-9_-]{1,100}$/;
|
||||
export default async function EventRedirectPage({ params }: { params: Promise<{ redirectUrl: string }> }) {
|
||||
const { redirectUrl } = await params;
|
||||
|
||||
let event: any = null;
|
||||
let event: (Event & { message?: string }) | null = null;
|
||||
|
||||
if (VALID_ALIAS.test(redirectUrl)) {
|
||||
try {
|
||||
event = await apiFetch<any>(`/api/events/by-alias/${redirectUrl}`);
|
||||
event = await apiFetch<Event & { message?: string }>(`/api/events/by-alias/${redirectUrl}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch event:", error);
|
||||
}
|
||||
|
||||
@@ -180,9 +180,10 @@ export default function AdminAuditLogPage() {
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
}, [token, buildQuery]);
|
||||
}, [token, buildQuery, setError]);
|
||||
|
||||
useEffect(() => { if (token) load(1); }, [token, actionFilter, fromFilter, toFilter]);
|
||||
// load changes whenever a filter does (via buildQuery), so this refetches page 1 on filter changes.
|
||||
useEffect(() => { if (token) load(1); }, [token, load]);
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto w-full p-6">
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import type { EventCost, EventCostType, EventFinancials, CashupMethod } from "@/types";
|
||||
import type { EventCost, EventCostType, EventFinancials, CashupMethod, Event } from "@/types";
|
||||
import { Wallet, DollarSign, ClipboardCheck, FileBarChart } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
@@ -37,25 +37,25 @@ export default function EventCashupPage() {
|
||||
|
||||
const isClosed = data?.event?.cashupStatus === "closed";
|
||||
|
||||
const load = async () => {
|
||||
const load = useCallback(async () => {
|
||||
if (!token || !eventId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [financials, event] = await Promise.all([
|
||||
apiFetch<EventFinancials>(`/api/cashups/event/${eventId}`, { authToken: token }),
|
||||
apiFetch<any>(`/api/events/${eventId}`, { authToken: token })
|
||||
apiFetch<Event>(`/api/events/${eventId}`, { authToken: token })
|
||||
]);
|
||||
setData(financials);
|
||||
setEventOptions((event.eventOptions || []).map((o: any) => ({ id: o.id, name: o.name })));
|
||||
setEventOptions((event.eventOptions || []).map((o) => ({ id: o.id, name: o.name })));
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load cashup data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [token, eventId, setError]);
|
||||
|
||||
useEffect(() => { load(); }, [token, eventId]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-4">
|
||||
@@ -258,7 +258,7 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Paid from (optional)</label>
|
||||
<select className="w-full border rounded px-2 py-1.5 text-sm" value={paidFromMethod} onChange={e => setPaidFromMethod(e.target.value as any)}>
|
||||
<select className="w-full border rounded px-2 py-1.5 text-sm" value={paidFromMethod} onChange={e => setPaidFromMethod(e.target.value as "" | CashupMethod)}>
|
||||
<option value="">Not from event takings</option>
|
||||
{METHODS.map(m => <option key={m} value={m}>{METHOD_LABELS[m]}</option>)}
|
||||
</select>
|
||||
@@ -307,16 +307,17 @@ function CashByUserSection({ eventId, token, onCashSummaryChange }: {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [editingUserId, setEditingUserId] = useState<string | null>(null);
|
||||
|
||||
const load = () => {
|
||||
const load = useCallback(() => {
|
||||
if (!token || !eventId) return;
|
||||
setLoading(true);
|
||||
apiFetch<AccountabilityResponse>(`/api/cashups/event/${eventId}/cash-by-user`, { authToken: token })
|
||||
.then(r => { setData(r); onCashSummaryChange?.({ actualTotal: r?.cashActualTotal ?? null, denominations: r?.cashDenominations || [] }); })
|
||||
.catch(() => setData({ rows: [], cashActualTotal: null, cashDenominations: [] }))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
}, [eventId, token, onCashSummaryChange]);
|
||||
|
||||
useEffect(load, [eventId, token]);
|
||||
// onCashSummaryChange is a state setter in the parent, so this only reruns when the event/token change.
|
||||
useEffect(load, [load]);
|
||||
|
||||
const rows = data?.rows || [];
|
||||
const sumOf = (m: "cash" | "card" | "eft" | "other", key: "total" | "count") =>
|
||||
@@ -569,7 +570,7 @@ function ReportTab({ eventId, token, data, isClosed }: {
|
||||
<div className="text-sm font-medium">Cash denomination count</div>
|
||||
<table className="text-sm">
|
||||
<tbody>
|
||||
{reconciled.byMethod.cash.denominations.map((d: any) => (
|
||||
{reconciled.byMethod.cash.denominations.map((d) => (
|
||||
<tr key={d.id}><td className="pr-4 py-0.5">{denomLabel(d.value)}</td><td className="pr-4 py-0.5">× {d.count}</td><td className="py-0.5 text-gray-500">{money(d.value * d.count)}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -591,7 +592,7 @@ function ReportTab({ eventId, token, data, isClosed }: {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.costs.map((c: any) => (
|
||||
{data.costs.map((c) => (
|
||||
<tr key={c.id} className="border-b last:border-0">
|
||||
<td className="py-1.5">{c.label}</td>
|
||||
<td className="py-1.5 capitalize">{c.paidFromMethod || "—"}</td>
|
||||
@@ -626,7 +627,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
||||
setBusy: (b: boolean) => void; setError: (e: string | null) => void; onChanged: () => void; onClosed: () => void;
|
||||
}) {
|
||||
const isClosed = data.event.cashupStatus === "closed";
|
||||
const draftLines = (data.event as any).cashupDraft?.lines as Array<{ method: string; actualAmount?: string | number; notes?: string; denominations?: { value: number; count: number }[] }> | undefined;
|
||||
const draftLines = data.event.cashupDraft?.lines;
|
||||
|
||||
const initialLines: Record<CashupMethod, LineInput> = useMemo(() => {
|
||||
const base: Record<CashupMethod, LineInput> = { cash: { actualAmount: "", notes: "" }, card: { actualAmount: "", notes: "" }, eft: { actualAmount: "", notes: "" }, other: { actualAmount: "", notes: "" } };
|
||||
@@ -775,7 +776,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
||||
return denoms.length > 0 ? (
|
||||
<table className="text-sm">
|
||||
<tbody>
|
||||
{denoms.map((d: any) => (
|
||||
{denoms.map((d: PersonDenom & { id?: string }) => (
|
||||
<tr key={d.id || d.value}><td className="pr-4 py-0.5">{denomLabel(d.value)}</td><td className="pr-4 py-0.5">× {d.count}</td><td className="py-0.5 text-gray-500">{money(d.value * d.count)}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -5,10 +5,11 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { beginImpersonation } from "@/lib/impersonation";
|
||||
import { beginImpersonation, type ImpersonationStart } from "@/lib/impersonation";
|
||||
import { Users as UsersIcon } from "lucide-react";
|
||||
import { RoleBadge, type Role as RoleBadgeRole } from "@/components/shared/RoleBadge";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Paginated } from "@/types";
|
||||
|
||||
interface UserItem {
|
||||
id: string;
|
||||
@@ -90,7 +91,7 @@ export default function AdminUsersPage() {
|
||||
setFetching(true);
|
||||
try {
|
||||
const url = buildQuery(p, ps);
|
||||
const res = await apiFetch<any>(url, { authToken: token });
|
||||
const res = await apiFetch<Paginated<UserItem>>(url, { authToken: token });
|
||||
setUsers(Array.isArray(res?.data) ? res.data : []);
|
||||
setTotal(res?.total ?? 0);
|
||||
setTotalPages(res?.pages ?? 1);
|
||||
@@ -100,16 +101,16 @@ export default function AdminUsersPage() {
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
}, [token, buildQuery]);
|
||||
}, [token, buildQuery, pageSize, setError]);
|
||||
|
||||
// Initial load + reload on filter change
|
||||
useEffect(() => { if (token) loadUsers(1); }, [token, debouncedQuery, roleFilter, activeFilter]);
|
||||
// Initial load, and back to page 1 whenever a filter or the page size changes
|
||||
// (loadUsers is recreated when any of them do).
|
||||
useEffect(() => { if (token) loadUsers(1); }, [token, loadUsers]);
|
||||
|
||||
const goToPage = (p: number) => loadUsers(p);
|
||||
|
||||
const handlePageSizeChange = (newSize: number) => {
|
||||
setPageSize(newSize);
|
||||
loadUsers(1, newSize);
|
||||
};
|
||||
|
||||
const resetCreateForm = () => {
|
||||
@@ -121,7 +122,7 @@ export default function AdminUsersPage() {
|
||||
if (!cEmail || !cPassword) { setError("Email and password are required"); return; }
|
||||
try {
|
||||
setCreating(true);
|
||||
const created = await apiFetch<any>("/api/users", {
|
||||
const created = await apiFetch<{ id?: string }>("/api/users", {
|
||||
method: "POST",
|
||||
body: { name: cName || cEmail.split("@")[0], email: cEmail, password: cPassword, phoneNumber: cPhone || undefined },
|
||||
});
|
||||
@@ -147,7 +148,7 @@ export default function AdminUsersPage() {
|
||||
if (!editingId) return;
|
||||
try {
|
||||
setSaving(true);
|
||||
const payload: any = {
|
||||
const payload: Partial<UserItem> & { password?: string } = {
|
||||
name: editData.name,
|
||||
email: editData.email,
|
||||
role: editData.role,
|
||||
@@ -189,7 +190,7 @@ export default function AdminUsersPage() {
|
||||
setImpBusy(true);
|
||||
setImpError(null);
|
||||
try {
|
||||
const res = await apiFetch<any>(`/api/admin/impersonate/${encodeURIComponent(impTarget.id)}`, { method: "POST", authToken: token, body: { reason: impReason } });
|
||||
const res = await apiFetch<ImpersonationStart>(`/api/admin/impersonate/${encodeURIComponent(impTarget.id)}`, { method: "POST", authToken: token, body: { reason: impReason } });
|
||||
beginImpersonation(res);
|
||||
} catch (e) {
|
||||
setImpError(errorMessage(e) || "Couldn't start impersonation");
|
||||
|
||||
@@ -9,6 +9,7 @@ import { formatDate } from "@/lib/date";
|
||||
import { QrImage } from "@/components/shared/QrImage";
|
||||
import { Ticket } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, Ticket as TicketRecord } from "@/types";
|
||||
|
||||
function EventTicketsContent() {
|
||||
const { token, user } = useAuth();
|
||||
@@ -18,9 +19,9 @@ function EventTicketsContent() {
|
||||
const paramEventId = params.get("eventId") || "";
|
||||
const paramUserId = params.get("userId") || "";
|
||||
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [eventId, setEventId] = useState<string>(paramEventId);
|
||||
const [tickets, setTickets] = useState<any[]>([]);
|
||||
const [tickets, setTickets] = useState<TicketRecord[]>([]);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -37,7 +38,7 @@ function EventTicketsContent() {
|
||||
(async () => {
|
||||
try {
|
||||
if (!token) return;
|
||||
const evs = await apiFetch<any[]>("/api/events/all", { authToken: token });
|
||||
const evs = await apiFetch<Event[]>("/api/events/all", { authToken: token });
|
||||
const now = Date.now();
|
||||
const active = (evs || []).filter((ev) => {
|
||||
const t = new Date(ev.endDate).getTime();
|
||||
@@ -48,25 +49,27 @@ function EventTicketsContent() {
|
||||
new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
|
||||
);
|
||||
setEvents(active);
|
||||
if (!paramEventId && active.length > 0) {
|
||||
setEventId(active[0].id);
|
||||
router.replace(
|
||||
`?eventId=${encodeURIComponent(active[0].id)}${
|
||||
selectedUserId ? `&userId=${encodeURIComponent(selectedUserId)}` : ""
|
||||
}`
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load events");
|
||||
}
|
||||
})();
|
||||
}, [token]);
|
||||
}, [token, setError]);
|
||||
|
||||
// Sync params → state
|
||||
// No event in the URL: default to the first upcoming one once the list arrives.
|
||||
useEffect(() => {
|
||||
if (paramEventId && paramEventId !== eventId) setEventId(paramEventId);
|
||||
if (paramUserId && paramUserId !== selectedUserId)
|
||||
setSelectedUserId(paramUserId);
|
||||
if (paramEventId || events.length === 0) return;
|
||||
setEventId(events[0].id);
|
||||
router.replace(
|
||||
`?eventId=${encodeURIComponent(events[0].id)}${
|
||||
selectedUserId ? `&userId=${encodeURIComponent(selectedUserId)}` : ""
|
||||
}`
|
||||
);
|
||||
}, [events, paramEventId, router, selectedUserId]);
|
||||
|
||||
// Sync params → state (setting the value it already has is a no-op)
|
||||
useEffect(() => {
|
||||
if (paramEventId) setEventId(paramEventId);
|
||||
if (paramUserId) setSelectedUserId(paramUserId);
|
||||
}, [paramEventId, paramUserId]);
|
||||
|
||||
// Load tickets when event changes
|
||||
@@ -76,7 +79,7 @@ function EventTicketsContent() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const list = await apiFetch<any[]>(
|
||||
const list = await apiFetch<TicketRecord[]>(
|
||||
`/api/tickets/event/${encodeURIComponent(eventId)}`,
|
||||
{ authToken: token }
|
||||
);
|
||||
@@ -87,7 +90,7 @@ function EventTicketsContent() {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [token, eventId]);
|
||||
}, [token, eventId, setError]);
|
||||
|
||||
// Build variants list for filter (only variants present in current event's tickets)
|
||||
const variantsList = useMemo(() => {
|
||||
@@ -139,7 +142,7 @@ function EventTicketsContent() {
|
||||
}, [tickets, eventId, selectedUserId, selectedVariantId]);
|
||||
|
||||
// Printing helpers
|
||||
const buildTicketHtmlCard = (t: any) => {
|
||||
const buildTicketHtmlCard = (t: TicketRecord) => {
|
||||
const eventTitle = t.event?.title || t.eventId || "Event";
|
||||
const eventDate = t.event?.startDate ? new Date(t.event.startDate) : null;
|
||||
const optBase = t.registrationOption?.eventOption?.name || "Ticket";
|
||||
@@ -217,7 +220,7 @@ function EventTicketsContent() {
|
||||
w.focus();
|
||||
};
|
||||
|
||||
const printTickets = (ticketList: any[]) => {
|
||||
const printTickets = (ticketList: TicketRecord[]) => {
|
||||
if (!ticketList || ticketList.length === 0) return;
|
||||
// Chunk into pages of 8 (2 columns × 4 rows per A4 sheet)
|
||||
const PAGE_SIZE = 8;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { QRScanner, QRScannerHandle } from "@/components/qr/QRScanner";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
@@ -11,18 +11,38 @@ import { useOfflineScanning } from "@/components/scanner/useOfflineScanning";
|
||||
import { OfflineScanPanel } from "@/components/scanner/OfflineScanPanel";
|
||||
import type { SnapshotTicket } from "@/lib/offlineScan";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, RecentScan, ScanStats, Section } from "@/types";
|
||||
|
||||
// GET /api/tickets/scan-preview/:qr — just the fields the confirm step needs. Offline
|
||||
// scans build the same shape from the downloaded ticket list.
|
||||
type ScanTicket = {
|
||||
id: string;
|
||||
eventId?: string;
|
||||
eventOptionId?: string;
|
||||
quantity?: number;
|
||||
consentStatus?: string;
|
||||
event?: { id?: string; title?: string } | null;
|
||||
registrationOption?: {
|
||||
eventOption?: { id?: string; name?: string } | null;
|
||||
variant?: { name?: string } | null;
|
||||
registration?: { user?: { name?: string } | null } | null;
|
||||
} | null;
|
||||
attendee?: { name: string } | null;
|
||||
user?: { name?: string; email?: string } | null;
|
||||
usages?: { id: string; scannedAt: string; quantityRedeemed?: number }[];
|
||||
};
|
||||
|
||||
export default function TicketScanningPage() {
|
||||
const router = useRouter();
|
||||
const scannerRef = useRef<QRScannerHandle>(null);
|
||||
const [scanInfo, setScanInfo] = useDismissingState<string | null>(null);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [alreadyUsedModal, setAlreadyUsedModal] = useState<{ message: string; ticket?: any } | null>(null);
|
||||
const [alreadyUsedModal, setAlreadyUsedModal] = useState<{ message: string; ticket?: ScanTicket } | null>(null);
|
||||
const [errorModal, setErrorModal] = useState<string | null>(null);
|
||||
const { token, user } = useAuth();
|
||||
const offline = useOfflineScanning(token);
|
||||
const [recentScans, setRecentScans] = useState<any[]>([]);
|
||||
const [stats, setStats] = useState<any | null>(null);
|
||||
const [recentScans, setRecentScans] = useState<RecentScan[]>([]);
|
||||
const [stats, setStats] = useState<ScanStats | null>(null);
|
||||
const [loadingStats, setLoadingStats] = useState<boolean>(false);
|
||||
const [successModal, setSuccessModal] = useState<{
|
||||
optionName: string;
|
||||
@@ -36,7 +56,7 @@ export default function TicketScanningPage() {
|
||||
// Confirm step state
|
||||
const [confirmModal, setConfirmModal] = useState<{
|
||||
qrCode: string;
|
||||
ticket: any;
|
||||
ticket: ScanTicket;
|
||||
remaining: number;
|
||||
offlineTicket?: SnapshotTicket; // scanned against the downloaded list (offline mode)
|
||||
} | null>(null);
|
||||
@@ -47,11 +67,11 @@ export default function TicketScanningPage() {
|
||||
const [confirmQtyRaw, setConfirmQtyRaw] = useState("1");
|
||||
|
||||
// Event selection state
|
||||
const [allEvents, setAllEvents] = useState<any[]>([]);
|
||||
const [allEvents, setAllEvents] = useState<Event[]>([]);
|
||||
const [includePastEvents, setIncludePastEvents] = useState(false);
|
||||
const [selectedEventId, setSelectedEventId] = useState<string>("all");
|
||||
const [loadingEvents, setLoadingEvents] = useState<boolean>(false);
|
||||
const [sections, setSections] = useState<any[]>([]);
|
||||
const [sections, setSections] = useState<Section[]>([]);
|
||||
const [selectedSectionId, setSelectedSectionId] = useState<string>("all");
|
||||
const [loadingSections, setLoadingSections] = useState(false);
|
||||
|
||||
@@ -61,7 +81,7 @@ export default function TicketScanningPage() {
|
||||
try {
|
||||
if (!token) return;
|
||||
setLoadingEvents(true);
|
||||
const evs = await apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token });
|
||||
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token });
|
||||
const sorted = (evs || []).sort((a, b) => new Date(a.endDate).getTime() - new Date(b.endDate).getTime());
|
||||
setAllEvents(sorted);
|
||||
} catch {
|
||||
@@ -90,7 +110,7 @@ export default function TicketScanningPage() {
|
||||
}
|
||||
try {
|
||||
setLoadingSections(true);
|
||||
const list = await apiFetch<any[]>(`/api/sections?eventId=${selectedEventId}`, { authToken: token });
|
||||
const list = await apiFetch<Section[]>(`/api/sections?eventId=${selectedEventId}`, { authToken: token });
|
||||
setSections(Array.isArray(list) ? list : []);
|
||||
setSelectedSectionId("all");
|
||||
} catch {
|
||||
@@ -156,9 +176,9 @@ export default function TicketScanningPage() {
|
||||
|
||||
// Always preview first to validate the ticket before showing confirm step
|
||||
// Uses the lightweight scan-preview endpoint (only fetches fields needed for the confirm modal)
|
||||
let preview: any;
|
||||
let preview: ScanTicket;
|
||||
try {
|
||||
preview = await apiFetch<any>(`/api/tickets/scan-preview/${encodeURIComponent(text)}`, { authToken: token });
|
||||
preview = await apiFetch<ScanTicket>(`/api/tickets/scan-preview/${encodeURIComponent(text)}`, { authToken: token });
|
||||
} catch (pe) {
|
||||
setErrorModal(errorMessage(pe) || 'Failed to validate ticket');
|
||||
return;
|
||||
@@ -181,7 +201,7 @@ export default function TicketScanningPage() {
|
||||
return;
|
||||
}
|
||||
const section = sections.find(s => s.id === selectedSectionId);
|
||||
const allowedOptionIds = (section?.allowedOptions || []).map((o: any) => o.eventOptionId);
|
||||
const allowedOptionIds = (section?.allowedOptions || []).map((o) => o.eventOptionId);
|
||||
if (!allowedOptionIds.includes(ticketOptionId)) {
|
||||
setErrorModal("This ticket is not valid for this section");
|
||||
return;
|
||||
@@ -191,7 +211,7 @@ export default function TicketScanningPage() {
|
||||
|
||||
// Compute remaining qty
|
||||
const ticketQty = preview?.quantity || 1;
|
||||
const totalRedeemed = (preview?.usages || []).reduce((s: number, u: any) => s + (u.quantityRedeemed || 1), 0);
|
||||
const totalRedeemed = (preview?.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
|
||||
const remaining = ticketQty - totalRedeemed;
|
||||
|
||||
if (remaining <= 0) {
|
||||
@@ -250,7 +270,7 @@ export default function TicketScanningPage() {
|
||||
const url = `/api/tickets/scan/${encodeURIComponent(qrCode)}${
|
||||
selectedEventId && selectedEventId !== "all" ? `?eventId=${encodeURIComponent(selectedEventId)}` : ''
|
||||
}`;
|
||||
await apiFetch<any>(url, {
|
||||
await apiFetch(url, {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: {
|
||||
@@ -279,28 +299,27 @@ export default function TicketScanningPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const loadRecent = async () => {
|
||||
const loadRecent = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const list = await apiFetch<any[]>(`/api/tickets/scans/recent?limit=10`, { authToken: token });
|
||||
const list = await apiFetch<RecentScan[]>(`/api/tickets/scans/recent?limit=10`, { authToken: token });
|
||||
setRecentScans(list);
|
||||
} catch {}
|
||||
};
|
||||
const loadStats = async () => {
|
||||
}, [token]);
|
||||
const loadStats = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
setLoadingStats(true);
|
||||
const s = await apiFetch<any>(`/api/tickets/scans/stats`, { authToken: token });
|
||||
const s = await apiFetch<ScanStats>(`/api/tickets/scans/stats`, { authToken: token });
|
||||
setStats(s);
|
||||
} catch {} finally {
|
||||
setLoadingStats(false);
|
||||
}
|
||||
};
|
||||
const refreshStatsAndRecent = () => { loadRecent(); loadStats(); };
|
||||
}, [token]);
|
||||
const refreshStatsAndRecent = useCallback(() => { loadRecent(); loadStats(); }, [loadRecent, loadStats]);
|
||||
useEffect(() => {
|
||||
refreshStatsAndRecent();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token]);
|
||||
}, [refreshStatsAndRecent]);
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto w-full p-4">
|
||||
@@ -402,11 +421,11 @@ export default function TicketScanningPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats?.byStaff?.length > 0 && (
|
||||
{stats && stats.byStaff?.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="text-sm font-medium mb-1">Today by staff</div>
|
||||
<ul className="text-sm text-gray-700 space-y-1">
|
||||
{stats.byStaff.map((s: any) => (
|
||||
{stats.byStaff.map((s) => (
|
||||
<li key={s.scannedById} className="flex justify-between">
|
||||
<span>{s.name || 'Staff'}</span>
|
||||
<span className="font-medium">{s.count}</span>
|
||||
@@ -418,7 +437,7 @@ export default function TicketScanningPage() {
|
||||
|
||||
<h3 className="text-lg font-semibold mb-2">Last 10 scans</h3>
|
||||
<ul className="text-sm space-y-2 max-h-80 overflow-auto pr-2">
|
||||
{recentScans.map((u: any) => (
|
||||
{recentScans.map((u) => (
|
||||
<li key={u.id} className="border rounded p-2 bg-white">
|
||||
<div className="flex justify-between">
|
||||
<div className="font-medium">{u.ticket?.event?.title || 'Event'}</div>
|
||||
@@ -532,8 +551,8 @@ export default function TicketScanningPage() {
|
||||
<div className="mt-2">
|
||||
<div className="font-medium">Usages:</div>
|
||||
<ul className="list-disc ml-5 mt-1 max-h-32 overflow-auto">
|
||||
{alreadyUsedModal.ticket.usages.map((u: any) => (
|
||||
<li key={u.id}>{new Date(u.scannedAt).toLocaleString()}{u.quantityRedeemed > 1 ? ` (×${u.quantityRedeemed})` : ''}</li>
|
||||
{alreadyUsedModal.ticket.usages.map((u) => (
|
||||
<li key={u.id}>{new Date(u.scannedAt).toLocaleString()}{(u.quantityRedeemed ?? 1) > 1 ? ` (×${u.quantityRedeemed})` : ''}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -9,10 +9,22 @@ import { formatDate } from "@/lib/date";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { BedDouble, Plus, Wand2, Printer, Trash2, Pencil, Eraser } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event } from "@/types";
|
||||
|
||||
type Group = { id: string; name: string; capacity: number | null; gender: "male" | "female" | null; leaderUserId: string | null; leader?: { id: string; name: string } | null };
|
||||
type AllocSet = { id: string; name: string; showOnTicket: boolean; groups: Group[]; assignments: { attendeeId: string; groupId: string }[] };
|
||||
type Person = { id: string; name: string; gender: string | null; dateOfBirth: string | null; registrationId: string; bookedBy: string };
|
||||
// GET /api/allocations/sets/:id/lists — medical/emergency only when ?medical=true and consent was given.
|
||||
type ListMember = {
|
||||
groupId: string; id: string; name: string; gender: string | null; dateOfBirth: string | null;
|
||||
phone: string | null; parentName: string | null; parentPhone: string | null; bookedBy: string; bookedByPhone: string | null;
|
||||
medical?: Record<string, unknown> | null; emergency?: Record<string, unknown> | null;
|
||||
};
|
||||
type SetLists = {
|
||||
set: { id: string; name: string };
|
||||
event: { id: string; title: string; startDate: string };
|
||||
groups: { id: string; name: string; capacity: number | null; gender: string | null; leader: string | null; members: ListMember[] }[];
|
||||
};
|
||||
type Board = { event: { id: string; title: string; startDate: string; collectGender: boolean; requiresParentalConsent: boolean }; sets: AllocSet[]; attendees: Person[]; leaders: { id: string; name: string }[]; unnamedPlaces: number };
|
||||
|
||||
const UNASSIGNED = "unassigned";
|
||||
@@ -68,7 +80,7 @@ export default function AllocationsPage() {
|
||||
const canView = useMemo(() => user?.role === "admin" || user?.role === "supervisor", [user]);
|
||||
useEffect(() => { if (!loading && !user) router.replace("/login"); }, [user, loading, router]);
|
||||
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [eventId, setEventId] = useState("");
|
||||
const [board, setBoard] = useState<Board | null>(null);
|
||||
const [setId, setSetId] = useState("");
|
||||
@@ -81,7 +93,7 @@ export default function AllocationsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !canView) return;
|
||||
apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token })
|
||||
apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token })
|
||||
.then(evs => {
|
||||
const upcoming = (evs || []).filter(e => new Date(e.endDate).getTime() > Date.now() - 7 * 864e5)
|
||||
.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
|
||||
@@ -161,19 +173,19 @@ export default function AllocationsPage() {
|
||||
const printLists = async (master: boolean, medical: boolean) => {
|
||||
if (!current || !board) return;
|
||||
try {
|
||||
const data = await apiFetch<any>(`/api/allocations/sets/${current.id}/lists${medical ? "?medical=true" : ""}`, { authToken: token! });
|
||||
const data = await apiFetch<SetLists>(`/api/allocations/sets/${current.id}/lists${medical ? "?medical=true" : ""}`, { authToken: token! });
|
||||
const w = window.open("", "_blank");
|
||||
if (!w) return;
|
||||
const row = (m: any) => `<tr><td>${esc(m.name)}</td><td>${esc(genderLabel(m.gender))}</td><td>${esc(ageOn(m.dateOfBirth, board.event.startDate) ?? "")}</td>`
|
||||
const row = (m: ListMember) => `<tr><td>${esc(m.name)}</td><td>${esc(genderLabel(m.gender))}</td><td>${esc(ageOn(m.dateOfBirth, board.event.startDate) ?? "")}</td>`
|
||||
+ `<td>${esc(m.phone || m.parentPhone || m.bookedByPhone || "")}</td><td>${esc(m.parentName || m.bookedBy || "")}</td>`
|
||||
+ (medical ? `<td>${esc(m.medical ? Object.values(m.medical).filter(Boolean).join("; ") : "")}</td><td>${esc(m.emergency ? Object.values(m.emergency).filter(Boolean).join(", ") : "")}</td>` : "") + `</tr>`;
|
||||
const head = `<tr><th>Name</th><th>Gender</th><th>Age</th><th>Phone</th><th>Parent / booked by</th>${medical ? "<th>Medical</th><th>Emergency contact</th>" : ""}</tr>`;
|
||||
let body = "";
|
||||
if (master) {
|
||||
const all = data.groups.flatMap((g: any) => g.members.map((m: any) => ({ ...m, group: g.name }))).sort((a: any, b: any) => a.name.localeCompare(b.name));
|
||||
body = `<h1>${esc(data.event.title)} — ${esc(data.set.name)} (master list)</h1><table><tr><th>${esc(data.set.name)}</th>${head.slice(4)}${all.map((m: any) => row(m).replace("<tr>", `<tr><td>${esc(m.group)}</td>`)).join("")}</table>`;
|
||||
const all = data.groups.flatMap((g) => g.members.map((m) => ({ ...m, group: g.name }))).sort((a, b) => a.name.localeCompare(b.name));
|
||||
body = `<h1>${esc(data.event.title)} — ${esc(data.set.name)} (master list)</h1><table><tr><th>${esc(data.set.name)}</th>${head.slice(4)}${all.map((m) => row(m).replace("<tr>", `<tr><td>${esc(m.group)}</td>`)).join("")}</table>`;
|
||||
} else {
|
||||
body = data.groups.map((g: any) => `<section><h1>${esc(data.event.title)} — ${esc(g.name)}</h1>`
|
||||
body = data.groups.map((g) => `<section><h1>${esc(data.event.title)} — ${esc(g.name)}</h1>`
|
||||
+ `<p>${esc(data.set.name)}${g.leader ? ` · Leader: ${esc(g.leader)}` : ""} · ${g.members.length}${g.capacity ? ` / ${g.capacity}` : ""} people</p>`
|
||||
+ `<table>${head}${g.members.map(row).join("")}</table></section>`).join("");
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { ShieldCheck, Send, RefreshCw, HeartPulse, Printer } from "lucide-react";
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event } from "@/types";
|
||||
|
||||
type ConsentRow = {
|
||||
attendeeId: string;
|
||||
@@ -52,9 +53,9 @@ export default function ConsentTrackerPage() {
|
||||
if (!user) router.replace("/login");
|
||||
}, [user, loading, router]);
|
||||
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [eventId, setEventId] = useState<string>(search.get("eventId") || "");
|
||||
const [data, setData] = useState<{ event: any; counts: Record<string, number>; rows: ConsentRow[] } | null>(null);
|
||||
const [data, setData] = useState<{ event: Pick<Event, "id" | "title">; counts: Record<string, number>; rows: ConsentRow[] } | null>(null);
|
||||
const [filter, setFilter] = useState<"minors" | "all" | "waiting">("minors");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
@@ -62,7 +63,7 @@ export default function ConsentTrackerPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !canView) return;
|
||||
apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token })
|
||||
apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token })
|
||||
.then(evs => {
|
||||
const youth = (evs || []).filter(e => e.requiresParentalConsent);
|
||||
setEvents(youth);
|
||||
@@ -115,7 +116,7 @@ export default function ConsentTrackerPage() {
|
||||
// Printable list for camp leaders. Opening it is recorded in the audit log.
|
||||
const printMedical = async () => {
|
||||
try {
|
||||
const res = await apiFetch<{ event: any; rows: MedicalRow[] }>(`/api/consent/events/${eventId}/medical`, { authToken: token! });
|
||||
const res = await apiFetch<{ event: Pick<Event, "id" | "title">; rows: MedicalRow[] }>(`/api/consent/events/${eventId}/medical`, { authToken: token! });
|
||||
const w = window.open("", "_blank");
|
||||
if (!w) { setError("Allow pop-ups to print the medical list."); return; }
|
||||
const rowsHtml = res.rows.map(r => `
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import React, { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import React, { Suspense, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Ticket } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, EventOption } from "@/types";
|
||||
|
||||
// Format a Date (or date-like input) to the value expected by <input type="datetime-local">
|
||||
// This returns local time (browser timezone) as YYYY-MM-DDTHH:mm
|
||||
@@ -22,7 +23,10 @@ function toLocalDateTimeInputValue(input: string | number | Date | null | undefi
|
||||
return `${y}-${m}-${day}T${hh}:${mm}`;
|
||||
}
|
||||
|
||||
function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers: { id?: string; deadline: string; price: number; order?: number }[]) => void }) {
|
||||
type TierInput = { id?: string; deadline: string; price: number; order?: number };
|
||||
type OptionPatch = Partial<Pick<EventOption, "name" | "price" | "isMainTicket">> & { earlyBirdTiers?: TierInput[] };
|
||||
|
||||
function EarlyBirdTiersEditor({ option, onSave }: { option: EventOption; onSave: (tiers: TierInput[]) => void }) {
|
||||
const [rows, setRows] = React.useState<{ id?: string; deadline: string; price: string; order?: number }[]>([]);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
@@ -31,8 +35,8 @@ function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers:
|
||||
const tiers = Array.isArray(option?.earlyBirdTiers) ? option.earlyBirdTiers : [];
|
||||
const normalized = tiers
|
||||
.slice()
|
||||
.sort((a: any, b: any) => new Date(a.deadline).getTime() - new Date(b.deadline).getTime() || (a.order || 0) - (b.order || 0))
|
||||
.map((t: any, i: number) => ({ id: t.id, deadline: toLocalDateTimeInputValue(t.deadline), price: String(t.price ?? ''), order: typeof t.order === 'number' ? t.order : i }));
|
||||
.sort((a, b) => new Date(a.deadline).getTime() - new Date(b.deadline).getTime() || (a.order || 0) - (b.order || 0))
|
||||
.map((t, i) => ({ id: t.id, deadline: toLocalDateTimeInputValue(t.deadline), price: String(t.price ?? ''), order: typeof t.order === 'number' ? t.order : i }));
|
||||
setRows(normalized);
|
||||
}, [option?.id, option?.earlyBirdTiers]);
|
||||
|
||||
@@ -46,7 +50,7 @@ function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers:
|
||||
};
|
||||
const updateRow = (idx: number, patch: Partial<{ deadline: string; price: string; order?: number }>) => {
|
||||
const copy = rows.slice();
|
||||
copy[idx] = { ...copy[idx], ...patch } as any;
|
||||
copy[idx] = { ...copy[idx], ...patch };
|
||||
setRows(copy);
|
||||
};
|
||||
|
||||
@@ -111,23 +115,23 @@ function EventOptionsContent() {
|
||||
if (!user) router.replace("/login");
|
||||
}, [user, loading, router]);
|
||||
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [selectedEventId, setSelectedEventId] = useState<string>("");
|
||||
const [options, setOptions] = useState<any[]>([]);
|
||||
const [options, setOptions] = useState<EventOption[]>([]);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||
|
||||
const loadEvents = async () => {
|
||||
const loadEvents = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const evs = await apiFetch<any[]>("/api/events/all", { authToken: token });
|
||||
const evs = await apiFetch<Event[]>("/api/events/all", { authToken: token });
|
||||
setEvents(evs || []);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => { loadEvents(); }, [token]);
|
||||
useEffect(() => { loadEvents(); }, [loadEvents]);
|
||||
|
||||
// Preselect event from query (?eventId=)
|
||||
useEffect(() => {
|
||||
@@ -167,7 +171,7 @@ function EventOptionsContent() {
|
||||
}
|
||||
};
|
||||
|
||||
const updateOption = async (opt: any, patch: any) => {
|
||||
const updateOption = async (opt: EventOption, patch: OptionPatch) => {
|
||||
if (!token) return;
|
||||
setError(null); setInfo(null);
|
||||
try {
|
||||
@@ -183,7 +187,7 @@ function EventOptionsContent() {
|
||||
}
|
||||
};
|
||||
|
||||
const deleteOption = async (opt: any) => {
|
||||
const deleteOption = async (opt: EventOption) => {
|
||||
if (!token) return;
|
||||
setError(null); setInfo(null);
|
||||
try {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { UserPlus } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Registration } from "@/types";
|
||||
|
||||
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'|'rating'|'multiple_choice'; label: string; isRequired?: boolean; helpText?: string|null; options?: unknown };
|
||||
|
||||
@@ -22,7 +23,7 @@ export default function ManualRegistrationPage() {
|
||||
const [phoneNumber, setPhoneNumber] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [createdReg, setCreatedReg] = useState<any | null>(null);
|
||||
const [createdReg, setCreatedReg] = useState<Registration | null>(null);
|
||||
const [form, setForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
|
||||
const [formsData, setFormsData] = useState<Record<number, Record<string, string>>>({});
|
||||
|
||||
@@ -37,7 +38,7 @@ export default function ManualRegistrationPage() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await apiFetch<any>("/api/registrations/manual", {
|
||||
const res = await apiFetch<Registration>("/api/registrations/manual", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: {
|
||||
@@ -49,7 +50,7 @@ export default function ManualRegistrationPage() {
|
||||
setCreatedReg(res);
|
||||
// Load form definition for this event (if any)
|
||||
try {
|
||||
const ev = await apiFetch<any>(`/api/events/${encodeURIComponent(eventId)}`);
|
||||
const ev = await apiFetch<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(`/api/events/${encodeURIComponent(eventId)}`);
|
||||
if (ev?.form) setForm(ev.form);
|
||||
} catch {}
|
||||
alert("Manual registration created");
|
||||
@@ -111,12 +112,14 @@ export default function ManualRegistrationPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function AttendeeFormsSection({ registration, form, formsData, setFormsData }: { registration: any; form: { isRequired: boolean; fields: FormField[] }; formsData: Record<number, Record<string,string>>; setFormsData: any; }) {
|
||||
type FormsData = Record<number, Record<string, string>>;
|
||||
|
||||
function AttendeeFormsSection({ registration, form, formsData, setFormsData }: { registration: Registration; form: { isRequired: boolean; fields: FormField[] }; formsData: FormsData; setFormsData: React.Dispatch<React.SetStateAction<FormsData>>; }) {
|
||||
const { token } = useAuth();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||
const mainTickets = (registration?.registrationOptions || []).filter((o: any) => o?.eventOption?.isMainTicket).reduce((s: number, o: any) => s + (o.quantity || 0), 0);
|
||||
const mainTickets = (registration?.registrationOptions || []).filter((o) => o?.eventOption?.isMainTicket).reduce((s, o) => s + (o.quantity || 0), 0);
|
||||
const count = Math.max(0, mainTickets);
|
||||
|
||||
const canSubmit = React.useMemo(() => {
|
||||
@@ -137,7 +140,7 @@ function AttendeeFormsSection({ registration, form, formsData, setFormsData }: {
|
||||
}, [form, formsData, count]);
|
||||
|
||||
const update = (idx: number, fieldId: string, value: string) => {
|
||||
setFormsData((prev: any) => ({ ...prev, [idx]: { ...(prev[idx]||{}), [fieldId]: value } }));
|
||||
setFormsData((prev) => ({ ...prev, [idx]: { ...(prev[idx]||{}), [fieldId]: value } }));
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
@@ -145,7 +148,7 @@ function AttendeeFormsSection({ registration, form, formsData, setFormsData }: {
|
||||
try {
|
||||
setSubmitting(true);
|
||||
setError(null); setInfo(null);
|
||||
const payload = [] as any[];
|
||||
const payload: { answers: Record<string, string> }[] = [];
|
||||
for (let i = 0; i < count; i++) payload.push({ answers: formsData[i] || {} });
|
||||
await apiFetch(`/api/registrations/${encodeURIComponent(registration.id)}/forms/responses`, {
|
||||
method: 'POST', authToken: token, body: { responses: payload }
|
||||
|
||||
@@ -7,6 +7,7 @@ import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { FileText } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Registration } from "@/types";
|
||||
|
||||
// Types for form fields
|
||||
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'|'rating'|'multiple_choice'; label: string; isRequired?: boolean; helpText?: string|null; options?: unknown };
|
||||
@@ -17,7 +18,7 @@ function FormsContent() {
|
||||
const { token } = useAuth();
|
||||
const registrationId = search.get("registrationId");
|
||||
|
||||
const [registration, setRegistration] = useState<any | null>(null);
|
||||
const [registration, setRegistration] = useState<Registration | null>(null);
|
||||
const [eventForm, setEventForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
@@ -33,13 +34,13 @@ function FormsContent() {
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
try {
|
||||
const reg = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
const reg = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
setRegistration(reg);
|
||||
const ev = await apiFetch<any>(`/api/events/${encodeURIComponent(reg.eventId)}`);
|
||||
const ev = await apiFetch<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(`/api/events/${encodeURIComponent(reg.eventId)}`);
|
||||
if (ev?.form) setEventForm(ev.form);
|
||||
// Load any saved draft
|
||||
try {
|
||||
const draft = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, { authToken: token });
|
||||
const draft = await apiFetch<{ data?: Record<number, Record<string, string>> } | null>(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, { authToken: token });
|
||||
if (draft && draft.data && typeof draft.data === 'object') setFormsData(draft.data);
|
||||
} catch {}
|
||||
} catch (e) {
|
||||
@@ -48,7 +49,7 @@ function FormsContent() {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [token, registrationId]);
|
||||
}, [token, registrationId, setError, setInfo]);
|
||||
|
||||
const submittedCount = useMemo(() => {
|
||||
return (registration?.formResponses || []).length;
|
||||
@@ -58,8 +59,8 @@ function FormsContent() {
|
||||
if (!registration) return 0;
|
||||
try {
|
||||
return (registration.registrationOptions || [])
|
||||
.filter((ro: any) => ro.eventOption?.isMainTicket)
|
||||
.reduce((s: number, ro: any) => s + (ro.quantity || 0), 0);
|
||||
.filter((ro) => ro.eventOption?.isMainTicket)
|
||||
.reduce((s, ro) => s + (ro.quantity || 0), 0);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
@@ -89,7 +90,7 @@ function FormsContent() {
|
||||
if (!token || !registrationId) return;
|
||||
try {
|
||||
setError(null); setInfo(null);
|
||||
const payload: any[] = [];
|
||||
const payload: { answers: Record<string, string> }[] = [];
|
||||
for (let i = 0; i < remaining; i++) {
|
||||
payload.push({ answers: formsData[i] || {} });
|
||||
}
|
||||
@@ -104,7 +105,7 @@ function FormsContent() {
|
||||
});
|
||||
setInfo('Attendee forms submitted successfully.');
|
||||
// reload registration to reflect new responses
|
||||
const reg = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
const reg = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
setRegistration(reg);
|
||||
setFormsData({});
|
||||
// On success, redirect to the user dashbaord
|
||||
@@ -160,14 +161,14 @@ function FormsContent() {
|
||||
<div className="border rounded p-3 mb-4 bg-gray-50">
|
||||
<div className="text-sm font-medium mb-2">Submitted responses</div>
|
||||
<ul className="space-y-2">
|
||||
{(registration.formResponses || []).map((resp: any, idx: number) => (
|
||||
{(registration.formResponses || []).map((resp, idx) => (
|
||||
<li key={resp.id} className="bg-white border rounded p-2">
|
||||
<div className="font-medium text-sm mb-1">Attendee {idx + 1}</div>
|
||||
{(resp.answers || []).length === 0 ? (
|
||||
<div className="text-xs text-gray-600">No answers recorded.</div>
|
||||
) : (
|
||||
<ul className="text-xs list-disc pl-5 space-y-0.5">
|
||||
{resp.answers.map((a: any) => (
|
||||
{(resp.answers || []).map((a) => (
|
||||
<li key={a.id}>
|
||||
<span className="text-gray-600">{eventForm?.fields.find(f => f.id === a.fieldId)?.label || (`Field ${a.fieldId}`)}</span>: {a.value}
|
||||
</li>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
@@ -55,7 +55,7 @@ export default function UserProfilePage() {
|
||||
setSavingProfile(true);
|
||||
setProfileMsg(null);
|
||||
try {
|
||||
const res = await apiFetch<any>("/api/users/profile", {
|
||||
const res = await apiFetch<{ token?: string }>("/api/users/profile", {
|
||||
method: "PUT",
|
||||
authToken: token,
|
||||
body: {
|
||||
@@ -96,7 +96,7 @@ export default function UserProfilePage() {
|
||||
setSavingPw(true);
|
||||
setPwMsg(null);
|
||||
try {
|
||||
const res = await apiFetch<any>("/api/users/profile", {
|
||||
const res = await apiFetch<{ token?: string }>("/api/users/profile", {
|
||||
method: "PUT",
|
||||
authToken: token,
|
||||
body: { currentPassword, password: newPassword },
|
||||
@@ -124,7 +124,7 @@ export default function UserProfilePage() {
|
||||
setRevoking(true);
|
||||
setRevokeMsg(null);
|
||||
try {
|
||||
const res = await apiFetch<any>("/api/users/revoke-sessions", {
|
||||
const res = await apiFetch<{ token?: string; message?: string }>("/api/users/revoke-sessions", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
});
|
||||
@@ -143,7 +143,7 @@ export default function UserProfilePage() {
|
||||
const [activity, setActivity] = useState<ActivityEvent[]>([]);
|
||||
const [loadingActivity, setLoadingActivity] = useState(false);
|
||||
|
||||
const loadActivity = async () => {
|
||||
const loadActivity = useCallback(async () => {
|
||||
if (!token) return;
|
||||
setLoadingActivity(true);
|
||||
try {
|
||||
@@ -154,9 +154,9 @@ export default function UserProfilePage() {
|
||||
} finally {
|
||||
setLoadingActivity(false);
|
||||
}
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => { loadActivity(); }, [token]);
|
||||
useEffect(() => { loadActivity(); }, [loadActivity]);
|
||||
|
||||
// ── Two-factor authentication ─────────────────────────────────────────────
|
||||
// Mirrors the saved account state locally: the auth context's `user` is only
|
||||
@@ -270,7 +270,7 @@ export default function UserProfilePage() {
|
||||
setClosing(true);
|
||||
setCloseMsg(null);
|
||||
try {
|
||||
const res = await apiFetch<any>("/api/users/close-account", {
|
||||
const res = await apiFetch<{ message?: string }>("/api/users/close-account", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: { password: closePassword, deleteData },
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { FileText } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Registration } from "@/types";
|
||||
|
||||
// Types for form fields
|
||||
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'|'rating'|'multiple_choice'; label: string; isRequired?: boolean; helpText?: string|null; options?: unknown };
|
||||
@@ -20,7 +21,7 @@ function FormsContent() {
|
||||
// Set when a named attendee opens the link sent to them — show only their form.
|
||||
const onlyAttendeeId = search.get("attendeeId");
|
||||
|
||||
const [registration, setRegistration] = useState<any | null>(null);
|
||||
const [registration, setRegistration] = useState<Registration | null>(null);
|
||||
const [eventForm, setEventForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -37,13 +38,13 @@ function FormsContent() {
|
||||
setInfo(null);
|
||||
try {
|
||||
// authToken optional — guests access via registrationId alone
|
||||
const reg = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token || undefined });
|
||||
const reg = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token || undefined });
|
||||
setRegistration(reg);
|
||||
const ev = await apiFetch<any>(`/api/events/${encodeURIComponent(reg.eventId)}`);
|
||||
const ev = await apiFetch<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(`/api/events/${encodeURIComponent(reg.eventId)}`);
|
||||
if (ev?.form) setEventForm(ev.form);
|
||||
// Load any saved draft to prefill fields
|
||||
try {
|
||||
const draft = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, { authToken: token || undefined });
|
||||
const draft = await apiFetch<{ data?: Record<string, Record<string, string>> } | null>(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, { authToken: token || undefined });
|
||||
if (draft && draft.data && typeof draft.data === 'object') setFormsData(draft.data);
|
||||
} catch {}
|
||||
} catch (e) {
|
||||
@@ -62,8 +63,8 @@ function FormsContent() {
|
||||
if (!registration) return 0;
|
||||
try {
|
||||
return (registration.registrationOptions || [])
|
||||
.filter((ro: any) => ro.eventOption?.isMainTicket)
|
||||
.reduce((s: number, ro: any) => s + (ro.quantity || 0), 0);
|
||||
.filter((ro) => ro.eventOption?.isMainTicket)
|
||||
.reduce((s, ro) => s + (ro.quantity || 0), 0);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
@@ -76,13 +77,13 @@ function FormsContent() {
|
||||
const blocks = useMemo((): FormBlock[] => {
|
||||
if (!registration) return [];
|
||||
const responses = registration.formResponses || [];
|
||||
const answered = new Set(responses.map((r: any) => r.attendeeId).filter(Boolean));
|
||||
const attendees = (registration.attendees || []) as any[];
|
||||
const answered = new Set(responses.map((r) => r.attendeeId).filter(Boolean));
|
||||
const attendees = registration.attendees || [];
|
||||
const named = attendees
|
||||
.filter(a => !answered.has(a.id))
|
||||
.map(a => ({ key: `a:${a.id}`, label: a.name, attendeeId: a.id as string }));
|
||||
.map(a => ({ key: `a:${a.id}`, label: a.name, attendeeId: a.id }));
|
||||
const unnamedPlaces = Math.max(0, requiredCount - attendees.length);
|
||||
const unnamedAnswered = responses.filter((r: any) => !r.attendeeId).length;
|
||||
const unnamedAnswered = responses.filter((r) => !r.attendeeId).length;
|
||||
const unnamed = Array.from({ length: Math.max(0, unnamedPlaces - unnamedAnswered) }, (_, i) => ({
|
||||
key: String(i), label: `Attendee ${unnamedAnswered + i + 1}`,
|
||||
}));
|
||||
@@ -90,7 +91,7 @@ function FormsContent() {
|
||||
return onlyAttendeeId ? all.filter(b => b.attendeeId === onlyAttendeeId) : all;
|
||||
}, [registration, requiredCount, onlyAttendeeId]);
|
||||
const remaining = blocks.length;
|
||||
const attendeeName = (id: string | null | undefined) => (registration?.attendees || []).find((a: any) => a.id === id)?.name;
|
||||
const attendeeName = (id: string | null | undefined) => (registration?.attendees || []).find((a) => a.id === id)?.name;
|
||||
|
||||
// Determine if all required fields are filled for each attendee form to be submitted
|
||||
const canSubmit = useMemo(() => {
|
||||
@@ -117,7 +118,7 @@ function FormsContent() {
|
||||
const payload = blocks.map(b => ({ ...(b.attendeeId ? { attendeeId: b.attendeeId } : {}), answers: formsData[b.key] || {} }));
|
||||
if (payload.length === 0) {
|
||||
// Nothing left to submit – still go to success page to continue the flow
|
||||
const totalDue = (registration?.registrationOptions || []).reduce((s: number, ro: any) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
|
||||
const totalDue = (registration?.registrationOptions || []).reduce((s, ro) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
|
||||
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}`);
|
||||
return;
|
||||
}
|
||||
@@ -127,7 +128,7 @@ function FormsContent() {
|
||||
body: { responses: payload }
|
||||
});
|
||||
// On success, redirect to the success page
|
||||
const totalDue = (registration?.registrationOptions || []).reduce((s: number, ro: any) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
|
||||
const totalDue = (registration?.registrationOptions || []).reduce((s, ro) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
|
||||
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}`);
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to submit forms');
|
||||
@@ -147,7 +148,7 @@ function FormsContent() {
|
||||
if (!token || !registrationId) return;
|
||||
try {
|
||||
// Redirect to the success page
|
||||
const totalDue = (registration?.registrationOptions || []).reduce((s: number, ro: any) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
|
||||
const totalDue = (registration?.registrationOptions || []).reduce((s, ro) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
|
||||
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}`);
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failded to go to next step');
|
||||
@@ -161,7 +162,7 @@ function FormsContent() {
|
||||
if (!registrationId) return;
|
||||
try {
|
||||
// Redirect to the success page
|
||||
const totalDue = (registration?.registrationOptions || []).reduce((s: number, ro: any) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
|
||||
const totalDue = (registration?.registrationOptions || []).reduce((s, ro) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
|
||||
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}`);
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failded to skip');
|
||||
@@ -211,14 +212,14 @@ function FormsContent() {
|
||||
<div className="border rounded p-3 mb-4 bg-gray-50">
|
||||
<div className="text-sm font-medium mb-2">Submitted responses</div>
|
||||
<ul className="space-y-2">
|
||||
{(registration.formResponses || []).map((resp: any, idx: number) => (
|
||||
{(registration.formResponses || []).map((resp, idx) => (
|
||||
<li key={resp.id} className="bg-white border rounded p-2">
|
||||
<div className="font-medium text-sm mb-1">{attendeeName(resp.attendeeId) || `Attendee ${idx + 1}`}</div>
|
||||
{(resp.answers || []).length === 0 ? (
|
||||
<div className="text-xs text-gray-600">No answers recorded.</div>
|
||||
) : (
|
||||
<ul className="text-xs list-disc pl-5 space-y-0.5">
|
||||
{resp.answers.map((a: any) => (
|
||||
{(resp.answers || []).map((a) => (
|
||||
<li key={a.id}>
|
||||
<span className="text-gray-600">{eventForm?.fields.find(f => f.id === a.fieldId)?.label || (`Field ${a.fieldId}`)}</span>: {a.value}
|
||||
</li>
|
||||
|
||||
@@ -101,7 +101,7 @@ export default function SetupPage() {
|
||||
apiFetch<{ needsSetup: boolean }>("/api/settings/needs-setup")
|
||||
.then((d) => { if (!d?.needsSetup) router.replace("/login"); })
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
}, [router]);
|
||||
|
||||
const next = () => setStep((s) => Math.min(s + 1, 6) as Step);
|
||||
const back = () => { setError(null); setStep((s) => Math.max(s - 1, 1) as Step); };
|
||||
|
||||
@@ -44,7 +44,7 @@ export function BackupsTab({ active }: { active: boolean }) {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
}, [token, setMessage]);
|
||||
|
||||
const saveRetainCount = async () => {
|
||||
if (!token) return;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Users, Send } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { isMinorOn } from "@/components/shared/AttendeeNamesEditor";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Attendee } from "@/types";
|
||||
|
||||
const CONSENT_LABEL: Record<string, { text: string; cls: string }> = {
|
||||
pending: { text: "consent pending", cls: "text-amber-700" },
|
||||
@@ -12,12 +13,7 @@ const CONSENT_LABEL: Record<string, { text: string; cls: string }> = {
|
||||
declined: { text: "consent declined", cls: "text-red-700" },
|
||||
};
|
||||
|
||||
export type Attendee = {
|
||||
id: string; eventOptionId: string; variantId: string | null; name: string; email: string | null; phone: string | null;
|
||||
dateOfBirth?: string | null; gender?: string | null; parentName?: string | null; parentEmail?: string | null; parentPhone?: string | null;
|
||||
ticket?: { id: string; isUsed: boolean } | null;
|
||||
consentRequests?: { id: string; status: "pending" | "given" | "declined" }[];
|
||||
};
|
||||
|
||||
type Row = {
|
||||
id?: string; eventOptionId: string; variantId: string | null; label: string; name: string; email: string; phone: string; locked: boolean;
|
||||
dateOfBirth: string; gender: string; parentName: string; parentEmail: string; parentPhone: string;
|
||||
|
||||
@@ -23,7 +23,7 @@ export function SetupGuard() {
|
||||
}
|
||||
})
|
||||
.catch(() => {/* API down — don't block */});
|
||||
}, [pathname]);
|
||||
}, [pathname, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -28,7 +28,9 @@ export function currentImpersonation(): Impersonation | null {
|
||||
export const isImpersonating = () => !!currentImpersonation();
|
||||
|
||||
/** Switches this browser to the impersonation token and opens the user's dashboard. */
|
||||
export function beginImpersonation(res: { token: string; session: { id: string; expiresAt: string; target: { name: string; email: string } } }) {
|
||||
export type ImpersonationStart = { token: string; session: { id: string; expiresAt: string; target: { name: string; email: string } } };
|
||||
|
||||
export function beginImpersonation(res: ImpersonationStart) {
|
||||
try {
|
||||
localStorage.setItem(ADMIN_TOKEN_KEY, localStorage.getItem(TOKEN_KEY) || "");
|
||||
localStorage.setItem(ADMIN_CACHE_KEY, localStorage.getItem(USER_CACHE_KEY) || "");
|
||||
|
||||
@@ -212,6 +212,16 @@ export type ReconciledSnapshot = {
|
||||
byMethod: Record<CashupMethod, ReconciledByMethod>;
|
||||
};
|
||||
|
||||
// Reconciliation figures saved part-way through, before the event is closed.
|
||||
export type CashupDraft = {
|
||||
lines?: {
|
||||
method: string;
|
||||
actualAmount?: string | number;
|
||||
notes?: string;
|
||||
denominations?: { value: number; count: number }[];
|
||||
}[];
|
||||
};
|
||||
|
||||
export type SalesByOption = {
|
||||
eventOptionId: string;
|
||||
name: string;
|
||||
@@ -220,7 +230,7 @@ export type SalesByOption = {
|
||||
};
|
||||
|
||||
export type EventFinancials = {
|
||||
event: Pick<Event, "id" | "title" | "cashupStatus" | "closedAt" | "closedById" | "reopenedAt" | "reopenedById"> & { cashupDraft?: any };
|
||||
event: Pick<Event, "id" | "title" | "cashupStatus" | "closedAt" | "closedById" | "reopenedAt" | "reopenedById"> & { cashupDraft?: CashupDraft | null };
|
||||
paymentsByMethod: Record<CashupMethod, number>;
|
||||
totalRevenue: number;
|
||||
costs: EventCost[];
|
||||
@@ -251,6 +261,18 @@ export type RegistrationOption = {
|
||||
variant?: OptionVariant | null;
|
||||
};
|
||||
|
||||
// A named person on a group registration, for one main-ticket place.
|
||||
export type Attendee = {
|
||||
id: string; eventOptionId: string; variantId: string | null; name: string; email: string | null; phone: string | null;
|
||||
registrationId?: string; registrationOptionId?: string | null; userId?: string | null; order?: number;
|
||||
dateOfBirth?: string | null; gender?: string | null; parentName?: string | null; parentEmail?: string | null; parentPhone?: string | null;
|
||||
ticket?: { id: string; isUsed: boolean } | null;
|
||||
consentRequests?: { id: string; status: "pending" | "given" | "declined" }[];
|
||||
};
|
||||
|
||||
export type FormAnswer = { id: string; responseId?: string; fieldId: string; value: string };
|
||||
export type FormResponse = { id: string; registrationId?: string; attendeeId?: string | null; createdAt?: string; answers?: FormAnswer[] };
|
||||
|
||||
export type Registration = {
|
||||
id: string;
|
||||
userId: string;
|
||||
@@ -264,6 +286,8 @@ export type Registration = {
|
||||
event?: Event;
|
||||
registrationOptions?: RegistrationOption[];
|
||||
payments?: Payment[];
|
||||
formResponses?: FormResponse[];
|
||||
attendees?: Attendee[];
|
||||
// Tranche-aware total the backend attaches to registration responses.
|
||||
totalDueComputed?: number | null;
|
||||
};
|
||||
@@ -282,8 +306,10 @@ export type Ticket = {
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
user?: UserSummary;
|
||||
attendee?: { id: string; name: string; email?: string | null; phone?: string | null } | null;
|
||||
event?: Event;
|
||||
registrationOption?: RegistrationOption;
|
||||
usages?: { id: string; scannedAt: string; quantityRedeemed?: number }[];
|
||||
};
|
||||
|
||||
export type Payment = {
|
||||
@@ -339,7 +365,7 @@ export type RecentScan = {
|
||||
id: string;
|
||||
eventId?: string;
|
||||
event?: { title: string } | null;
|
||||
registrationOption?: { eventOption?: { name: string } | null } | null;
|
||||
registrationOption?: { eventOption?: { name: string } | null; variant?: { name: string } | null } | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
@@ -349,3 +375,12 @@ export type DashboardStats = {
|
||||
paymentStats: PaymentStats;
|
||||
activeEventsCount: number;
|
||||
};
|
||||
|
||||
// A scanning section (e.g. "Main hall") and the ticket options valid at it.
|
||||
export type Section = {
|
||||
id: string;
|
||||
eventId: string;
|
||||
name: string;
|
||||
allowedOptions?: { id: string; sectionId?: string; eventOptionId: string; eventOption?: EventOption }[];
|
||||
event?: Pick<Event, "id" | "title">;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user