1202 lines
57 KiB
TypeScript
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">•</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">•</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>•</span>
|
|
<span className="flex items-center gap-1">
|
|
<Clock className="w-3 h-3" />
|
|
{formatDate(ev.createdAt)}
|
|
</span>
|
|
<span>•</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> • Size: <strong className="text-slate-200">{inspectEvidence.fileSize || '3.5 MB'}</strong> • 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} • 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>
|
|
);
|
|
};
|