Add participant documents
This commit is contained in:
parent
569ba6e97c
commit
c803b9d253
|
|
@ -0,0 +1,394 @@
|
|||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { X, Download, Trash2, FileText } from 'lucide-react';
|
||||
import { participantDocumentsApi } from '../../../services/api';
|
||||
import type { ParticipantDocumentRow } from '../../../services/api/participantDocuments';
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
participantId: string;
|
||||
participantName: string;
|
||||
eventId: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function formatWhen(iso?: string) {
|
||||
if (!iso) return '—';
|
||||
try {
|
||||
return new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
|
||||
export function ParticipantDocumentsPanel({ open, participantId, participantName, eventId, onClose }: Props) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [loadingDocs, setLoadingDocs] = useState(false);
|
||||
const [docs, setDocs] = useState<ParticipantDocumentRow[]>([]);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [deletedMsg, setDeletedMsg] = useState<string | null>(null);
|
||||
|
||||
const loadDocs = async () => {
|
||||
setLoadingDocs(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const r = await participantDocumentsApi.getParticipantDocuments(participantId, eventId);
|
||||
setDocs(r.data?.documents ?? []);
|
||||
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Failed to load documents');
|
||||
setDocs([]);
|
||||
} finally {
|
||||
setLoadingDocs(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
void loadDocs();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, participantId, eventId]);
|
||||
|
||||
const onUpload = async () => {
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const res = await participantDocumentsApi.uploadParticipantDocument(participantId, { eventId, file });
|
||||
if (!res.ok) {
|
||||
let msg = res.statusText;
|
||||
try {
|
||||
const j = await res.json();
|
||||
msg = j?.error?.message || msg;
|
||||
} catch {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
setFile(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
await loadDocs();
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Upload failed');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async (doc: ParticipantDocumentRow) => {
|
||||
// Backend enforces authz. Frontend can only optionally hide.
|
||||
const ok = window.confirm(`Delete "${doc.originalFileName}"?`);
|
||||
if (!ok) return;
|
||||
|
||||
setErr(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await participantDocumentsApi.deleteParticipantDocument(participantId, doc._id, eventId);
|
||||
if (!res.ok) {
|
||||
let msg = res.statusText;
|
||||
try {
|
||||
const j = await res.json();
|
||||
msg = j?.error?.message || msg;
|
||||
} catch {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
setDeletedMsg('Document deleted.');
|
||||
await loadDocs();
|
||||
setTimeout(() => setDeletedMsg(null), 1800);
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Delete failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
onClick={onClose}
|
||||
role="presentation"
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 200 }}
|
||||
/>
|
||||
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="participant-docs-title"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
right: 0,
|
||||
height: '100%',
|
||||
width: 'min(720px, 100vw)',
|
||||
background: 'var(--card)',
|
||||
zIndex: 201,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxShadow: '-4px 0 40px rgba(0,0,0,0.2)',
|
||||
animation: 'participantDocsSlideIn 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<style>{`@keyframes participantDocsSlideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }`}</style>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
padding: '16px 18px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<p style={{ fontSize: 'var(--text-xs)', fontFamily: 'var(--font-noto-sans)', color: 'var(--muted)', margin: '0 0 4px' }}>
|
||||
Documents
|
||||
</p>
|
||||
<h2
|
||||
id="participant-docs-title"
|
||||
style={{
|
||||
fontFamily: 'var(--font-inter)',
|
||||
fontSize: 'var(--text-lg)',
|
||||
fontWeight: 'var(--font-weight-medium)',
|
||||
color: 'var(--foreground)',
|
||||
margin: 0,
|
||||
lineHeight: 1.3,
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{participantName}
|
||||
</h2>
|
||||
<p style={{ margin: '6px 0 0', fontSize: 'var(--text-xs)', color: 'var(--muted)', fontFamily: 'var(--font-noto-sans)' }}>
|
||||
Event-scoped upload (current event only)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: 36,
|
||||
height: 36,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 'var(--radius-button)',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--card)',
|
||||
color: 'var(--muted)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'flex-start',marginTop: 22, gap: 10 }}>
|
||||
<div style={{ flex: '1 1 280px' }}>
|
||||
<label style={{ display: 'block', fontSize: 10, fontFamily: 'var(--font-noto-sans)', fontWeight: 700, color: 'var(--muted)', textTransform: 'uppercase', marginBottom: 6 }}>
|
||||
Upload Document
|
||||
</label>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0] || null;
|
||||
setFile(f);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
width: "100%",
|
||||
padding: "10px 12px",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--radius-button)",
|
||||
background: "var(--card)",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
style={{
|
||||
padding: "8px 14px",
|
||||
border: "1px solid var(--primary)",
|
||||
borderRadius: "var(--radius-button)",
|
||||
background: "var(--primary)",
|
||||
color: "var(--primary-foreground)",
|
||||
cursor: "pointer",
|
||||
fontWeight: 600,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
📁 Choose File
|
||||
</button>
|
||||
|
||||
<span
|
||||
style={{
|
||||
color: file ? "var(--foreground)" : "var(--muted)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
flex: 1,
|
||||
fontSize: "var(--text-sm)",
|
||||
}}
|
||||
>
|
||||
{file ? file.name : "No file selected"}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
marginTop: 8,
|
||||
fontSize: "var(--text-xs)",
|
||||
color: "var(--muted)",
|
||||
}}
|
||||
>
|
||||
Supported: PDF, JPG, PNG, DOC, DOCX
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!file || uploading}
|
||||
onClick={() => void onUpload()}
|
||||
style={{
|
||||
minWidth: 180,
|
||||
height: 44,
|
||||
padding: "0 20px",
|
||||
alignSelf: "flex-start",
|
||||
marginTop: 30, // aligns with Choose File row
|
||||
border: "none",
|
||||
borderRadius: "var(--radius-button)",
|
||||
background:
|
||||
!file || uploading
|
||||
? "#cbd5e1"
|
||||
: "var(--primary)",
|
||||
color: "#fff",
|
||||
fontWeight: 600,
|
||||
cursor: !file || uploading ? "not-allowed" : "pointer",
|
||||
transition: "all .2s",
|
||||
}}
|
||||
>
|
||||
{uploading ? "Uploading..." : "Upload Document"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '0 0 16px' }}>
|
||||
{err ? (
|
||||
<p style={{ padding: '16px 18px', margin: 0, color: 'var(--destructive)', fontSize: 'var(--text-sm)' }}>{err}</p>
|
||||
) : null}
|
||||
{deletedMsg ? (
|
||||
<p style={{ padding: '16px 18px', margin: 0, color: 'var(--success)', fontSize: 'var(--text-sm)' }}>{deletedMsg}</p>
|
||||
) : null}
|
||||
|
||||
{loadingDocs ? (
|
||||
<p style={{ padding: '24px 18px', margin: 0, color: 'var(--muted)' }}>Loading…</p>
|
||||
) : docs.length === 0 ? (
|
||||
<div style={{ padding: '26px 18px' }}>
|
||||
<div style={{ border: '2px dashed var(--border)', borderRadius: 'var(--radius-card)', padding: '22px 16px', textAlign: 'center' }}>
|
||||
<FileText size={32} style={{ margin: '0 auto 10px', color: 'var(--muted)' }} />
|
||||
<p style={{ margin: 0, fontFamily: 'var(--font-inter)', fontSize: 'var(--text-base)', color: 'var(--foreground)', fontWeight: 700 }}>
|
||||
No documents
|
||||
</p>
|
||||
<p style={{ margin: '8px 0 0', fontFamily: 'var(--font-noto-sans)', fontSize: 'var(--text-sm)', color: 'var(--muted)' }}>
|
||||
Upload the first document for this participant in the current event.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: '12px 18px' }}>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--text-xs)', fontFamily: 'var(--font-noto-sans)' }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--secondary)', borderBottom: '1px solid var(--border)' }}>
|
||||
<th style={{ textAlign: 'left', padding: '10px 12px', whiteSpace: 'nowrap' }}>Document</th>
|
||||
<th style={{ textAlign: 'left', padding: '10px 12px', whiteSpace: 'nowrap' }}>Uploaded By</th>
|
||||
<th style={{ textAlign: 'left', padding: '10px 12px', whiteSpace: 'nowrap' }}>Uploaded At</th>
|
||||
<th style={{ textAlign: 'center', padding: '10px 12px', whiteSpace: 'nowrap' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{docs.map((d) => (
|
||||
<tr key={d._id} style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '10px 12px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<FileText size={14} style={{ color: 'var(--muted)' }} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 700, color: 'var(--foreground)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 260 }}>
|
||||
{d.originalFileName}
|
||||
</div>
|
||||
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--muted)' }}>{d.mimeType || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '10px 12px', color: 'var(--muted)', whiteSpace: 'nowrap' }}>
|
||||
{d.uploadedBy ? d.uploadedBy : '—'}
|
||||
</td>
|
||||
<td style={{ padding: '10px 12px', color: 'var(--muted)', whiteSpace: 'nowrap' }}>{formatWhen(d.uploadedAt)}</td>
|
||||
<td style={{ padding: '10px 12px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, alignItems: 'center' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void participantDocumentsApi.downloadParticipantDocument(participantId, d._id, eventId, d.originalFileName)}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '6px 10px',
|
||||
borderRadius: 'var(--radius-button)',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--secondary)',
|
||||
color: 'var(--foreground)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 'var(--text-xs)',
|
||||
}}
|
||||
>
|
||||
<Download size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void onDelete(d)}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '6px 10px',
|
||||
borderRadius: 'var(--radius-button)',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--card)',
|
||||
color: 'var(--destructive)',
|
||||
cursor: busy ? 'not-allowed' : 'pointer',
|
||||
fontSize: 'var(--text-xs)',
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -4,7 +4,7 @@ import { useAppNav } from "../../contexts/AppNavContext";
|
|||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { usePermission } from "../../contexts/PermissionContext";
|
||||
import {
|
||||
Search, Plus, Download, Upload, Eye, Pencil, Trash2, Check, X,
|
||||
Search, Plus, Download, Upload, Eye, Pencil, Trash2, Check, X, FileText,
|
||||
Phone, Mail, Calendar, User, Filter, UserCheck,
|
||||
AlertCircle, List, LayoutGrid, ChevronDown, CheckCircle2, Clock,
|
||||
Users, ChevronLeft, ChevronRight, QrCode,
|
||||
|
|
@ -15,6 +15,9 @@ import { portfolioListOrganizationId } from "../../../services/api/scopedOrganiz
|
|||
import { AddParticipantPanel } from "../panels/AddParticipantPanel";
|
||||
import { CreateCustomListPanel } from "../panels/CreateCustomListPanel";
|
||||
import { ParticipantQrModal } from "./ParticipantQrModal";
|
||||
import { ParticipantDocumentsPanel } from "../panels/ParticipantDocumentsPanel";
|
||||
|
||||
|
||||
import { tagChipStyle } from "./participantTagUtils";
|
||||
import { useParticipantSelection } from "./useParticipantSelection";
|
||||
import { useParticipantBulkActions } from "./useParticipantBulkActions";
|
||||
|
|
@ -212,6 +215,7 @@ interface RowProps {
|
|||
onConfirm: () => void;
|
||||
onReject: () => void;
|
||||
onQr: () => void;
|
||||
onDocs: () => void;
|
||||
canEdit?: boolean;
|
||||
canDelete?: boolean;
|
||||
selectable?: boolean;
|
||||
|
|
@ -219,7 +223,7 @@ interface RowProps {
|
|||
onToggleSelect?: () => void;
|
||||
}
|
||||
|
||||
function ParticipantRow({ p, onView, onEdit, onDelete, onConfirm, onReject, onQr, canEdit, canDelete, selectable, selected, onToggleSelect }: RowProps) {
|
||||
function ParticipantRow({ p, onView,onEdit, onDelete, onConfirm, onReject, onQr, onDocs, canEdit, canDelete, selectable, selected, onToggleSelect, }: RowProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const isPending = p.status === "Pending";
|
||||
|
||||
|
|
@ -322,12 +326,103 @@ function ParticipantRow({ p, onView, onEdit, onDelete, onConfirm, onReject, onQr
|
|||
</button>
|
||||
</>
|
||||
)}
|
||||
{/* View Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onView}
|
||||
title="View profile"
|
||||
aria-label="View profile"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
padding: "4px 10px",
|
||||
borderRadius: "var(--radius-button)",
|
||||
background: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
color: "var(--card-foreground)",
|
||||
fontSize: "var(--text-xs)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--primary)";
|
||||
e.currentTarget.style.color = "var(--primary)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--border)";
|
||||
e.currentTarget.style.color = "var(--card-foreground)";
|
||||
}}
|
||||
>
|
||||
<Eye size={11} /> View
|
||||
</button>
|
||||
|
||||
{/* QR Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onQr}
|
||||
title="Check-in QR code (EventBuzz Checkins app)"
|
||||
aria-label="Check-in QR code"
|
||||
style={iconBtnStyle}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
padding: "4px 10px",
|
||||
borderRadius: "var(--radius-button)",
|
||||
background: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
color: "var(--card-foreground)",
|
||||
fontSize: "var(--text-xs)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--primary)";
|
||||
e.currentTarget.style.color = "var(--primary)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--border)";
|
||||
e.currentTarget.style.color = "var(--card-foreground)";
|
||||
}}
|
||||
>
|
||||
<QrCode size={11} /> QR
|
||||
</button>
|
||||
<button
|
||||
onClick={onDocs}
|
||||
title="Participant documents"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
padding: "4px 10px",
|
||||
borderRadius: "var(--radius-button)",
|
||||
background: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
color: "var(--card-foreground)",
|
||||
fontSize: "var(--text-xs)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<FileText size={11} /> Document
|
||||
</button>
|
||||
<button
|
||||
onClick={onQr}
|
||||
title="Check-in QR code (EventBuzz Checkins app)"
|
||||
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
padding: "4px 10px",
|
||||
borderRadius: "var(--radius-button)",
|
||||
background: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
color: "var(--card-foreground)",
|
||||
fontSize: "var(--text-xs)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.borderColor = "var(--primary)";
|
||||
(e.currentTarget as HTMLElement).style.color = "var(--primary)";
|
||||
|
|
@ -350,6 +445,7 @@ function ParticipantRow({ p, onView, onEdit, onDelete, onConfirm, onReject, onQr
|
|||
>
|
||||
<Eye size={13} />
|
||||
</button>
|
||||
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -384,7 +480,8 @@ function ParticipantRow({ p, onView, onEdit, onDelete, onConfirm, onReject, onQr
|
|||
|
||||
/* ─────────────── Participant Card ─────────────── */
|
||||
|
||||
function ParticipantCard({ p, onView, onEdit, onDelete, onConfirm, onReject, onQr, canEdit, canDelete, selectable, selected, onToggleSelect }: RowProps) {
|
||||
function ParticipantCard({ p, onView, onEdit, onDelete, onConfirm, onReject, onQr, onDocs, canEdit, canDelete, selectable, selected, onToggleSelect }: RowProps) {
|
||||
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const isPending = p.status === "Pending";
|
||||
|
||||
|
|
@ -479,6 +576,30 @@ function ParticipantCard({ p, onView, onEdit, onDelete, onConfirm, onReject, onQ
|
|||
<div style={{ display: "flex", gap: "4px" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDocs}
|
||||
title="Participant documents"
|
||||
style={{
|
||||
width: "26px",
|
||||
height: "26px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "var(--secondary)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--radius-button)",
|
||||
cursor: "pointer",
|
||||
color: "var(--muted)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.color = "var(--primary)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.color = "var(--muted)";
|
||||
}}
|
||||
>
|
||||
<FileText size={12} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onQr}
|
||||
title="Check-in QR code"
|
||||
aria-label="Check-in QR code"
|
||||
|
|
@ -678,7 +799,9 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
|
|||
|
||||
const [viewModal, setViewModal] = useState<ParticipantDisplay | null>(null);
|
||||
const [qrParticipant, setQrParticipant] = useState<ParticipantDisplay | null>(null);
|
||||
const [docsParticipant, setDocsParticipant] = useState<ParticipantDisplay | null>(null);
|
||||
const [showAddPanel, setShowAddPanel] = useState(false);
|
||||
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [confirm, setConfirm] = useState<{ msg: string; onConfirm: () => void } | null>(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
|
@ -723,10 +846,14 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
|
|||
});
|
||||
}, [selectedEventId, listNonce, enableBulk]);
|
||||
|
||||
const searchDebounce = useRef<ReturnType<typeof setTimeout>>();
|
||||
const searchDebounce = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
searchDebounce.current = setTimeout(() => setSearch(searchInput.trim()), 300);
|
||||
return () => { if (searchDebounce.current) clearTimeout(searchDebounce.current); };
|
||||
return () => {
|
||||
if (searchDebounce.current) {
|
||||
clearTimeout(searchDebounce.current);
|
||||
}
|
||||
};
|
||||
}, [searchInput]);
|
||||
|
||||
useEffect(() => { setPage(1); }, [search, statusFilter, designationFilter, activeTag, limit]);
|
||||
|
|
@ -1020,7 +1147,7 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.map((p) => (
|
||||
{list.map((p) => (
|
||||
<ParticipantRow
|
||||
key={p.id}
|
||||
p={p}
|
||||
|
|
@ -1035,8 +1162,11 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
|
|||
onQr={() => setQrParticipant(p)}
|
||||
canEdit={canEditParticipants}
|
||||
canDelete={canDeleteParticipants}
|
||||
onDocs={() => setDocsParticipant(p)}
|
||||
// canEdit={isSuperAdmin}
|
||||
/>
|
||||
))}
|
||||
{/* )) */}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
@ -1072,6 +1202,8 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
|
|||
onDelete={() => deleteParticipant(p.id)}
|
||||
onConfirm={() => confirmRegistration(p.id)}
|
||||
onReject={() => rejectRegistration(p.id)}
|
||||
// onToggleCheckIn={() => toggleCheckIn(p.id)}
|
||||
onDocs={() => setDocsParticipant(p)}
|
||||
onQr={() => setQrParticipant(p)}
|
||||
canEdit={canEditParticipants}
|
||||
canDelete={canDeleteParticipants}
|
||||
|
|
@ -1123,6 +1255,15 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
|
|||
onClose={() => setQrParticipant(null)}
|
||||
/>
|
||||
)}
|
||||
{docsParticipant && selectedEventId && (
|
||||
<ParticipantDocumentsPanel
|
||||
open={true}
|
||||
participantId={docsParticipant.id}
|
||||
participantName={docsParticipant.name}
|
||||
eventId={selectedEventId}
|
||||
onClose={() => setDocsParticipant(null)}
|
||||
/>
|
||||
)}
|
||||
{showAddPanel && selectedEventId && (
|
||||
<AddParticipantPanel
|
||||
eventId={selectedEventId}
|
||||
|
|
@ -1195,4 +1336,4 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
|
|||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
|
||||
export type { ApiRequestInit };
|
||||
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api';
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3002/api';
|
||||
const BASE_URL = API_BASE_URL;
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ export { authApi } from './auth';
|
|||
export { eventsApi } from './events';
|
||||
export { rewardPointsApi, type LeaderboardRow, type CreateRewardPointPayload } from './rewardPoints';
|
||||
export { participantsApi, type ParticipantWithEvent } from './participants';
|
||||
export { participantDocumentsApi, type ParticipantDocumentRow } from './participantDocuments';
|
||||
|
||||
export {
|
||||
attendancesApi,
|
||||
type CreateAttendancePayload,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
import { api, fetchWithAuth, getAccessToken, API_BASE_URL } from './client';
|
||||
|
||||
export interface ParticipantDocumentRow {
|
||||
_id: string;
|
||||
participant: string;
|
||||
organization: string;
|
||||
event: string;
|
||||
uploadedBy?: string | null;
|
||||
fileName: string;
|
||||
originalFileName: string;
|
||||
url: string;
|
||||
mimeType?: string;
|
||||
size?: number;
|
||||
uploadedAt?: string;
|
||||
}
|
||||
|
||||
export const participantDocumentsApi = {
|
||||
getParticipantDocuments(participantId: string, eventId: string) {
|
||||
return api.get<{ documents: ParticipantDocumentRow[] }>(
|
||||
`/participants/${participantId}/documents?eventId=${encodeURIComponent(eventId)}`,
|
||||
);
|
||||
},
|
||||
|
||||
uploadParticipantDocument(participantId: string, payload: { eventId: string; file: File }) {
|
||||
const fd = new FormData();
|
||||
fd.append('eventId', payload.eventId);
|
||||
fd.append('file', payload.file, payload.file.name);
|
||||
fd.append('name', payload.file.name);
|
||||
// Send explicit mimeType so backend can use it even if Hapi strips multipart metadata
|
||||
fd.append('mimeType', payload.file.type || 'application/octet-stream');
|
||||
|
||||
return fetchWithAuth(`/participants/${participantId}/documents`, {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
});
|
||||
},
|
||||
|
||||
async deleteParticipantDocument(participantId: string, documentId: string, eventId: string) {
|
||||
return fetchWithAuth(
|
||||
`/participants/${participantId}/documents/${documentId}?eventId=${encodeURIComponent(eventId)}`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
},
|
||||
|
||||
async downloadParticipantDocument(participantId: string, documentId: string, eventId: string, fileName?: string) {
|
||||
// Use backend proxy endpoint that streams the file with proper Content-Disposition headers.
|
||||
// This avoids cross-origin download issues with CloudFront URLs.
|
||||
const token = getAccessToken();
|
||||
const url = `${API_BASE_URL}/participants/${participantId}/documents/${documentId}/download?eventId=${encodeURIComponent(eventId)}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let msg = `Download failed (${res.status})`;
|
||||
try {
|
||||
const j = await res.json();
|
||||
msg = j?.error?.message || msg;
|
||||
} catch {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
const effectiveFileName = fileName || 'download';
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = effectiveFileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
|
||||
// Revoke the blob URL after a short delay to allow download to start
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 5000);
|
||||
} catch (err) {
|
||||
console.error('[downloadParticipantDocument]', err);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue