+
+ {/* Core Details Grid: Owner & Creation Date */}
+
-
-
-
Programme Lead
-
- {getUserEmail(programmeFreshDetail?.ownedBy || selectedProgrammeForDetail?.ownedBy)}
-
+ {/* Metrics: Total Rules and Total Instructions */}
+
+ {/* Total Rules */}
+
+
+
+
+ Total Rules
+
+
+ {loadingDetailRules ? (
+
+ ) : (
+ programmeDetailRules.length
+ )}
+
+
+
{
+ const pId = selectedProgrammeForDetail?.id;
+ setIsProgrammeDetailModalOpen(false);
+ setSelectedProgrammeForDetail(null);
+ setPreselectedProgForRule(pId || null);
+ handleTabChange('rules');
+ }}
+ className="text-[11px] font-semibold text-amber-400 hover:text-amber-300 inline-flex items-center gap-1 mt-1 transition cursor-pointer self-start"
+ >
+ View in Rules Tab →
+
-
-
- {/* Metrics: Total Rules and Total Instructions */}
-
- {/* Total Rules */}
-
-
-
-
- Total Rules
-
-
- {loadingDetailRules ? (
-
- ) : (
- programmeDetailRules.length
- )}
-
+ {/* Total Funding Instruments */}
+
+
+
+
+ Total Funding Instruments
+
+
+ {loadingDetailInstructions ? (
+
+ ) : (
+ programmeDetailInstructions.length
+ )}
+
+
+
{
+ const pId = selectedProgrammeForDetail?.id;
+ setIsProgrammeDetailModalOpen(false);
+ setSelectedProgrammeForDetail(null);
+ setPreselectedProgForInstruction(pId || null);
+ handleTabChange('instruments');
+ }}
+ className="text-[11px] font-semibold text-blue-400 hover:text-blue-300 inline-flex items-center gap-1 mt-1 transition cursor-pointer self-start"
+ >
+ View in Funding Instruments Tab →
+
-
{
- const pId = selectedProgrammeForDetail?.id;
- setIsProgrammeDetailModalOpen(false);
- setSelectedProgrammeForDetail(null);
- setPreselectedProgForRule(pId || null);
- handleTabChange('rules');
- }}
- className="text-[11px] font-semibold text-amber-400 hover:text-amber-300 inline-flex items-center gap-1 mt-1 transition cursor-pointer self-start"
- >
- View in Rules Tab →
-
-
-
- {/* Total Funding Instruments */}
-
-
-
-
- Total Funding Instruments
-
-
- {loadingDetailInstructions ? (
-
- ) : (
- programmeDetailInstructions.length
- )}
-
-
-
{
- const pId = selectedProgrammeForDetail?.id;
- setIsProgrammeDetailModalOpen(false);
- setSelectedProgrammeForDetail(null);
- setPreselectedProgForInstruction(pId || null);
- handleTabChange('instruments');
- }}
- className="text-[11px] font-semibold text-blue-400 hover:text-blue-300 inline-flex items-center gap-1 mt-1 transition cursor-pointer self-start"
- >
- View in Funding Instruments Tab →
-
diff --git a/PostFundManagement.ConsoleUI/src/pages/GrantsPage.tsx b/PostFundManagement.ConsoleUI/src/pages/GrantsPage.tsx
index 1c6e406..454201d 100644
--- a/PostFundManagement.ConsoleUI/src/pages/GrantsPage.tsx
+++ b/PostFundManagement.ConsoleUI/src/pages/GrantsPage.tsx
@@ -5,7 +5,6 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
Coins,
- FileText,
PieChart,
Banknote,
Plus,
@@ -17,26 +16,19 @@ import {
Lock,
ExternalLink,
DollarSign,
- FileCheck2,
Download,
- Upload,
- Link as LinkIcon,
Edit2,
+ Search,
+ RefreshCw,
} from 'lucide-react';
import {
getAwards,
getAward,
createAward,
- getContracts,
- getContract,
- createContract,
- updateContract,
- uploadContractFile,
- getContractSecureLink,
- executeContract,
getBudgets,
getBudget,
createBudget,
+ updateBudget,
getDisbursements,
getDisbursement,
createDisbursement,
@@ -50,17 +42,16 @@ import {
import { Modal } from '../components/ui/Modal';
import { HelpTooltip } from '../components/ui/HelpTooltip';
import { useToast } from '../components/ui/Toast';
+import { ExportButton } from '../components/ui/ExportButton';
+import { ExportColumn } from '../utils/exportUtils';
import {
Award,
- Contract,
Budget,
Disbursement,
ApprovalStatus,
- ContractStatus,
CreateAwardRequest,
- CreateContractRequest,
- ExecuteContractRequest,
CreateBudgetRequest,
+ UpdateBudgetRequest,
CreateDisbursementRequest,
} from '../types';
import {
@@ -68,32 +59,22 @@ import {
formatCompactZAR,
formatDate,
getApprovalStatusBadge,
- getContractStatusBadge,
getUserEmail,
} from '../lib/formatters';
// Validation schemas for grant administration
const awardSchema = z.object({
referenceNo: z.string().min(4, 'Approval / Reference number is mandatory'),
- amount: z.coerce.number().min(1, 'Approved funding amount must be greater than zero'),
+ projectId: z.coerce.number().min(1, 'Select target project'),
+ amount: z
+ .coerce
+ .number()
+ .positive('Approved funding amount must be strictly greater than zero (no negative or zero values)'),
fundingPeriod: z.string().min(3, 'Funding period is mandatory (e.g. 24 Months)'),
organisationId: z.coerce.number().min(1, 'Select recipient organisation'),
programmeId: z.coerce.number().min(1, 'Select funding programme'),
});
-const contractSchema = z.object({
- awardId: z.coerce.number().min(1, 'Select approved award'),
- totalValue: z.coerce.number().min(1, 'Total value is required'),
- effectiveAt: z.string().min(4, 'Effective date is mandatory'),
- expiresAt: z.string().min(4, 'Expiry date is mandatory'),
-});
-
-const executeContractSchema = z.object({
- contractId: z.coerce.number().min(1),
- externalSignatureId: z.string().min(4, 'Digital signature ID is required'),
- signedDocumentUrl: z.string().url('Must be a valid document URL'),
-});
-
const budgetSchema = z.object({
projectId: z.coerce.number().min(1, 'Select project'),
name: z.string().min(3, 'Budget category name required'),
@@ -103,12 +84,13 @@ const budgetSchema = z.object({
const disbursementSchema = z.object({
projectId: z.coerce.number().min(1, 'Select project'),
milestoneId: z.coerce.number().optional(),
- amount: z.coerce.number().min(1, 'Disbursement tranche amount required'),
+ amount: z
+ .coerce
+ .number()
+ .positive('Disbursement tranche amount must be strictly greater than zero (no negative or zero values)'),
});
type AwardFormValues = z.infer
;
-type ContractFormValues = z.infer;
-type ExecuteContractFormValues = z.infer;
type BudgetFormValues = z.infer;
type DisbursementFormValues = z.infer;
@@ -116,34 +98,44 @@ export const GrantsPage: React.FC = () => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
- const [activeTab, setActiveTab] = useState<'awards' | 'contracts' | 'budgets' | 'disbursements'>('awards');
+ const [activeTab, setActiveTab] = useState<'awards' | 'budgets' | 'disbursements'>('awards');
// Modals
const [isAwardModalOpen, setIsAwardModalOpen] = useState(false);
- const [isContractModalOpen, setIsContractModalOpen] = useState(false);
- const [isExecuteModalOpen, setIsExecuteModalOpen] = useState(false);
- const [selectedContractForExecution, setSelectedContractForExecution] = useState(null);
const [isBudgetModalOpen, setIsBudgetModalOpen] = useState(false);
+ const [editingBudget, setEditingBudget] = useState(null);
+ const [isEditBudgetModalOpen, setIsEditBudgetModalOpen] = useState(false);
+ const [selectedBudgetStatus, setSelectedBudgetStatus] = useState(ApprovalStatus.Approved);
const [isDisbursementModalOpen, setIsDisbursementModalOpen] = useState(false);
const [editingDisbursement, setEditingDisbursement] = useState(null);
// Queries
- const { data: awards = [], isLoading: loadingAwards } = useQuery({
+ const {
+ data: awards = [],
+ isLoading: loadingAwards,
+ refetch: refetchAwards,
+ isFetching: isFetchingAwards,
+ } = useQuery({
queryKey: ['awards'],
queryFn: () => getAwards(1, 100),
});
- const { data: contracts = [], isLoading: loadingContracts } = useQuery({
- queryKey: ['contracts'],
- queryFn: () => getContracts(1, 100),
- });
-
- const { data: budgets = [], isLoading: loadingBudgets } = useQuery({
+ const {
+ data: budgets = [],
+ isLoading: loadingBudgets,
+ refetch: refetchBudgets,
+ isFetching: isFetchingBudgets,
+ } = useQuery({
queryKey: ['budgets'],
queryFn: () => getBudgets(1, 100),
});
- const { data: disbursements = [], isLoading: loadingDisbursements } = useQuery({
+ const {
+ data: disbursements = [],
+ isLoading: loadingDisbursements,
+ refetch: refetchDisbursements,
+ isFetching: isFetchingDisbursements,
+ } = useQuery({
queryKey: ['disbursements'],
queryFn: () => getDisbursements(1, 100),
});
@@ -173,6 +165,7 @@ export const GrantsPage: React.FC = () => {
resolver: zodResolver(awardSchema) as any,
defaultValues: {
referenceNo: '',
+ projectId: projects[0]?.id ? Number(projects[0].id) : 401,
amount: 10000000,
fundingPeriod: '24 Months',
organisationId: organisations[0]?.id ? Number(organisations[0].id) : 101,
@@ -180,25 +173,6 @@ export const GrantsPage: React.FC = () => {
},
});
- const contractForm = useForm({
- resolver: zodResolver(contractSchema) as any,
- defaultValues: {
- awardId: awards[0]?.id ? Number(awards[0].id) : 501,
- totalValue: 10000000,
- effectiveAt: new Date().toISOString().slice(0, 10),
- expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10),
- },
- });
-
- const executeForm = useForm({
- resolver: zodResolver(executeContractSchema) as any,
- defaultValues: {
- contractId: 0,
- externalSignatureId: '',
- signedDocumentUrl: '',
- },
- });
-
const budgetForm = useForm({
resolver: zodResolver(budgetSchema) as any,
defaultValues: {
@@ -221,11 +195,10 @@ export const GrantsPage: React.FC = () => {
mutationFn: (data: CreateAwardRequest) => createAward(data),
onSuccess: (newAwd) => {
queryClient.invalidateQueries({ queryKey: ['awards'] });
- queryClient.invalidateQueries({ queryKey: ['contracts'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
success(
- 'Award Registered & Contract Initiated',
- `Award ${newAwd.referenceNo} recorded. Automated Contract workflow initiated (BR-002).`
+ 'Award Registered',
+ `Award ${newAwd.referenceNo} recorded successfully.`
);
setIsAwardModalOpen(false);
awardForm.reset();
@@ -235,35 +208,6 @@ export const GrantsPage: React.FC = () => {
},
});
- const contractMutation = useMutation({
- mutationFn: (data: CreateContractRequest) => createContract(data),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['contracts'] });
- success('Contract generated', 'New legal agreement created in Draft state');
- setIsContractModalOpen(false);
- contractForm.reset();
- },
- onError: (err: unknown) => {
- toastError('Contract creation failed', err);
- },
- });
-
- const executeMutation = useMutation({
- mutationFn: (data: ExecuteContractRequest) => executeContract(data),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['contracts'] });
- success(
- 'Contract Executed & Locked',
- 'Agreement locked as authoritative version. Downstream budgets and disbursements enabled.'
- );
- setIsExecuteModalOpen(false);
- executeForm.reset();
- },
- onError: (err: unknown) => {
- toastError('Execution trigger failed', err);
- },
- });
-
const budgetMutation = useMutation({
mutationFn: (data: CreateBudgetRequest) => createBudget(data),
onSuccess: () => {
@@ -277,6 +221,19 @@ export const GrantsPage: React.FC = () => {
},
});
+ const updateBudgetMutation = useMutation({
+ mutationFn: (data: UpdateBudgetRequest) => updateBudget(data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['budgets'] });
+ success('Budget Updated', 'Budget compliance and approval status updated.');
+ setIsEditBudgetModalOpen(false);
+ setEditingBudget(null);
+ },
+ onError: (err: unknown) => {
+ toastError('Budget update failed', err);
+ },
+ });
+
const disbursementMutation = useMutation({
mutationFn: async (data: DisbursementFormValues) => {
if (editingDisbursement) {
@@ -307,12 +264,185 @@ export const GrantsPage: React.FC = () => {
},
});
+ // Watched values for award project budget validation
+ const watchedAwardProjectId =
+ awardForm.watch('projectId') || (projects[0]?.id ? Number(projects[0].id) : 0);
+ const watchedAwardAmountRaw = awardForm.watch('amount');
+ const watchedAwardAmount = Number(watchedAwardAmountRaw) || 0;
+
+ // Sum of all allocated budgets for the chosen project
+ const selectedProjectBudgetsForAward = budgets.filter(
+ (b) => String(b.projectId) === String(watchedAwardProjectId)
+ );
+ const totalAllocatedBudgetForAwardProject = selectedProjectBudgetsForAward.reduce(
+ (sum, b) => sum + (Number(b.amount) || 0),
+ 0
+ );
+
+ // Award validation flags
+ const isAwardNegativeOrZero = watchedAwardAmount <= 0;
+ const hasNoBudgetForAwardProject = totalAllocatedBudgetForAwardProject <= 0;
+ const isAwardExceedingBudget = watchedAwardAmount > totalAllocatedBudgetForAwardProject;
+ const isAwardDisallowed =
+ isAwardNegativeOrZero || isAwardExceedingBudget || hasNoBudgetForAwardProject;
+
+ const handleAwardSubmit = (data: AwardFormValues) => {
+ const amountNum = Number(data.amount);
+ const targetProjId = Number(data.projectId);
+
+ if (amountNum <= 0) {
+ toastError(
+ 'Invalid Award Amount',
+ 'Approved funding amount must be strictly greater than zero. Negative or zero values are disallowed.'
+ );
+ return;
+ }
+
+ const projBudgets = budgets.filter((b) => String(b.projectId) === String(targetProjId));
+ const totBudget = projBudgets.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
+
+ if (totBudget <= 0) {
+ toastError(
+ 'Award Disallowed: No Budget',
+ `The chosen project #${targetProjId} has zero allocated budget lines (ZAR 0.00). Establish budget lines before creating an award.`
+ );
+ return;
+ }
+
+ if (amountNum > totBudget) {
+ const overBy = amountNum - totBudget;
+ toastError(
+ 'Award Exceeds Project Budget',
+ `The entered award amount of ${formatZAR(amountNum)} exceeds the total allocated project budget (${formatZAR(totBudget)}) by ${formatZAR(overBy)}. Creation disallowed.`
+ );
+ return;
+ }
+
+ awardMutation.mutate({
+ organisationId: Number(data.organisationId),
+ programmeId: Number(data.programmeId),
+ amount: Number(data.amount),
+ referenceNo: data.referenceNo,
+ fundingPeriod: data.fundingPeriod,
+ });
+ };
+
+ const openCreateAward = () => {
+ const firstProj = projects[0];
+ const firstProjId = firstProj?.id ? Number(firstProj.id) : 401;
+ const projBudgets = budgets.filter((b) => String(b.projectId) === String(firstProjId));
+ const totBudget = projBudgets.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
+
+ awardForm.reset({
+ referenceNo: `AWD-2026-${Math.floor(100 + Math.random() * 900)}`,
+ projectId: firstProjId,
+ amount: totBudget > 0 ? totBudget : 0,
+ fundingPeriod: '24 Months',
+ organisationId: organisations[0]?.id ? Number(organisations[0].id) : 101,
+ programmeId: firstProj?.programmeId
+ ? Number(firstProj.programmeId)
+ : programmes[0]?.id
+ ? Number(programmes[0].id)
+ : 301,
+ });
+ setIsAwardModalOpen(true);
+ };
+
+ // Watched values for disbursement budget ceiling and validation
+ const watchedDisbursementProjectId =
+ disbursementForm.watch('projectId') || (projects[0]?.id ? Number(projects[0].id) : 0);
+ const watchedDisbursementAmountRaw = disbursementForm.watch('amount');
+ const watchedDisbursementAmount = Number(watchedDisbursementAmountRaw) || 0;
+
+ // 1. Sum of all allocated project budgets
+ const selectedProjectBudgets = budgets.filter(
+ (b) => String(b.projectId) === String(watchedDisbursementProjectId)
+ );
+ const totalAllocatedProjectBudget = selectedProjectBudgets.reduce(
+ (sum, b) => sum + (Number(b.amount) || 0),
+ 0
+ );
+
+ // 2. Sum of all existing project disbursements (excluding current when editing)
+ const selectedProjectDisbursements = disbursements.filter(
+ (d) =>
+ String(d.projectId) === String(watchedDisbursementProjectId) &&
+ (!editingDisbursement || String(d.id) !== String(editingDisbursement.id))
+ );
+ const totalExistingProjectDisbursements = selectedProjectDisbursements.reduce(
+ (sum, d) => sum + (Number(d.amount) || 0),
+ 0
+ );
+
+ // 3. Available disbursement headroom: sum(allocated project budgets) - sum(existing project disbursements)
+ const availableDisbursementCapacity = Math.max(
+ 0,
+ totalAllocatedProjectBudget - totalExistingProjectDisbursements
+ );
+
+ // 4. Strict validation state flags
+ const isDisbursementNegativeOrZero = watchedDisbursementAmount <= 0;
+ const isDisbursementExceedingBudget = watchedDisbursementAmount > availableDisbursementCapacity;
+ const hasNoProjectBudget = totalAllocatedProjectBudget <= 0;
+ const isDisbursementDisallowed =
+ isDisbursementNegativeOrZero || isDisbursementExceedingBudget || hasNoProjectBudget;
+
+ const handleDisbursementSubmit = (data: DisbursementFormValues) => {
+ const amountNum = Number(data.amount);
+ const targetProjId = Number(data.projectId);
+
+ if (amountNum <= 0) {
+ toastError(
+ 'Invalid Tranche Amount',
+ 'Disbursement amount must be strictly greater than zero. Negative or zero values are not allowed.'
+ );
+ return;
+ }
+
+ const projBudgets = budgets.filter((b) => String(b.projectId) === String(targetProjId));
+ const totBudget = projBudgets.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
+
+ const projDisbs = disbursements.filter(
+ (d) =>
+ String(d.projectId) === String(targetProjId) &&
+ (!editingDisbursement || String(d.id) !== String(editingDisbursement.id))
+ );
+ const totDisb = projDisbs.reduce((sum, d) => sum + (Number(d.amount) || 0), 0);
+ const maxCapacity = Math.max(0, totBudget - totDisb);
+
+ if (totBudget <= 0) {
+ toastError(
+ 'Disbursement Blocked: No Budget',
+ `Project #${targetProjId} has zero allocated budget lines. Establish budgets in the Budgets tab before scheduling disbursements.`
+ );
+ return;
+ }
+
+ if (amountNum > maxCapacity) {
+ const overBy = amountNum - maxCapacity;
+ toastError(
+ 'Disbursement Exceeds Budget Ceilings',
+ `Requested tranche of ${formatZAR(amountNum)} exceeds available budget headroom (${formatZAR(maxCapacity)}) by ${formatZAR(overBy)}. Disallowed.`
+ );
+ return;
+ }
+
+ disbursementMutation.mutate(data);
+ };
+
const openCreateDisbursement = () => {
setEditingDisbursement(null);
+ const firstProjId = projects[0]?.id ? Number(projects[0].id) : 401;
+ const projBudgets = budgets.filter((b) => String(b.projectId) === String(firstProjId));
+ const totBudget = projBudgets.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
+ const projDisbs = disbursements.filter((d) => String(d.projectId) === String(firstProjId));
+ const totDisb = projDisbs.reduce((sum, d) => sum + (Number(d.amount) || 0), 0);
+ const avail = Math.max(0, totBudget - totDisb);
+
disbursementForm.reset({
- projectId: projects[0]?.id ? Number(projects[0].id) : 401,
+ projectId: firstProjId,
milestoneId: undefined,
- amount: 150000,
+ amount: avail > 0 ? Math.min(avail, 150000) : 0,
});
setIsDisbursementModalOpen(true);
};
@@ -342,56 +472,6 @@ export const GrantsPage: React.FC = () => {
},
});
- const openExecuteModal = (c: Contract) => {
- setSelectedContractForExecution(c);
- executeForm.reset({
- contractId: Number(c.id),
- externalSignatureId: `SIG-DIGITAL-${Math.floor(100000 + Math.random() * 900000)}`,
- signedDocumentUrl: `https://documents.ifri.khongisa.co.za/contracts/CTR-${c.id}-Signed.pdf`,
- });
- setIsExecuteModalOpen(true);
- };
-
- const handleGetContractSecureLink = async (contractId: string | number) => {
- try {
- const url = await getContractSecureLink(contractId);
- 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('Secure URL Retrieved', 'Temporary signed download link opened.');
- } else {
- toastError('Download Error', 'No active secure download link generated.');
- }
- } catch (err: any) {
- toastError('Link Error', err);
- }
- };
-
- const handleContractFileUpload = async (contractId: string | number, file: File) => {
- try {
- await uploadContractFile(contractId, file);
- success('Document Attached', `Contract #${contractId} document uploaded successfully.`);
- queryClient.invalidateQueries({ queryKey: ['contracts'] });
- } catch (err: any) {
- toastError('Upload Failed', err);
- }
- };
-
- const handleUpdateContractStatus = async (contractId: string | number, status: ContractStatus) => {
- try {
- await updateContract({ contractId, status });
- success('Contract Updated', `Contract status updated to ${getContractStatusBadge(status).label}.`);
- queryClient.invalidateQueries({ queryKey: ['contracts'] });
- } catch (err: any) {
- toastError('Update Failed', err);
- }
- };
-
// Summary calculations
const totalAwardValue = awards.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
const totalDisbursedValue = disbursements
@@ -401,6 +481,117 @@ export const GrantsPage: React.FC = () => {
.filter((d) => d.status === ApprovalStatus.PendingReview)
.reduce((sum, d) => sum + (Number(d.amount) || 0), 0);
+ // Search & Filter State
+ const [searchTerm, setSearchTerm] = useState('');
+
+ // Export Columns Definitions
+ const awardExportColumns: ExportColumn[] = [
+ { header: 'Award Reference', key: 'referenceNo', format: (val, item) => val || `AWD-${item.id}` },
+ { header: 'Award ID', key: 'id' },
+ {
+ header: 'Recipient Organisation',
+ key: 'organisationId',
+ format: (val, item) => {
+ const org = organisations.find((o) => String(o.id) === String(val));
+ return org ? org.name : item.organisationName || `Org #${val}`;
+ },
+ },
+ {
+ header: 'Funding Programme',
+ key: 'programmeId',
+ format: (val, item) => {
+ const prog = programmes.find((p) => String(p.id) === String(val));
+ return prog ? prog.name : item.programmeName || `Programme #${val}`;
+ },
+ },
+ { header: 'Approved Amount (ZAR)', key: 'amount', format: (val) => formatZAR(val) },
+ { header: 'Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label },
+ { header: 'Date Registered', key: 'createdAt', format: (val) => formatDate(val) },
+ { header: 'Created By', key: 'createdBy', format: (val) => getUserEmail(val) },
+ ];
+
+ const budgetExportColumns: ExportColumn[] = [
+ { header: 'Budget Line ID', key: 'id' },
+ { header: 'Budget Line Category', key: 'name' },
+ {
+ header: 'Target Project',
+ key: 'projectId',
+ format: (val) => {
+ const proj = projects.find((p) => String(p.id) === String(val));
+ return proj ? `${proj.name} (#${val})` : `Project #${val}`;
+ },
+ },
+ { header: 'Approved Amount (ZAR)', key: 'amount', format: (val) => formatZAR(val) },
+ { header: 'Status', key: 'status', format: (val) => getApprovalStatusBadge(val ?? ApprovalStatus.Approved).label },
+ { header: 'Date Established', key: 'createdAt', format: (val) => formatDate(val) },
+ { header: 'Created By', key: 'createdBy', format: (val) => getUserEmail(val) },
+ ];
+
+ const disbursementExportColumns: ExportColumn[] = [
+ { header: 'Disbursement ID', key: 'id', format: (val) => `DSB-${val}` },
+ {
+ header: 'Project',
+ key: 'projectId',
+ format: (val) => {
+ const proj = projects.find((p) => String(p.id) === String(val));
+ return proj ? `${proj.name} (#${val})` : `Project #${val}`;
+ },
+ },
+ {
+ header: 'Linked Milestone',
+ key: 'milestoneId',
+ format: (val) => {
+ if (!val) return 'Advance Tranche (No milestone)';
+ const ms = milestones.find((m) => String(m.id) === String(val));
+ return ms ? `${ms.name} (#${val})` : `Milestone #${val}`;
+ },
+ },
+ { header: 'Tranche Amount (ZAR)', key: 'amount', format: (val) => formatZAR(val) },
+ { header: 'Payment Reference', key: 'paymentReference', format: (val) => val || 'Pending Release' },
+ { header: 'Approval Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label },
+ { header: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) },
+ { header: 'Created By', key: 'createdBy', format: (val) => getUserEmail(val) },
+ ];
+
+ // Filtering
+ const filteredAwards = awards.filter((a) => {
+ if (!searchTerm.trim()) return true;
+ const q = searchTerm.toLowerCase();
+ const ref = (a.referenceNo || '').toLowerCase();
+ const id = String(a.id);
+ const org = organisations.find((o) => String(o.id) === String(a.organisationId));
+ const orgName = (org?.name || a.organisationName || '').toLowerCase();
+ const prog = programmes.find((p) => String(p.id) === String(a.programmeId));
+ const progName = (prog?.name || a.programmeName || '').toLowerCase();
+ const creator = getUserEmail(a.createdBy).toLowerCase();
+ return ref.includes(q) || id.includes(q) || orgName.includes(q) || progName.includes(q) || creator.includes(q);
+ });
+
+ const filteredBudgets = budgets.filter((b) => {
+ if (!searchTerm.trim()) return true;
+ const q = searchTerm.toLowerCase();
+ const name = (b.name || '').toLowerCase();
+ const id = String(b.id);
+ const projId = String(b.projectId);
+ const proj = projects.find((p) => String(p.id) === String(b.projectId));
+ const projName = (proj?.name || '').toLowerCase();
+ const creator = getUserEmail(b.createdBy).toLowerCase();
+ return name.includes(q) || id.includes(q) || projId.includes(q) || projName.includes(q) || creator.includes(q);
+ });
+
+ const filteredDisbursements = disbursements.filter((d) => {
+ if (!searchTerm.trim()) return true;
+ const q = searchTerm.toLowerCase();
+ const id = String(d.id);
+ const projId = String(d.projectId);
+ const proj = projects.find((p) => String(p.id) === String(d.projectId));
+ const projName = (proj?.name || '').toLowerCase();
+ const ref = (d.paymentReference || '').toLowerCase();
+ const msId = d.milestoneId ? String(d.milestoneId) : '';
+ const creator = getUserEmail(d.createdBy).toLowerCase();
+ return id.includes(q) || projId.includes(q) || projName.includes(q) || ref.includes(q) || msId.includes(q) || creator.includes(q);
+ });
+
return (
{/* Header & Execution Pipeline Summary */}
@@ -413,39 +604,30 @@ export const GrantsPage: React.FC = () => {
- End-to-end controlled execution thread: Funding Award Intake → Contracting → Budgeting → Condition-Checked Disbursement.
+ End-to-end controlled execution thread: Funding Award Intake → Budgeting → Condition-Checked Disbursement.