Updated evidence handling

This commit is contained in:
2026-10-25 18:58:30 +02:00
parent fddf400ddd
commit 0bcc3484dc
5 changed files with 199 additions and 65 deletions
@@ -11,7 +11,7 @@ public class GetProjectEvidenceEndpoint : IEndpoint
{ {
public void Map(IEndpointRouteBuilder builder) public void Map(IEndpointRouteBuilder builder)
{ {
builder.MapGet("api/evidence/{projectId}", async (string projectId, IEvidenceService service, builder.MapGet("api/evidence/projects/{projectId}", async (string projectId, IEvidenceService service,
CancellationToken cancellationToken = default) => CancellationToken cancellationToken = default) =>
await service.GetProjectEvidencesAsync(projectId, cancellationToken) switch await service.GetProjectEvidencesAsync(projectId, cancellationToken) switch
{ {
@@ -1229,13 +1229,13 @@ export const getMilestoneEvidence = async (milestoneId: string | number): Promis
export const getProjectEvidence = async (projectId: string | number): Promise<Evidence[]> => { export const getProjectEvidence = async (projectId: string | number): Promise<Evidence[]> => {
try { try {
const hashedProjectId = await toolsService.resolveProjectId(projectId); const hashedProjectId = await toolsService.resolveProjectId(projectId);
const res = await apiClient.get<Evidence[]>(`/v1/api/evidence/${hashedProjectId}`); const res = await apiClient.get<Evidence[]>(`/v1/api/evidence/projects/${hashedProjectId}`);
return res.data || []; return res.data || [];
} catch (err: any) { } catch (err: any) {
// If hashed path parameter returns 404 or 400 and projectId differs, attempt with raw ID // If hashed path parameter returns 404 or 400 and projectId differs, attempt with raw ID
if ((err?.response?.status === 404 || err?.response?.status === 400) && String(projectId) !== '') { if ((err?.response?.status === 404 || err?.response?.status === 400) && String(projectId) !== '') {
try { try {
const rawRes = await apiClient.get<Evidence[]>(`/v1/api/evidence/${projectId}`); const rawRes = await apiClient.get<Evidence[]>(`/v1/api/evidence/projects/${projectId}`);
return rawRes.data || []; return rawRes.data || [];
} catch (fallbackErr: any) { } catch (fallbackErr: any) {
if (fallbackErr?.response?.status === 404) { if (fallbackErr?.response?.status === 404) {
@@ -1,8 +1,25 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Project, Award, Milestone, Activity, Evidence, Contract, Organisation, ApprovalStatus } from '../../services/api/models'; import { useQuery } from '@tanstack/react-query';
import {
Project,
Award,
Milestone,
Activity,
Evidence,
Contract,
Organisation,
ApprovalStatus
} from '../../services/api/models';
import { encodeHashIdSync } from '../../services/toolsService'; import { encodeHashIdSync } from '../../services/toolsService';
import { toIntegerId } from '../../services/api'; import {
toIntegerId,
getMilestones,
getActivities,
getProjectEvidence,
submitActivityForApproval
} from '../../services/api';
import { getEvidenceTypeLabel } from '../../lib/formatters'; import { getEvidenceTypeLabel } from '../../lib/formatters';
import { EvidenceUploadModal } from './EvidenceUploadModal';
import { import {
FolderKanban, FolderKanban,
ChevronDown, ChevronDown,
@@ -15,15 +32,17 @@ import {
Clock, Clock,
FileText, FileText,
UploadCloud, UploadCloud,
ExternalLink, ExternalLink,
ShieldCheck, ShieldCheck,
DollarSign, DollarSign,
Paperclip, Paperclip,
Building, Building,
Check, Check,
Send, Send,
Loader2, Loader2,
Info Info,
RefreshCw,
Plus
} from 'lucide-react'; } from 'lucide-react';
export interface EnrichedPartnerProject { export interface EnrichedPartnerProject {
@@ -42,8 +61,6 @@ interface ProjectCardProps {
item: EnrichedPartnerProject; item: EnrichedPartnerProject;
isExpanded?: boolean; isExpanded?: boolean;
onToggleExpand?: () => void; onToggleExpand?: () => void;
onUploadEvidence: (target: { project: Project; milestone?: Milestone | null; activity: Activity }) => void;
onSubmitActivityForApproval: (activity: Activity, milestone?: Milestone | null, evidences?: Evidence[]) => Promise<void>;
onOpenSecureFile: (type: 'contract' | 'evidence', id: string | number) => void; onOpenSecureFile: (type: 'contract' | 'evidence', id: string | number) => void;
} }
@@ -51,8 +68,6 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
item, item,
isExpanded: externalIsExpanded, isExpanded: externalIsExpanded,
onToggleExpand, onToggleExpand,
onUploadEvidence,
onSubmitActivityForApproval,
onOpenSecureFile, onOpenSecureFile,
}) => { }) => {
const [internalExpanded, setInternalExpanded] = useState(false); const [internalExpanded, setInternalExpanded] = useState(false);
@@ -61,7 +76,14 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
const [submittingActivityId, setSubmittingActivityId] = useState<string | number | null>(null); const [submittingActivityId, setSubmittingActivityId] = useState<string | number | null>(null);
const { project, award, contract, fundingOrganisation, invitedOrganisation, milestones, evidence: evidenceList, activities, disbursements } = item; // Evidence upload modal state (Requirement #3: Allow user to add evidence, once uploaded refresh data)
const [uploadTarget, setUploadTarget] = useState<{
project: Project;
milestone?: Milestone | null;
activity: Activity;
} | null>(null);
const { project, award, contract, fundingOrganisation, invitedOrganisation, disbursements } = item;
const hashedProjectId = encodeHashIdSync(project.id); const hashedProjectId = encodeHashIdSync(project.id);
// Financial calculations // Financial calculations
@@ -90,7 +112,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
// Date formatter // Date formatter
const formatDate = (dateStr?: string | null): string => { const formatDate = (dateStr?: string | null): string => {
if (!dateStr) return 'N/A'; if (!dateStr) return 'TBD';
try { try {
return new Date(dateStr).toLocaleDateString('en-ZA', { day: '2-digit', month: 'short', year: 'numeric' }); return new Date(dateStr).toLocaleDateString('en-ZA', { day: '2-digit', month: 'short', year: 'numeric' });
} catch { } catch {
@@ -98,6 +120,17 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
} }
}; };
// Date & Time formatter (Requirement #2: sorted by time, displaying time)
const formatDateTime = (dateStr?: string | null): string => {
if (!dateStr) return 'TBD';
try {
const d = new Date(dateStr);
return `${d.toLocaleDateString('en-ZA', { day: '2-digit', month: 'short', year: 'numeric' })} ${d.toLocaleTimeString('en-ZA', { hour: '2-digit', minute: '2-digit' })}`;
} catch {
return String(dateStr);
}
};
// Status Badge Helper // Status Badge Helper
const renderStatusBadge = (status?: any, fallback = 'Active') => { const renderStatusBadge = (status?: any, fallback = 'Active') => {
const num = Number(status); const num = Number(status);
@@ -124,24 +157,69 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
); );
}; };
// =========================================================================
// ON-DEMAND FETCH: WHEN "View details" IS CLICKED, FETCH USING projectId ON HAND
// (Followup Requirement #1 & #2)
// =========================================================================
const {
data: projectDetails,
isLoading: isLoadingDetails,
isFetching: isFetchingDetails,
refetch: refetchProjectData,
} = useQuery({
queryKey: ['project-milestones-activities-evidence', String(project.id)],
queryFn: async () => {
const pid = project.id;
const [milestonesRes, activitiesRes, evidenceRes] = await Promise.all([
getMilestones(pid).catch((err) => {
console.warn('Failed to load milestones for project', pid, err);
return [] as Milestone[];
}),
getActivities(pid).catch((err) => {
console.warn('Failed to load activities for project', pid, err);
return [] as Activity[];
}),
getProjectEvidence(pid).catch((err) => {
console.warn('Failed to load evidence for project', pid, err);
return [] as Evidence[];
}),
]);
return {
milestones: milestonesRes || [],
activities: activitiesRes || [],
evidence: evidenceRes || [],
};
},
enabled: isExpanded,
staleTime: 1000 * 20,
});
// Effective data sources (prioritize freshly fetched projectDetails, fallback to item arrays)
const effectiveMilestones: Milestone[] = projectDetails?.milestones ?? (item.milestones?.length ? item.milestones : []);
const effectiveActivities: Activity[] = projectDetails?.activities ?? (item.activities?.length ? item.activities : []);
const effectiveEvidence: Evidence[] = projectDetails?.evidence ?? (item.evidence?.length ? item.evidence : []);
// 1. Sort Milestones according to Start Date (or createdAt) then Due Date (Requirement #5) // 1. Sort Milestones according to Start Date (or createdAt) then Due Date (Requirement #5)
const sortedMilestones = [...milestones].sort((a, b) => { const sortedMilestones = [...effectiveMilestones].sort((a, b) => {
const startA = new Date(a.createdAt || a.dueAt || 0).getTime(); const startA = new Date(a.createdAt || a.dueAt || 0).getTime();
const startB = new Date(b.createdAt || b.dueAt || 0).getTime(); const startB = new Date(b.createdAt || b.dueAt || 0).getTime();
if (startA !== startB) return startA - startB; if (startA !== startB) return startA - startB;
return new Date(a.dueAt || 0).getTime() - new Date(b.dueAt || 0).getTime(); return new Date(a.dueAt || 0).getTime() - new Date(b.dueAt || 0).getTime();
}); });
// 2. Map evidence to activity (Requirement #10) // 2. Fetch project evidence, list existing evidence under matching activity by activityId sorted by time (Requirement #2)
const getEvidenceForActivity = (activityId: string | number): Evidence[] => { const getEvidenceForActivity = (activityId: string | number): Evidence[] => {
const targetNum = toIntegerId(activityId); const targetNum = toIntegerId(activityId);
return evidenceList.filter(e => toIntegerId(e.activityId) === targetNum); return effectiveEvidence
.filter(e => toIntegerId(e.activityId) === targetNum)
.sort((e1, e2) => new Date(e2.createdAt || 0).getTime() - new Date(e1.createdAt || 0).getTime());
}; };
// 3. Nested activities matching MilestoneId sorted by date (Requirement #6) // 3. Nested activities matching MilestoneId sorted by date (Requirement #6)
const getActivitiesForMilestone = (milestoneId: string | number): Activity[] => { const getActivitiesForMilestone = (milestoneId: string | number): Activity[] => {
const targetMilestoneNum = toIntegerId(milestoneId); const targetMilestoneNum = toIntegerId(milestoneId);
return activities return effectiveActivities
.filter(a => toIntegerId(a.milestoneId) === targetMilestoneNum) .filter(a => toIntegerId(a.milestoneId) === targetMilestoneNum)
.sort((a, b) => { .sort((a, b) => {
const dateA = new Date(a.performedAt || a.createdAt || 0).getTime(); const dateA = new Date(a.performedAt || a.createdAt || 0).getTime();
@@ -150,8 +228,8 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
}); });
}; };
// Activities not assigned to any milestone // Unassigned activities
const unassignedActivities = activities const unassignedActivities = effectiveActivities
.filter(a => !a.milestoneId || toIntegerId(a.milestoneId) === 0) .filter(a => !a.milestoneId || toIntegerId(a.milestoneId) === 0)
.sort((a, b) => { .sort((a, b) => {
const dateA = new Date(a.performedAt || a.createdAt || 0).getTime(); const dateA = new Date(a.performedAt || a.createdAt || 0).getTime();
@@ -159,23 +237,26 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
return dateA - dateB; return dateA - dateB;
}); });
// Calculate overall evidence completion progress // Evidence fulfillment progress
const totalActivitiesCount = activities.length; const totalActivitiesCount = effectiveActivities.length;
const fulfilledActivitiesCount = activities.filter(a => { const fulfilledActivitiesCount = effectiveActivities.filter(a => {
const evs = getEvidenceForActivity(a.id); const evs = getEvidenceForActivity(a.id);
return evs.length > 0 || Number(a.status) === ApprovalStatus.Approved || Number(a.status) === ApprovalStatus.Review; return evs.length > 0 || Number(a.status) === ApprovalStatus.Approved || Number(a.status) === ApprovalStatus.Review;
}).length; }).length;
const progressPercent = totalActivitiesCount > 0 const progressPercent = totalActivitiesCount > 0
? Math.round((fulfilledActivitiesCount / totalActivitiesCount) * 100) ? Math.round((fulfilledActivitiesCount / totalActivitiesCount) * 100)
: (milestones.length > 0 ? 0 : 100); : (effectiveMilestones.length > 0 ? 0 : 100);
// Handle submit activity for approval // Handle submit activity for approval
const handleSubmitActivity = async (activity: Activity, milestone?: Milestone | null) => { const handleSubmitActivity = async (activity: Activity, milestone?: Milestone | null) => {
const evs = getEvidenceForActivity(activity.id); const evs = getEvidenceForActivity(activity.id);
setSubmittingActivityId(activity.id); setSubmittingActivityId(activity.id);
try { try {
await onSubmitActivityForApproval(activity, milestone, evs); await submitActivityForApproval(activity, project.id, evs);
await refetchProjectData();
} catch (err) {
console.error(err);
} finally { } finally {
setSubmittingActivityId(null); setSubmittingActivityId(null);
} }
@@ -185,7 +266,6 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
<div className="w-full rounded-[16px] bg-[#181622] border border-white/[0.06] hover:border-white/[0.12] transition-all p-6 sm:p-7 shadow-sm"> <div className="w-full rounded-[16px] bg-[#181622] border border-white/[0.06] hover:border-white/[0.12] transition-all p-6 sm:p-7 shadow-sm">
{/* ========================================================================= {/* =========================================================================
TOP PORTION: FULL-WIDTH HEADER, METADATA FROM APPLICANT DATA & METRICS TOP PORTION: FULL-WIDTH HEADER, METADATA FROM APPLICANT DATA & METRICS
(Ratings, location, time, and Contact button have been removed)
========================================================================= */} ========================================================================= */}
<div className="space-y-5"> <div className="space-y-5">
{/* Row 1: Badges & Project Title */} {/* Row 1: Badges & Project Title */}
@@ -329,7 +409,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
<span className="p-1 rounded-md bg-teal-500/10 text-teal-400 flex items-center justify-center"> <span className="p-1 rounded-md bg-teal-500/10 text-teal-400 flex items-center justify-center">
<Layers className="w-3.5 h-3.5" /> <Layers className="w-3.5 h-3.5" />
</span> </span>
<span className="font-semibold text-[#F1F1F6]">{milestones.length} Milestones</span> <span className="font-semibold text-[#F1F1F6]">{effectiveMilestones.length} Milestones</span>
<span className="text-[#9A95B0]">• {totalActivitiesCount} Activities</span> <span className="text-[#9A95B0]">• {totalActivitiesCount} Activities</span>
</div> </div>
@@ -338,7 +418,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
<span className="p-1 rounded-md bg-cyan-500/10 text-cyan-400 flex items-center justify-center"> <span className="p-1 rounded-md bg-cyan-500/10 text-cyan-400 flex items-center justify-center">
<Paperclip className="w-3.5 h-3.5" /> <Paperclip className="w-3.5 h-3.5" />
</span> </span>
<span className="font-bold text-[#F1F1F6] font-mono">{evidenceList.length}</span> <span className="font-bold text-[#F1F1F6] font-mono">{effectiveEvidence.length}</span>
<span className="text-[#9A95B0]">Uploaded Proofs</span> <span className="text-[#9A95B0]">Uploaded Proofs</span>
</div> </div>
</div> </div>
@@ -358,8 +438,8 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
</div> </div>
{/* ========================================================================= {/* =========================================================================
INLINE EXPANDED SECTION: MILESTONES & ACTIVITIES (NO MODAL USED) INLINE EXPANDED SECTION: MILESTONES & ACTIVITIES
(Requirements #4, #5, #6, #7, #8, #9, #10, #11) (Followup Requirements #1, #2, #3)
========================================================================= */} ========================================================================= */}
{isExpanded && ( {isExpanded && (
<div className="mt-6 pt-6 border-t border-white/10 space-y-6 animate-in fade-in duration-300"> <div className="mt-6 pt-6 border-t border-white/10 space-y-6 animate-in fade-in duration-300">
@@ -369,24 +449,53 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
<h4 className="text-sm font-bold text-white flex items-center gap-2"> <h4 className="text-sm font-bold text-white flex items-center gap-2">
<Layers className="w-4 h-4 text-purple-400" /> <Layers className="w-4 h-4 text-purple-400" />
<span>Project Milestones & Contractual Activities</span> <span>Project Milestones & Contractual Activities</span>
{isFetchingDetails && (
<RefreshCw className="w-3.5 h-3.5 text-purple-400 animate-spin ml-2" />
)}
</h4> </h4>
<p className="text-[11px] text-[#9A95B0] mt-0.5"> <p className="text-[11px] text-[#9A95B0] mt-0.5">
All milestones are enabled. Complete activities by uploading supporting evidence documents, then submit for review and approval by organisation staff. All milestones are enabled. Complete activities by uploading supporting evidence documents, then submit for review and approval by organisation staff.
</p> </p>
</div> </div>
<div className="flex items-center gap-1.5 text-[11px] text-purple-300 bg-purple-950/60 border border-purple-500/30 px-3 py-1 rounded-lg shrink-0"> <div className="flex items-center gap-2 shrink-0">
<Info className="w-3.5 h-3.5" /> <button
<span>Partner Role: Evidence & Fulfillment</span> type="button"
onClick={() => refetchProjectData()}
disabled={isFetchingDetails}
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-lg bg-white/5 hover:bg-white/10 text-xs text-slate-300 hover:text-white transition-colors cursor-pointer"
title="Refresh project deliverables & evidence"
>
<RefreshCw className={`w-3.5 h-3.5 ${isFetchingDetails ? 'animate-spin text-purple-400' : ''}`} />
<span>Refresh</span>
</button>
<div className="flex items-center gap-1.5 text-[11px] text-purple-300 bg-purple-950/60 border border-purple-500/30 px-3 py-1 rounded-lg">
<Info className="w-3.5 h-3.5" />
<span>Fulfillment Only</span>
</div>
</div> </div>
</div> </div>
{/* MILESTONES LISTING (Sorted by Start Date then Due Date) */} {/* LOADING STATE WHEN VIEW DETAILS IS FIRST CLICKED */}
{sortedMilestones.length === 0 ? ( {isLoadingDetails ? (
<div className="p-8 rounded-2xl bg-[#0F0E17]/60 border border-white/5 flex flex-col items-center justify-center space-y-3 text-xs text-[#9A95B0]">
<Loader2 className="w-6 h-6 text-purple-400 animate-spin" />
<span>Fetching project milestones, activities, and evidence...</span>
</div>
) : sortedMilestones.length === 0 ? (
<div className="p-8 rounded-2xl bg-[#0F0E17]/60 border border-white/5 text-center text-xs text-[#9A95B0] space-y-2"> <div className="p-8 rounded-2xl bg-[#0F0E17]/60 border border-white/5 text-center text-xs text-[#9A95B0] space-y-2">
<Layers className="w-8 h-8 text-slate-600 mx-auto" /> <Layers className="w-8 h-8 text-slate-600 mx-auto" />
<p>No contractual milestones have been recorded for this project yet.</p> <p>No contractual milestones recorded for this project yet.</p>
<button
type="button"
onClick={() => refetchProjectData()}
className="inline-flex items-center gap-1 text-purple-400 hover:underline pt-1 cursor-pointer"
>
<RefreshCw className="w-3 h-3" />
<span>Click here to re-check schedule</span>
</button>
</div> </div>
) : ( ) : (
/* MILESTONES LISTING (Sorted by Start Date then Due Date) */
<div className="space-y-5"> <div className="space-y-5">
{sortedMilestones.map((milestone, mIdx) => { {sortedMilestones.map((milestone, mIdx) => {
const milestoneActs = getActivitiesForMilestone(milestone.id); const milestoneActs = getActivitiesForMilestone(milestone.id);
@@ -439,12 +548,12 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
</div> </div>
</div> </div>
{/* NESTED ACTIVITIES LISTING (Requirement #6: Matched by MilestoneId, listed by date) */} {/* NESTED ACTIVITIES LISTING (Requirement #1: Fetched for each milestone) */}
<div className="space-y-3 pt-1"> <div className="space-y-3 pt-1">
<div className="text-[11px] font-semibold uppercase tracking-wider text-[#9A95B0] flex items-center justify-between"> <div className="text-[11px] font-semibold uppercase tracking-wider text-[#9A95B0] flex items-center justify-between">
<span>Activities Under Milestone ({milestoneActs.length})</span> <span>Activities Under Milestone ({milestoneActs.length})</span>
<span className="text-[10px] font-normal normal-case text-slate-500"> <span className="text-[10px] font-normal normal-case text-slate-500">
Sorted by activity execution / target date Sorted by execution date
</span> </span>
</div> </div>
@@ -465,7 +574,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
key={act.id} key={act.id}
className="p-4 rounded-xl bg-white/[0.02] border border-white/5 hover:border-white/10 transition-all space-y-3" className="p-4 rounded-xl bg-white/[0.02] border border-white/5 hover:border-white/10 transition-all space-y-3"
> >
{/* Activity Top Row */} {/* Activity Details Row */}
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3"> <div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
<div className="space-y-1.5 min-w-0"> <div className="space-y-1.5 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
@@ -516,20 +625,20 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
</div> </div>
</div> </div>
{/* Action Buttons for Activity: Upload Evidence + Submit for Approval */} {/* Action Buttons: Add Evidence + Submit for Approval */}
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
{/* Upload Evidence Button (Multiple uploads supported - Requirement #8, #9, #10) */} {/* Add Evidence Button (Requirement #3: Allow user to add evidence, once uploaded refresh data) */}
<button <button
type="button" type="button"
onClick={() => onUploadEvidence({ project, milestone, activity: act })} onClick={() => setUploadTarget({ project, milestone, activity: act })}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-purple-500/10 hover:bg-purple-500/20 border border-purple-500/30 text-purple-300 hover:text-purple-200 text-xs font-semibold transition-all cursor-pointer shadow-sm active:scale-95" className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-purple-500/10 hover:bg-purple-500/20 border border-purple-500/30 text-purple-300 hover:text-purple-200 text-xs font-semibold transition-all cursor-pointer shadow-sm active:scale-95"
title="Upload supporting evidence document for this activity" title="Add supporting evidence document for this activity"
> >
<UploadCloud className="w-3.5 h-3.5" /> <Plus className="w-3.5 h-3.5" />
<span>{hasEvidence ? '+ Upload Another Proof' : 'Upload Evidence'}</span> <span>{hasEvidence ? 'Add More Evidence' : 'Add Evidence'}</span>
</button> </button>
{/* Submit for Approval Button (Requirement #11) */} {/* Submit for Approval Button */}
{!isApproved && !isUnderReview && ( {!isApproved && !isUnderReview && (
<button <button
type="button" type="button"
@@ -549,13 +658,13 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
</div> </div>
</div> </div>
{/* LIST OF ATTACHED EVIDENCE DOCUMENTS (Requirement #10: Multiple uploads mapped) */} {/* LIST EXISTING EVIDENCE UNDER MATCHING ACTIVITY BY activityId SORTED BY TIME, WITH TYPE AND STATUS (Requirement #2) */}
{actEvidences.length > 0 && ( {actEvidences.length > 0 && (
<div className="pt-2 border-t border-white/5 space-y-2"> <div className="pt-2 border-t border-white/5 space-y-2">
<div className="text-[10px] uppercase font-bold tracking-wider text-slate-400 flex items-center justify-between"> <div className="text-[10px] uppercase font-bold tracking-wider text-slate-400 flex items-center justify-between">
<span>Attached Proof Documents ({actEvidences.length}):</span> <span>Attached Proof Documents ({actEvidences.length}):</span>
<span className="text-[10px] normal-case text-emerald-400"> <span className="text-[10px] normal-case text-emerald-400">
Multiple documents mapped from EvidenceService Sorted by upload time
</span> </span>
</div> </div>
@@ -572,7 +681,10 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
{(ev as any).title || ev.fileName || `Evidence #${encodeHashIdSync(ev.id)}`} {(ev as any).title || ev.fileName || `Evidence #${encodeHashIdSync(ev.id)}`}
</p> </p>
<p className="text-[10px] text-slate-500 truncate"> <p className="text-[10px] text-slate-500 truncate">
{getEvidenceTypeLabel(ev.type)} • {formatDate(ev.createdAt)} <span className="font-semibold text-purple-300">
{getEvidenceTypeLabel(ev.type)}
</span>{' '}
• {formatDateTime(ev.createdAt)}
</p> </p>
</div> </div>
</div> </div>
@@ -605,7 +717,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
</div> </div>
)} )}
{/* UNASSIGNED ACTIVITIES (If any activities are recorded without milestoneId) */} {/* UNASSIGNED ACTIVITIES */}
{unassignedActivities.length > 0 && ( {unassignedActivities.length > 0 && (
<div className="rounded-2xl border border-white/[0.08] bg-[#0F0E17]/90 p-5 space-y-4 shadow-sm"> <div className="rounded-2xl border border-white/[0.08] bg-[#0F0E17]/90 p-5 space-y-4 shadow-sm">
<div className="border-b border-white/5 pb-2"> <div className="border-b border-white/5 pb-2">
@@ -652,11 +764,11 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<button <button
type="button" type="button"
onClick={() => onUploadEvidence({ project, activity: act })} onClick={() => setUploadTarget({ project, activity: act })}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-purple-500/10 hover:bg-purple-500/20 border border-purple-500/30 text-purple-300 text-xs font-semibold cursor-pointer" className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-purple-500/10 hover:bg-purple-500/20 border border-purple-500/30 text-purple-300 text-xs font-semibold cursor-pointer"
> >
<UploadCloud className="w-3.5 h-3.5" /> <Plus className="w-3.5 h-3.5" />
<span>{hasEvidence ? '+ Upload Another Proof' : 'Upload Evidence'}</span> <span>{hasEvidence ? 'Add More Evidence' : 'Add Evidence'}</span>
</button> </button>
{!isApproved && !isUnderReview && ( {!isApproved && !isUnderReview && (
@@ -680,9 +792,14 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
key={ev.id} key={ev.id}
className="flex items-center justify-between p-2.5 rounded-lg bg-[#0F0E17] border border-white/10 text-xs" className="flex items-center justify-between p-2.5 rounded-lg bg-[#0F0E17] border border-white/10 text-xs"
> >
<span className="text-slate-200 truncate"> <div className="min-w-0 pr-2">
{(ev as any).title || ev.fileName || `Evidence #${encodeHashIdSync(ev.id)}`} <span className="text-slate-200 truncate block">
</span> {(ev as any).title || ev.fileName || `Evidence #${encodeHashIdSync(ev.id)}`}
</span>
<span className="text-[10px] text-slate-500">
{getEvidenceTypeLabel(ev.type)} • {formatDateTime(ev.createdAt)}
</span>
</div>
<button <button
type="button" type="button"
onClick={() => onOpenSecureFile('evidence', ev.id)} onClick={() => onOpenSecureFile('evidence', ev.id)}
@@ -702,6 +819,23 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
)} )}
</div> </div>
)} )}
{/* =========================================================================
EVIDENCE UPLOAD MODAL (Requirement #3: Allow user to add evidence, once uploaded refresh data)
========================================================================= */}
{uploadTarget && (
<EvidenceUploadModal
isOpen={true}
onClose={() => setUploadTarget(null)}
project={uploadTarget.project}
milestone={uploadTarget.milestone}
activity={uploadTarget.activity}
onEvidenceUploaded={async () => {
await refetchProjectData();
setUploadTarget(null);
}}
/>
)}
</div> </div>
); );
}; };
@@ -374,7 +374,7 @@ export const evidenceApi = {
apiClient.get<Models.Evidence[]>(`/v1/api/evidence/milestone/${hashParam(milestoneId)}`), apiClient.get<Models.Evidence[]>(`/v1/api/evidence/milestone/${hashParam(milestoneId)}`),
getProjectEvidence: (projectId: string | number) => getProjectEvidence: (projectId: string | number) =>
apiClient.get<Models.Evidence[]>(`/v1/api/evidence/${hashParam(projectId)}`), apiClient.get<Models.Evidence[]>(`/v1/api/evidence/projects/${hashParam(projectId)}`),
createMilestoneRequest: (payload: Models.CreateMilestoneRequest) => createMilestoneRequest: (payload: Models.CreateMilestoneRequest) =>
apiClient.post<Models.Milestone>('/v1/api/evidence/milestones', payload), apiClient.post<Models.Milestone>('/v1/api/evidence/milestones', payload),
@@ -1418,12 +1418,12 @@ export const getMilestoneEvidence = async (milestoneId: string | number): Promis
export const getProjectEvidence = async (projectId: string | number): Promise<Evidence[]> => { export const getProjectEvidence = async (projectId: string | number): Promise<Evidence[]> => {
try { try {
const hashedProjectId = await toolsService.resolveProjectId(projectId); const hashedProjectId = await toolsService.resolveProjectId(projectId);
const res = await apiClient.get<Evidence[]>(`/v1/api/evidence/${hashedProjectId}`); const res = await apiClient.get<Evidence[]>(`/v1/api/evidence/projects/${hashedProjectId}`);
return res.data || []; return res.data || [];
} catch (err: any) { } catch (err: any) {
if ((err?.response?.status === 404 || err?.response?.status === 400) && String(projectId) !== '') { if ((err?.response?.status === 404 || err?.response?.status === 400) && String(projectId) !== '') {
try { try {
const rawRes = await apiClient.get<Evidence[]>(`/v1/api/evidence/${projectId}`); const rawRes = await apiClient.get<Evidence[]>(`/v1/api/evidence/projects/${projectId}`);
return rawRes.data || []; return rawRes.data || [];
} catch (fallbackErr: any) { } catch (fallbackErr: any) {
if (fallbackErr?.response?.status === 404) { if (fallbackErr?.response?.status === 404) {