Files
pfm/PostFundManagement.ConsoleUI/src/pages/GrantsPage.tsx
T
2026-09-26 16:36:45 +02:00

1727 lines
79 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
Coins,
PieChart,
Banknote,
Plus,
CheckCircle,
Clock,
ShieldCheck,
AlertTriangle,
ArrowRight,
Lock,
ExternalLink,
DollarSign,
Download,
Edit2,
Search,
RefreshCw,
} from 'lucide-react';
import {
getAwards,
getAward,
createAward,
getBudgets,
getBudget,
createBudget,
updateBudget,
getDisbursements,
getDisbursement,
createDisbursement,
updateDisbursement,
approveDisbursement,
getOrganisations,
getAllProgrammes,
getProjects,
getAllMilestones,
} from '../services/api';
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,
Budget,
Disbursement,
ApprovalStatus,
CreateAwardRequest,
CreateBudgetRequest,
UpdateBudgetRequest,
CreateDisbursementRequest,
} from '../types';
import {
formatZAR,
formatCompactZAR,
formatDate,
getApprovalStatusBadge,
getUserEmail,
} from '../lib/formatters';
// Validation schemas for grant administration
const awardSchema = z.object({
referenceNo: z.string().min(4, 'Approval / Reference number is mandatory'),
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 budgetSchema = z.object({
projectId: z.coerce.number().min(1, 'Select project'),
name: z.string().min(3, 'Budget category name required'),
amount: z.coerce.number().min(1, 'Amount must be greater than zero'),
});
const disbursementSchema = z.object({
projectId: z.coerce.number().min(1, 'Select project'),
milestoneId: z.coerce.number().optional(),
amount: z
.coerce
.number()
.positive('Disbursement tranche amount must be strictly greater than zero (no negative or zero values)'),
});
type AwardFormValues = z.infer<typeof awardSchema>;
type BudgetFormValues = z.infer<typeof budgetSchema>;
type DisbursementFormValues = z.infer<typeof disbursementSchema>;
export const GrantsPage: React.FC = () => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const [activeTab, setActiveTab] = useState<'awards' | 'budgets' | 'disbursements'>('awards');
// Modals
const [isAwardModalOpen, setIsAwardModalOpen] = useState(false);
const [isBudgetModalOpen, setIsBudgetModalOpen] = useState(false);
const [editingBudget, setEditingBudget] = useState<Budget | null>(null);
const [isEditBudgetModalOpen, setIsEditBudgetModalOpen] = useState(false);
const [selectedBudgetStatus, setSelectedBudgetStatus] = useState<ApprovalStatus>(ApprovalStatus.Approved);
const [isDisbursementModalOpen, setIsDisbursementModalOpen] = useState(false);
const [editingDisbursement, setEditingDisbursement] = useState<Disbursement | null>(null);
// Queries
const {
data: awards = [],
isLoading: loadingAwards,
refetch: refetchAwards,
isFetching: isFetchingAwards,
} = useQuery({
queryKey: ['awards'],
queryFn: () => getAwards(1, 100),
});
const {
data: budgets = [],
isLoading: loadingBudgets,
refetch: refetchBudgets,
isFetching: isFetchingBudgets,
} = useQuery({
queryKey: ['budgets'],
queryFn: () => getBudgets(1, 100),
});
const {
data: disbursements = [],
isLoading: loadingDisbursements,
refetch: refetchDisbursements,
isFetching: isFetchingDisbursements,
} = useQuery({
queryKey: ['disbursements'],
queryFn: () => getDisbursements(1, 100),
});
const { data: organisations = [] } = useQuery({
queryKey: ['organisations'],
queryFn: () => getOrganisations(),
});
const { data: programmes = [] } = useQuery({
queryKey: ['programmes'],
queryFn: () => getAllProgrammes(),
});
const { data: projects = [] } = useQuery({
queryKey: ['projects'],
queryFn: () => getProjects(),
});
const { data: milestones = [] } = useQuery({
queryKey: ['milestones-all'],
queryFn: () => getAllMilestones(),
});
// Forms
const awardForm = useForm<AwardFormValues>({
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,
programmeId: programmes[0]?.id ? Number(programmes[0].id) : 301,
},
});
const budgetForm = useForm<BudgetFormValues>({
resolver: zodResolver(budgetSchema) as any,
defaultValues: {
projectId: projects[0]?.id ? Number(projects[0].id) : 401,
name: '',
amount: 1000000,
},
});
const disbursementForm = useForm<DisbursementFormValues>({
resolver: zodResolver(disbursementSchema) as any,
defaultValues: {
projectId: projects[0]?.id ? Number(projects[0].id) : 401,
amount: 2500000,
},
});
// Mutations
const awardMutation = useMutation({
mutationFn: (data: CreateAwardRequest) => createAward(data),
onSuccess: (newAwd) => {
queryClient.invalidateQueries({ queryKey: ['awards'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
success(
'Award Registered',
`Award ${newAwd.referenceNo} recorded successfully.`
);
setIsAwardModalOpen(false);
awardForm.reset();
},
onError: (err: unknown) => {
toastError('Award registration failed', err);
},
});
const budgetMutation = useMutation({
mutationFn: (data: CreateBudgetRequest) => createBudget(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['budgets'] });
success('Budget line established', 'Budget line created against project');
setIsBudgetModalOpen(false);
budgetForm.reset();
},
onError: (err: unknown) => {
toastError('Budget creation failed', err);
},
});
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) {
return updateDisbursement({
disbursementId: editingDisbursement.id,
milestoneId: data.milestoneId ? Number(data.milestoneId) : null,
amount: Number(data.amount),
});
}
return createDisbursement({
projectId: Number(data.projectId),
milestoneId: data.milestoneId ? Number(data.milestoneId) : null,
amount: Number(data.amount),
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['disbursements'] });
success(
editingDisbursement ? 'Disbursement Updated' : 'Disbursement Scheduled',
editingDisbursement ? 'Payment tranche details updated' : 'Payment tranche added in Pending Review state'
);
setIsDisbursementModalOpen(false);
setEditingDisbursement(null);
disbursementForm.reset();
},
onError: (err: unknown) => {
toastError(editingDisbursement ? 'Disbursement update failed' : 'Disbursement creation failed', err);
},
});
// 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: firstProjId,
milestoneId: undefined,
amount: avail > 0 ? Math.min(avail, 150000) : 0,
});
setIsDisbursementModalOpen(true);
};
const openEditDisbursement = (d: Disbursement) => {
setEditingDisbursement(d);
disbursementForm.reset({
projectId: Number(d.projectId) || (projects[0]?.id ? Number(projects[0].id) : 401),
milestoneId: d.milestoneId ? Number(d.milestoneId) : undefined,
amount: d.amount,
});
setIsDisbursementModalOpen(true);
};
const approveMutation = useMutation({
mutationFn: (id: string | number) => approveDisbursement(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['disbursements'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
success(
'Payment Approved & Released',
'Conditions precedent verified. EFT reference generated and disbursement ledger updated.'
);
},
onError: (err: unknown) => {
toastError('Disbursement approval failed', err);
},
});
// Summary calculations
const totalAwardValue = awards.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
const totalDisbursedValue = disbursements
.filter((d) => d.status === ApprovalStatus.Approved)
.reduce((sum, d) => sum + (Number(d.amount) || 0), 0);
const pendingDisbursementValue = disbursements
.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<Award>[] = [
{ 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<Budget>[] = [
{ 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<Disbursement>[] = [
{ 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 (
<div className="space-y-6 max-w-7xl mx-auto">
{/* Header & Execution Pipeline Summary */}
<div className="bg-[#0f1523]/80 backdrop-blur-md p-6 rounded-2xl border border-slate-800/80 shadow-xl space-y-5">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold text-white tracking-tight">
Grant & Financial Management
</h2>
<HelpTooltip
title="Grant Financial Lifecycle"
content="Enforces the statutory financial control thread: Awards Intake (M01) -> Multi-Year Budgets (M03) -> Condition-Checked Tranche Disbursements (M04). Contracts are managed under the Monitoring module."
size="md"
placement="right"
/>
</div>
<p className="text-xs text-slate-400 mt-1">
End-to-end controlled execution thread: Funding Award Intake &rarr; Budgeting &rarr; Condition-Checked Disbursement.
</p>
</div>
<div className="flex items-center gap-2">
{activeTab === 'awards' && (
<button
onClick={openCreateAward}
className="inline-flex items-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-lg shadow-blue-500/20 transition cursor-pointer"
>
<Plus className="w-4 h-4" />
Register Approved Award
</button>
)}
{activeTab === 'budgets' && (
<button
onClick={() => setIsBudgetModalOpen(true)}
className="inline-flex items-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-lg shadow-blue-500/20 transition cursor-pointer"
>
<Plus className="w-4 h-4" />
Add Budget Line
</button>
)}
{activeTab === 'disbursements' && (
<button
onClick={openCreateDisbursement}
className="inline-flex items-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-lg shadow-blue-500/20 transition cursor-pointer"
>
<Plus className="w-4 h-4" />
Schedule Disbursement
</button>
)}
</div>
</div>
{/* Pipeline Summary Bar */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 pt-3 border-t border-slate-800/80 text-xs">
<div className="p-3.5 rounded-xl bg-slate-900/60 border border-slate-800/80 flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="text-slate-400 font-medium">Total Awards Registered</span>
<HelpTooltip
title="Total Awards"
content="Cumulative grant allocation amounts committed across all approved beneficiary entities."
size="sm"
placement="top"
/>
</div>
<span className="font-bold text-white font-mono">{formatCompactZAR(totalAwardValue)}</span>
</div>
<div className="p-3.5 rounded-xl bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="text-emerald-400 font-medium">Disbursed (Verified & Paid)</span>
<HelpTooltip
title="Disbursed Capital"
content="Funds verified, authorized by accounting officer, and disbursed into beneficiary bank accounts."
size="sm"
placement="top"
/>
</div>
<span className="font-bold text-emerald-300 font-mono">{formatCompactZAR(totalDisbursedValue)}</span>
</div>
<div className="p-3.5 rounded-xl bg-amber-500/10 border border-amber-500/20 flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="text-amber-400 font-medium">Pending Release</span>
<HelpTooltip
title="Pending Release"
content="Tranches currently pending condition precedent verification, milestone sign-off, or statutory review."
size="sm"
placement="top"
/>
</div>
<span className="font-bold text-amber-300 font-mono">{formatCompactZAR(pendingDisbursementValue)}</span>
</div>
</div>
</div>
{/* Tabs */}
<div className="flex gap-2 border-b border-slate-800/80 pb-3 overflow-x-auto">
<button
onClick={() => setActiveTab('awards')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 cursor-pointer ${
activeTab === 'awards'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`}
>
<Coins className="w-4 h-4" />
Awards Intake ({awards.length})
</button>
<button
onClick={() => setActiveTab('budgets')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 cursor-pointer ${
activeTab === 'budgets'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`}
>
<PieChart className="w-4 h-4" />
Budget Lines ({budgets.length})
</button>
<button
onClick={() => setActiveTab('disbursements')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 cursor-pointer ${
activeTab === 'disbursements'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`}
>
<Banknote className="w-4 h-4" />
Disbursements & Approvals ({disbursements.length})
</button>
</div>
{/* Tab 1: Awards Table (M01) */}
{activeTab === 'awards' && (
<div className="space-y-3">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
<div className="relative max-w-md flex-1">
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-3" />
<input
type="text"
placeholder="Search by reference #, organisation, programme, or ID..."
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>
<div className="flex items-center gap-2 self-end sm:self-center">
<button
type="button"
onClick={() => {
refetchAwards();
success('Refreshed', 'Awards intake list refreshed from API.');
}}
disabled={isFetchingAwards}
className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-slate-300 bg-[#0d121f] hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
title="Force refresh awards from API"
>
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetchingAwards ? 'animate-spin' : ''}`} />
<span>Refresh Awards</span>
</button>
<ExportButton
title="Grant Awards Registry"
subtitle="Official directory of approved beneficiary funding allocations"
filename={`pfm-awards-${new Date().toISOString().split('T')[0]}`}
columns={awardExportColumns}
data={filteredAwards}
/>
</div>
</div>
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
<th className="py-3.5 px-5">Approval Reference #</th>
<th className="py-3.5 px-4">Recipient Organisation</th>
<th className="py-3.5 px-4">Funding Programme</th>
<th className="py-3.5 px-4 text-right">Approved Amount (ZAR)</th>
<th className="py-3.5 px-4">Status</th>
<th className="py-3.5 px-5 text-right">Date Registered</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{loadingAwards ? (
<tr>
<td colSpan={6} className="py-8 text-center text-slate-500">
Loading registered awards...
</td>
</tr>
) : filteredAwards.length === 0 ? (
<tr>
<td colSpan={6} className="py-8 text-center text-slate-500">
{searchTerm ? 'No awards match your search query.' : 'No registered awards found.'}
</td>
</tr>
) : (
filteredAwards.map((awd) => {
const statusBadge = getApprovalStatusBadge(awd.status);
return (
<tr key={awd.id} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-5">
<span className="font-mono font-bold text-white block">{awd.referenceNo}</span>
<span className="text-[10px] text-slate-500 font-mono">System ID: #{awd.id}</span>
</td>
<td className="py-3.5 px-4 text-slate-200 font-medium">
{awd.organisationName || `Org #${awd.organisationId}`}
</td>
<td className="py-3.5 px-4 text-slate-400">
{awd.programmeName || `Programme #${awd.programmeId}`}
</td>
<td className="py-3.5 px-4 text-right font-mono font-bold text-white text-xs">
{formatZAR(awd.amount)}
</td>
<td className="py-3.5 px-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
{statusBadge.label}
</span>
</td>
<td className="py-3.5 px-5 text-right font-mono text-slate-400">
{formatDate(awd.createdAt)}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* Tab 3: Budgets (M05) */}
{activeTab === 'budgets' && (
<div className="space-y-3">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
<div className="relative max-w-md flex-1">
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-3" />
<input
type="text"
placeholder="Search by category name, project, ID, or creator..."
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>
<div className="flex items-center gap-2 self-end sm:self-center">
<button
type="button"
onClick={() => {
refetchBudgets();
success('Refreshed', 'Budget line categories refreshed from API.');
}}
disabled={isFetchingBudgets}
className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-slate-300 bg-[#0d121f] hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
title="Force refresh budgets from API"
>
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetchingBudgets ? 'animate-spin' : ''}`} />
<span>Refresh Budgets</span>
</button>
<ExportButton
title="Project Budgets Registry"
subtitle="Detailed statutory project budget allocations and category lines"
filename={`pfm-budgets-${new Date().toISOString().split('T')[0]}`}
columns={budgetExportColumns}
data={filteredBudgets}
/>
</div>
</div>
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
<th className="py-3.5 px-5">Budget Line Category</th>
<th className="py-3.5 px-4">Project ID</th>
<th className="py-3.5 px-4 text-right">Approved Amount (ZAR)</th>
<th className="py-3.5 px-4">Date Established</th>
<th className="py-3.5 px-4">Status</th>
<th className="py-3.5 px-4">Created By</th>
<th className="py-3.5 px-5 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{loadingBudgets ? (
<tr>
<td colSpan={7} className="py-8 text-center text-slate-500">
Loading budget lines...
</td>
</tr>
) : filteredBudgets.length === 0 ? (
<tr>
<td colSpan={7} className="py-8 text-center text-slate-500">
{searchTerm ? 'No budget lines match your search query.' : 'No budget lines established yet.'}
</td>
</tr>
) : (
filteredBudgets.map((b) => {
const statusBadge = getApprovalStatusBadge(b.status ?? ApprovalStatus.Approved);
return (
<tr key={b.id} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-5">
<span className="font-semibold text-white block">{b.name}</span>
<span className="text-[10px] text-slate-500 font-mono">ID: #{b.id}</span>
</td>
<td className="py-3.5 px-4 text-slate-400 font-mono">Project #{b.projectId}</td>
<td className="py-3.5 px-4 text-right font-mono font-bold text-white">
{formatZAR(b.amount)}
</td>
<td className="py-3.5 px-4 font-mono text-slate-400">{formatDate(b.createdAt)}</td>
<td className="py-3.5 px-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
{statusBadge.label}
</span>
</td>
<td className="py-3.5 px-4 font-mono text-slate-400">{getUserEmail(b.createdBy)}</td>
<td className="py-3.5 px-5 text-right">
<div className="flex items-center justify-end gap-1.5">
<button
type="button"
onClick={() => {
setEditingBudget(b);
setSelectedBudgetStatus(b.status ?? ApprovalStatus.Approved);
setIsEditBudgetModalOpen(true);
}}
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1.5 rounded-lg transition shadow-xs cursor-pointer"
title="Update Budget Status"
>
<Edit2 className="w-3 h-3" />
<span>Update</span>
</button>
{b.status !== ApprovalStatus.Approved && (
<button
type="button"
disabled={updateBudgetMutation.isPending}
onClick={() => {
updateBudgetMutation.mutate({
budgetId: b.id,
status: ApprovalStatus.Approved,
});
}}
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-300 hover:text-white bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/30 px-2.5 py-1.5 rounded-lg transition shadow-xs cursor-pointer"
title="Approve Budget Line"
>
<CheckCircle className="w-3 h-3" />
<span>Approve</span>
</button>
)}
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* Tab 4: Disbursements & Approval Trigger (M06) */}
{activeTab === 'disbursements' && (
<div className="space-y-3">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
<div className="relative max-w-md flex-1">
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-3" />
<input
type="text"
placeholder="Search by disbursement ID, project, ref, or creator..."
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>
<div className="flex items-center gap-2 self-end sm:self-center">
<button
type="button"
onClick={() => {
refetchDisbursements();
success('Refreshed', 'Disbursement tranches refreshed from API.');
}}
disabled={isFetchingDisbursements}
className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-slate-300 bg-[#0d121f] hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
title="Force refresh disbursements from API"
>
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetchingDisbursements ? 'animate-spin' : ''}`} />
<span>Refresh Disbursements</span>
</button>
<ExportButton
title="Disbursement Tranches Ledger"
subtitle="Milestone-linked funding releases and payment authorizations"
filename={`pfm-disbursements-${new Date().toISOString().split('T')[0]}`}
columns={disbursementExportColumns}
data={filteredDisbursements}
/>
</div>
</div>
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
<div className="p-4 border-b border-slate-800/80 bg-amber-500/10 flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-amber-300">
<ShieldCheck className="w-4 h-4 text-amber-400" />
<span className="font-semibold">Disbursement Control:</span>
<span className="text-amber-200/80">Disbursement cannot be released unless contractual conditions are verified. Duplicate approvals blocked.</span>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
<th className="py-3.5 px-5">Disbursement ID</th>
<th className="py-3.5 px-4">Project ID</th>
<th className="py-3.5 px-4">Milestone Association</th>
<th className="py-3.5 px-4 text-right">Tranche Amount</th>
<th className="py-3.5 px-4">Payment Reference</th>
<th className="py-3.5 px-4">Approval Status</th>
<th className="py-3.5 px-5 text-right">Approval Action</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{loadingDisbursements ? (
<tr>
<td colSpan={7} className="py-8 text-center text-slate-500">
Loading disbursements...
</td>
</tr>
) : filteredDisbursements.length === 0 ? (
<tr>
<td colSpan={7} className="py-8 text-center text-slate-500">
{searchTerm ? 'No disbursements match your search query.' : 'No disbursement tranches recorded yet.'}
</td>
</tr>
) : (
filteredDisbursements.map((d) => {
const statusBadge = getApprovalStatusBadge(d.status);
const isApproved = d.status === ApprovalStatus.Approved;
return (
<tr key={d.id} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-5 font-mono font-bold text-white">
DSB-{d.id}
</td>
<td className="py-3.5 px-4 text-slate-300 font-mono">Project #{d.projectId}</td>
<td className="py-3.5 px-4 text-slate-400 font-medium">
{d.milestoneId ? `Milestone #${d.milestoneId}` : 'Initial Advance Tranche'}
</td>
<td className="py-3.5 px-4 text-right font-mono font-bold text-white text-xs">
{formatZAR(d.amount)}
</td>
<td className="py-3.5 px-4 font-mono text-slate-400 text-[11px]">
{d.paymentReference || <span className="text-slate-500 italic">Pending Release</span>}
</td>
<td className="py-3.5 px-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
{statusBadge.label}
</span>
</td>
<td className="py-3.5 px-5 text-right">
<div className="flex items-center justify-end gap-1.5">
{isApproved ? (
<span className="inline-flex items-center gap-1 text-xs text-emerald-400 font-semibold bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20">
<CheckCircle className="w-3.5 h-3.5" />
Released
</span>
) : (
<>
<button
type="button"
onClick={() => openEditDisbursement(d)}
title="Edit Disbursement"
className="p-1.5 rounded-lg text-slate-400 hover:text-white bg-slate-800 hover:bg-slate-700 transition cursor-pointer"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
<button
onClick={() => approveMutation.mutate(d.id)}
disabled={approveMutation.isPending}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-lg shadow-md shadow-blue-500/20 transition cursor-pointer"
>
<CheckCircle className="w-3.5 h-3.5" />
Approve Payment
</button>
</>
)}
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* Modal: Register Approved Award */}
<Modal
isOpen={isAwardModalOpen}
onClose={() => setIsAwardModalOpen(false)}
title="Register Approved Funding Award"
subtitle="Step 1 of 3 — Award Details"
helpText="Register an approved grant allocation. The award amount cannot exceed the sum of all allocated budgets for the chosen project."
>
<form onSubmit={awardForm.handleSubmit(handleAwardSubmit)} className="space-y-4">
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Target Project *</label>
<HelpTooltip
title="Target Project Entity"
content="The project receiving this grant allocation. Award quantum cannot exceed the sum of this project's allocated budgets."
size="sm"
placement="right"
/>
</div>
<select
{...awardForm.register('projectId', {
onChange: (e) => {
const pId = Number(e.target.value);
const pBudgets = budgets.filter((b) => String(b.projectId) === String(pId));
const totB = pBudgets.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
if (totB > 0) {
awardForm.setValue('amount', totB, { shouldValidate: true });
}
},
})}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
{/* Project Budget Ceiling Card */}
<div className="p-3.5 bg-[#090d16] rounded-xl border border-slate-800 space-y-2.5">
<div className="flex items-center justify-between">
<span className="text-[11px] font-bold uppercase tracking-wider text-slate-400">
Project Budget Ceiling
</span>
<span className="text-[10px] font-mono text-blue-400 bg-blue-500/10 px-2 py-0.5 rounded-full border border-blue-500/30">
Project #{watchedAwardProjectId}
</span>
</div>
<div className="grid grid-cols-2 gap-2 pt-1 border-t border-slate-800/80 text-xs">
<div>
<span className="text-[10px] text-slate-500 block uppercase">Allocated Budget Sum</span>
<span className={`font-mono font-bold text-sm ${totalAllocatedBudgetForAwardProject > 0 ? 'text-emerald-400' : 'text-rose-400'}`}>
{formatZAR(totalAllocatedBudgetForAwardProject)}
</span>
</div>
<div>
<span className="text-[10px] text-slate-500 block uppercase">Budget Lines</span>
<span className="font-mono font-semibold text-slate-300">
{selectedProjectBudgetsForAward.length} Category Line{selectedProjectBudgetsForAward.length === 1 ? '' : 's'}
</span>
</div>
</div>
{totalAllocatedBudgetForAwardProject > 0 && (
<div className="pt-2 border-t border-slate-800/80 flex items-center justify-between">
<span className="text-[10px] text-slate-400">
Max allowable award for this project:
</span>
<button
type="button"
onClick={() => awardForm.setValue('amount', totalAllocatedBudgetForAwardProject, { shouldValidate: true })}
className="px-2.5 py-1 text-[11px] font-semibold text-blue-400 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 rounded-lg transition cursor-pointer"
>
Set to Max Budget ({formatZAR(totalAllocatedBudgetForAwardProject)})
</button>
</div>
)}
</div>
{/* Validation Alert Banners */}
{hasNoBudgetForAwardProject && (
<div className="p-3 bg-amber-500/10 border border-amber-500/30 rounded-xl text-xs text-amber-300 flex items-start gap-2">
<AlertTriangle className="w-4 h-4 shrink-0 text-amber-400 mt-0.5" />
<div>
<strong className="block font-semibold">No Budget Allocated</strong>
The chosen project has ZAR 0.00 in allocated budget lines. Establish budget lines in the Budgets tab before creating an award.
</div>
</div>
)}
{isAwardExceedingBudget && !hasNoBudgetForAwardProject && (
<div className="p-3 bg-rose-500/10 border border-rose-500/30 rounded-xl text-xs text-rose-300 flex items-start gap-2">
<AlertTriangle className="w-4 h-4 shrink-0 text-rose-400 mt-0.5" />
<div>
<strong className="block font-semibold">Exceeds Project Budget Ceiling</strong>
The entered award amount of{' '}
<span className="font-mono font-bold text-white">{formatZAR(watchedAwardAmount)}</span>{' '}
exceeds the sum of all allocated budgets for the chosen project ({' '}
<span className="font-mono font-bold text-white">{formatZAR(totalAllocatedBudgetForAwardProject)}</span>{' '}
) by{' '}
<span className="font-mono font-bold text-rose-400">
{formatZAR(watchedAwardAmount - totalAllocatedBudgetForAwardProject)}
</span>
. Creation is disallowed.
</div>
</div>
)}
{isAwardNegativeOrZero && (
<div className="p-3 bg-rose-500/10 border border-rose-500/30 rounded-xl text-xs text-rose-300 flex items-start gap-2">
<AlertTriangle className="w-4 h-4 shrink-0 text-rose-400 mt-0.5" />
<div>
<strong className="block font-semibold">Invalid Award Value</strong>
Approved funding amount must be strictly greater than zero. Negative or zero values are prohibited.
</div>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Approval / Reference Number *
</label>
<HelpTooltip
title="Statutory Reference Number"
content="Formal committee approval voucher or gazetted award identifier."
size="sm"
placement="top"
/>
</div>
<input
type="text"
{...awardForm.register('referenceNo')}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-mono transition"
placeholder="AWD-2026-NSI-088"
/>
{awardForm.formState.errors.referenceNo && (
<p className="text-[10px] text-rose-400 mt-1">{awardForm.formState.errors.referenceNo.message}</p>
)}
</div>
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Approved Funding Amount (ZAR) *
</label>
<HelpTooltip
title="Award Quantum"
content="Total approved allocation authorized by the statutory grants board (must not exceed project budget sum)."
size="sm"
placement="top"
/>
</div>
<input
type="number"
min="1"
step="any"
{...awardForm.register('amount')}
className={`w-full px-3 py-2 text-xs bg-slate-900/80 border rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 font-mono transition ${
isAwardDisallowed
? 'border-rose-500/80 focus:ring-rose-500/50 focus:border-rose-500'
: 'border-slate-700/80 focus:ring-blue-500/50 focus:border-blue-500'
}`}
/>
{awardForm.formState.errors.amount && (
<p className="text-[10px] text-rose-400 mt-1">{awardForm.formState.errors.amount.message}</p>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Recipient Organisation *
</label>
<HelpTooltip
title="Beneficiary Entity"
content="Verified enterprise, university, or consortium holding the funding agreement."
size="sm"
placement="top"
/>
</div>
<select
{...awardForm.register('organisationId')}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
>
{organisations.map((o) => (
<option key={o.id} value={o.id}>
{o.name}
</option>
))}
</select>
</div>
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Funding Programme / Instrument *
</label>
<HelpTooltip
title="Statutory Fund Instrument"
content="The national budget vote or sub-programme financing this grant award."
size="sm"
placement="top"
/>
</div>
<select
{...awardForm.register('programmeId')}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
>
{programmes.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
</div>
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Funding Period *</label>
<HelpTooltip
title="Funding Duration"
content="Tenure of the grant agreement (e.g., 24 Months, 36 Months)."
size="sm"
placement="right"
/>
</div>
<input
type="text"
{...awardForm.register('fundingPeriod')}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition"
placeholder="e.g. 24 Months (1 Oct 2026 – 30 Sep 2028)"
/>
</div>
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
<button
type="button"
onClick={() => setIsAwardModalOpen(false)}
className="px-4 py-2 text-xs font-medium text-slate-300 bg-slate-800/80 hover:bg-slate-700 rounded-xl transition cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={awardMutation.isPending || isAwardDisallowed}
className="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-lg shadow-blue-500/20 transition cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
{awardMutation.isPending ? 'Registering...' : 'Register Award'}
</button>
</div>
</form>
</Modal>
{/* Modal: Add Budget */}
<Modal
isOpen={isBudgetModalOpen}
onClose={() => setIsBudgetModalOpen(false)}
title="Add Project Budget Line"
subtitle="Maintain approved expenditure breakdown and commitments"
helpText="Establishes financial lines for project cost categories. Ensures all disbursements are mapped to audited budget ceilings."
>
<form onSubmit={budgetForm.handleSubmit((data) => budgetMutation.mutate(data as any))} className="space-y-4">
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Associated Project *</label>
<HelpTooltip
title="Funded Project"
content="Target project entity against which expenditure is tracked."
size="sm"
placement="right"
/>
</div>
<select
{...budgetForm.register('projectId')}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Budget Category Name *</label>
<HelpTooltip
title="Category Name"
content="Standard chart of accounts line (e.g. Personnel, Research Consumables, Capital Equipment)."
size="sm"
placement="right"
/>
</div>
<input
type="text"
{...budgetForm.register('name')}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition"
placeholder="e.g. Specialized Equipment & Prototype Probes"
/>
</div>
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Approved Line Amount (ZAR) *</label>
<HelpTooltip
title="Line Allocation"
content="Ceiling limit allocated for this specific cost category."
size="sm"
placement="right"
/>
</div>
<input
type="number"
{...budgetForm.register('amount')}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-mono transition"
/>
</div>
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
<button
type="button"
onClick={() => setIsBudgetModalOpen(false)}
className="px-4 py-2 text-xs font-medium text-slate-300 bg-slate-800/80 hover:bg-slate-700 rounded-xl transition"
>
Cancel
</button>
<button
type="submit"
disabled={budgetMutation.isPending}
className="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-lg shadow-blue-500/20 transition"
>
{budgetMutation.isPending ? 'Saving...' : 'Add Budget Line'}
</button>
</div>
</form>
</Modal>
{/* Modal: Update Budget Status */}
<Modal
isOpen={isEditBudgetModalOpen}
onClose={() => {
setIsEditBudgetModalOpen(false);
setEditingBudget(null);
}}
title={`Update Budget: ${editingBudget?.name || ''}`}
subtitle={`Budget Line #${editingBudget?.id || ''} for Project #${editingBudget?.projectId || ''}`}
helpText="Update the statutory approval and compliance status for this project budget category line."
>
<div className="space-y-4 text-xs">
<div className="p-3.5 bg-[#0d121f] rounded-xl border border-slate-800 space-y-2">
<div className="flex justify-between items-center">
<span className="text-slate-400 font-medium">Budget Category:</span>
<span className="text-white font-semibold">{editingBudget?.name}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-slate-400 font-medium">Allocated Amount:</span>
<span className="font-mono text-emerald-400 font-bold">{editingBudget ? formatZAR(editingBudget.amount) : '—'}</span>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
Approval &amp; Compliance Status *
</label>
<select
value={selectedBudgetStatus}
onChange={(e) => setSelectedBudgetStatus(Number(e.target.value) as ApprovalStatus)}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-medium [&>option]:bg-slate-900 [&>option]:text-white"
>
<option value={ApprovalStatus.Approved}>Approved</option>
<option value={ApprovalStatus.Pending}>Pending Review</option>
<option value={ApprovalStatus.Rejected}>Rejected / Blocked</option>
</select>
</div>
<div className="pt-3 border-t border-slate-800 flex justify-end gap-2">
<button
type="button"
onClick={() => {
setIsEditBudgetModalOpen(false);
setEditingBudget(null);
}}
className="px-4 py-2 text-xs font-medium text-slate-300 bg-slate-800/80 hover:bg-slate-700 rounded-xl transition cursor-pointer"
>
Cancel
</button>
<button
type="button"
disabled={updateBudgetMutation.isPending || !editingBudget}
onClick={() => {
if (editingBudget) {
updateBudgetMutation.mutate({
budgetId: editingBudget.id,
status: selectedBudgetStatus,
});
}
}}
className="inline-flex items-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 disabled:opacity-50"
>
{updateBudgetMutation.isPending ? 'Saving Changes...' : 'Save Changes'}
</button>
</div>
</div>
</Modal>
{/* Modal: Schedule or Edit Disbursement */}
<Modal
isOpen={isDisbursementModalOpen}
onClose={() => {
setIsDisbursementModalOpen(false);
setEditingDisbursement(null);
}}
title={editingDisbursement ? 'Edit Disbursement Tranche' : 'Schedule Disbursement Tranche'}
subtitle={
editingDisbursement
? `Updating payment tranche DSB-${editingDisbursement.id}`
: 'Link disbursement to milestone verification and conditions precedent'
}
helpText="Create or adjust scheduled disbursement tranches. Payment releases are strictly limited to the project's allocated budget ceiling minus all existing disbursements."
>
<form onSubmit={disbursementForm.handleSubmit(handleDisbursementSubmit)} className="space-y-4">
{!editingDisbursement && (
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Select Project *</label>
<HelpTooltip
title="Target Project"
content="The project entity receiving this payment tranche."
size="sm"
placement="right"
/>
</div>
<select
{...disbursementForm.register('projectId')}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
)}
{/* Budget Ceiling & Headroom Calculation Card */}
<div className="p-3.5 bg-[#090d16] rounded-xl border border-slate-800 space-y-2.5">
<div className="flex items-center justify-between">
<span className="text-[11px] font-bold uppercase tracking-wider text-slate-400">
Disbursement Budget Ceiling
</span>
<span className="text-[10px] font-mono text-blue-400 bg-blue-500/10 px-2 py-0.5 rounded-full border border-blue-500/30">
Project #{watchedDisbursementProjectId}
</span>
</div>
<div className="grid grid-cols-2 gap-2 pt-1 border-t border-slate-800/80 text-xs">
<div>
<span className="text-[10px] text-slate-500 block uppercase">Allocated Budget</span>
<span className="font-mono font-semibold text-white">
{formatZAR(totalAllocatedProjectBudget)}
</span>
</div>
<div>
<span className="text-[10px] text-slate-500 block uppercase">Prior Disbursements</span>
<span className="font-mono font-semibold text-slate-300">
{formatZAR(totalExistingProjectDisbursements)}
</span>
</div>
</div>
<div className="pt-2 border-t border-slate-800/80 flex items-center justify-between">
<div>
<span className="text-[10px] text-slate-400 block uppercase font-medium">
Available Disbursement Capacity
</span>
<span
className={`font-mono font-bold text-sm ${
availableDisbursementCapacity > 0 ? 'text-emerald-400' : 'text-rose-400'
}`}
>
{formatZAR(availableDisbursementCapacity)}
</span>
</div>
{availableDisbursementCapacity > 0 && !editingDisbursement && (
<button
type="button"
onClick={() => disbursementForm.setValue('amount', availableDisbursementCapacity, { shouldValidate: true })}
className="px-2.5 py-1 text-[11px] font-semibold text-blue-400 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 rounded-lg transition cursor-pointer"
>
Fill Max Available
</button>
)}
</div>
</div>
{/* Validation Alert Banners */}
{hasNoProjectBudget && (
<div className="p-3 bg-amber-500/10 border border-amber-500/30 rounded-xl text-xs text-amber-300 flex items-start gap-2">
<AlertTriangle className="w-4 h-4 shrink-0 text-amber-400 mt-0.5" />
<div>
<strong className="block font-semibold">No Budget Lines Allocated</strong>
This project currently has ZAR 0.00 in allocated budgets. You must establish budget lines in the Budgets tab before creating disbursements.
</div>
</div>
)}
{isDisbursementExceedingBudget && !hasNoProjectBudget && (
<div className="p-3 bg-rose-500/10 border border-rose-500/30 rounded-xl text-xs text-rose-300 flex items-start gap-2">
<AlertTriangle className="w-4 h-4 shrink-0 text-rose-400 mt-0.5" />
<div>
<strong className="block font-semibold">Exceeds Project Budget Ceiling</strong>
The requested tranche of{' '}
<span className="font-mono font-bold text-white">{formatZAR(watchedDisbursementAmount)}</span>{' '}
exceeds the available budget capacity of{' '}
<span className="font-mono font-bold text-white">{formatZAR(availableDisbursementCapacity)}</span>{' '}
by{' '}
<span className="font-mono font-bold text-rose-400">
{formatZAR(watchedDisbursementAmount - availableDisbursementCapacity)}
</span>
. Creation is disallowed.
</div>
</div>
)}
{isDisbursementNegativeOrZero && (
<div className="p-3 bg-rose-500/10 border border-rose-500/30 rounded-xl text-xs text-rose-300 flex items-start gap-2">
<AlertTriangle className="w-4 h-4 shrink-0 text-rose-400 mt-0.5" />
<div>
<strong className="block font-semibold">Invalid Tranche Value</strong>
Disbursement amounts must be strictly positive. Negative or zero values are prohibited.
</div>
</div>
)}
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Linked Milestone Deliverable</label>
<HelpTooltip
title="Milestone Requirement"
content="If linked, the disbursement is held until the milestone is marked Approved/Verified."
size="sm"
placement="right"
/>
</div>
<select
{...disbursementForm.register('milestoneId')}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
>
<option value="">-- Advance Tranche (No linked milestone) --</option>
{milestones
.filter((m) => String(m.projectId) === String(watchedDisbursementProjectId))
.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-1">
<label className="block text-xs font-semibold text-slate-300">Tranche Amount (ZAR) *</label>
<HelpTooltip
title="Tranche Quantum"
content="Exact currency value to be transferred upon authorization. Must be > 0 and <= available budget capacity."
size="sm"
placement="right"
/>
</div>
{availableDisbursementCapacity > 0 && watchedDisbursementAmount > 0 && !isDisbursementExceedingBudget && (
<span className="text-[11px] font-mono text-slate-400">
Remaining after tranche: <span className="text-emerald-400 font-semibold">{formatZAR(availableDisbursementCapacity - watchedDisbursementAmount)}</span>
</span>
)}
</div>
<input
type="number"
min="1"
step="any"
{...disbursementForm.register('amount')}
className={`w-full px-3 py-2 text-xs bg-slate-900/80 border rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 font-mono transition ${
isDisbursementDisallowed
? 'border-rose-500/80 focus:ring-rose-500/50 focus:border-rose-500'
: 'border-slate-700/80 focus:ring-blue-500/50 focus:border-blue-500'
}`}
/>
{disbursementForm.formState.errors.amount && (
<p className="text-[11px] text-rose-400 mt-1">
{disbursementForm.formState.errors.amount.message}
</p>
)}
</div>
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
<button
type="button"
onClick={() => {
setIsDisbursementModalOpen(false);
setEditingDisbursement(null);
}}
className="px-4 py-2 text-xs font-medium text-slate-300 bg-slate-800/80 hover:bg-slate-700 rounded-xl transition cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={disbursementMutation.isPending || isDisbursementDisallowed}
className="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-lg shadow-blue-500/20 transition cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
{disbursementMutation.isPending
? 'Saving...'
: editingDisbursement
? 'Update Disbursement'
: 'Schedule Disbursement'}
</button>
</div>
</form>
</Modal>
</div>
);
};