1122 lines
50 KiB
TypeScript
1122 lines
50 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,
|
||
FileText,
|
||
PieChart,
|
||
Banknote,
|
||
Plus,
|
||
CheckCircle,
|
||
Clock,
|
||
ShieldCheck,
|
||
AlertTriangle,
|
||
ArrowRight,
|
||
Lock,
|
||
ExternalLink,
|
||
DollarSign,
|
||
FileCheck2,
|
||
Download,
|
||
Upload,
|
||
Link as LinkIcon,
|
||
} from 'lucide-react';
|
||
import {
|
||
getAwards,
|
||
getAward,
|
||
createAward,
|
||
getContracts,
|
||
getContract,
|
||
createContract,
|
||
updateContract,
|
||
uploadContractFile,
|
||
getContractSecureLink,
|
||
executeContract,
|
||
getBudgets,
|
||
getBudget,
|
||
createBudget,
|
||
getDisbursements,
|
||
getDisbursement,
|
||
createDisbursement,
|
||
approveDisbursement,
|
||
getOrganisations,
|
||
getAllProgrammes,
|
||
getProjects,
|
||
getAllMilestones,
|
||
} from '../services/api';
|
||
import { Modal } from '../components/ui/Modal';
|
||
import { useToast } from '../components/ui/Toast';
|
||
import {
|
||
Award,
|
||
Contract,
|
||
Budget,
|
||
Disbursement,
|
||
ApprovalStatus,
|
||
ContractStatus,
|
||
CreateAwardRequest,
|
||
CreateContractRequest,
|
||
ExecuteContractRequest,
|
||
CreateBudgetRequest,
|
||
CreateDisbursementRequest,
|
||
} from '../types';
|
||
import {
|
||
formatZAR,
|
||
formatCompactZAR,
|
||
formatDate,
|
||
getApprovalStatusBadge,
|
||
getContractStatusBadge,
|
||
getUserEmail,
|
||
} from '../lib/formatters';
|
||
|
||
// Schemas matching Figure 9 and OpenAPI constraints
|
||
const awardSchema = z.object({
|
||
referenceNo: z.string().min(4, 'Approval / Reference number is mandatory (FR-M01-002)'),
|
||
amount: z.coerce.number().min(1, 'Approved funding amount must be > 0 (FR-M01-003)'),
|
||
fundingPeriod: z.string().min(3, 'Funding period is mandatory (e.g. 24 Months)'),
|
||
organisationId: z.coerce.number().min(1, 'Select recipient organisation (FR-M01-006)'),
|
||
programmeId: z.coerce.number().min(1, 'Select funding programme (FR-M01-005)'),
|
||
});
|
||
|
||
const contractSchema = z.object({
|
||
awardId: z.coerce.number().min(1, 'Select approved award'),
|
||
totalValue: z.coerce.number().min(1, 'Total value is required'),
|
||
effectiveAt: z.string().min(4, 'Effective date is mandatory'),
|
||
expiresAt: z.string().min(4, 'Expiry date is mandatory'),
|
||
});
|
||
|
||
const executeContractSchema = z.object({
|
||
contractId: z.coerce.number().min(1),
|
||
externalSignatureId: z.string().min(4, 'Digital signature ID is required'),
|
||
signedDocumentUrl: z.string().url('Must be a valid document URL'),
|
||
});
|
||
|
||
const budgetSchema = z.object({
|
||
projectId: z.coerce.number().min(1, 'Select project'),
|
||
name: z.string().min(3, 'Budget category name required'),
|
||
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().min(1, 'Disbursement tranche amount required'),
|
||
});
|
||
|
||
type AwardFormValues = z.infer<typeof awardSchema>;
|
||
type ContractFormValues = z.infer<typeof contractSchema>;
|
||
type ExecuteContractFormValues = z.infer<typeof executeContractSchema>;
|
||
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' | 'contracts' | 'budgets' | 'disbursements'>('awards');
|
||
|
||
// Modals
|
||
const [isAwardModalOpen, setIsAwardModalOpen] = useState(false);
|
||
const [isContractModalOpen, setIsContractModalOpen] = useState(false);
|
||
const [isExecuteModalOpen, setIsExecuteModalOpen] = useState(false);
|
||
const [selectedContractForExecution, setSelectedContractForExecution] = useState<Contract | null>(null);
|
||
const [isBudgetModalOpen, setIsBudgetModalOpen] = useState(false);
|
||
const [isDisbursementModalOpen, setIsDisbursementModalOpen] = useState(false);
|
||
|
||
// Queries
|
||
const { data: awards = [], isLoading: loadingAwards } = useQuery({
|
||
queryKey: ['awards'],
|
||
queryFn: () => getAwards(1, 100),
|
||
});
|
||
|
||
const { data: contracts = [], isLoading: loadingContracts } = useQuery({
|
||
queryKey: ['contracts'],
|
||
queryFn: () => getContracts(1, 100),
|
||
});
|
||
|
||
const { data: budgets = [], isLoading: loadingBudgets } = useQuery({
|
||
queryKey: ['budgets'],
|
||
queryFn: () => getBudgets(1, 100),
|
||
});
|
||
|
||
const { data: disbursements = [], isLoading: loadingDisbursements } = 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: '',
|
||
amount: 10000000,
|
||
fundingPeriod: '24 Months',
|
||
organisationId: organisations[0]?.id ? Number(organisations[0].id) : 101,
|
||
programmeId: programmes[0]?.id ? Number(programmes[0].id) : 301,
|
||
},
|
||
});
|
||
|
||
const contractForm = useForm<ContractFormValues>({
|
||
resolver: zodResolver(contractSchema) as any,
|
||
defaultValues: {
|
||
awardId: awards[0]?.id ? Number(awards[0].id) : 501,
|
||
totalValue: 10000000,
|
||
effectiveAt: new Date().toISOString().slice(0, 10),
|
||
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10),
|
||
},
|
||
});
|
||
|
||
const executeForm = useForm<ExecuteContractFormValues>({
|
||
resolver: zodResolver(executeContractSchema) as any,
|
||
defaultValues: {
|
||
contractId: 0,
|
||
externalSignatureId: '',
|
||
signedDocumentUrl: '',
|
||
},
|
||
});
|
||
|
||
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: ['contracts'] });
|
||
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||
success(
|
||
'Award Registered & Contract Initiated',
|
||
`Award ${newAwd.referenceNo} recorded. Automated Contract workflow initiated (BR-002).`
|
||
);
|
||
setIsAwardModalOpen(false);
|
||
awardForm.reset();
|
||
},
|
||
onError: (err: unknown) => {
|
||
toastError('Award registration failed', String(err));
|
||
},
|
||
});
|
||
|
||
const contractMutation = useMutation({
|
||
mutationFn: (data: CreateContractRequest) => createContract(data),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['contracts'] });
|
||
success('Contract generated', 'New legal agreement created in Draft state');
|
||
setIsContractModalOpen(false);
|
||
contractForm.reset();
|
||
},
|
||
onError: (err: unknown) => {
|
||
toastError('Contract creation failed', String(err));
|
||
},
|
||
});
|
||
|
||
const executeMutation = useMutation({
|
||
mutationFn: (data: ExecuteContractRequest) => executeContract(data),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['contracts'] });
|
||
success(
|
||
'Contract Executed & Locked',
|
||
'Agreement locked as authoritative version (AT-02). Downstream budgets and disbursements enabled.'
|
||
);
|
||
setIsExecuteModalOpen(false);
|
||
executeForm.reset();
|
||
},
|
||
onError: (err: unknown) => {
|
||
toastError('Execution trigger failed', String(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', String(err));
|
||
},
|
||
});
|
||
|
||
const disbursementMutation = useMutation({
|
||
mutationFn: (data: CreateDisbursementRequest) => createDisbursement(data),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['disbursements'] });
|
||
success('Disbursement Scheduled', 'Payment tranche added in Pending Review state');
|
||
setIsDisbursementModalOpen(false);
|
||
disbursementForm.reset();
|
||
},
|
||
onError: (err: unknown) => {
|
||
toastError('Disbursement creation failed', String(err));
|
||
},
|
||
});
|
||
|
||
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 (AT-05).'
|
||
);
|
||
},
|
||
onError: (err: unknown) => {
|
||
toastError('Disbursement approval failed', String(err));
|
||
},
|
||
});
|
||
|
||
const openExecuteModal = (c: Contract) => {
|
||
setSelectedContractForExecution(c);
|
||
executeForm.reset({
|
||
contractId: Number(c.id),
|
||
externalSignatureId: `SIG-DIGITAL-${Math.floor(100000 + Math.random() * 900000)}`,
|
||
signedDocumentUrl: `https://documents.ifri.khongisa.co.za/contracts/CTR-${c.id}-Signed.pdf`,
|
||
});
|
||
setIsExecuteModalOpen(true);
|
||
};
|
||
|
||
const handleGetContractSecureLink = async (contractId: string | number) => {
|
||
try {
|
||
const url = await getContractSecureLink(contractId);
|
||
if (url) {
|
||
window.open(url, '_blank', 'noopener,noreferrer');
|
||
success('Secure URL Retrieved', 'Temporary signed download link opened.');
|
||
} else {
|
||
toastError('Download Error', 'No active secure download link generated.');
|
||
}
|
||
} catch (err: any) {
|
||
toastError('Link Error', err?.message || 'Failed to fetch contract secure link.');
|
||
}
|
||
};
|
||
|
||
const handleContractFileUpload = async (contractId: string | number, file: File) => {
|
||
try {
|
||
await uploadContractFile(contractId, file);
|
||
success('Document Attached', `Contract #${contractId} document uploaded successfully.`);
|
||
queryClient.invalidateQueries({ queryKey: ['contracts'] });
|
||
} catch (err: any) {
|
||
toastError('Upload Failed', err?.message || 'Contract file upload failed.');
|
||
}
|
||
};
|
||
|
||
const handleUpdateContractStatus = async (contractId: string | number, status: ContractStatus) => {
|
||
try {
|
||
await updateContract({ contractId, status });
|
||
success('Contract Updated', `Contract status updated to ${getContractStatusBadge(status).label}.`);
|
||
queryClient.invalidateQueries({ queryKey: ['contracts'] });
|
||
} catch (err: any) {
|
||
toastError('Update Failed', err?.message || 'Failed to update contract status.');
|
||
}
|
||
};
|
||
|
||
// 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);
|
||
|
||
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>
|
||
<h2 className="text-2xl font-bold text-white tracking-tight flex items-center gap-2">
|
||
Grant & Financial Management
|
||
</h2>
|
||
<p className="text-xs text-slate-400 mt-1">
|
||
End-to-end controlled execution thread: Funding Award Intake → Contracting → Budgeting → Condition-Checked Disbursement.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
{activeTab === 'awards' && (
|
||
<button
|
||
onClick={() => setIsAwardModalOpen(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"
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
Register Approved Award
|
||
</button>
|
||
)}
|
||
{activeTab === 'contracts' && (
|
||
<button
|
||
onClick={() => setIsContractModalOpen(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"
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
Draft Contract
|
||
</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"
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
Add Budget Line
|
||
</button>
|
||
)}
|
||
{activeTab === 'disbursements' && (
|
||
<button
|
||
onClick={() => setIsDisbursementModalOpen(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"
|
||
>
|
||
<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">
|
||
<span className="text-slate-400 font-medium">Total Awards Registered</span>
|
||
<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">
|
||
<span className="text-emerald-400 font-medium">Disbursed (Verified & Paid)</span>
|
||
<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">
|
||
<span className="text-amber-400 font-medium">Pending Release</span>
|
||
<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 ${
|
||
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('contracts')}
|
||
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${
|
||
activeTab === 'contracts'
|
||
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
||
}`}
|
||
>
|
||
<FileText className="w-4 h-4" />
|
||
Contracts & Agreements ({contracts.length})
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab('budgets')}
|
||
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${
|
||
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 ${
|
||
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="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>
|
||
) : (
|
||
awards.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 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>
|
||
)}
|
||
|
||
{/* Tab 2: Contracts Table (M02) */}
|
||
{activeTab === 'contracts' && (
|
||
<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">Contract ID</th>
|
||
<th className="py-3.5 px-4">Associated Award</th>
|
||
<th className="py-3.5 px-4 text-right">Total Contract Value</th>
|
||
<th className="py-3.5 px-4">Effective / Expiry</th>
|
||
<th className="py-3.5 px-4">Status</th>
|
||
<th className="py-3.5 px-5 text-right">Action</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-800/50">
|
||
{loadingContracts ? (
|
||
<tr>
|
||
<td colSpan={6} className="py-8 text-center text-slate-500">
|
||
Loading contracts...
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
contracts.map((c) => {
|
||
const statusBadge = getContractStatusBadge(c.status);
|
||
const isExecuted = c.status === ContractStatus.Executed;
|
||
return (
|
||
<tr key={c.id} className="hover:bg-slate-800/40 transition-colors">
|
||
<td className="py-3.5 px-5 font-mono font-bold text-white">
|
||
CTR-{c.id}
|
||
</td>
|
||
<td className="py-3.5 px-4 text-slate-300 font-medium">Award #{c.awardId}</td>
|
||
<td className="py-3.5 px-4 text-right font-mono font-bold text-white">
|
||
{formatZAR(c.totalValue)}
|
||
</td>
|
||
<td className="py-3.5 px-4 text-slate-400 font-mono text-[11px]">
|
||
{formatDate(c.effectiveAt)} → {formatDate(c.expiresAt)}
|
||
</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">
|
||
<button
|
||
type="button"
|
||
onClick={() => handleGetContractSecureLink(c.id)}
|
||
title="Fetch Secure Download URL"
|
||
className="p-1.5 rounded-lg text-slate-400 hover:text-white bg-slate-900/60 hover:bg-slate-800 border border-slate-700/60 transition cursor-pointer"
|
||
>
|
||
<LinkIcon className="w-3.5 h-3.5" />
|
||
</button>
|
||
|
||
<label
|
||
title="Upload Executed Contract Document"
|
||
className="p-1.5 rounded-lg text-slate-400 hover:text-white bg-slate-900/60 hover:bg-slate-800 border border-slate-700/60 transition cursor-pointer inline-flex items-center"
|
||
>
|
||
<Upload className="w-3.5 h-3.5" />
|
||
<input
|
||
type="file"
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
const file = e.target.files?.[0];
|
||
if (file) handleContractFileUpload(c.id, file);
|
||
}}
|
||
/>
|
||
</label>
|
||
|
||
<select
|
||
value={c.status}
|
||
onChange={(e) => handleUpdateContractStatus(c.id, Number(e.target.value) as ContractStatus)}
|
||
className="text-[10px] bg-slate-900/80 border border-slate-700/80 rounded-lg px-2 py-1 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900 [&>option]:text-white cursor-pointer"
|
||
title="Update Status"
|
||
>
|
||
<option value={ContractStatus.Draft}>Draft</option>
|
||
<option value={ContractStatus.PendingApproval}>Pending Approval</option>
|
||
<option value={ContractStatus.Active}>Active / Executed</option>
|
||
<option value={ContractStatus.Terminated}>Terminated</option>
|
||
</select>
|
||
|
||
{isExecuted ? (
|
||
<span className="inline-flex items-center gap-1 text-[11px] text-emerald-400 font-semibold bg-emerald-500/10 px-2 py-1 rounded-lg border border-emerald-500/20">
|
||
<Lock className="w-3 h-3" />
|
||
Locked
|
||
</span>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
onClick={() => openExecuteModal(c)}
|
||
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] 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-xs shadow-blue-500/20 transition cursor-pointer"
|
||
>
|
||
<FileCheck2 className="w-3 h-3" />
|
||
Execute
|
||
</button>
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Tab 3: Budgets (M05) */}
|
||
{activeTab === 'budgets' && (
|
||
<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-5 text-right">Created By</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-800/50">
|
||
{loadingBudgets ? (
|
||
<tr>
|
||
<td colSpan={5} className="py-8 text-center text-slate-500">
|
||
Loading budget lines...
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
budgets.map((b) => (
|
||
<tr key={b.id} className="hover:bg-slate-800/40 transition-colors">
|
||
<td className="py-3.5 px-5 font-semibold text-white">{b.name}</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-5 text-right font-mono text-slate-400">{getUserEmail(b.createdBy)}</td>
|
||
</tr>
|
||
))
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Tab 4: Disbursements & Approval Trigger (M06) */}
|
||
{activeTab === 'disbursements' && (
|
||
<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>
|
||
) : (
|
||
disbursements.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 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">
|
||
{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
|
||
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"
|
||
>
|
||
<CheckCircle className="w-3.5 h-3.5" />
|
||
Approve Payment
|
||
</button>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Modal: Register Approved Award */}
|
||
<Modal
|
||
isOpen={isAwardModalOpen}
|
||
onClose={() => setIsAwardModalOpen(false)}
|
||
title="Register Approved Funding Award"
|
||
subtitle="Step 1 of 3 — Award Details"
|
||
>
|
||
<form onSubmit={awardForm.handleSubmit((data) => awardMutation.mutate(data as any))} className="space-y-4">
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
|
||
Approval / Reference Number *
|
||
</label>
|
||
<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>
|
||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
|
||
Approved Funding Amount (ZAR) *
|
||
</label>
|
||
<input
|
||
type="number"
|
||
{...awardForm.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"
|
||
/>
|
||
{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>
|
||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
|
||
Recipient Organisation [search] *
|
||
</label>
|
||
<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>
|
||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
|
||
Funding Programme / Instrument *
|
||
</label>
|
||
<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>
|
||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Funding Period *</label>
|
||
<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="p-3 rounded-xl bg-blue-500/10 border border-blue-500/20 text-[11px] text-blue-300 space-y-1">
|
||
<span className="font-bold block">Workflow Automation:</span>
|
||
<span>Submitting this record will automatically initiate the Contract/Agreement workflow.</span>
|
||
</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"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={awardMutation.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"
|
||
>
|
||
{awardMutation.isPending ? 'Submitting...' : 'Submit Award & Initiate Contract'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</Modal>
|
||
|
||
{/* Modal: Draft Contract */}
|
||
<Modal
|
||
isOpen={isContractModalOpen}
|
||
onClose={() => setIsContractModalOpen(false)}
|
||
title="Draft Contract Agreement"
|
||
subtitle="Convert approved funding decision into legally enforceable terms"
|
||
>
|
||
<form onSubmit={contractForm.handleSubmit((data) => contractMutation.mutate(data as any))} className="space-y-4">
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Select Award *</label>
|
||
<select
|
||
{...contractForm.register('awardId')}
|
||
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"
|
||
>
|
||
{awards.map((a) => (
|
||
<option key={a.id} value={a.id}>
|
||
{a.referenceNo} — {formatZAR(a.amount)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Contract Total Value (ZAR) *</label>
|
||
<input
|
||
type="number"
|
||
{...contractForm.register('totalValue')}
|
||
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="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Effective Date *</label>
|
||
<input
|
||
type="date"
|
||
{...contractForm.register('effectiveAt')}
|
||
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"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Expiry Date *</label>
|
||
<input
|
||
type="date"
|
||
{...contractForm.register('expiresAt')}
|
||
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"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsContractModalOpen(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={contractMutation.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"
|
||
>
|
||
{contractMutation.isPending ? 'Generating...' : 'Create Contract'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</Modal>
|
||
|
||
{/* Modal: Execute Contract */}
|
||
<Modal
|
||
isOpen={isExecuteModalOpen}
|
||
onClose={() => setIsExecuteModalOpen(false)}
|
||
title="Execute Contract & Lock Agreement"
|
||
subtitle={`Locking Contract #${selectedContractForExecution?.id} as Authoritative Version`}
|
||
>
|
||
<form onSubmit={executeForm.handleSubmit((data) => executeMutation.mutate(data as any))} className="space-y-4">
|
||
<div className="p-3.5 bg-slate-900/60 border border-slate-800/80 rounded-xl text-xs space-y-1 text-slate-300">
|
||
<p>
|
||
<strong className="text-white">Obligation:</strong> Once executed, the agreement is locked and downstream budget control and
|
||
disbursement requests are enabled.
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
|
||
External Electronic Signature ID *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
{...executeForm.register('externalSignatureId')}
|
||
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="e.g. SIG-DOCUSIGN-993821"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Signed Document URL / Vault *</label>
|
||
<input
|
||
type="url"
|
||
{...executeForm.register('signedDocumentUrl')}
|
||
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={() => setIsExecuteModalOpen(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={executeMutation.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"
|
||
>
|
||
{executeMutation.isPending ? 'Executing...' : 'Confirm Execution & Lock'}
|
||
</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"
|
||
>
|
||
<form onSubmit={budgetForm.handleSubmit((data) => budgetMutation.mutate(data as any))} className="space-y-4">
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Associated Project *</label>
|
||
<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>
|
||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Budget Category Name *</label>
|
||
<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>
|
||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Approved Line Amount (ZAR) *</label>
|
||
<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: Schedule Disbursement */}
|
||
<Modal
|
||
isOpen={isDisbursementModalOpen}
|
||
onClose={() => setIsDisbursementModalOpen(false)}
|
||
title="Schedule Disbursement Tranche"
|
||
subtitle="Link disbursement to milestone verification and conditions precedent"
|
||
>
|
||
<form onSubmit={disbursementForm.handleSubmit((data) => disbursementMutation.mutate(data as any))} className="space-y-4">
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Select Project *</label>
|
||
<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>
|
||
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Linked Milestone Deliverable</label>
|
||
<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.map((m) => (
|
||
<option key={m.id} value={m.id}>
|
||
{m.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Tranche Amount (ZAR) *</label>
|
||
<input
|
||
type="number"
|
||
{...disbursementForm.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={() => setIsDisbursementModalOpen(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={disbursementMutation.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"
|
||
>
|
||
{disbursementMutation.isPending ? 'Scheduling...' : 'Schedule Disbursement'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
};
|