1727 lines
79 KiB
TypeScript
1727 lines
79 KiB
TypeScript
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 → Budgeting → 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 & 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>
|
||
);
|
||
};
|