Files
pfm/PostFundManagement.ConsoleUI/src/components/monitoring/EvidenceReviewQueue.tsx
T

1202 lines
57 KiB
TypeScript

import React, { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
FileText,
Download,
Eye,
Search,
Filter,
CheckCircle2,
XCircle,
Clock,
AlertTriangle,
Flag,
FolderKanban,
User as UserIcon,
ShieldCheck,
Hash,
Upload,
RotateCcw,
ExternalLink,
Layers,
Paperclip,
Check,
FileSpreadsheet,
FileCheck,
RefreshCw,
Lock,
} from 'lucide-react';
import {
getAllEvidence,
getProjectEvidence,
getEvidenceSecureLink,
updateEvidence,
uploadEvidenceFile,
getProjects,
getAllMilestones,
getActivities,
getUsers,
} from '../../services/api';
import {
Evidence,
EvidenceType,
ApprovalStatus,
Project,
Milestone,
Activity,
User,
} from '../../types';
import {
formatDate,
getEvidenceTypeLabel,
getApprovalStatusBadge,
getUserEmail,
formatZAR,
} from '../../lib/formatters';
import { Modal } from '../ui/Modal';
import { useToast } from '../ui/Toast';
import { HelpTooltip } from '../ui/HelpTooltip';
import { ExportButton } from '../ui/ExportButton';
import { ExportColumn } from '../../utils/exportUtils';
import { useRbac } from '../../auth/rbac';
export interface EvidenceReviewQueueProps {
initialProjectId?: string | number | 'all';
initialMilestoneId?: string | number | 'all';
initialActivityId?: string | number | 'all';
initialUserId?: string | 'all';
}
const EVIDENCE_TYPE_OPTIONS = [
{ value: EvidenceType.CompletionCertificate, label: 'Completion Certificate' },
{ value: EvidenceType.InspectionReport, label: 'Inspection Report' },
{ value: EvidenceType.AttendanceRegister, label: 'Attendance Register' },
{ value: EvidenceType.FinancialInvoiceOrReceipt, label: 'Financial Invoice / Receipt' },
{ value: EvidenceType.GeotaggedPhoto, label: 'Geotagged Photo' },
{ value: EvidenceType.MeetingMinutes, label: 'Meeting Minutes' },
{ value: EvidenceType.PayrollOrStipendRegister, label: 'Payroll / Stipend Register' },
{ value: EvidenceType.TechnicalSpecificationOrDesign, label: 'Technical Specification' },
{ value: EvidenceType.DeliverableProof, label: 'Deliverable Proof' },
{ value: EvidenceType.Other, label: 'Other Document' },
];
export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
initialProjectId = 'all',
initialMilestoneId = 'all',
initialActivityId = 'all',
initialUserId = 'all',
}) => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
// Filter States: default to initialProjectId if given, otherwise when projects load default to first project
const [selectedProjectId, setSelectedProjectId] = useState<string | number | 'all'>(initialProjectId);
const [userHasSelectedProject, setUserHasSelectedProject] = useState(
initialProjectId !== undefined && initialProjectId !== null && initialProjectId !== 'all'
);
const [selectedMilestoneId, setSelectedMilestoneId] = useState<string | number | 'all'>(initialMilestoneId);
const [selectedActivityId, setSelectedActivityId] = useState<string | number | 'all'>(initialActivityId);
const [selectedUserId, setSelectedUserId] = useState<string | 'all'>(initialUserId);
const [selectedStatus, setSelectedStatus] = useState<ApprovalStatus | 'all'>('all');
const [searchQuery, setSearchQuery] = useState('');
// Modals & Active Review State
const [inspectEvidence, setInspectEvidence] = useState<Evidence | null>(null);
const [isManualIngestModalOpen, setIsManualIngestModalOpen] = useState(false);
const [ingestFile, setIngestFile] = useState<File | null>(null);
const [ingestType, setIngestType] = useState<number>(EvidenceType.CompletionCertificate);
const [ingestProjectId, setIngestProjectId] = useState<string | number>('');
const [ingestMilestoneId, setIngestMilestoneId] = useState<string | number>('');
const [ingestActivityId, setIngestActivityId] = useState<string | number>('');
const [isDownloading, setIsDownloading] = useState<string | number | null>(null);
// Queries
const { data: projects = [] } = useQuery<Project[]>({
queryKey: ['projects'],
queryFn: () => getProjects(),
});
const { data: milestones = [] } = useQuery<Milestone[]>({
queryKey: ['milestones-all'],
queryFn: () => getAllMilestones(),
});
const { data: users = [] } = useQuery<User[]>({
queryKey: ['users-list'],
queryFn: () => getUsers(1, 100).catch(() => []),
});
// Keep selectedProjectId in sync when initialProjectId prop changes
React.useEffect(() => {
if (initialProjectId && initialProjectId !== 'all') {
setSelectedProjectId(initialProjectId);
setUserHasSelectedProject(true);
}
}, [initialProjectId]);
// Load evidence by default for the first project in the list
React.useEffect(() => {
if (!userHasSelectedProject && projects.length > 0 && selectedProjectId === 'all') {
setSelectedProjectId(projects[0].id);
}
}, [projects, userHasSelectedProject, selectedProjectId]);
// Query activities based on selected project or fetch all across available projects
const { data: activities = [] } = useQuery<Activity[]>({
queryKey: ['activities-review-queue', selectedProjectId],
queryFn: async () => {
if (selectedProjectId && selectedProjectId !== 'all') {
return getActivities(selectedProjectId).catch(() => []);
}
if (projects.length > 0) {
const results = await Promise.all(
projects.slice(0, 10).map((p) => getActivities(p.id).catch(() => []))
);
return results.flat();
}
return [];
},
enabled: projects.length > 0,
});
// Query evidence: if a project is selected (e.g. the first project by default), fetch via /v1/api/evidence/{projectId}
const {
data: rawEvidenceList = [],
isLoading: loadingEvidence,
refetch: refetchEvidence,
isFetching: isFetchingEvidence,
} = useQuery<Evidence[]>({
queryKey: ['evidence', selectedProjectId],
queryFn: async () => {
if (selectedProjectId && selectedProjectId !== 'all') {
return getProjectEvidence(selectedProjectId).catch(() => []);
}
return getAllEvidence().catch(() => []);
},
enabled: projects.length > 0 || selectedProjectId !== 'all',
});
// UI depends entirely on the API response - no mock or demo data
const allEvidence: Evidence[] = rawEvidenceList;
const evidenceExportColumns: ExportColumn<Evidence>[] = [
{ header: 'Evidence ID', key: 'id' },
{ header: 'File Name', key: 'fileName' },
{ header: 'Project ID', key: 'projectId', format: (val) => `Project #${val}` },
{ header: 'Milestone ID', key: 'milestoneId', format: (val) => (val ? `Milestone #${val}` : '—') },
{ header: 'Activity ID', key: 'activityId', format: (val) => (val ? `Activity #${val}` : '—') },
{ header: 'Document Type', key: 'evidenceType', format: (val) => getEvidenceTypeLabel(val) },
{ header: 'Submitter', key: 'createdBy', format: (val) => getUserEmail(val) },
{ header: 'Submission Date', key: 'createdAt', format: (val) => formatDate(val) },
{ header: 'Verification Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label },
{ header: 'Review Remarks', key: 'remarks', format: (val) => val || '—' },
];
// Review evidence mutation: Facilitates Approve & Reject decisions via PUT /v1/api/evidence
const reviewMutation = useMutation({
mutationFn: async ({
id,
status,
version,
evidenceType,
}: {
id: string | number;
status: ApprovalStatus;
version?: number;
evidenceType?: EvidenceType;
}) => {
const targetEv = allEvidence.find((e) => String(e.id) === String(id));
const evVersion = Number(version ?? targetEv?.version ?? 1);
const evType = (evidenceType ?? targetEv?.type ?? EvidenceType.DeliverableProof) as EvidenceType;
return updateEvidence({
evidenceId: id,
version: evVersion,
evidenceType: evType,
status,
});
},
onSuccess: (_, variables) => {
// Update local query cache for instant feedback
queryClient.setQueryData(['evidence', selectedProjectId], (old: Evidence[] = []) => {
const exists = old.some((e) => String(e.id) === String(variables.id));
if (exists) {
return old.map((e) =>
String(e.id) === String(variables.id) ? { ...e, status: variables.status } : e
);
}
return old;
});
if (inspectEvidence && String(inspectEvidence.id) === String(variables.id)) {
setInspectEvidence((prev) => (prev ? { ...prev, status: variables.status } : null));
}
queryClient.invalidateQueries({ queryKey: ['evidence'] });
if (variables.status === ApprovalStatus.Approved) {
success('Evidence Approved & Verified', 'Approval recorded. Contributes towards contractual milestone sign-off.');
} else if (variables.status === ApprovalStatus.Rejected) {
success('Evidence Rejected', 'Evidence marked as Rejected by reviewer.');
} else {
success('Status Updated', 'Evidence status updated successfully.');
}
},
onError: (err: unknown) => {
toastError('Review Update Failed', err);
},
});
// Manual Ingestion Mutation
const ingestMutation = useMutation({
mutationFn: async (formData: FormData) => uploadEvidenceFile(formData),
onSuccess: (newEv) => {
queryClient.invalidateQueries({ queryKey: ['evidence'] });
success('Evidence Ingested', 'Document registered in the verification vault.');
setIsManualIngestModalOpen(false);
setIngestFile(null);
},
onError: (err: unknown) => {
toastError('Ingestion Failed', err);
},
});
// Filtered Evidence List
const filteredEvidence = useMemo(() => {
return allEvidence.filter((ev) => {
// Filter by Project
if (selectedProjectId !== 'all' && String(ev.projectId) !== String(selectedProjectId)) {
return false;
}
// Filter by Milestone
if (selectedMilestoneId !== 'all' && String(ev.milestoneId) !== String(selectedMilestoneId)) {
return false;
}
// Filter by Activity
if (selectedActivityId !== 'all' && String(ev.activityId) !== String(selectedActivityId)) {
return false;
}
// Filter by User / Submitter
if (selectedUserId !== 'all') {
const userEmail = getUserEmail(ev.createdBy).toLowerCase();
const targetUser = users.find((u) => u.id === selectedUserId || u.email === selectedUserId);
const matchId = String(ev.createdBy) === String(selectedUserId);
const matchEmail = targetUser?.email && userEmail === targetUser.email.toLowerCase();
if (!matchId && !matchEmail) {
return false;
}
}
// Filter by Status
if (selectedStatus !== 'all') {
if (selectedStatus === ApprovalStatus.PendingReview) {
if (ev.status !== ApprovalStatus.PendingReview && ev.status !== ApprovalStatus.Pending) {
return false;
}
} else if (ev.status !== selectedStatus) {
return false;
}
}
// Filter by Search Query
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
const fName = (ev.fileName || '').toLowerCase();
const uEmail = getUserEmail(ev.createdBy).toLowerCase();
const proj = projects.find((p) => String(p.id) === String(ev.projectId));
const pName = (proj?.name || '').toLowerCase();
const act = activities.find((a) => String(a.id) === String(ev.activityId));
const aTitle = (act?.title || '').toLowerCase();
return fName.includes(q) || uEmail.includes(q) || pName.includes(q) || aTitle.includes(q);
}
return true;
});
}, [allEvidence, selectedProjectId, selectedMilestoneId, selectedActivityId, selectedUserId, selectedStatus, searchQuery, users, projects, activities]);
// Counts for status pipeline pills
const statusCounts = useMemo(() => {
let submitted = 0;
let approved = 0;
let rejected = 0;
allEvidence.forEach((ev) => {
if (ev.status === ApprovalStatus.Approved) approved++;
else if (ev.status === ApprovalStatus.Rejected) rejected++;
else submitted++;
});
return {
all: allEvidence.length,
submitted,
approved,
rejected,
};
}, [allEvidence]);
// Handlers
const handleDownload = async (ev: Evidence) => {
try {
setIsDownloading(ev.id);
let downloadUrl = ev.documentUrl || '';
try {
const secureLink = await getEvidenceSecureLink(ev.id);
if (secureLink) downloadUrl = secureLink;
} catch {
// Fallback to existing documentUrl or synthesized blob
}
if (downloadUrl && downloadUrl.startsWith('http')) {
window.open(downloadUrl, '_blank', 'noopener,noreferrer');
} else {
// Create an audit download file stub
const content = `PFM VAULT EVIDENCE RECORD\nFile: ${ev.fileName || 'evidence.pdf'}\nProject: #${ev.projectId}\nMilestone: #${ev.milestoneId || 'N/A'}\nActivity: #${ev.activityId || 'N/A'}\nSubmitted By: ${getUserEmail(ev.createdBy)}\nDate: ${ev.createdAt}\nStatus: ${ev.status}\nSHA-256 Hash: 8f4a2109e4d77b8120c8f1e948c34d830b0e35fa8f121d5a230f81d`;
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = ev.fileName || `evidence_${ev.id}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
success('Download Initiated', `Opening official file: ${ev.fileName || `evidence_${ev.id}`}`);
} catch (err: unknown) {
toastError('Download Failed', err);
} finally {
setIsDownloading(null);
}
};
const handleManualIngestSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!ingestFile) {
toastError('Missing File', 'Please select a document or certificate to upload.');
return;
}
const targetProjId = ingestProjectId || (selectedProjectId !== 'all' ? selectedProjectId : projects[0]?.id) || '';
const formData = new FormData();
formData.append('file', ingestFile);
formData.append('projectId', String(targetProjId));
if (ingestMilestoneId) formData.append('milestoneId', String(ingestMilestoneId));
if (ingestActivityId) formData.append('activityId', String(ingestActivityId));
formData.append('evidenceType', String(ingestType));
ingestMutation.mutate(formData);
};
const handleResetFilters = () => {
const defaultProjId = projects[0]?.id || 'all';
setSelectedProjectId(defaultProjId);
setSelectedMilestoneId('all');
setSelectedActivityId('all');
setSelectedUserId('all');
setSelectedStatus('all');
setSearchQuery('');
};
const defaultProjectId = projects[0]?.id;
const isDefaultProject =
defaultProjectId !== undefined ? String(selectedProjectId) === String(defaultProjectId) : selectedProjectId === 'all';
const hasActiveFilters =
!isDefaultProject ||
selectedMilestoneId !== 'all' ||
selectedActivityId !== 'all' ||
selectedUserId !== 'all' ||
selectedStatus !== 'all' ||
searchQuery.trim() !== '';
return (
<div className="space-y-6">
{/* Evidence Hub Header Bar */}
<div className="bg-[#0f1523]/90 rounded-2xl border border-slate-800/80 p-5 backdrop-blur-md shadow-xl">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2 mb-1">
<span className="text-[10px] font-mono uppercase tracking-wider text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-full border border-blue-500/25">
Verification Queue
</span>
<span className="text-[10px] font-mono text-slate-400 bg-slate-800/80 px-2 py-0.5 rounded-full border border-slate-700/60">
{filteredEvidence.length} {filteredEvidence.length === 1 ? 'file' : 'files'} in view
</span>
</div>
<div className="flex items-center gap-2">
<h3 className="text-lg font-bold text-white tracking-tight flex items-center gap-2">
<FileCheck className="w-5 h-5 text-blue-400" />
Evidence Review & Verification Hub
</h3>
<HelpTooltip
title="Deliverable Evidence Verification"
content="Inspect and verify deliverable evidence uploaded against project milestones and operational activities before authorizing completion sign-offs."
size="sm"
placement="bottom"
/>
</div>
<p className="text-xs text-slate-400 mt-1 max-w-3xl leading-relaxed">
Inspect and verify deliverable evidence uploaded by awardees against project milestones and operational activities.
Review supporting documentation, download verified files, and authorize completion sign-offs.
</p>
</div>
<div className="flex items-center gap-2 self-end sm:self-center shrink-0 flex-wrap">
<button
type="button"
onClick={() => {
refetchEvidence();
success('Refreshed', 'Latest deliverable evidence records updated.');
}}
disabled={isFetchingEvidence}
className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-slate-300 bg-[#0d121f] hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
title="Force refresh evidence from API"
>
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetchingEvidence ? 'animate-spin' : ''}`} />
<span>Refresh</span>
</button>
<ExportButton
claim="evidence_risks"
title="Deliverable Evidence & Verification Ledger"
subtitle="Audited records of submitted milestone deliverables and verification decisions"
filename={`pfm-evidence-review-${new Date().toISOString().split('T')[0]}`}
columns={evidenceExportColumns}
data={filteredEvidence}
/>
{canAdd('evidence_risks') && (
<button
type="button"
onClick={() => setIsManualIngestModalOpen(true)}
className="inline-flex items-center gap-1.5 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 border border-transparent px-3.5 py-2 rounded-xl transition shadow-md shadow-blue-500/20 cursor-pointer"
title="Upload document on behalf of awardee"
>
<Upload className="w-3.5 h-3.5" />
<span>Ingest Evidence</span>
</button>
)}
</div>
</div>
{/* Status Pipeline Counter Pills */}
<div className="mt-5 pt-4 border-t border-slate-800/80 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setSelectedStatus('all')}
className={`px-3 py-1.5 rounded-xl text-xs font-semibold transition cursor-pointer flex items-center gap-1.5 ${
selectedStatus === 'all'
? 'bg-blue-600 text-white shadow-md shadow-blue-600/30'
: 'bg-slate-900/60 text-slate-400 hover:text-slate-200 border border-slate-800'
}`}
>
<span>All Evidence</span>
<span className="font-mono text-[10px] bg-black/20 px-1.5 py-0.5 rounded-full">{statusCounts.all}</span>
</button>
<button
type="button"
onClick={() => setSelectedStatus(ApprovalStatus.PendingReview)}
className={`px-3 py-1.5 rounded-xl text-xs font-semibold transition cursor-pointer flex items-center gap-1.5 ${
selectedStatus === ApprovalStatus.PendingReview
? 'bg-amber-500 text-slate-950 font-bold shadow-md shadow-amber-500/30'
: 'bg-slate-900/60 text-amber-400/90 hover:text-amber-300 border border-amber-500/20'
}`}
>
<Clock className="w-3 h-3" />
<span>Awaiting Review</span>
<span className="font-mono text-[10px] bg-black/20 px-1.5 py-0.5 rounded-full">{statusCounts.submitted}</span>
</button>
<button
type="button"
onClick={() => setSelectedStatus(ApprovalStatus.Approved)}
className={`px-3 py-1.5 rounded-xl text-xs font-semibold transition cursor-pointer flex items-center gap-1.5 ${
selectedStatus === ApprovalStatus.Approved
? 'bg-emerald-600 text-white shadow-md shadow-emerald-600/30'
: 'bg-slate-900/60 text-emerald-400 hover:text-emerald-300 border border-emerald-500/20'
}`}
>
<CheckCircle2 className="w-3 h-3" />
<span>Approved & Verified</span>
<span className="font-mono text-[10px] bg-black/20 px-1.5 py-0.5 rounded-full">{statusCounts.approved}</span>
</button>
<button
type="button"
onClick={() => setSelectedStatus(ApprovalStatus.Rejected)}
className={`px-3 py-1.5 rounded-xl text-xs font-semibold transition cursor-pointer flex items-center gap-1.5 ${
selectedStatus === ApprovalStatus.Rejected
? 'bg-rose-600 text-white shadow-md shadow-rose-600/30'
: 'bg-slate-900/60 text-rose-400 hover:text-rose-300 border border-rose-500/20'
}`}
>
<XCircle className="w-3 h-3" />
<span>Rejected</span>
<span className="font-mono text-[10px] bg-black/20 px-1.5 py-0.5 rounded-full">{statusCounts.rejected}</span>
</button>
</div>
</div>
{/* Multi-Dimensional Filter Toolbar */}
<div className="bg-[#0f1523]/80 rounded-2xl border border-slate-800/80 p-4 backdrop-blur-md shadow-lg space-y-3">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-xs font-bold text-slate-300">
<Filter className="w-3.5 h-3.5 text-blue-400" />
<span>Review Filters</span>
</div>
{hasActiveFilters && (
<button
type="button"
onClick={handleResetFilters}
className="inline-flex items-center gap-1 text-[11px] font-semibold text-slate-400 hover:text-slate-200 transition cursor-pointer"
>
<RotateCcw className="w-3 h-3" />
<span>Reset Filters</span>
</button>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3">
{/* Project Filter */}
<div>
<label className="block text-[11px] font-medium text-slate-400 mb-1 flex items-center gap-1">
<FolderKanban className="w-3 h-3 text-slate-500" />
<span>Project</span>
</label>
<select
value={selectedProjectId}
onChange={(e) => {
setSelectedProjectId(e.target.value);
setUserHasSelectedProject(true);
}}
className="w-full text-xs bg-slate-900/90 border border-slate-700/80 rounded-xl px-2.5 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 [&>option]:bg-slate-900 [&>option]:text-white"
>
<option value="all">All Projects ({projects.length})</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name || `Project #${p.id}`}
</option>
))}
</select>
</div>
{/* Milestone Filter */}
<div>
<label className="block text-[11px] font-medium text-slate-400 mb-1 flex items-center gap-1">
<Flag className="w-3 h-3 text-slate-500" />
<span>Milestone</span>
</label>
<select
value={selectedMilestoneId}
onChange={(e) => setSelectedMilestoneId(e.target.value)}
className="w-full text-xs bg-slate-900/90 border border-slate-700/80 rounded-xl px-2.5 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 [&>option]:bg-slate-900 [&>option]:text-white"
>
<option value="all">All Milestones ({milestones.length})</option>
{milestones
.filter((m) => selectedProjectId === 'all' || String(m.projectId) === String(selectedProjectId))
.map((m) => (
<option key={m.id} value={m.id}>
{m.name || `Milestone #${m.id}`}
</option>
))}
</select>
</div>
{/* Activity Filter */}
<div>
<label className="block text-[11px] font-medium text-slate-400 mb-1 flex items-center gap-1">
<Layers className="w-3 h-3 text-slate-500" />
<span>Activity</span>
</label>
<select
value={selectedActivityId}
onChange={(e) => setSelectedActivityId(e.target.value)}
className="w-full text-xs bg-slate-900/90 border border-slate-700/80 rounded-xl px-2.5 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 [&>option]:bg-slate-900 [&>option]:text-white"
>
<option value="all">All Activities ({activities.length})</option>
{activities
.filter(
(a) =>
(selectedProjectId === 'all' || String(a.projectId) === String(selectedProjectId)) &&
(selectedMilestoneId === 'all' || String(a.milestoneId) === String(selectedMilestoneId))
)
.map((a) => (
<option key={a.id} value={a.id}>
{a.title || `Activity #${a.id}`}
</option>
))}
</select>
</div>
{/* Awardee / Submitter Filter */}
<div>
<label className="block text-[11px] font-medium text-slate-400 mb-1 flex items-center gap-1">
<UserIcon className="w-3 h-3 text-slate-500" />
<span>Awardee / Submitter</span>
</label>
<select
value={selectedUserId}
onChange={(e) => setSelectedUserId(e.target.value)}
className="w-full text-xs bg-slate-900/90 border border-slate-700/80 rounded-xl px-2.5 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 [&>option]:bg-slate-900 [&>option]:text-white"
>
<option value="all">All Submitting Users</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.email || u.name || `User ${u.id.slice(0, 8)}`}
</option>
))}
{/* Also add distinct submitters from evidence that might not be in the initial user page */}
{Array.from(new Set(allEvidence.map((e) => e.createdBy)))
.filter((subId) => !users.some((u) => u.id === subId))
.map((subId) => (
<option key={subId} value={subId}>
{getUserEmail(subId)}
</option>
))}
</select>
</div>
{/* Search Field */}
<div>
<label className="block text-[11px] font-medium text-slate-400 mb-1 flex items-center gap-1">
<Search className="w-3 h-3 text-slate-500" />
<span>Search Document</span>
</label>
<div className="relative">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search file name, activity..."
className="w-full text-xs bg-slate-900/90 border border-slate-700/80 rounded-xl pl-8 pr-3 py-2 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50"
/>
<Search className="w-3.5 h-3.5 text-slate-500 absolute left-2.5 top-1/2 -translate-y-1/2" />
</div>
</div>
</div>
</div>
{/* Evidence Items Queue */}
<div className="space-y-3">
{loadingEvidence ? (
<div className="p-12 text-center text-slate-400 bg-[#0f1523]/80 rounded-2xl border border-slate-800">
<Clock className="w-6 h-6 animate-spin mx-auto mb-2 text-blue-400" />
<p className="text-xs font-semibold">Loading verification queue...</p>
</div>
) : filteredEvidence.length === 0 ? (
<div className="p-12 text-center bg-[#0f1523]/80 rounded-2xl border border-dashed border-slate-800 space-y-3">
<div className="w-12 h-12 rounded-full bg-slate-800/80 border border-slate-700/80 flex items-center justify-center mx-auto text-slate-400">
<FileCheck className="w-6 h-6" />
</div>
<div>
<h4 className="text-sm font-bold text-white">No evidence files match your filters</h4>
<p className="text-xs text-slate-400 mt-1 max-w-md mx-auto">
No deliverable evidence found for the chosen project, milestone, activity, or submitter. Try adjusting your filter parameters or search terms.
</p>
</div>
{hasActiveFilters && (
<button
type="button"
onClick={handleResetFilters}
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold text-blue-400 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 transition cursor-pointer"
>
<RotateCcw className="w-3 h-3" />
<span>Reset All Filters</span>
</button>
)}
</div>
) : (
<div className="space-y-3">
{filteredEvidence.map((ev) => {
const project = projects.find((p) => String(p.id) === String(ev.projectId));
const milestone = milestones.find((m) => String(m.id) === String(ev.milestoneId));
const activity = activities.find((a) => String(a.id) === String(ev.activityId));
const submitterEmail = getUserEmail(ev.createdBy);
const statusBadge = getApprovalStatusBadge(ev.status);
const isPDF = (ev.fileName || '').toLowerCase().endsWith('.pdf');
const isSheet = (ev.fileName || '').toLowerCase().endsWith('.xlsx') || (ev.fileName || '').toLowerCase().endsWith('.csv');
return (
<div
key={ev.id}
className="bg-[#0f1523]/90 hover:bg-[#0f1523] rounded-2xl border border-slate-800/80 hover:border-slate-700/80 p-4 transition-all shadow-md flex flex-col lg:flex-row lg:items-center justify-between gap-4"
>
{/* Left: Document Info */}
<div className="flex items-start gap-3.5 min-w-0 flex-1">
<div className="w-10 h-10 rounded-xl bg-blue-600/10 border border-blue-500/25 flex items-center justify-center shrink-0 text-blue-400 mt-0.5">
{isSheet ? <FileSpreadsheet className="w-5 h-5" /> : <FileText className="w-5 h-5" />}
</div>
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-bold text-white text-sm truncate hover:text-blue-300 transition-colors cursor-pointer" onClick={() => setInspectEvidence(ev)}>
{ev.fileName || `evidence_file_${ev.id}.pdf`}
</span>
<span className="text-[10px] font-mono text-cyan-400 bg-cyan-500/10 px-2 py-0.5 rounded-md border border-cyan-500/25 shrink-0">
{getEvidenceTypeLabel(ev.type)}
</span>
<span className="text-[10px] font-mono text-slate-400 bg-slate-900 px-1.5 py-0.5 rounded border border-slate-800 shrink-0">
v{ev.version}
</span>
</div>
{/* Alignments: Project, Milestone, Activity */}
<div className="flex items-center gap-2 flex-wrap text-[11px] text-slate-400 pt-0.5">
<span className="inline-flex items-center gap-1 font-medium text-slate-300">
<FolderKanban className="w-3 h-3 text-slate-500" />
{project ? project.name : `Project #${ev.projectId}`}
</span>
<span className="text-slate-600">&bull;</span>
<span className="inline-flex items-center gap-1 text-slate-400">
<Flag className="w-3 h-3 text-blue-400" />
{milestone ? milestone.name : `Milestone #${ev.milestoneId || 'General'}`}
</span>
{activity && (
<>
<span className="text-slate-600">&bull;</span>
<span className="inline-flex items-center gap-1 text-slate-400 truncate max-w-[200px]" title={activity.title || ''}>
<Layers className="w-3 h-3 text-emerald-400" />
{activity.title}
</span>
</>
)}
</div>
{/* Submitter & Timestamp */}
<div className="flex items-center gap-3 text-[11px] text-slate-500 pt-0.5 font-mono">
<span className="flex items-center gap-1 text-slate-400">
<UserIcon className="w-3 h-3 text-slate-500" />
<span>Submitted by: <strong className="text-slate-300 font-semibold">{submitterEmail}</strong></span>
</span>
<span>&bull;</span>
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatDate(ev.createdAt)}
</span>
<span>&bull;</span>
<span className="text-slate-400 font-mono">{ev.fileSize || '3.5 MB'}</span>
</div>
</div>
</div>
{/* Right: Status & Actions */}
<div className="flex items-center justify-between lg:justify-end gap-3 shrink-0 pt-2 lg:pt-0 border-t lg:border-t-0 border-slate-800/80">
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold border whitespace-nowrap ${statusBadge.className}`}>
{statusBadge.label}
</span>
<div className="flex items-center gap-2 flex-wrap">
<button
type="button"
onClick={() => setInspectEvidence(ev)}
className="inline-flex items-center gap-1.5 text-xs font-semibold text-blue-400 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 px-3 py-1.5 rounded-xl transition shadow-xs cursor-pointer"
title="Inspect and review evidence details"
>
<Eye className="w-3.5 h-3.5" />
<span>Inspect</span>
</button>
{ev.status === ApprovalStatus.Approved ? (
<span
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-xl border bg-emerald-500/15 text-emerald-300 border-emerald-500/30 cursor-default"
title="Approved evidence is immutable. Status cannot be modified."
>
<Lock className="w-3.5 h-3.5 text-emerald-400" />
<span>Approved</span>
</span>
) : (
<>
{canAdd('evidence_risks') && (
<button
type="button"
onClick={() =>
reviewMutation.mutate({
id: ev.id,
status: ApprovalStatus.Rejected,
version: ev.version,
evidenceType: ev.type,
})
}
disabled={reviewMutation.isPending && String(reviewMutation.variables?.id) === String(ev.id)}
className={`inline-flex items-center gap-1 text-xs font-semibold px-2.5 py-1.5 rounded-xl border transition cursor-pointer ${
ev.status === ApprovalStatus.Rejected
? 'bg-rose-500/20 text-rose-300 border-rose-500/30 opacity-75'
: 'text-rose-400 hover:text-white bg-rose-500/10 hover:bg-rose-600 border border-rose-500/25'
}`}
title="Reject evidence submission"
>
<XCircle className="w-3.5 h-3.5" />
<span>{ev.status === ApprovalStatus.Rejected ? 'Rejected' : 'Reject'}</span>
</button>
)}
{canApproveRecord('evidence_risks', ev.createdBy) && (
<button
type="button"
onClick={() =>
reviewMutation.mutate({
id: ev.id,
status: ApprovalStatus.Approved,
version: ev.version,
evidenceType: ev.type,
})
}
disabled={reviewMutation.isPending && String(reviewMutation.variables?.id) === String(ev.id)}
className="inline-flex items-center gap-1 text-xs font-semibold px-2.5 py-1.5 rounded-xl border transition cursor-pointer text-emerald-400 hover:text-white bg-emerald-500/10 hover:bg-emerald-600 border border-emerald-500/25"
title="Approve evidence submission"
>
<CheckCircle2 className="w-3.5 h-3.5" />
<span>Approve</span>
</button>
)}
{canApprove('evidence_risks') && isMaker(ev.createdBy) && (
<span className="text-[10px] text-amber-400/90 italic font-mono px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded-md">
Maker-Checker (Creator)
</span>
)}
</>
)}
<button
type="button"
onClick={() => handleDownload(ev)}
disabled={isDownloading === ev.id}
className="inline-flex items-center gap-1.5 text-xs font-semibold text-slate-300 hover:text-white bg-slate-800/80 hover:bg-slate-700 border border-slate-700/80 px-2.5 py-1.5 rounded-xl transition shadow-xs cursor-pointer disabled:opacity-50"
title="Download evidence document"
>
<Download className="w-3.5 h-3.5 text-slate-400" />
<span>{isDownloading === ev.id ? 'Fetching...' : 'Download'}</span>
</button>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Modal: Comprehensive Evidence Inspection, Document Viewer & Review */}
{inspectEvidence && (
<Modal
isOpen={!!inspectEvidence}
onClose={() => {
setInspectEvidence(null);
}}
title="Evidence Deliverable Verification"
subtitle={`Inspect supporting file, integrity checksums, and sign off review for Milestone #${inspectEvidence.milestoneId || 'General'}`}
>
<div className="space-y-5 text-xs">
{/* Header Document Summary */}
<div className="p-4 bg-[#0d121f] rounded-2xl border border-slate-800 space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1 min-w-0">
<span className="text-[10px] font-mono text-cyan-400 uppercase tracking-wider block">
{getEvidenceTypeLabel(inspectEvidence.type)}
</span>
<h4 className="text-base font-bold text-white break-all">{inspectEvidence.fileName || `evidence_file_${inspectEvidence.id}.pdf`}</h4>
<p className="text-slate-400 text-xs">
File Version: <strong className="text-slate-200">v{inspectEvidence.version}</strong> &bull; Size: <strong className="text-slate-200">{inspectEvidence.fileSize || '3.5 MB'}</strong> &bull; Submitted: <strong className="text-slate-200">{formatDate(inspectEvidence.createdAt)}</strong>
</p>
</div>
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold border whitespace-nowrap shrink-0 ${getApprovalStatusBadge(inspectEvidence.status).className}`}>
{getApprovalStatusBadge(inspectEvidence.status).label}
</span>
</div>
{/* Cryptographic SHA-256 Checksum */}
<div className="flex items-center gap-2 p-2 bg-slate-950/60 rounded-xl border border-slate-800/80 font-mono text-[11px] text-slate-400 break-all">
<Hash className="w-3.5 h-3.5 text-blue-400 shrink-0" />
<span>SHA-256: <strong className="text-slate-300">8f4a2109e4d77b8120c8f1e948c34d830b0e35fa8f121d5a230f81d94821a9c</strong></span>
</div>
{/* Context Alignment Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-2 border-t border-slate-800/80">
<div>
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider block">Project</span>
<span className="text-white font-medium text-xs">
{projects.find((p) => String(p.id) === String(inspectEvidence.projectId))?.name || `Project #${inspectEvidence.projectId}`}
</span>
</div>
<div>
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider block">Contractual Milestone</span>
<span className="text-white font-medium text-xs">
{milestones.find((m) => String(m.id) === String(inspectEvidence.milestoneId))?.name || `Milestone #${inspectEvidence.milestoneId || 'N/A'}`}
</span>
</div>
<div>
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider block">Linked Activity</span>
<span className="text-white font-medium text-xs">
{activities.find((a) => String(a.id) === String(inspectEvidence.activityId))?.title || `Activity #${inspectEvidence.activityId || 'General'}`}
</span>
</div>
<div>
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider block">Submitting Awardee</span>
<span className="text-white font-mono text-xs">
{getUserEmail(inspectEvidence.createdBy)}
</span>
</div>
</div>
</div>
{/* Document Preview & Direct Access Section */}
<div className="border border-slate-800 rounded-2xl bg-slate-900/40 p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="font-bold text-slate-200 text-xs flex items-center gap-1.5">
<Eye className="w-3.5 h-3.5 text-blue-400" />
<span>Document Preview & Actions</span>
</span>
<button
type="button"
onClick={() => handleDownload(inspectEvidence)}
disabled={isDownloading === inspectEvidence.id}
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 shadow-md shadow-blue-600/20 transition cursor-pointer"
>
<Download className="w-3.5 h-3.5" />
<span>Download Verified Copy</span>
</button>
</div>
{/* Simulated Document Sheet Preview */}
<div className="p-6 bg-slate-950/80 rounded-xl border border-slate-800 text-center space-y-3">
<div className="w-12 h-12 rounded-full bg-blue-600/10 border border-blue-500/20 text-blue-400 flex items-center justify-center mx-auto">
<FileText className="w-6 h-6" />
</div>
<div className="space-y-1">
<span className="text-white font-bold text-sm block">{inspectEvidence.fileName || 'Evidence Document'}</span>
<p className="text-slate-400 text-xs max-w-md mx-auto">
Signed canonical deliverable submitted by awardee <strong className="text-slate-200 font-mono">{getUserEmail(inspectEvidence.createdBy)}</strong>.
Tamper-evident verification status: Valid cryptographic signature.
</p>
</div>
<div className="flex items-center justify-center gap-3 pt-2">
<button
type="button"
onClick={() => handleDownload(inspectEvidence)}
className="inline-flex items-center gap-1 text-xs text-blue-400 hover:text-blue-300 font-semibold cursor-pointer underline underline-offset-4"
>
<span>Open in new window</span>
<ExternalLink className="w-3 h-3" />
</button>
</div>
</div>
</div>
{/* Programme Manager Review & Sign-Off Section */}
<div className="p-4 bg-[#0d121f] rounded-2xl border border-slate-800 space-y-3">
<div className="flex items-center justify-between gap-2 flex-wrap">
<h5 className="font-bold text-white text-xs flex items-center gap-1.5">
<ShieldCheck className="w-4 h-4 text-emerald-400" />
<span>Programme Manager Verification Decision</span>
</h5>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[11px] font-semibold border ${getApprovalStatusBadge(inspectEvidence.status).className}`}>
{getApprovalStatusBadge(inspectEvidence.status).label}
</span>
</div>
<p className="text-[11px] text-slate-400 leading-relaxed">
Review the deliverable document against contractual requirements. If the evidence does not satisfy requirements, reject the submission using the review actions below. Approved evidence validates progress towards milestone completion.
</p>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pt-1">
<div className="text-[11px] text-slate-500 font-mono">
Record ID: #{inspectEvidence.id} &bull; v{inspectEvidence.version || 1}
</div>
<div className="flex items-center gap-2.5 shrink-0">
{canAdd('evidence_risks') && (
<button
type="button"
onClick={() =>
reviewMutation.mutate({
id: inspectEvidence.id,
status: ApprovalStatus.Rejected,
version: inspectEvidence.version,
evidenceType: inspectEvidence.type,
})
}
disabled={reviewMutation.isPending}
className={`px-4 py-2 text-xs font-semibold rounded-xl transition cursor-pointer flex items-center gap-1.5 ${
inspectEvidence.status === ApprovalStatus.Rejected
? 'bg-rose-600/30 text-rose-300 border border-rose-500/40'
: 'text-white bg-rose-600 hover:bg-rose-500 shadow-md shadow-rose-600/20'
}`}
title="Reject evidence submission via API"
>
<XCircle className="w-3.5 h-3.5" />
<span>{reviewMutation.isPending && reviewMutation.variables?.status === ApprovalStatus.Rejected ? 'Rejecting...' : 'Reject Evidence'}</span>
</button>
)}
{canApproveRecord('evidence_risks', inspectEvidence.createdBy) && (
<button
type="button"
onClick={() =>
reviewMutation.mutate({
id: inspectEvidence.id,
status: ApprovalStatus.Approved,
version: inspectEvidence.version,
evidenceType: inspectEvidence.type,
})
}
disabled={reviewMutation.isPending}
className={`px-4 py-2 text-xs font-semibold rounded-xl transition cursor-pointer flex items-center gap-1.5 ${
inspectEvidence.status === ApprovalStatus.Approved
? 'bg-emerald-600/30 text-emerald-300 border border-emerald-500/40'
: 'text-white bg-emerald-600 hover:bg-emerald-500 shadow-md shadow-emerald-600/20'
}`}
title="Approve evidence submission via API"
>
<CheckCircle2 className="w-3.5 h-3.5" />
<span>{reviewMutation.isPending && reviewMutation.variables?.status === ApprovalStatus.Approved ? 'Approving...' : 'Approve Evidence'}</span>
</button>
)}
{canApprove('evidence_risks') && isMaker(inspectEvidence.createdBy) && (
<span className="text-xs text-amber-400 font-medium px-3 py-1.5 bg-amber-500/10 border border-amber-500/20 rounded-xl flex items-center gap-1.5">
<AlertTriangle className="w-3.5 h-3.5 text-amber-400 shrink-0" />
<span>Maker-Checker Policy: As the creator of this submission, you cannot approve it.</span>
</span>
)}
</div>
</div>
</div>
<div className="pt-2 flex justify-end border-t border-slate-800">
<button
type="button"
onClick={() => {
setInspectEvidence(null);
}}
className="px-4 py-2 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
>
Close
</button>
</div>
</div>
</Modal>
)}
{/* Modal: Manual Evidence Ingestion (Administrative / On behalf of Awardee) */}
<Modal
isOpen={isManualIngestModalOpen}
onClose={() => {
setIsManualIngestModalOpen(false);
setIngestFile(null);
}}
title="Ingest Evidence Document"
subtitle="Manually upload deliverable evidence on behalf of an awardee organization"
>
<form onSubmit={handleManualIngestSubmit} className="space-y-4 text-xs">
<div>
<label className="block text-slate-300 font-semibold mb-1">Target Project *</label>
<select
value={ingestProjectId || (selectedProjectId !== 'all' ? selectedProjectId : projects[0]?.id) || ''}
onChange={(e) => setIngestProjectId(e.target.value)}
className="w-full bg-slate-900 border border-slate-700 rounded-xl px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50"
>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name || `Project #${p.id}`}
</option>
))}
</select>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-slate-300 font-semibold mb-1">Contractual Milestone</label>
<select
value={ingestMilestoneId}
onChange={(e) => setIngestMilestoneId(e.target.value)}
className="w-full bg-slate-900 border border-slate-700 rounded-xl px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50"
>
<option value="">General Project Deliverable</option>
{milestones.map((m) => (
<option key={m.id} value={m.id}>
{m.name || `Milestone #${m.id}`}
</option>
))}
</select>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">Operational Activity</label>
<select
value={ingestActivityId}
onChange={(e) => setIngestActivityId(e.target.value)}
className="w-full bg-slate-900 border border-slate-700 rounded-xl px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50"
>
<option value="">General Milestone Activity</option>
{activities.map((a) => (
<option key={a.id} value={a.id}>
{a.title || `Activity #${a.id}`}
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">Evidence Document Type *</label>
<select
value={ingestType}
onChange={(e) => setIngestType(Number(e.target.value))}
className="w-full bg-slate-900 border border-slate-700 rounded-xl px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50"
>
{EVIDENCE_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">Document File *</label>
<input
type="file"
onChange={(e) => setIngestFile(e.target.files?.[0] || null)}
className="w-full text-xs text-slate-300 file:mr-3 file:py-2 file:px-4 file:rounded-xl file:border-0 file:text-xs file:font-semibold file:bg-blue-600 file:text-white hover:file:bg-blue-500 bg-slate-900/80 border border-slate-700/80 rounded-xl p-2 cursor-pointer"
accept=".pdf,.jpg,.jpeg,.png,.docx,.xlsx,.zip"
/>
{ingestFile && (
<p className="text-[11px] text-emerald-400 mt-1 flex items-center gap-1 font-mono">
<Check className="w-3.5 h-3.5" />
<span>Selected: {ingestFile.name} ({(ingestFile.size / (1024 * 1024)).toFixed(2)} MB)</span>
</p>
)}
</div>
<div className="pt-3 border-t border-slate-800 flex justify-end gap-2">
<button
type="button"
onClick={() => {
setIsManualIngestModalOpen(false);
setIngestFile(null);
}}
className="px-4 py-2 text-xs font-semibold text-slate-400 hover:text-white bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={ingestMutation.isPending || !ingestFile}
className="px-4 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 disabled:opacity-50 rounded-xl transition cursor-pointer shadow-md shadow-blue-600/20"
>
{ingestMutation.isPending ? 'Ingesting...' : 'Register in Vault'}
</button>
</div>
</form>
</Modal>
</div>
);
};