Files
pfm/PostFundManagement.ConsoleUI/src/components/monitoring/ContractsManagement.tsx
T
hermes 5d7e2f74ce RBAC Improvements
Caching improvements
Maker-Checker Implementation
2026-10-21 07:27:47 +02:00

1293 lines
54 KiB
TypeScript

import React, { useState, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
FileText,
Plus,
Upload,
Download,
Clock,
CheckCircle2,
AlertTriangle,
Building2,
FolderKanban,
Briefcase,
Calendar,
Lock,
RefreshCw,
Search,
Filter,
ExternalLink,
Edit2,
FileCheck,
Paperclip,
Check,
ShieldCheck,
RotateCcw,
} from 'lucide-react';
import {
getContracts,
createContract,
updateContract,
uploadContractFile,
getContractSecureLink,
executeContract,
getAwards,
getOrganisations,
getAllProgrammes,
getProjects,
} from '../../services/api';
import { toolsService } from '../../services/toolsService';
import { Modal } from '../ui/Modal';
import { useToast } from '../ui/Toast';
import { ProblemDetailsAlert } from '../ui/ProblemDetailsAlert';
import { HelpTooltip } from '../ui/HelpTooltip';
import { ExportButton } from '../ui/ExportButton';
import { ExportColumn } from '../../utils/exportUtils';
import { useTableSort, SortableTh } from '../../utils/tableSort';
import { useRbac } from '../../auth/rbac';
import {
Contract,
Award,
Organisation,
Programme,
Project,
ApprovalStatus,
ContractStatus,
} from '../../types';
import {
formatZAR,
formatDate,
getContractStatusBadge,
getApprovalStatusBadge,
CONTRACT_STATUS_OPTIONS,
} from '../../lib/formatters';
interface ContractsManagementProps {
onOpenCreateModal?: () => void;
isCreateModalOpenExternal?: boolean;
onCloseCreateModalExternal?: () => void;
}
export const ContractsManagement: React.FC<ContractsManagementProps> = ({
isCreateModalOpenExternal,
onCloseCreateModalExternal,
}) => {
const queryClient = useQueryClient();
const { success, error: toastError, info } = useToast();
const { canAdd, canApprove, canApproveRecord, isMaker } = useRbac();
// Component state
const [internalCreateModalOpen, setInternalCreateModalOpen] = useState(false);
const isCreateModalOpen = isCreateModalOpenExternal ?? internalCreateModalOpen;
const setCreateModalOpen = (open: boolean) => {
if (onCloseCreateModalExternal && !open) {
onCloseCreateModalExternal();
}
setInternalCreateModalOpen(open);
};
const [statusFilter, setStatusFilter] = useState<string>('all');
const [searchTerm, setSearchTerm] = useState<string>('');
// Selected contract for status update
const [contractToUpdate, setContractToUpdate] = useState<Contract | null>(null);
const [newStatus, setNewStatus] = useState<number>(ContractStatus.Active);
// Selected contract for file upload / replace
const [contractForFileUpload, setContractForFileUpload] = useState<Contract | null>(null);
const [selectedUploadFile, setSelectedUploadFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Create contract form state
const [selectedAwardId, setSelectedAwardId] = useState<string>('');
const [totalValueInput, setTotalValueInput] = useState<number>(0);
const [effectiveAtInput, setEffectiveAtInput] = useState<string>(
new Date().toISOString().split('T')[0]
);
const [expiresAtInput, setExpiresAtInput] = useState<string>(() => {
const d = new Date();
d.setFullYear(d.getFullYear() + 1);
return d.toISOString().split('T')[0];
});
const [contractPdfFile, setContractPdfFile] = useState<File | null>(null);
const [onlyApprovedAwards, setOnlyApprovedAwards] = useState<boolean>(true);
// API Error states for modal banner display
const [createApiError, setCreateApiError] = useState<unknown>(null);
const [uploadApiError, setUploadApiError] = useState<unknown>(null);
const [updateApiError, setUpdateApiError] = useState<unknown>(null);
// Loading indicator for secure download
const [downloadingContractId, setDownloadingContractId] = useState<string | number | null>(null);
// Queries
const {
data: contracts = [],
isLoading: loadingContracts,
refetch: refetchContracts,
isRefetching: refetchingContracts,
} = useQuery({
queryKey: ['contracts'],
queryFn: () => getContracts(1, 100),
});
const { data: awards = [], isLoading: loadingAwards } = useQuery({
queryKey: ['awards'],
queryFn: () => getAwards(1, 100),
});
const { data: organisations = [] } = useQuery({
queryKey: ['organisations'],
queryFn: () => getOrganisations(1, 100),
});
const { data: programmes = [] } = useQuery({
queryKey: ['all-programmes'],
queryFn: () => getAllProgrammes(1, 100),
});
const { data: projects = [] } = useQuery({
queryKey: ['projects'],
queryFn: () => getProjects(),
});
// Helper to match award and its related metadata
const getAwardForContract = (contract: Contract): Award | undefined => {
return awards.find(
(a) =>
String(a.id) === String(contract.awardId) ||
toolsService.encodeIdSync(a.id) === String(contract.awardId)
);
};
const getOrganisationForAward = (award?: Award): Organisation | undefined => {
if (!award) return undefined;
return organisations.find(
(o) =>
String(o.id) === String(award.organisationId) ||
toolsService.encodeIdSync(o.id) === String(award.organisationId)
);
};
const getProgrammeForAward = (award?: Award): Programme | undefined => {
if (!award) return undefined;
return programmes.find(
(p) =>
String(p.id) === String(award.programmeId) ||
toolsService.encodeIdSync(p.id) === String(award.programmeId)
);
};
const getProjectForAward = (award?: Award, prog?: Programme): Project | undefined => {
if (!award) return undefined;
// Match by direct project ID on award or by programme link
return projects.find(
(pr) =>
(award.projectId && (String(pr.id) === String(award.projectId) || toolsService.encodeIdSync(pr.id) === String(award.projectId))) ||
(award.programmeId && String(pr.programmeId) === String(award.programmeId)) ||
(prog && String(pr.programmeId) === String(prog.id))
);
};
// Filter awards for dropdown: Approved status or all
const filteredAwardsForDropdown = awards.filter((a) => {
if (onlyApprovedAwards) {
return Number(a.status) === ApprovalStatus.Approved;
}
return true;
});
// When selected award changes in modal, auto-populate value and defaults
const handleAwardSelect = (awardIdStr: string) => {
setSelectedAwardId(awardIdStr);
setCreateApiError(null);
const award = awards.find(
(a) =>
String(a.id) === awardIdStr ||
toolsService.encodeIdSync(a.id) === awardIdStr
);
if (award) {
if (award.amount && award.amount > 0) {
setTotalValueInput(award.amount);
}
}
};
// Selected award metadata preview
const activeSelectedAward = awards.find(
(a) =>
String(a.id) === selectedAwardId ||
toolsService.encodeIdSync(a.id) === selectedAwardId
);
const activeSelectedOrg = getOrganisationForAward(activeSelectedAward);
const activeSelectedProg = getProgrammeForAward(activeSelectedAward);
const activeSelectedProj = getProjectForAward(activeSelectedAward, activeSelectedProg);
// Mutations
const createContractMutation = useMutation({
mutationFn: async () => {
setCreateApiError(null);
if (!selectedAwardId) {
throw new Error('Please select an approved award from the list.');
}
if (!totalValueInput || totalValueInput <= 0) {
throw new Error('Please enter a valid total contract value greater than 0.');
}
if (!contractPdfFile) {
throw new Error('Please upload a PDF document of the contract.');
}
// Step 1: Create contract via POST /v1/api/grants/contracts (awardId will be hashed by service)
const createdContract = await createContract({
awardId: selectedAwardId,
totalValue: Number(totalValueInput),
effectiveAt: new Date(effectiveAtInput).toISOString(),
expiresAt: new Date(expiresAtInput).toISOString(),
});
// Target contractId for upload
const contractIdToUploadAgainst =
createdContract?.id ?? (createdContract as any)?.contractId ?? selectedAwardId;
// Step 2: Upload PDF document to POST /v1/api/grants/contracts/{contractId}
if (contractPdfFile && contractIdToUploadAgainst) {
await uploadContractFile(contractIdToUploadAgainst, contractPdfFile);
}
return createdContract;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contracts'] });
queryClient.invalidateQueries({ queryKey: ['awards'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
success(
'Contract Created & Document Uploaded',
'The contract record has been registered and the PDF document securely stored in S3.'
);
setCreateModalOpen(false);
resetCreateForm();
},
onError: (err: unknown) => {
setCreateApiError(err);
toastError('Contract Creation Failed', err);
},
});
const uploadFileMutation = useMutation({
mutationFn: async () => {
setUploadApiError(null);
if (!contractForFileUpload) throw new Error('No contract selected for upload.');
if (!selectedUploadFile) throw new Error('Please select a PDF document to upload.');
await uploadContractFile(contractForFileUpload.id, selectedUploadFile);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contracts'] });
success('Contract Document Updated', 'New contract PDF document uploaded successfully.');
setContractForFileUpload(null);
setSelectedUploadFile(null);
},
onError: (err: unknown) => {
setUploadApiError(err);
toastError('Document Upload Failed', err);
},
});
const updateStatusMutation = useMutation({
mutationFn: async () => {
setUpdateApiError(null);
if (!contractToUpdate) throw new Error('No contract selected.');
await updateContract({
contractId: contractToUpdate.id,
status: newStatus,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contracts'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
success('Contract Status Updated', 'The contract lifecycle status has been updated.');
setContractToUpdate(null);
},
onError: (err: unknown) => {
setUpdateApiError(err);
toastError('Status Update Failed', err);
},
});
const resetCreateForm = () => {
setSelectedAwardId('');
setTotalValueInput(0);
setContractPdfFile(null);
setCreateApiError(null);
};
// Secure download link handler (15-minute on-demand link)
const handleDownloadSecureLink = async (contract: Contract) => {
setDownloadingContractId(contract.id);
try {
const url = await getContractSecureLink(contract.id);
if (url && typeof url === 'string' && url.trim() !== '') {
const link = document.createElement('a');
link.href = url.trim();
link.target = '_blank';
link.rel = 'noopener noreferrer';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
success(
'15-Minute Secure Link Generated',
'A time-limited download URL has been generated on-demand and opened.'
);
} else {
toastError('Download Link Unavailable', 'The server returned an empty secure download URL.');
}
} catch (err: unknown) {
toastError('Failed to Retrieve Secure Link', err);
} finally {
setDownloadingContractId(null);
}
};
// Filter and search contracts
const filteredContracts = contracts.filter((c) => {
// Status filter
if (statusFilter !== 'all') {
const statusNum = Number(c.status);
if (statusNum !== Number(statusFilter)) return false;
}
// Search filter
if (searchTerm.trim()) {
const q = searchTerm.toLowerCase();
const cId = String(c.id).toLowerCase();
const aId = String(c.awardId).toLowerCase();
const award = getAwardForContract(c);
const org = getOrganisationForAward(award);
const prog = getProgrammeForAward(award);
const proj = getProjectForAward(award, prog);
const orgName = org?.name?.toLowerCase() || '';
const progName = prog?.name?.toLowerCase() || '';
const projName = proj?.name?.toLowerCase() || '';
if (
!cId.includes(q) &&
!aId.includes(q) &&
!orgName.includes(q) &&
!progName.includes(q) &&
!projName.includes(q)
) {
return false;
}
}
return true;
});
// Calculate summary metrics
const totalContractCount = contracts.length;
const activeContractsValue = contracts
.filter((c) => Number(c.status) === ContractStatus.Active)
.reduce((sum, c) => sum + (Number(c.totalValue) || 0), 0);
const pendingContractsCount = contracts.filter(
(c) =>
Number(c.status) === ContractStatus.Draft ||
Number(c.status) === ContractStatus.PendingApproval
).length;
const contractExportColumns: ExportColumn<Contract>[] = [
{ header: 'Contract ID', key: 'id' },
{ header: 'Description / Title', key: 'description' },
{ header: 'Project', key: 'projectId', format: (val) => (val ? `Project #${val}` : '—') },
{ header: 'Award ID', key: 'awardId', format: (val) => (val ? `Award #${val}` : '—') },
{ header: 'Organisation ID', key: 'organisationId', format: (val) => (val ? `Org #${val}` : '—') },
{ header: 'Status', key: 'status', format: (val) => getContractStatusBadge(val).label },
{ header: 'Signed Date', key: 'signedDate', format: (val) => (val ? formatDate(val) : '—') },
{ header: 'Expiry Date', key: 'expiryDate', format: (val) => (val ? formatDate(val) : '—') },
{ header: 'Budget Allocated', key: 'budgetAllocated', format: (val) => (val ? formatZAR(val) : '—') },
{ header: 'Document URI', key: 'fileUri', format: (val) => val || '—' },
];
// Table sorting hook
const {
sortedData: sortedContracts,
sortColumn: contractSortCol,
sortDirection: contractSortDir,
handleSort: handleContractSort,
} = useTableSort<Contract>(filteredContracts, null, null, {
id: (c: any) => c.id,
awardId: (c: any) => c.totalValue || c.awardId,
programmeId: (c: any) => {
const a = getAwardForContract(c);
const pr = getProgrammeForAward(a);
return pr?.name || '';
},
organisationId: (c: any) => {
const a = getAwardForContract(c);
const o = getOrganisationForAward(a);
return o?.name || '';
},
signedDate: (c: any) => c.signedDate,
status: (c: any) => getContractStatusBadge(c.status).label,
fileUri: (c: any) => c.fileUri || '',
});
return (
<div className="space-y-6">
{/* Summary Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-lg">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
Total Contracts
</span>
<HelpTooltip
title="Total Legal Contracts"
content="Total grant agreements and legal instruments registered across portfolios."
size="sm"
/>
</div>
<div className="w-8 h-8 rounded-lg bg-blue-500/10 text-blue-400 flex items-center justify-center border border-blue-500/20">
<FileText className="w-4 h-4" />
</div>
</div>
<div className="text-2xl font-bold text-white mt-2">
{loadingContracts ? '...' : totalContractCount}
</div>
<p className="text-[11px] text-slate-400 mt-1">
Linked to approved grant funding awards
</p>
</div>
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-lg">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
Active Value (ZAR)
</span>
<HelpTooltip
title="Active Contract Value"
content="Cumulative financial value of legally active commitments."
size="sm"
/>
</div>
<div className="w-8 h-8 rounded-lg bg-emerald-500/10 text-emerald-400 flex items-center justify-center border border-emerald-500/20">
<ShieldCheck className="w-4 h-4" />
</div>
</div>
<div className="text-2xl font-bold text-emerald-400 mt-2">
{loadingContracts ? '...' : formatZAR(activeContractsValue)}
</div>
<p className="text-[11px] text-slate-400 mt-1">
Legally executed active commitments
</p>
</div>
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-lg">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
In Review / Draft
</span>
<HelpTooltip
title="Draft & Review Contracts"
content="Contracts currently undergoing legal review, signing, or counter-signature."
size="sm"
/>
</div>
<div className="w-8 h-8 rounded-lg bg-amber-500/10 text-amber-400 flex items-center justify-center border border-amber-500/20">
<Clock className="w-4 h-4" />
</div>
</div>
<div className="text-2xl font-bold text-amber-400 mt-2">
{loadingContracts ? '...' : pendingContractsCount}
</div>
<p className="text-[11px] text-slate-400 mt-1">
Pending legal review or signature
</p>
</div>
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-lg">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
Approved Awards Available
</span>
<HelpTooltip
title="Approved Awards"
content="Approved grant awards eligible for formal contract generation and document attachment."
size="sm"
/>
</div>
<div className="w-8 h-8 rounded-lg bg-indigo-500/10 text-indigo-400 flex items-center justify-center border border-indigo-500/20">
<Briefcase className="w-4 h-4" />
</div>
</div>
<div className="text-2xl font-bold text-indigo-400 mt-2">
{loadingAwards
? '...'
: awards.filter((a) => Number(a.status) === ApprovalStatus.Approved).length}
</div>
<p className="text-[11px] text-slate-400 mt-1">
Ready to establish contractual legal binding
</p>
</div>
</div>
{/* Action and Filter Bar */}
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 bg-[#0d121f]/90 p-4 rounded-2xl border border-slate-800/80 backdrop-blur-sm">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 flex-1 flex-wrap">
{/* Search */}
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-3" />
<input
type="text"
placeholder="Search contracts, award, org, programme..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-9 pr-4 py-2.5 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-slate-200 placeholder:text-slate-500 transition shadow-xs"
/>
</div>
{/* Status Filter */}
<div className="flex items-center gap-2">
<label className="text-xs text-slate-400 whitespace-nowrap flex items-center gap-1 font-medium">
<Filter className="w-3.5 h-3.5 text-slate-400" />
Status:
</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900"
>
<option value="all">All Statuses</option>
{CONTRACT_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{(searchTerm || statusFilter !== 'all') && (
<button
type="button"
onClick={() => {
setSearchTerm('');
setStatusFilter('all');
}}
className="inline-flex items-center gap-1 text-[11px] font-semibold text-slate-400 hover:text-white px-2.5 py-2 bg-slate-800 hover:bg-slate-700 rounded-xl transition cursor-pointer"
>
<RotateCcw className="w-3 h-3" />
<span>Reset</span>
</button>
)}
</div>
{/* Action Buttons: Refresh, Export & Create */}
<div className="flex items-center gap-2 self-end lg:self-center">
<button
type="button"
onClick={() => {
refetchContracts();
success('Refreshed', 'Contract registry and award linkages updated.');
}}
disabled={refetchingContracts}
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="Refresh contracts from API"
>
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${refetchingContracts ? 'animate-spin' : ''}`} />
<span>Refresh</span>
</button>
<ExportButton
claim="grant_financials"
title="Contract Registry & Agreements"
subtitle="Authoritative repository of bilateral legal agreements and contract files"
filename={`pfm-contracts-${new Date().toISOString().split('T')[0]}`}
columns={contractExportColumns}
data={filteredContracts}
/>
{canAdd('grant_financials') && (
<button
type="button"
onClick={() => {
resetCreateForm();
setCreateModalOpen(true);
}}
className="inline-flex items-center justify-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 transition cursor-pointer"
>
<Plus className="w-4 h-4" />
Create Contract
</button>
)}
</div>
</div>
{/* Contracts Table */}
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 overflow-hidden shadow-xl">
<div className="p-4 border-b border-slate-800/80 flex items-center justify-between">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<FileText className="w-4 h-4 text-blue-400" />
Contract Registry & Associated Programme Hierarchy
</h3>
<span className="text-xs text-slate-400">
Showing {filteredContracts.length} of {contracts.length} contracts
</span>
</div>
{loadingContracts ? (
<div className="p-12 text-center text-slate-400 text-xs flex flex-col items-center gap-3">
<RefreshCw className="w-6 h-6 animate-spin text-blue-400" />
Loading contractual records and resolving Award links...
</div>
) : filteredContracts.length === 0 ? (
<div className="p-12 text-center text-slate-400 text-xs flex flex-col items-center gap-2">
<FileText className="w-8 h-8 text-slate-600 mb-1" />
<p className="font-semibold text-slate-300">No contracts found</p>
<p className="text-slate-500 max-w-sm">
{searchTerm || statusFilter !== 'all'
? 'No contracts match the current search filters. Try resetting the filters.'
: 'No contracts have been created yet. Click "Create Contract & Upload PDF" to link an approved award.'}
</p>
{!(searchTerm || statusFilter !== 'all') && (
<button
onClick={() => {
resetCreateForm();
setCreateModalOpen(true);
}}
className="mt-3 px-3 py-1.5 text-xs text-blue-400 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 rounded-xl transition"
>
Create First Contract
</button>
)}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-xs text-slate-300">
<thead className="bg-[#151b2c]/80 text-slate-400 border-b border-slate-800/80 uppercase font-semibold text-[11px] tracking-wider">
<tr>
<SortableTh column="id" currentColumn={contractSortCol} direction={contractSortDir} onSort={handleContractSort} className="py-3.5 px-4">
Contract / Ref
</SortableTh>
<SortableTh column="awardId" currentColumn={contractSortCol} direction={contractSortDir} onSort={handleContractSort} className="py-3.5 px-4">
Award & Amount
</SortableTh>
<SortableTh column="programmeId" currentColumn={contractSortCol} direction={contractSortDir} onSort={handleContractSort} className="py-3.5 px-4">
Programme & Project
</SortableTh>
<SortableTh column="organisationId" currentColumn={contractSortCol} direction={contractSortDir} onSort={handleContractSort} className="py-3.5 px-4">
Organisation
</SortableTh>
<SortableTh column="signedDate" currentColumn={contractSortCol} direction={contractSortDir} onSort={handleContractSort} className="py-3.5 px-4">
Validity Period
</SortableTh>
<SortableTh column="status" currentColumn={contractSortCol} direction={contractSortDir} onSort={handleContractSort} className="py-3.5 px-4">
Status
</SortableTh>
<SortableTh column="fileUri" currentColumn={contractSortCol} direction={contractSortDir} onSort={handleContractSort} className="py-3.5 px-4">
Document / S3
</SortableTh>
<th className="py-3.5 px-4 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{sortedContracts.map((contract) => {
const award = getAwardForContract(contract);
const org = getOrganisationForAward(award);
const prog = getProgrammeForAward(award);
const proj = getProjectForAward(award, prog);
const statusBadge = getContractStatusBadge(contract.status);
const isDownloading = downloadingContractId === contract.id;
return (
<tr
key={contract.id}
className="hover:bg-slate-800/30 transition-colors"
>
{/* Contract ID */}
<td className="py-3.5 px-4">
<div className="font-semibold text-white font-mono text-xs flex items-center gap-1">
#{contract.id}
</div>
<div className="text-[10px] text-slate-500 mt-0.5 font-mono">
Hash: {toolsService.encodeIdSync(contract.id)}
</div>
<div className="text-[10px] text-emerald-400 font-semibold mt-1">
{formatZAR(contract.totalValue)}
</div>
</td>
{/* Linked Award */}
<td className="py-3.5 px-4">
{award ? (
<div>
<div className="font-medium text-slate-200 flex items-center gap-1.5">
<span className="font-mono text-slate-400">Award #{award.id}</span>
{award.referenceNo && (
<span className="text-[10px] text-slate-400">
({award.referenceNo})
</span>
)}
</div>
<div className="text-[11px] text-slate-400 mt-0.5">
Funded: {formatZAR(award.amount)}
</div>
{award.status && (
<span
className={`inline-block text-[10px] px-1.5 py-0.5 rounded border mt-1 ${
getApprovalStatusBadge(award.status).className
}`}
>
{getApprovalStatusBadge(award.status).label}
</span>
)}
</div>
) : (
<span className="font-mono text-slate-400">
Award #{contract.awardId}
</span>
)}
</td>
{/* Programme & Project */}
<td className="py-3.5 px-4">
<div className="flex items-center gap-1 text-slate-200 font-medium">
<FolderKanban className="w-3 h-3 text-indigo-400 shrink-0" />
<span className="truncate max-w-[180px]">
{prog?.name || 'Programme Linked'}
</span>
</div>
<div className="flex items-center gap-1 text-[11px] text-slate-400 mt-1">
<Briefcase className="w-3 h-3 text-sky-400 shrink-0" />
<span className="truncate max-w-[180px]">
{proj?.name || (award?.projectId ? `Project #${award.projectId}` : 'Project Scope')}
</span>
</div>
</td>
{/* Organisation */}
<td className="py-3.5 px-4">
<div className="flex items-center gap-1 text-slate-200 font-medium">
<Building2 className="w-3 h-3 text-amber-400 shrink-0" />
<span className="truncate max-w-[170px]">
{org?.name || 'Recipient Organisation'}
</span>
</div>
{org?.registrationNo && (
<div className="text-[10px] text-slate-500 mt-0.5">
Reg: {org.registrationNo}
</div>
)}
</td>
{/* Validity Period */}
<td className="py-3.5 px-4">
<div className="flex items-center gap-1 text-[11px] text-slate-300">
<Calendar className="w-3 h-3 text-slate-400" />
<span>{formatDate(contract.effectiveAt)}</span>
</div>
<div className="text-[10px] text-slate-500 mt-0.5">
to {formatDate(contract.expiresAt)}
</div>
</td>
{/* Status */}
<td className="py-3.5 px-4">
<span
className={`inline-block px-2 py-0.5 text-[11px] rounded-full border ${statusBadge.className}`}
>
{statusBadge.label}
</span>
</td>
{/* Document / File S3 Download */}
<td className="py-3.5 px-4">
<div className="flex items-center gap-1.5 flex-wrap">
<button
type="button"
onClick={() => handleDownloadSecureLink(contract)}
disabled={isDownloading}
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-medium text-blue-300 hover:text-white bg-blue-500/10 hover:bg-blue-500/25 border border-blue-500/30 rounded-lg transition cursor-pointer"
title="Generate 15-minute secure download link on demand"
>
{isDownloading ? (
<RefreshCw className="w-3 h-3 animate-spin text-blue-400" />
) : (
<Download className="w-3 h-3 text-blue-400" />
)}
<span>Secure PDF</span>
</button>
<button
type="button"
onClick={() => {
setContractForFileUpload(contract);
setSelectedUploadFile(null);
setUploadApiError(null);
}}
className="inline-flex items-center gap-1 px-2 py-1 text-[11px] text-slate-400 hover:text-slate-200 bg-slate-800 hover:bg-slate-700 rounded-lg border border-slate-700 transition cursor-pointer"
title="Upload fresh PDF or replace existing contract document"
>
<Upload className="w-3 h-3" />
<span>Replace</span>
</button>
</div>
<div className="text-[9px] text-slate-500 mt-1 flex items-center gap-1">
<Lock className="w-2.5 h-2.5 text-slate-500" />
<span>15-min secure link</span>
</div>
</td>
{/* Actions */}
<td className="py-3.5 px-4 text-right">
<button
type="button"
onClick={() => {
setContractToUpdate(contract);
setNewStatus(Number(contract.status));
setUpdateApiError(null);
}}
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-medium text-slate-300 hover:text-white bg-slate-800/80 hover:bg-slate-700 rounded-lg border border-slate-700 transition cursor-pointer"
>
<Edit2 className="w-3 h-3" />
<span>Status</span>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* ======================================================== */}
{/* MODAL 1: Create Contract & Initial PDF Upload Workflow */}
{/* ======================================================== */}
<Modal
isOpen={isCreateModalOpen}
onClose={() => setCreateModalOpen(false)}
title="Create Legal Contract & Attach PDF"
>
<form
onSubmit={(e) => {
e.preventDefault();
createContractMutation.mutate();
}}
className="space-y-4"
>
{/* ProblemDetails Error Alert */}
{createApiError && (
<ProblemDetailsAlert
error={createApiError}
onDismiss={() => setCreateApiError(null)}
title="Contract Creation Rejected"
/>
)}
{/* Award Selection Dropdown */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="block text-xs font-medium text-slate-200">
Select Approved Award <span className="text-rose-400">*</span>
</label>
<label className="flex items-center gap-1.5 text-[11px] text-slate-400 cursor-pointer">
<input
type="checkbox"
checked={onlyApprovedAwards}
onChange={(e) => setOnlyApprovedAwards(e.target.checked)}
className="rounded border-slate-700 text-blue-600 focus:ring-blue-500 bg-[#151b2c]"
/>
<span>Show Approved Awards Only</span>
</label>
</div>
<select
value={selectedAwardId}
onChange={(e) => handleAwardSelect(e.target.value)}
className="w-full bg-[#151b2c] border border-slate-700 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500"
required
>
<option value="">-- Choose Award --</option>
{filteredAwardsForDropdown.map((award) => {
const org = getOrganisationForAward(award);
const isApproved = Number(award.status) === ApprovalStatus.Approved;
return (
<option key={award.id} value={String(award.id)}>
Award #{award.id} {award.referenceNo ? `(${award.referenceNo})` : ''} —{' '}
{org?.name || 'Organisation'} • {formatZAR(award.amount)}
{!isApproved ? ` [Status: ${getApprovalStatusBadge(award.status).label}]` : ' [Approved]'}
</option>
);
})}
</select>
<p className="text-[11px] text-slate-500 mt-1">
Selecting an approved award links Programme, Project, and Organisation automatically.
</p>
</div>
{/* Associated Context Preview */}
{activeSelectedAward && (
<div className="bg-[#121827] border border-slate-800 rounded-xl p-3 text-xs space-y-2">
<div className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider flex items-center gap-1">
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
Linked Award Hierarchy
</div>
<div className="grid grid-cols-2 gap-2 text-[11px]">
<div>
<span className="text-slate-500 block">Recipient Organisation:</span>
<span className="text-slate-200 font-medium">
{activeSelectedOrg?.name || 'Unknown'}
</span>
</div>
<div>
<span className="text-slate-500 block">Funding Programme:</span>
<span className="text-slate-200 font-medium">
{activeSelectedProg?.name || 'Programme Attached'}
</span>
</div>
<div>
<span className="text-slate-500 block">Associated Project:</span>
<span className="text-slate-200 font-medium">
{activeSelectedProj?.name || (activeSelectedAward.projectId ? `Project #${activeSelectedAward.projectId}` : 'Portfolio Project')}
</span>
</div>
<div>
<span className="text-slate-500 block">Approved Award Amount:</span>
<span className="text-emerald-400 font-semibold">
{formatZAR(activeSelectedAward.amount)}
</span>
</div>
</div>
</div>
)}
{/* Total Value */}
<div>
<label className="block text-xs font-medium text-slate-200 mb-1">
Total Contract Value (ZAR) <span className="text-rose-400">*</span>
</label>
<input
type="number"
min="1"
step="any"
value={totalValueInput || ''}
onChange={(e) => setTotalValueInput(Number(e.target.value))}
placeholder="e.g. 500000"
className="w-full bg-[#151b2c] border border-slate-700 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500"
required
/>
</div>
{/* Dates */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-200 mb-1">
Effective Date <span className="text-rose-400">*</span>
</label>
<input
type="date"
value={effectiveAtInput}
onChange={(e) => setEffectiveAtInput(e.target.value)}
className="w-full bg-[#151b2c] border border-slate-700 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500"
required
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-200 mb-1">
Expiry Date <span className="text-rose-400">*</span>
</label>
<input
type="date"
value={expiresAtInput}
onChange={(e) => setExpiresAtInput(e.target.value)}
className="w-full bg-[#151b2c] border border-slate-700 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500"
required
/>
</div>
</div>
{/* Mandatory Contract PDF File Upload */}
<div>
<label className="block text-xs font-medium text-slate-200 mb-1">
Upload Contract Document (PDF) <span className="text-rose-400">*</span>
</label>
<div className="border-2 border-dashed border-slate-700 hover:border-blue-500/80 rounded-xl p-4 text-center transition-colors bg-[#111724]">
{contractPdfFile ? (
<div className="flex items-center justify-between bg-[#151b2c] p-2.5 rounded-lg border border-slate-700">
<div className="flex items-center gap-2 overflow-hidden">
<FileText className="w-5 h-5 text-rose-400 shrink-0" />
<div className="text-left truncate">
<p className="text-xs font-medium text-white truncate">
{contractPdfFile.name}
</p>
<p className="text-[10px] text-slate-400">
{(contractPdfFile.size / 1024).toFixed(1)} KB • application/pdf
</p>
</div>
</div>
<button
type="button"
onClick={() => setContractPdfFile(null)}
className="text-slate-400 hover:text-rose-400 text-xs px-2 py-1 transition cursor-pointer"
>
Remove
</button>
</div>
) : (
<label className="flex flex-col items-center justify-center cursor-pointer py-2">
<Upload className="w-7 h-7 text-slate-400 mb-1.5" />
<span className="text-xs text-blue-400 font-medium hover:underline">
Click to browse or drop PDF document
</span>
<span className="text-[10px] text-slate-500 mt-1">
Accepts official signed .PDF documents up to 50MB
</span>
<input
type="file"
accept=".pdf,application/pdf"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) setContractPdfFile(f);
}}
className="hidden"
/>
</label>
)}
</div>
<p className="text-[11px] text-slate-500 mt-1">
Upon contract creation, the PDF is immediately uploaded to S3 and linked to this contract record.
</p>
</div>
{/* Modal Actions */}
<div className="flex items-center justify-end gap-2 pt-3 border-t border-slate-800">
<button
type="button"
onClick={() => setCreateModalOpen(false)}
className="px-4 py-2 text-xs font-medium text-slate-400 hover:text-white rounded-xl hover:bg-slate-800 transition cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={createContractMutation.isPending}
className="px-4 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 disabled:opacity-50 rounded-xl shadow-lg shadow-blue-600/30 transition flex items-center gap-1.5 cursor-pointer"
>
{createContractMutation.isPending ? (
<>
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
<span>Creating & Uploading...</span>
</>
) : (
<>
<Check className="w-3.5 h-3.5" />
<span>Create Contract</span>
</>
)}
</button>
</div>
</form>
</Modal>
{/* ======================================================== */}
{/* MODAL 2: Replace / Upload New Contract PDF File */}
{/* ======================================================== */}
<Modal
isOpen={Boolean(contractForFileUpload)}
onClose={() => setContractForFileUpload(null)}
title={`Upload New Contract Document (Contract #${contractForFileUpload?.id})`}
>
<form
onSubmit={(e) => {
e.preventDefault();
uploadFileMutation.mutate();
}}
className="space-y-4"
>
{uploadApiError && (
<ProblemDetailsAlert
error={uploadApiError}
onDismiss={() => setUploadApiError(null)}
title="File Upload Failed"
/>
)}
<p className="text-xs text-slate-300">
Contracts can always upload a new PDF file if there was an issue or amendment with the previous document.
</p>
<div>
<label className="block text-xs font-medium text-slate-200 mb-1">
Select Replacement PDF File <span className="text-rose-400">*</span>
</label>
<div className="border-2 border-dashed border-slate-700 hover:border-blue-500/80 rounded-xl p-4 text-center transition-colors bg-[#111724]">
{selectedUploadFile ? (
<div className="flex items-center justify-between bg-[#151b2c] p-2.5 rounded-lg border border-slate-700">
<div className="flex items-center gap-2 overflow-hidden">
<FileText className="w-5 h-5 text-rose-400 shrink-0" />
<div className="text-left truncate">
<p className="text-xs font-medium text-white truncate">
{selectedUploadFile.name}
</p>
<p className="text-[10px] text-slate-400">
{(selectedUploadFile.size / 1024).toFixed(1)} KB • application/pdf
</p>
</div>
</div>
<button
type="button"
onClick={() => setSelectedUploadFile(null)}
className="text-slate-400 hover:text-rose-400 text-xs px-2 py-1 transition cursor-pointer"
>
Remove
</button>
</div>
) : (
<label className="flex flex-col items-center justify-center cursor-pointer py-2">
<Upload className="w-7 h-7 text-slate-400 mb-1.5" />
<span className="text-xs text-blue-400 font-medium hover:underline">
Click to select new contract PDF
</span>
<input
type="file"
accept=".pdf,application/pdf"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) setSelectedUploadFile(f);
}}
className="hidden"
/>
</label>
)}
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-3 border-t border-slate-800">
<button
type="button"
onClick={() => setContractForFileUpload(null)}
className="px-4 py-2 text-xs font-medium text-slate-400 hover:text-white rounded-xl hover:bg-slate-800 transition cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={uploadFileMutation.isPending || !selectedUploadFile}
className="px-4 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 disabled:opacity-50 rounded-xl shadow-lg shadow-blue-600/30 transition flex items-center gap-1.5 cursor-pointer"
>
{uploadFileMutation.isPending ? (
<>
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
<span>Uploading to S3...</span>
</>
) : (
<>
<Upload className="w-3.5 h-3.5" />
<span>Upload & Update</span>
</>
)}
</button>
</div>
</form>
</Modal>
{/* ======================================================== */}
{/* MODAL 3: Update Contract Status */}
{/* ======================================================== */}
<Modal
isOpen={Boolean(contractToUpdate)}
onClose={() => setContractToUpdate(null)}
title={`Update Contract Status (Contract #${contractToUpdate?.id})`}
>
<form
onSubmit={(e) => {
e.preventDefault();
updateStatusMutation.mutate();
}}
className="space-y-4"
>
{updateApiError && (
<ProblemDetailsAlert
error={updateApiError}
onDismiss={() => setUpdateApiError(null)}
title="Status Transition Failed"
/>
)}
<p className="text-xs text-slate-300">
Like any other data element, contracts can be updated and their status changed across the lifecycle.
</p>
<div>
<label className="block text-xs font-medium text-slate-200 mb-1">
Contract Lifecycle Status <span className="text-rose-400">*</span>
</label>
{(() => {
const canApproveContract = canApproveRecord('grant_financials', contractToUpdate?.createdBy);
const isSelf = isMaker(contractToUpdate?.createdBy);
return (
<select
value={newStatus}
onChange={(e) => setNewStatus(Number(e.target.value))}
className="w-full bg-[#151b2c] border border-slate-700 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500 font-medium"
>
{CONTRACT_STATUS_OPTIONS.map((opt) => {
const isActiveOpt = opt.value === ContractStatus.Active;
const isOptDisabled = isActiveOpt && !canApproveContract;
let reasonText = '';
if (isActiveOpt && !canApprove('grant_financials')) {
reasonText = ' (Requires Update Permission)';
} else if (isActiveOpt && isSelf) {
reasonText = ' (Maker-Checker: Cannot approve own record)';
}
return (
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
{opt.label}{reasonText}
</option>
);
})}
</select>
);
})()}
</div>
<div className="flex items-center justify-end gap-2 pt-3 border-t border-slate-800">
<button
type="button"
onClick={() => setContractToUpdate(null)}
className="px-4 py-2 text-xs font-medium text-slate-400 hover:text-white rounded-xl hover:bg-slate-800 transition cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={updateStatusMutation.isPending}
className="px-4 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 disabled:opacity-50 rounded-xl shadow-lg shadow-blue-600/30 transition flex items-center gap-1.5 cursor-pointer"
>
{updateStatusMutation.isPending ? (
<>
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
<span>Updating Status...</span>
</>
) : (
<>
<Check className="w-3.5 h-3.5" />
<span>Save Status</span>
</>
)}
</button>
</div>
</form>
</Modal>
</div>
);
};