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)
{
builder.MapGet("api/evidence/{projectId}", async (string projectId, IEvidenceService service,
builder.MapGet("api/evidence/projects/{projectId}", async (string projectId, IEvidenceService service,
CancellationToken cancellationToken = default) =>
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[]> => {
try {
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 || [];
} catch (err: any) {
// 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) !== '') {
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 || [];
} catch (fallbackErr: any) {
if (fallbackErr?.response?.status === 404) {
@@ -1,8 +1,25 @@
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 { toIntegerId } from '../../services/api';
import {
toIntegerId,
getMilestones,
getActivities,
getProjectEvidence,
submitActivityForApproval
} from '../../services/api';
import { getEvidenceTypeLabel } from '../../lib/formatters';
import { EvidenceUploadModal } from './EvidenceUploadModal';
import {
FolderKanban,
ChevronDown,
@@ -15,15 +32,17 @@ import {
Clock,
FileText,
UploadCloud,
ExternalLink,
ShieldCheck,
DollarSign,
Paperclip,
Building,
Check,
Send,
Loader2,
Info
ExternalLink,
ShieldCheck,
DollarSign,
Paperclip,
Building,
Check,
Send,
Loader2,
Info,
RefreshCw,
Plus
} from 'lucide-react';
export interface EnrichedPartnerProject {
@@ -42,8 +61,6 @@ interface ProjectCardProps {
item: EnrichedPartnerProject;
isExpanded?: boolean;
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;
}
@@ -51,8 +68,6 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
item,
isExpanded: externalIsExpanded,
onToggleExpand,
onUploadEvidence,
onSubmitActivityForApproval,
onOpenSecureFile,
}) => {
const [internalExpanded, setInternalExpanded] = useState(false);
@@ -61,7 +76,14 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
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);
// Financial calculations
@@ -90,7 +112,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
// Date formatter
const formatDate = (dateStr?: string | null): string => {
if (!dateStr) return 'N/A';
if (!dateStr) return 'TBD';
try {
return new Date(dateStr).toLocaleDateString('en-ZA', { day: '2-digit', month: 'short', year: 'numeric' });
} 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
const renderStatusBadge = (status?: any, fallback = 'Active') => {
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)
const sortedMilestones = [...milestones].sort((a, b) => {
const sortedMilestones = [...effectiveMilestones].sort((a, b) => {
const startA = new Date(a.createdAt || a.dueAt || 0).getTime();
const startB = new Date(b.createdAt || b.dueAt || 0).getTime();
if (startA !== startB) return startA - startB;
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 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)
const getActivitiesForMilestone = (milestoneId: string | number): Activity[] => {
const targetMilestoneNum = toIntegerId(milestoneId);
return activities
return effectiveActivities
.filter(a => toIntegerId(a.milestoneId) === targetMilestoneNum)
.sort((a, b) => {
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
const unassignedActivities = activities
// Unassigned activities
const unassignedActivities = effectiveActivities
.filter(a => !a.milestoneId || toIntegerId(a.milestoneId) === 0)
.sort((a, b) => {
const dateA = new Date(a.performedAt || a.createdAt || 0).getTime();
@@ -159,23 +237,26 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
return dateA - dateB;
});
// Calculate overall evidence completion progress
const totalActivitiesCount = activities.length;
const fulfilledActivitiesCount = activities.filter(a => {
// Evidence fulfillment progress
const totalActivitiesCount = effectiveActivities.length;
const fulfilledActivitiesCount = effectiveActivities.filter(a => {
const evs = getEvidenceForActivity(a.id);
return evs.length > 0 || Number(a.status) === ApprovalStatus.Approved || Number(a.status) === ApprovalStatus.Review;
}).length;
const progressPercent = totalActivitiesCount > 0
? Math.round((fulfilledActivitiesCount / totalActivitiesCount) * 100)
: (milestones.length > 0 ? 0 : 100);
: (effectiveMilestones.length > 0 ? 0 : 100);
// Handle submit activity for approval
const handleSubmitActivity = async (activity: Activity, milestone?: Milestone | null) => {
const evs = getEvidenceForActivity(activity.id);
setSubmittingActivityId(activity.id);
try {
await onSubmitActivityForApproval(activity, milestone, evs);
await submitActivityForApproval(activity, project.id, evs);
await refetchProjectData();
} catch (err) {
console.error(err);
} finally {
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">
{/* =========================================================================
TOP PORTION: FULL-WIDTH HEADER, METADATA FROM APPLICANT DATA & METRICS
(Ratings, location, time, and Contact button have been removed)
========================================================================= */}
<div className="space-y-5">
{/* 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">
<Layers className="w-3.5 h-3.5" />
</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>
</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">
<Paperclip className="w-3.5 h-3.5" />
</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>
</div>
</div>
@@ -358,8 +438,8 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
</div>
{/* =========================================================================
INLINE EXPANDED SECTION: MILESTONES & ACTIVITIES (NO MODAL USED)
(Requirements #4, #5, #6, #7, #8, #9, #10, #11)
INLINE EXPANDED SECTION: MILESTONES & ACTIVITIES
(Followup Requirements #1, #2, #3)
========================================================================= */}
{isExpanded && (
<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">
<Layers className="w-4 h-4 text-purple-400" />
<span>Project Milestones & Contractual Activities</span>
{isFetchingDetails && (
<RefreshCw className="w-3.5 h-3.5 text-purple-400 animate-spin ml-2" />
)}
</h4>
<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.
</p>
</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">
<Info className="w-3.5 h-3.5" />
<span>Partner Role: Evidence & Fulfillment</span>
<div className="flex items-center gap-2 shrink-0">
<button
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>
{/* MILESTONES LISTING (Sorted by Start Date then Due Date) */}
{sortedMilestones.length === 0 ? (
{/* LOADING STATE WHEN VIEW DETAILS IS FIRST CLICKED */}
{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">
<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>
) : (
/* MILESTONES LISTING (Sorted by Start Date then Due Date) */
<div className="space-y-5">
{sortedMilestones.map((milestone, mIdx) => {
const milestoneActs = getActivitiesForMilestone(milestone.id);
@@ -439,12 +548,12 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
</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="text-[11px] font-semibold uppercase tracking-wider text-[#9A95B0] flex items-center justify-between">
<span>Activities Under Milestone ({milestoneActs.length})</span>
<span className="text-[10px] font-normal normal-case text-slate-500">
Sorted by activity execution / target date
Sorted by execution date
</span>
</div>
@@ -465,7 +574,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
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"
>
{/* Activity Top Row */}
{/* Activity Details Row */}
<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="flex items-center gap-2 flex-wrap">
@@ -516,20 +625,20 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
</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">
{/* 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
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"
title="Upload supporting evidence document for this activity"
title="Add supporting evidence document for this activity"
>
<UploadCloud className="w-3.5 h-3.5" />
<span>{hasEvidence ? '+ Upload Another Proof' : 'Upload Evidence'}</span>
<Plus className="w-3.5 h-3.5" />
<span>{hasEvidence ? 'Add More Evidence' : 'Add Evidence'}</span>
</button>
{/* Submit for Approval Button (Requirement #11) */}
{/* Submit for Approval Button */}
{!isApproved && !isUnderReview && (
<button
type="button"
@@ -549,13 +658,13 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
</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 && (
<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">
<span>Attached Proof Documents ({actEvidences.length}):</span>
<span className="text-[10px] normal-case text-emerald-400">
Multiple documents mapped from EvidenceService
Sorted by upload time
</span>
</div>
@@ -572,7 +681,10 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
{(ev as any).title || ev.fileName || `Evidence #${encodeHashIdSync(ev.id)}`}
</p>
<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>
</div>
</div>
@@ -605,7 +717,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
</div>
)}
{/* UNASSIGNED ACTIVITIES (If any activities are recorded without milestoneId) */}
{/* UNASSIGNED ACTIVITIES */}
{unassignedActivities.length > 0 && (
<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">
@@ -652,11 +764,11 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
<div className="flex items-center gap-2 shrink-0">
<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"
>
<UploadCloud className="w-3.5 h-3.5" />
<span>{hasEvidence ? '+ Upload Another Proof' : 'Upload Evidence'}</span>
<Plus className="w-3.5 h-3.5" />
<span>{hasEvidence ? 'Add More Evidence' : 'Add Evidence'}</span>
</button>
{!isApproved && !isUnderReview && (
@@ -680,9 +792,14 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
key={ev.id}
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">
{(ev as any).title || ev.fileName || `Evidence #${encodeHashIdSync(ev.id)}`}
</span>
<div className="min-w-0 pr-2">
<span className="text-slate-200 truncate block">
{(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
type="button"
onClick={() => onOpenSecureFile('evidence', ev.id)}
@@ -702,6 +819,23 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
)}
</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>
);
};
@@ -374,7 +374,7 @@ export const evidenceApi = {
apiClient.get<Models.Evidence[]>(`/v1/api/evidence/milestone/${hashParam(milestoneId)}`),
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) =>
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[]> => {
try {
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 || [];
} catch (err: any) {
if ((err?.response?.status === 404 || err?.response?.status === 400) && String(projectId) !== '') {
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 || [];
} catch (fallbackErr: any) {
if (fallbackErr?.response?.status === 404) {