From c803b9d253602a532b1f3d83f82e7d2a1a21de18 Mon Sep 17 00:00:00 2001 From: Tanuja Pansare Date: Wed, 22 Jul 2026 10:31:31 +0530 Subject: [PATCH] Add participant documents --- .../panels/ParticipantDocumentsPanel.tsx | 394 ++++++++++++++++++ .../participants/ParticipantsPage.tsx | 157 ++++++- src/services/api/client.ts | 2 +- src/services/api/index.ts | 2 + src/services/api/participantDocuments.ts | 85 ++++ 5 files changed, 631 insertions(+), 9 deletions(-) create mode 100644 src/app/components/panels/ParticipantDocumentsPanel.tsx create mode 100644 src/services/api/participantDocuments.ts diff --git a/src/app/components/panels/ParticipantDocumentsPanel.tsx b/src/app/components/panels/ParticipantDocumentsPanel.tsx new file mode 100644 index 00000000..f809500e --- /dev/null +++ b/src/app/components/panels/ParticipantDocumentsPanel.tsx @@ -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([]); + const [err, setErr] = useState(null); + + const [file, setFile] = useState(null); + const [uploading, setUploading] = useState(false); + const fileInputRef = useRef(null); + + const [deletedMsg, setDeletedMsg] = useState(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 ( + <> +
+ +
+ + +
+
+

+ Documents +

+

+ {participantName} +

+

+ Event-scoped upload (current event only) +

+
+ + +
+ +
+
+
+ + { + const f = e.target.files?.[0] || null; + setFile(f); + }} + /> + +
+ + + + {file ? file.name : "No file selected"} + +
+

+ Supported: PDF, JPG, PNG, DOC, DOCX +

+
+ +
+
+ +
+ {err ? ( +

{err}

+ ) : null} + {deletedMsg ? ( +

{deletedMsg}

+ ) : null} + + {loadingDocs ? ( +

Loading…

+ ) : docs.length === 0 ? ( +
+
+ +

+ No documents +

+

+ Upload the first document for this participant in the current event. +

+
+
+ ) : ( +
+
+ + + + + + + + + + + {docs.map((d) => ( + + + + + + + ))} + +
DocumentUploaded ByUploaded AtActions
+
+ +
+
+ {d.originalFileName} +
+
{d.mimeType || 'β€”'}
+
+
+
+ {d.uploadedBy ? d.uploadedBy : 'β€”'} + {formatWhen(d.uploadedAt)} +
+ + +
+
+
+
+ )} +
+
+ + ); +} + diff --git a/src/app/components/participants/ParticipantsPage.tsx b/src/app/components/participants/ParticipantsPage.tsx index aceed977..19cc4ea5 100644 --- a/src/app/components/participants/ParticipantsPage.tsx +++ b/src/app/components/participants/ParticipantsPage.tsx @@ -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 )} + {/* View Button */} + + + {/* QR Button */} + + + {canEdit && ( +
@@ -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 && ( + setDocsParticipant(null)} + /> + )} {showAddPanel && selectedEventId && ( ); -} \ No newline at end of file +} diff --git a/src/services/api/client.ts b/src/services/api/client.ts index f7ac6a7e..ee625a3a 100644 --- a/src/services/api/client.ts +++ b/src/services/api/client.ts @@ -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 = { diff --git a/src/services/api/index.ts b/src/services/api/index.ts index f4874235..50597c62 100644 --- a/src/services/api/index.ts +++ b/src/services/api/index.ts @@ -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, diff --git a/src/services/api/participantDocuments.ts b/src/services/api/participantDocuments.ts new file mode 100644 index 00000000..2ef5706c --- /dev/null +++ b/src/services/api/participantDocuments.ts @@ -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; + } + }, +}; +