EventBuzz_Console/src/app/components/attendance/AttendancePage.tsx

707 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useCallback, useEffect, useState } from "react";
import {
ClipboardCheck,
Plus,
Search,
Download,
Link2,
Pencil,
Trash2,
ChevronLeft,
UserCheck,
Copy,
RefreshCw,
X,
} from "lucide-react";
import { useAppNav } from "../../contexts/AppNavContext";
import { usePermission } from "../../contexts/PermissionContext";
import { attendancesApi } from "../../../services/api";
import type {
AttendanceRecord,
AttendanceSearchParticipant,
AttendanceCheckInRow,
} from "../../../services/api/types";
function publicAttendanceUrl(token: string) {
return `${window.location.origin}/attendance/${token}`;
}
function formatDt(iso?: string | null) {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
interface FormState {
name: string;
description: string;
allowDuplicateCheckIn: boolean;
publicLinkEnabled: boolean;
}
const emptyForm: FormState = {
name: "",
description: "",
allowDuplicateCheckIn: false,
publicLinkEnabled: true,
};
export function AttendancePage() {
const { selectedEventId } = useAppNav();
const { canModule } = usePermission();
const canReadAttendance = canModule("attendance", "read");
const canWriteAttendance = canModule("attendance", "write");
const canDeleteAttendance = canModule("attendance", "delete");
const [sessions, setSessions] = useState<AttendanceRecord[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [panelOpen, setPanelOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
const [saving, setSaving] = useState(false);
const [copyMsg, setCopyMsg] = useState<string | null>(null);
const [searchQ, setSearchQ] = useState("");
const [searchResults, setSearchResults] = useState<AttendanceSearchParticipant[]>([]);
const [searching, setSearching] = useState(false);
const [checkInBusy, setCheckInBusy] = useState<string | null>(null);
const [checkIns, setCheckIns] = useState<AttendanceCheckInRow[]>([]);
const [checkInsTotal, setCheckInsTotal] = useState(0);
const [checkInsLoading, setCheckInsLoading] = useState(false);
const selected = sessions.find((s) => s._id === selectedId) ?? null;
const loadSessions = useCallback(async () => {
if (!selectedEventId || !canReadAttendance) return;
setLoading(true);
setError(null);
const res = await attendancesApi.list(selectedEventId);
setLoading(false);
if (res.error) {
setError(res.error);
return;
}
setSessions(res.data?.attendances ?? []);
}, [selectedEventId, canReadAttendance]);
useEffect(() => {
loadSessions();
}, [loadSessions]);
const loadCheckIns = useCallback(async () => {
if (!selectedEventId || !selectedId) return;
setCheckInsLoading(true);
const res = await attendancesApi.listCheckIns(selectedEventId, selectedId, { limit: 50 });
setCheckInsLoading(false);
if (res.data) {
setCheckIns(res.data.checkIns);
setCheckInsTotal(res.data.total);
}
}, [selectedEventId, selectedId]);
useEffect(() => {
if (selectedId) loadCheckIns();
}, [selectedId, loadCheckIns]);
useEffect(() => {
if (!selectedEventId || !selectedId) return;
const t = setTimeout(async () => {
setSearching(true);
const res = await attendancesApi.searchParticipants(
selectedEventId,
selectedId,
searchQ.trim() || undefined
);
setSearching(false);
if (res.data) setSearchResults(res.data.participants);
}, 300);
return () => clearTimeout(t);
}, [searchQ, selectedEventId, selectedId]);
const openCreate = () => {
setEditingId(null);
setForm(emptyForm);
setPanelOpen(true);
};
const openEdit = (s: AttendanceRecord) => {
setEditingId(s._id);
setForm({
name: s.name,
description: s.description || "",
allowDuplicateCheckIn: Boolean(s.settings?.allowDuplicateCheckIn),
publicLinkEnabled: s.settings?.publicLinkEnabled !== false,
});
setPanelOpen(true);
};
const saveSession = async () => {
if (!selectedEventId || !form.name.trim()) return;
setSaving(true);
const payload = {
name: form.name.trim(),
description: form.description.trim(),
settings: {
allowDuplicateCheckIn: form.allowDuplicateCheckIn,
publicLinkEnabled: form.publicLinkEnabled,
},
};
const res = editingId
? await attendancesApi.update(selectedEventId, editingId, payload)
: await attendancesApi.create(selectedEventId, payload);
setSaving(false);
if (res.error) {
setError(res.error);
return;
}
setPanelOpen(false);
await loadSessions();
if (res.data?._id && !editingId) setSelectedId(res.data._id);
};
const archiveSession = async (id: string) => {
if (!selectedEventId || !confirm("Archive this attendance session?")) return;
const res = await attendancesApi.remove(selectedEventId, id);
if (res.error) {
setError(res.error);
return;
}
if (selectedId === id) setSelectedId(null);
await loadSessions();
};
const handleCheckIn = async (participantId: string) => {
if (!selectedEventId || !selectedId) return;
setCheckInBusy(participantId);
const res = await attendancesApi.checkIn(selectedEventId, selectedId, participantId);
setCheckInBusy(null);
if (res.error) {
setError(res.error);
return;
}
await loadSessions();
await loadCheckIns();
const sr = await attendancesApi.searchParticipants(
selectedEventId,
selectedId,
searchQ.trim() || undefined
);
if (sr.data) setSearchResults(sr.data.participants);
};
const copyLink = async (token: string) => {
try {
await navigator.clipboard.writeText(publicAttendanceUrl(token));
setCopyMsg("Link copied");
setTimeout(() => setCopyMsg(null), 2000);
} catch {
setCopyMsg("Could not copy");
}
};
const regenerateLink = async (id: string) => {
if (!selectedEventId || !confirm("Regenerate public link? Old links will stop working.")) return;
const res = await attendancesApi.regenerateToken(selectedEventId, id);
if (res.error) {
setError(res.error);
return;
}
await loadSessions();
};
const exportCsv = async () => {
if (!selectedEventId || !selectedId || !selected) return;
const safe = selected.name.replace(/[^a-z0-9-_]+/gi, "-").slice(0, 40);
await attendancesApi.downloadExport(selectedEventId, selectedId, `${safe}-check-ins.csv`);
};
if (!selectedEventId) {
return (
<div style={{ padding: 24, color: "var(--muted)" }}>
Select an event to manage attendance.
</div>
);
}
if (!canReadAttendance) {
return (
<div style={{ padding: 24, color: "var(--muted)", fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)" }}>
You do not have permission to view Attendance.
</div>
);
}
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }}>
<div
style={{
padding: "16px 20px",
borderBottom: "1px solid var(--border)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
flexWrap: "wrap",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<ClipboardCheck size={20} color="var(--primary)" />
<h1
style={{
margin: 0,
fontSize: "var(--text-lg)",
fontFamily: "var(--font-inter)",
fontWeight: 600,
}}
>
Attendance
</h1>
</div>
{canWriteAttendance && (
<button
type="button"
onClick={openCreate}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "8px 14px",
background: "var(--primary)",
color: "#fff",
border: "none",
borderRadius: "var(--radius-button)",
cursor: "pointer",
fontSize: "var(--text-sm)",
}}
>
<Plus size={14} /> Add session
</button>
)}
</div>
{error && (
<div
style={{
margin: "12px 20px 0",
padding: "10px 12px",
background: "#fef2f2",
color: "#b91c1c",
borderRadius: "var(--radius-button)",
fontSize: "var(--text-sm)",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
{error}
<button type="button" onClick={() => setError(null)} style={{ background: "none", border: "none", cursor: "pointer" }}>
<X size={14} />
</button>
</div>
)}
{copyMsg && (
<div style={{ margin: "8px 20px 0", fontSize: "var(--text-sm)", color: "var(--primary)" }}>{copyMsg}</div>
)}
<div style={{ display: "flex", flex: 1, minHeight: 0, overflow: "hidden" }}>
<div
style={{
width: selectedId ? 280 : "100%",
maxWidth: selectedId ? 280 : undefined,
borderRight: selectedId ? "1px solid var(--border)" : undefined,
overflow: "auto",
flexShrink: 0,
}}
>
{loading && <p style={{ padding: 20, color: "var(--muted)" }}>Loading…</p>}
{!loading && sessions.length === 0 && (
<p style={{ padding: 20, color: "var(--muted)", fontSize: "var(--text-sm)" }}>
No attendance sessions yet. Create one to start check-ins.
</p>
)}
{sessions.map((s) => (
<button
key={s._id}
type="button"
onClick={() => setSelectedId(s._id)}
style={{
display: "block",
width: "100%",
textAlign: "left",
padding: "14px 16px",
border: "none",
borderBottom: "1px solid var(--border)",
background: selectedId === s._id ? "var(--secondary)" : "transparent",
cursor: "pointer",
}}
>
<div style={{ fontWeight: 600, fontSize: "var(--text-sm)", marginBottom: 4 }}>{s.name}</div>
<div style={{ fontSize: "11px", color: "var(--muted)" }}>
{s.totalCheckedIn ?? 0} check-in{(s.totalCheckedIn ?? 0) === 1 ? "" : "s"} ·{" "}
{s.uniqueParticipants ?? 0} people
</div>
{s.status === "archived" && (
<span style={{ fontSize: "10px", color: "var(--muted)" }}>Archived</span>
)}
</button>
))}
</div>
{selected && selectedEventId && (
<div style={{ flex: 1, overflow: "auto", padding: 20, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 12, marginBottom: 20, flexWrap: "wrap" }}>
<div>
<button
type="button"
onClick={() => setSelectedId(null)}
style={{
display: "none",
alignItems: "center",
gap: 4,
background: "none",
border: "none",
cursor: "pointer",
color: "var(--muted)",
fontSize: "var(--text-sm)",
marginBottom: 8,
}}
className="attendance-back-mobile"
>
<ChevronLeft size={16} /> Back
</button>
<h2 style={{ margin: 0, fontSize: "var(--text-base)", fontWeight: 600 }}>{selected.name}</h2>
{selected.description && (
<p style={{ margin: "6px 0 0", fontSize: "var(--text-sm)", color: "var(--muted)" }}>
{selected.description}
</p>
)}
</div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{canWriteAttendance && (
<button
type="button"
onClick={() => openEdit(selected)}
title="Edit"
style={iconBtn}
>
<Pencil size={14} />
</button>
)}
<button type="button" onClick={exportCsv} title="Export CSV" style={iconBtn}>
<Download size={14} />
</button>
<button
type="button"
onClick={() => copyLink(selected.publicToken)}
title="Copy public link"
style={iconBtn}
disabled={!selected.settings?.publicLinkEnabled}
>
<Link2 size={14} />
</button>
{canWriteAttendance && (
<button
type="button"
onClick={() => regenerateLink(selected._id)}
title="Regenerate link"
style={iconBtn}
>
<RefreshCw size={14} />
</button>
)}
{canDeleteAttendance && (
<button type="button" onClick={() => archiveSession(selected._id)} title="Archive" style={iconBtn}>
<Trash2 size={14} />
</button>
)}
</div>
</div>
<div style={{ marginBottom: 24 }}>
<label style={{ display: "block", fontSize: "var(--text-sm)", fontWeight: 500, marginBottom: 8 }}>
<Search size={14} style={{ verticalAlign: "middle", marginRight: 6 }} />
Find participant to check in
</label>
<input
type="search"
value={searchQ}
onChange={(e) => setSearchQ(e.target.value)}
placeholder="Name, email, or phone…"
style={inputStyle}
/>
{searching && <p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", marginTop: 8 }}>Searching…</p>}
<div style={{ marginTop: 12, display: "flex", flexDirection: "column", gap: 8 }}>
{searchResults.map((p) => {
const canCheckIn =
!p.alreadyCheckedIn || Boolean(selected.settings?.allowDuplicateCheckIn);
return (
<div
key={p._id}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
padding: "12px 14px",
border: "1px solid var(--border)",
borderRadius: "var(--radius-button)",
background: "var(--card)",
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 500, fontSize: "var(--text-sm)" }}>{p.displayName}</div>
<div style={{ fontSize: "11px", color: "var(--muted)" }}>
{[p.email, p.phone].filter(Boolean).join(" · ") || "—"}
</div>
{p.alreadyCheckedIn && (
<div style={{ fontSize: "11px", color: "#15803d", marginTop: 4 }}>
Checked in {p.checkInCount} time{p.checkInCount === 1 ? "" : "s"}
{p.lastCheckedInAt ? ` · ${formatDt(p.lastCheckedInAt)}` : ""}
</div>
)}
</div>
<button
type="button"
disabled={!canWriteAttendance || !canCheckIn || checkInBusy === p._id}
onClick={() => handleCheckIn(p._id)}
style={{
...checkInBtn,
opacity: canWriteAttendance && canCheckIn ? 1 : 0.5,
}}
>
<UserCheck size={14} />
{p.alreadyCheckedIn && selected.settings?.allowDuplicateCheckIn
? "Again"
: "Check in"}
</button>
</div>
);
})}
{!searching && searchResults.length === 0 && searchQ.trim() && (
<p style={{ fontSize: "var(--text-sm)", color: "var(--muted)" }}>No participants found.</p>
)}
</div>
</div>
<div>
<h3 style={{ fontSize: "var(--text-sm)", fontWeight: 600, marginBottom: 12 }}>
Check-ins ({checkInsTotal})
</h3>
{checkInsLoading && <p style={{ color: "var(--muted)", fontSize: "var(--text-sm)" }}>Loading…</p>}
{!checkInsLoading && checkIns.length === 0 && (
<p style={{ color: "var(--muted)", fontSize: "var(--text-sm)" }}>No check-ins yet.</p>
)}
<div style={{ overflowX: "auto" }}>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: "var(--text-sm)" }}>
<thead>
<tr style={{ borderBottom: "1px solid var(--border)", textAlign: "left" }}>
<th style={thStyle}>Name</th>
<th style={thStyle}>Email</th>
<th style={thStyle}>Phone</th>
<th style={thStyle}>Last check-in</th>
<th style={thStyle}>Count</th>
</tr>
</thead>
<tbody>
{checkIns.map((row) => {
const name = [row.firstName, row.lastName].filter(Boolean).join(" ").trim() || "—";
return (
<tr key={row._id} style={{ borderBottom: "1px solid var(--border)" }}>
<td style={tdStyle}>{name}</td>
<td style={tdStyle}>{row.email || "—"}</td>
<td style={tdStyle}>{row.phone || "—"}</td>
<td style={tdStyle}>{formatDt(row.lastCheckedInAt)}</td>
<td style={tdStyle}>{row.checkInCount}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
)}
{!selected && sessions.length > 0 && (
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--muted)",
fontSize: "var(--text-sm)",
}}
>
Select a session to manage check-ins
</div>
)}
</div>
{panelOpen && (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.35)",
zIndex: 200,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 24,
}}
onClick={() => setPanelOpen(false)}
>
<div
style={{
background: "var(--card)",
borderRadius: "var(--radius-card)",
width: "100%",
maxWidth: 440,
boxShadow: "var(--elevation-sm)",
}}
onClick={(e) => e.stopPropagation()}
>
<div style={{ padding: "20px 24px", borderBottom: "1px solid var(--border)" }}>
<h3 style={{ margin: 0, fontSize: "var(--text-base)", fontWeight: 600 }}>
{editingId ? "Edit session" : "New attendance session"}
</h3>
</div>
<div style={{ padding: "20px 24px", display: "flex", flexDirection: "column", gap: 14 }}>
<label style={labelStyle}>
Name *
<input
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
style={inputStyle}
placeholder="e.g. Main entrance – Day 1"
/>
</label>
<label style={labelStyle}>
Description
<textarea
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
style={{ ...inputStyle, minHeight: 72, resize: "vertical" }}
/>
</label>
<label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: "var(--text-sm)", cursor: "pointer" }}>
<input
type="checkbox"
checked={form.allowDuplicateCheckIn}
onChange={(e) => setForm((f) => ({ ...f, allowDuplicateCheckIn: e.target.checked }))}
/>
Allow duplicate check-ins
</label>
<label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: "var(--text-sm)", cursor: "pointer" }}>
<input
type="checkbox"
checked={form.publicLinkEnabled}
onChange={(e) => setForm((f) => ({ ...f, publicLinkEnabled: e.target.checked }))}
/>
Enable public check-in link
</label>
</div>
<div
style={{
padding: "14px 24px",
borderTop: "1px solid var(--border)",
display: "flex",
justifyContent: "flex-end",
gap: 8,
}}
>
<button type="button" onClick={() => setPanelOpen(false)} style={secondaryBtn}>
Cancel
</button>
<button
type="button"
onClick={saveSession}
disabled={saving || !form.name.trim()}
style={primaryBtn}
>
{saving ? "Saving…" : editingId ? "Save" : "Create"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
const iconBtn: React.CSSProperties = {
width: 32,
height: 32,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "var(--radius-button)",
cursor: "pointer",
color: "var(--muted)",
};
const inputStyle: React.CSSProperties = {
width: "100%",
height: 40,
padding: "0 12px",
marginTop: 6,
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: "var(--card)",
fontSize: "var(--text-sm)",
boxSizing: "border-box",
};
const labelStyle: React.CSSProperties = {
display: "block",
fontSize: "var(--text-sm)",
fontWeight: 500,
};
const thStyle: React.CSSProperties = { padding: "8px 10px", fontWeight: 600, color: "var(--muted)" };
const tdStyle: React.CSSProperties = { padding: "10px" };
const checkInBtn: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "8px 12px",
background: "var(--primary)",
color: "#fff",
border: "none",
borderRadius: "var(--radius-button)",
cursor: "pointer",
fontSize: "var(--text-sm)",
flexShrink: 0,
};
const secondaryBtn: React.CSSProperties = {
padding: "8px 16px",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: "var(--card)",
cursor: "pointer",
fontSize: "var(--text-sm)",
};
const primaryBtn: React.CSSProperties = {
padding: "8px 16px",
borderRadius: "var(--radius-button)",
border: "none",
background: "var(--primary)",
color: "#fff",
cursor: "pointer",
fontSize: "var(--text-sm)",
};