1293 lines
54 KiB
TypeScript
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>
|
|
);
|
|
};
|