diff --git a/PostFundManagement.Api/Endpoints/Evidence/GetProjectEvidenceEndpoint.cs b/PostFundManagement.Api/Endpoints/Evidence/GetProjectEvidenceEndpoint.cs index 3ebac97..2573a07 100644 --- a/PostFundManagement.Api/Endpoints/Evidence/GetProjectEvidenceEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Evidence/GetProjectEvidenceEndpoint.cs @@ -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 { diff --git a/PostFundManagement.ConsoleUI/src/services/api.ts b/PostFundManagement.ConsoleUI/src/services/api.ts index d81ab01..9668b86 100644 --- a/PostFundManagement.ConsoleUI/src/services/api.ts +++ b/PostFundManagement.ConsoleUI/src/services/api.ts @@ -1229,13 +1229,13 @@ export const getMilestoneEvidence = async (milestoneId: string | number): Promis export const getProjectEvidence = async (projectId: string | number): Promise => { try { const hashedProjectId = await toolsService.resolveProjectId(projectId); - const res = await apiClient.get(`/v1/api/evidence/${hashedProjectId}`); + const res = await apiClient.get(`/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(`/v1/api/evidence/${projectId}`); + const rawRes = await apiClient.get(`/v1/api/evidence/projects/${projectId}`); return rawRes.data || []; } catch (fallbackErr: any) { if (fallbackErr?.response?.status === 404) { diff --git a/PostFundManagement.PartnerUI/src/components/dashboard/ProjectCard.tsx b/PostFundManagement.PartnerUI/src/components/dashboard/ProjectCard.tsx index b142471..bd8b88b 100644 --- a/PostFundManagement.PartnerUI/src/components/dashboard/ProjectCard.tsx +++ b/PostFundManagement.PartnerUI/src/components/dashboard/ProjectCard.tsx @@ -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; onOpenSecureFile: (type: 'contract' | 'evidence', id: string | number) => void; } @@ -51,8 +68,6 @@ export const ProjectCard: React.FC = ({ item, isExpanded: externalIsExpanded, onToggleExpand, - onUploadEvidence, - onSubmitActivityForApproval, onOpenSecureFile, }) => { const [internalExpanded, setInternalExpanded] = useState(false); @@ -61,7 +76,14 @@ export const ProjectCard: React.FC = ({ const [submittingActivityId, setSubmittingActivityId] = useState(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 = ({ // 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 = ({ } }; + // 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 = ({ ); }; + // ========================================================================= + // 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 = ({ }); }; - // 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 = ({ 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 = ({
{/* ========================================================================= TOP PORTION: FULL-WIDTH HEADER, METADATA FROM APPLICANT DATA & METRICS - (Ratings, location, time, and Contact button have been removed) ========================================================================= */}
{/* Row 1: Badges & Project Title */} @@ -329,7 +409,7 @@ export const ProjectCard: React.FC = ({ - {milestones.length} Milestones + {effectiveMilestones.length} Milestones • {totalActivitiesCount} Activities
@@ -338,7 +418,7 @@ export const ProjectCard: React.FC = ({ - {evidenceList.length} + {effectiveEvidence.length} Uploaded Proofs
@@ -358,8 +438,8 @@ export const ProjectCard: React.FC = ({ {/* ========================================================================= - 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 && (
@@ -369,24 +449,53 @@ export const ProjectCard: React.FC = ({

Project Milestones & Contractual Activities + {isFetchingDetails && ( + + )}

All milestones are enabled. Complete activities by uploading supporting evidence documents, then submit for review and approval by organisation staff.

-
- - Partner Role: Evidence & Fulfillment +
+ +
+ + Fulfillment Only +
- {/* MILESTONES LISTING (Sorted by Start Date then Due Date) */} - {sortedMilestones.length === 0 ? ( + {/* LOADING STATE WHEN VIEW DETAILS IS FIRST CLICKED */} + {isLoadingDetails ? ( +
+ + Fetching project milestones, activities, and evidence... +
+ ) : sortedMilestones.length === 0 ? (
-

No contractual milestones have been recorded for this project yet.

+

No contractual milestones recorded for this project yet.

+
) : ( + /* MILESTONES LISTING (Sorted by Start Date then Due Date) */
{sortedMilestones.map((milestone, mIdx) => { const milestoneActs = getActivitiesForMilestone(milestone.id); @@ -439,12 +548,12 @@ export const ProjectCard: React.FC = ({
- {/* NESTED ACTIVITIES LISTING (Requirement #6: Matched by MilestoneId, listed by date) */} + {/* NESTED ACTIVITIES LISTING (Requirement #1: Fetched for each milestone) */}
Activities Under Milestone ({milestoneActs.length}) - Sorted by activity execution / target date + Sorted by execution date
@@ -465,7 +574,7 @@ export const ProjectCard: React.FC = ({ 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 */}
@@ -516,20 +625,20 @@ export const ProjectCard: React.FC = ({
- {/* Action Buttons for Activity: Upload Evidence + Submit for Approval */} + {/* Action Buttons: Add Evidence + Submit for Approval */}
- {/* Upload Evidence Button (Multiple uploads supported - Requirement #8, #9, #10) */} + {/* Add Evidence Button (Requirement #3: Allow user to add evidence, once uploaded refresh data) */} - {/* Submit for Approval Button (Requirement #11) */} + {/* Submit for Approval Button */} {!isApproved && !isUnderReview && (
- {/* 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 && (
Attached Proof Documents ({actEvidences.length}): - Multiple documents mapped from EvidenceService + Sorted by upload time
@@ -572,7 +681,10 @@ export const ProjectCard: React.FC = ({ {(ev as any).title || ev.fileName || `Evidence #${encodeHashIdSync(ev.id)}`}

- {getEvidenceTypeLabel(ev.type)} • {formatDate(ev.createdAt)} + + {getEvidenceTypeLabel(ev.type)} + {' '} + • {formatDateTime(ev.createdAt)}

@@ -605,7 +717,7 @@ export const ProjectCard: React.FC = ({ )} - {/* UNASSIGNED ACTIVITIES (If any activities are recorded without milestoneId) */} + {/* UNASSIGNED ACTIVITIES */} {unassignedActivities.length > 0 && (
@@ -652,11 +764,11 @@ export const ProjectCard: React.FC = ({
{!isApproved && !isUnderReview && ( @@ -680,9 +792,14 @@ export const ProjectCard: React.FC = ({ key={ev.id} className="flex items-center justify-between p-2.5 rounded-lg bg-[#0F0E17] border border-white/10 text-xs" > - - {(ev as any).title || ev.fileName || `Evidence #${encodeHashIdSync(ev.id)}`} - +
+ + {(ev as any).title || ev.fileName || `Evidence #${encodeHashIdSync(ev.id)}`} + + + {getEvidenceTypeLabel(ev.type)} • {formatDateTime(ev.createdAt)} + +
)} + + {/* ========================================================================= + EVIDENCE UPLOAD MODAL (Requirement #3: Allow user to add evidence, once uploaded refresh data) + ========================================================================= */} + {uploadTarget && ( + setUploadTarget(null)} + project={uploadTarget.project} + milestone={uploadTarget.milestone} + activity={uploadTarget.activity} + onEvidenceUploaded={async () => { + await refetchProjectData(); + setUploadTarget(null); + }} + /> + )}
); }; diff --git a/PostFundManagement.PartnerUI/src/services/api/endpoints.ts b/PostFundManagement.PartnerUI/src/services/api/endpoints.ts index 9d0884b..c31539c 100644 --- a/PostFundManagement.PartnerUI/src/services/api/endpoints.ts +++ b/PostFundManagement.PartnerUI/src/services/api/endpoints.ts @@ -374,7 +374,7 @@ export const evidenceApi = { apiClient.get(`/v1/api/evidence/milestone/${hashParam(milestoneId)}`), getProjectEvidence: (projectId: string | number) => - apiClient.get(`/v1/api/evidence/${hashParam(projectId)}`), + apiClient.get(`/v1/api/evidence/projects/${hashParam(projectId)}`), createMilestoneRequest: (payload: Models.CreateMilestoneRequest) => apiClient.post('/v1/api/evidence/milestones', payload), diff --git a/PostFundManagement.PartnerUI/src/services/api/index.ts b/PostFundManagement.PartnerUI/src/services/api/index.ts index 46dea92..dd5007f 100644 --- a/PostFundManagement.PartnerUI/src/services/api/index.ts +++ b/PostFundManagement.PartnerUI/src/services/api/index.ts @@ -1418,12 +1418,12 @@ export const getMilestoneEvidence = async (milestoneId: string | number): Promis export const getProjectEvidence = async (projectId: string | number): Promise => { try { const hashedProjectId = await toolsService.resolveProjectId(projectId); - const res = await apiClient.get(`/v1/api/evidence/${hashedProjectId}`); + const res = await apiClient.get(`/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(`/v1/api/evidence/${projectId}`); + const rawRes = await apiClient.get(`/v1/api/evidence/projects/${projectId}`); return rawRes.data || []; } catch (fallbackErr: any) { if (fallbackErr?.response?.status === 404) {