Implemented data export

This commit is contained in:
2026-09-26 13:45:42 +02:00
parent 15a8d8f449
commit 2949d6218b
12 changed files with 10939 additions and 359 deletions
@@ -17,19 +17,23 @@ import {
Tag,
CheckCircle2,
AlertCircle,
Coins,
} from 'lucide-react';
import { Modal } from '../ui/Modal';
import { HelpTooltip } from '../ui/HelpTooltip';
import { useToast } from '../ui/Toast';
import { ExportButton } from '../ui/ExportButton';
import { ExportColumn } from '../../utils/exportUtils';
import {
Instrument,
Instruction,
Programme,
ActivityStatus,
} from '../../types';
import {
getAllInstructions,
createInstruction,
updateInstruction,
getAllInstruments,
createInstrument,
updateInstrument,
} from '../../services/api';
import {
getActivityStatusBadge,
@@ -38,17 +42,17 @@ import {
ACTIVITY_STATUS_OPTIONS,
} from '../../lib/formatters';
const instructionSchema = z.object({
const instrumentSchema = z.object({
programmeId: z.coerce.number().min(1, 'Please select an associated programme'),
name: z.string().min(3, 'Instruction title must be at least 3 characters'),
name: z.string().min(3, 'Funding instrument title must be at least 3 characters'),
code: z.string().optional(),
description: z.string().min(5, 'Description is required (minimum 5 characters)'),
status: z.coerce.number().default(ActivityStatus.Active),
});
type InstructionFormValues = z.infer<typeof instructionSchema>;
type InstrumentFormValues = z.infer<typeof instrumentSchema>;
interface InstructionsSectionProps {
interface FundingInstrumentsSectionProps {
programmes: Programme[];
currentUserId: string;
preselectedProgrammeId?: string | number | null;
@@ -57,7 +61,7 @@ interface InstructionsSectionProps {
onCloseCreateModalExternal?: () => void;
}
export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
export const FundingInstrumentsSection: React.FC<FundingInstrumentsSectionProps> = ({
programmes,
currentUserId,
preselectedProgrammeId,
@@ -76,27 +80,12 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
// Modals
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingInstruction, setEditingInstruction] = useState<Instruction | null>(null);
const [inspectingInstruction, setInspectingInstruction] = useState<Instruction | null>(null);
// Sync external create modal trigger if provided
React.useEffect(() => {
if (isCreateModalOpenExternal) {
setEditingInstruction(null);
instructionForm.reset({
programmeId: preselectedProgrammeId ? Number(preselectedProgrammeId) : (programmes[0]?.id ? Number(programmes[0].id) : 0),
name: '',
code: '',
description: '',
status: ActivityStatus.Active,
});
setIsModalOpen(true);
}
}, [isCreateModalOpenExternal, preselectedProgrammeId, programmes]);
const [editingInstrument, setEditingInstrument] = useState<Instrument | null>(null);
const [inspectingInstrument, setInspectingInstrument] = useState<Instrument | null>(null);
// Form setup
const instructionForm = useForm<InstructionFormValues>({
resolver: zodResolver(instructionSchema),
const instrumentForm = useForm<InstrumentFormValues>({
resolver: zodResolver(instrumentSchema),
defaultValues: {
programmeId: programmes[0]?.id ? Number(programmes[0].id) : 0,
name: '',
@@ -106,15 +95,30 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
},
});
// Query: Get all instructions
// Sync external create modal trigger if provided
React.useEffect(() => {
if (isCreateModalOpenExternal) {
setEditingInstrument(null);
instrumentForm.reset({
programmeId: preselectedProgrammeId ? Number(preselectedProgrammeId) : (programmes[0]?.id ? Number(programmes[0].id) : 0),
name: '',
code: '',
description: '',
status: ActivityStatus.Active,
});
setIsModalOpen(true);
}
}, [isCreateModalOpenExternal, preselectedProgrammeId, programmes, instrumentForm]);
// Query: Get all funding instruments primarily from API
const {
data: instructions = [],
data: instruments = [],
isLoading,
isFetching,
refetch,
} = useQuery({
queryKey: ['all-instructions', programmes],
queryFn: () => getAllInstructions(programmes),
queryKey: ['all-instruments', programmes],
queryFn: () => getAllInstruments(programmes),
});
// Programme lookup helper
@@ -124,12 +128,14 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
return map;
}, [programmes]);
// Mutation: Create or Update Instruction
// Mutation: Create or Update Funding Instrument
const mutation = useMutation({
mutationFn: async (data: InstructionFormValues) => {
if (editingInstruction) {
await updateInstruction({
instructionId: editingInstruction.id,
mutationFn: async (data: InstrumentFormValues) => {
if (editingInstrument) {
await updateInstrument({
instructionId: editingInstrument.id,
instrumentId: editingInstrument.id,
id: editingInstrument.id,
programmeId: data.programmeId,
code: data.code,
name: data.name,
@@ -138,7 +144,7 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
userId: currentUserId,
});
} else {
await createInstruction({
await createInstrument({
programmeId: data.programmeId,
code: data.code,
name: data.name,
@@ -149,27 +155,28 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['all-instruments'] });
queryClient.invalidateQueries({ queryKey: ['all-instructions'] });
queryClient.invalidateQueries({ queryKey: ['instruments'] });
success(
editingInstruction ? 'Instruction Updated' : 'Instruction Created',
editingInstruction
? 'Funding instruction record updated successfully.'
: 'New funding instruction attached to programme successfully.'
editingInstrument ? 'Funding Instrument Updated' : 'Funding Instrument Created',
editingInstrument
? 'Funding instrument record updated successfully via the API.'
: 'New funding instrument created and linked to strategic programme successfully.'
);
setIsModalOpen(false);
setEditingInstruction(null);
setEditingInstrument(null);
if (onCloseCreateModalExternal) onCloseCreateModalExternal();
},
onError: (err: any) => {
toastError('Save Failed', err?.message || 'Failed to save instruction');
toastError('Save Failed', err?.message || 'Failed to save funding instrument');
},
});
// Handlers
const openCreateModal = (programmeId?: number | string) => {
setEditingInstruction(null);
instructionForm.reset({
setEditingInstrument(null);
instrumentForm.reset({
programmeId: programmeId
? Number(programmeId)
: selectedProgrammeFilter !== 'all'
@@ -185,9 +192,9 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
setIsModalOpen(true);
};
const openEditModal = (inst: Instruction) => {
setEditingInstruction(inst);
instructionForm.reset({
const openEditModal = (inst: Instrument) => {
setEditingInstrument(inst);
instrumentForm.reset({
programmeId: Number(inst.programmeId),
name: inst.name || '',
code: inst.code || '',
@@ -199,13 +206,13 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
const handleCloseModal = () => {
setIsModalOpen(false);
setEditingInstruction(null);
setEditingInstrument(null);
if (onCloseCreateModalExternal) onCloseCreateModalExternal();
};
// Filter instructions
const filteredInstructions = useMemo(() => {
return instructions.filter((inst) => {
// Filter funding instruments
const filteredInstruments = useMemo(() => {
return instruments.filter((inst) => {
// Programme filter
if (selectedProgrammeFilter !== 'all' && String(inst.programmeId) !== String(selectedProgrammeFilter)) {
return false;
@@ -228,7 +235,29 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
}
return true;
});
}, [instructions, selectedProgrammeFilter, statusFilter, searchTerm, programmeMap]);
}, [instruments, selectedProgrammeFilter, statusFilter, searchTerm, programmeMap]);
// Export columns definition
const instrumentExportColumns: ExportColumn<Instrument>[] = useMemo(
() => [
{ header: 'Instrument ID', key: 'id' },
{ header: 'Instrument Code', key: 'code', format: (val) => val || '—' },
{ header: 'Title / Name', key: 'name', format: (val) => val || 'Untitled' },
{
header: 'Strategic Programme',
key: 'programmeId',
format: (val) => programmeMap.get(String(val))?.name || `Programme #${val}`,
},
{
header: 'Status',
key: 'status',
format: (val) => (Number(val) === ActivityStatus.Active ? 'Active' : 'Inactive'),
},
{ header: 'Description', key: 'description', format: (val) => val || '—' },
{ header: 'Created Date', key: 'createdAt', format: (val) => (val ? formatDate(val) : '—') },
],
[programmeMap]
);
return (
<div className="space-y-4">
@@ -240,7 +269,7 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-3" />
<input
type="text"
placeholder="Search instructions by title, code, description, or programme..."
placeholder="Search funding instruments by title, code, description, or programme..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-9 pr-4 py-2.5 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-slate-200 placeholder:text-slate-500 transition shadow-xs"
@@ -290,60 +319,72 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
</div>
</div>
{/* Action Buttons */}
{/* Action Buttons: Refresh, Export & Add */}
<div className="flex items-center gap-2 self-end lg:self-center">
<button
type="button"
onClick={() => refetch()}
onClick={() => {
refetch();
success('Refreshed', 'Funding instruments updated from API.');
}}
disabled={isFetching}
className="inline-flex items-center gap-1.5 px-3 py-2 text-xs font-semibold text-slate-300 bg-slate-800/80 hover:bg-slate-700 rounded-xl border border-slate-700 transition cursor-pointer"
title="Refresh Instructions"
className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-slate-300 bg-[#0d121f] hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
title="Refresh Funding Instruments from API"
>
<RefreshCw className={`w-3.5 h-3.5 ${isFetching ? 'animate-spin' : ''}`} />
Refresh
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetching ? 'animate-spin' : ''}`} />
<span>Refresh</span>
</button>
<ExportButton
title="Funding Instruments Registry"
subtitle="Authoritative funding instruments and financing directives attached to strategic programmes"
filename={`pfm-funding-instruments-${new Date().toISOString().split('T')[0]}`}
columns={instrumentExportColumns}
data={filteredInstruments}
/>
<button
type="button"
onClick={() => openCreateModal()}
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"
>
<Plus className="w-4 h-4" />
Add Instruction
<span>Add Instrument</span>
</button>
</div>
</div>
{/* Instructions Table & Cards */}
{/* Funding Instruments Table & Cards */}
<div className="rounded-2xl border border-slate-800/80 bg-[#0d121f]/90 overflow-hidden shadow-xl">
<div className="p-4 border-b border-slate-800/80 flex items-center justify-between">
<div className="flex items-center gap-2">
<FileText className="w-4 h-4 text-blue-400" />
<Coins className="w-4 h-4 text-blue-400" />
<h3 className="text-xs font-bold text-slate-200 uppercase tracking-wider">
Funding Instructions & Instrument Guidelines ({filteredInstructions.length})
Funding Instruments & Financing Directives ({filteredInstruments.length})
</h3>
<HelpTooltip
title="Funding Instructions"
content="Binding operational instructions and instrument guidelines tied to strategic funding programmes. Defines implementation directives and compliance standards."
title="Funding Instruments"
content="Binding funding instruments and financing directives tied to strategic funding programmes. Defines capital allocation mechanisms, rules, and statutory guidelines."
placement="right"
size="sm"
/>
</div>
<span className="text-[11px] text-slate-400 font-mono">
{filteredInstructions.length} of {instructions.length} recorded
{filteredInstruments.length} of {instruments.length} recorded
</span>
</div>
{isLoading ? (
<div className="p-12 text-center text-xs text-slate-400 flex flex-col items-center justify-center gap-2">
<RefreshCw className="w-5 h-5 animate-spin text-blue-400" />
<span>Loading instructions directory...</span>
<span>Loading funding instruments directory...</span>
</div>
) : filteredInstructions.length === 0 ? (
) : filteredInstruments.length === 0 ? (
<div className="p-12 text-center text-xs text-slate-400 space-y-3">
<div className="text-slate-500">
{searchTerm || selectedProgrammeFilter !== 'all' || statusFilter !== 'all'
? 'No funding instructions match the active filters.'
: 'No funding instructions registered yet. Link instructions to programmes using the button below.'}
? 'No funding instruments match the active filters.'
: 'No funding instruments registered yet. Link funding instruments to programmes using the button below.'}
</div>
<button
type="button"
@@ -351,7 +392,7 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl transition cursor-pointer"
>
<Plus className="w-4 h-4" />
Add First Instruction
Add First Funding Instrument
</button>
</div>
) : (
@@ -360,7 +401,7 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
<thead>
<tr className="border-b border-slate-800/80 bg-slate-900/50 text-[11px] font-semibold text-slate-400 uppercase tracking-wider">
<th className="py-3 px-4">Code / Ref</th>
<th className="py-3 px-4">Instruction Title</th>
<th className="py-3 px-4">Instrument Title</th>
<th className="py-3 px-4">Associated Programme</th>
<th className="py-3 px-4">Status</th>
<th className="py-3 px-4">Created Info</th>
@@ -368,7 +409,7 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60 text-xs text-slate-300">
{filteredInstructions.map((inst) => {
{filteredInstruments.map((inst) => {
const statusBadge = getActivityStatusBadge(inst.status);
const parentProg = programmeMap.get(String(inst.programmeId));
@@ -416,9 +457,9 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
<div className="flex items-center justify-end gap-1.5">
<button
type="button"
onClick={() => setInspectingInstruction(inst)}
onClick={() => setInspectingInstrument(inst)}
className="inline-flex items-center gap-1 text-xs font-semibold text-cyan-400 hover:text-white bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
title="Inspect Instruction"
title="Inspect Instrument"
>
<Eye className="w-3 h-3" />
Inspect
@@ -427,7 +468,7 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
type="button"
onClick={() => openEditModal(inst)}
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 rounded-lg transition shadow-xs cursor-pointer"
title="Edit / Mutate Instruction"
title="Edit / Update Instrument"
>
<Edit2 className="w-3 h-3" />
Edit
@@ -443,20 +484,20 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
)}
</div>
{/* Modal: Create / Edit Instruction */}
{/* Modal: Create / Edit Funding Instrument */}
<Modal
isOpen={isModalOpen}
onClose={handleCloseModal}
title={editingInstruction ? 'Edit Funding Instruction' : 'Add New Funding Instruction'}
title={editingInstrument ? 'Edit Funding Instrument' : 'Add New Funding Instrument'}
subtitle={
editingInstruction
? `Mutating Instruction ${editingInstruction.code || `#${editingInstruction.id}`}`
: 'Authoritative operational instruction attached to a strategic programme'
editingInstrument
? `Updating Instrument ${editingInstrument.code || `#${editingInstrument.id}`}`
: 'Authoritative funding instrument attached to a strategic programme'
}
helpText="Instructions operationalize funding programmes into concrete binding directives. Choose an existing programme from the dropdown to link data."
helpText="Funding instruments operationalize funding programmes into concrete financing mechanisms. Select a strategic programme from the dropdown."
>
<form
onSubmit={instructionForm.handleSubmit((data) => mutation.mutate(data))}
onSubmit={instrumentForm.handleSubmit((data) => mutation.mutate(data))}
className="space-y-4"
>
{/* Associated Programme Dropdown */}
@@ -467,12 +508,12 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
</label>
<HelpTooltip
title="Associated Programme"
content="Select the strategic funding programme under which this operational instruction is established."
content="Select the strategic funding programme under which this operational funding instrument is established."
size="sm"
/>
</div>
<select
{...instructionForm.register('programmeId')}
{...instrumentForm.register('programmeId')}
className="w-full px-3 py-2 text-xs bg-[#090d16] border border-slate-700 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">Select an associated programme...</option>
@@ -482,9 +523,9 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
</option>
))}
</select>
{instructionForm.formState.errors.programmeId && (
{instrumentForm.formState.errors.programmeId && (
<p className="text-red-400 text-[11px] mt-1">
{instructionForm.formState.errors.programmeId.message}
{instrumentForm.formState.errors.programmeId.message}
</p>
)}
</div>
@@ -493,23 +534,23 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
<div>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">
Instruction Title *
Instrument Title *
</label>
<HelpTooltip
title="Instruction Title"
content="Formal title of the funding instruction or instrument (e.g., SME Technology Transfer Subsidy Guideline)."
title="Instrument Title"
content="Formal title of the funding instrument (e.g., SME Technology Transfer Subsidy Guideline)."
size="sm"
/>
</div>
<input
type="text"
placeholder="e.g. Clean Energy Grant Directive 2026"
{...instructionForm.register('name')}
{...instrumentForm.register('name')}
className="w-full px-3 py-2 text-xs bg-[#090d16] border border-slate-700 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
{instructionForm.formState.errors.name && (
{instrumentForm.formState.errors.name && (
<p className="text-red-400 text-[11px] mt-1">
{instructionForm.formState.errors.name.message}
{instrumentForm.formState.errors.name.message}
</p>
)}
</div>
@@ -518,10 +559,10 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
<div>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">
Instruction Code (Optional)
Instrument Code (Optional)
</label>
<HelpTooltip
title="Instruction Code"
title="Instrument Code"
content="Unique operational reference code (e.g., INST-CE-001). If left blank, one is generated automatically."
size="sm"
/>
@@ -529,7 +570,7 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
<input
type="text"
placeholder="e.g. INST-CE-001"
{...instructionForm.register('code')}
{...instrumentForm.register('code')}
className="w-full px-3 py-2 text-xs bg-[#090d16] border border-slate-700 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 font-mono"
/>
</div>
@@ -538,23 +579,23 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
<div>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">
Description & Implementation Directives *
Description & Financing Directives *
</label>
<HelpTooltip
title="Instruction Description"
content="Comprehensive directives, statutory mandate, eligibility scope, and operational guidelines."
title="Instrument Description"
content="Comprehensive directives, statutory mandate, eligibility scope, and financing guidelines."
size="sm"
/>
</div>
<textarea
rows={4}
placeholder="Detail the operational mandate, eligibility scope, disbursement triggers, and statutory compliance requirements..."
{...instructionForm.register('description')}
placeholder="Detail the financing mandate, eligibility scope, disbursement triggers, and statutory compliance requirements..."
{...instrumentForm.register('description')}
className="w-full px-3 py-2 text-xs bg-[#090d16] border border-slate-700 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
{instructionForm.formState.errors.description && (
{instrumentForm.formState.errors.description && (
<p className="text-red-400 text-[11px] mt-1">
{instructionForm.formState.errors.description.message}
{instrumentForm.formState.errors.description.message}
</p>
)}
</div>
@@ -564,13 +605,13 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Status</label>
<HelpTooltip
title="Instruction Lifecycle Status"
content="Indicates whether this instruction is actively enforceable or archived/inactive."
title="Instrument Lifecycle Status"
content="Indicates whether this funding instrument is actively enforceable or archived/inactive."
size="sm"
/>
</div>
<select
{...instructionForm.register('status')}
{...instrumentForm.register('status')}
className="w-full px-3 py-2 text-xs bg-[#090d16] border border-slate-700 rounded-xl text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
{ACTIVITY_STATUS_OPTIONS.map((opt) => (
@@ -596,29 +637,29 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl shadow-md transition disabled:opacity-50 cursor-pointer"
>
{mutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />}
{editingInstruction ? 'Save Instruction Changes' : 'Create Instruction'}
{editingInstrument ? 'Save Instrument Changes' : 'Create Funding Instrument'}
</button>
</div>
</form>
</Modal>
{/* Modal: Inspect Instruction Detail */}
{inspectingInstruction && (
{/* Modal: Inspect Funding Instrument Detail */}
{inspectingInstrument && (
<Modal
isOpen={!!inspectingInstruction}
onClose={() => setInspectingInstruction(null)}
title={`Instruction: ${inspectingInstruction.name || ''}`}
subtitle={`Reference: ${inspectingInstruction.code || `INST-${inspectingInstruction.id}`}`}
isOpen={!!inspectingInstrument}
onClose={() => setInspectingInstrument(null)}
title={`Funding Instrument: ${inspectingInstrument.name || ''}`}
subtitle={`Reference: ${inspectingInstrument.code || `INST-${inspectingInstrument.id}`}`}
maxWidth="lg"
>
<div className="space-y-4 text-xs">
<div className="p-4 rounded-xl border border-slate-800 bg-[#0d121f] space-y-3">
<div className="flex items-center justify-between">
<span className="font-mono text-xs font-bold text-blue-400 bg-blue-500/10 px-2.5 py-1 rounded border border-blue-500/20">
{inspectingInstruction.code || `INST-${inspectingInstruction.id}`}
{inspectingInstrument.code || `INST-${inspectingInstrument.id}`}
</span>
{(() => {
const badge = getActivityStatusBadge(inspectingInstruction.status);
const badge = getActivityStatusBadge(inspectingInstrument.status);
return (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold border ${badge.className}`}
@@ -630,9 +671,9 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
</div>
<div>
<h4 className="text-sm font-bold text-white mb-1">{inspectingInstruction.name}</h4>
<h4 className="text-sm font-bold text-white mb-1">{inspectingInstrument.name}</h4>
<p className="text-slate-300 whitespace-pre-wrap leading-relaxed">
{inspectingInstruction.description || 'No detailed description provided.'}
{inspectingInstrument.description || 'No detailed description provided.'}
</p>
</div>
@@ -640,27 +681,27 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
<div>
<span className="text-slate-500 block">Associated Programme</span>
<span className="text-slate-200 font-semibold">
{programmeMap.get(String(inspectingInstruction.programmeId))?.name ||
`Programme #${inspectingInstruction.programmeId}`}
{programmeMap.get(String(inspectingInstrument.programmeId))?.name ||
`Programme #${inspectingInstrument.programmeId}`}
</span>
</div>
<div>
<span className="text-slate-500 block">Created On</span>
<span className="text-slate-200 font-mono">
{formatDate(inspectingInstruction.createdAt)}
{formatDate(inspectingInstrument.createdAt)}
</span>
</div>
<div>
<span className="text-slate-500 block">Created By</span>
<span className="text-slate-200 font-mono">
{inspectingInstruction.createdBy ? getUserEmail(inspectingInstruction.createdBy) : '—'}
{inspectingInstrument.createdBy ? getUserEmail(inspectingInstrument.createdBy) : '—'}
</span>
</div>
{inspectingInstruction.updatedAt && (
{inspectingInstrument.updatedAt && (
<div>
<span className="text-slate-500 block">Last Mutated</span>
<span className="text-slate-200 font-mono">
{formatDate(inspectingInstruction.updatedAt)}
{formatDate(inspectingInstrument.updatedAt)}
</span>
</div>
)}
@@ -671,18 +712,18 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
<button
type="button"
onClick={() => {
const inst = inspectingInstruction;
setInspectingInstruction(null);
const inst = inspectingInstrument;
setInspectingInstrument(null);
openEditModal(inst);
}}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-slate-200 bg-slate-800 hover:bg-slate-700 rounded-xl border border-slate-700 cursor-pointer"
>
<Edit2 className="w-3.5 h-3.5" />
Edit Instruction
Edit Funding Instrument
</button>
<button
type="button"
onClick={() => setInspectingInstruction(null)}
onClick={() => setInspectingInstrument(null)}
className="px-4 py-2 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
>
Close
@@ -694,3 +735,6 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
</div>
);
};
export const InstructionsSection = FundingInstrumentsSection;
@@ -22,6 +22,8 @@ import {
import { Modal } from '../ui/Modal';
import { HelpTooltip } from '../ui/HelpTooltip';
import { useToast } from '../ui/Toast';
import { ExportButton } from '../ui/ExportButton';
import { ExportColumn } from '../../utils/exportUtils';
import {
Rule,
Programme,
@@ -231,6 +233,28 @@ export const RulesSection: React.FC<RulesSectionProps> = ({
});
}, [rules, selectedProgrammeFilter, statusFilter, searchTerm, programmeMap]);
// Export columns definition
const ruleExportColumns: ExportColumn<Rule>[] = useMemo(
() => [
{ header: 'Rule ID', key: 'id' },
{ header: 'Rule Name', key: 'name', format: (val) => val || 'Untitled Rule' },
{
header: 'Strategic Programme',
key: 'programmeId',
format: (val) => programmeMap.get(String(val))?.name || `Programme #${val}`,
},
{ header: 'Version', key: 'version', format: (val) => `v${val || 1}` },
{
header: 'Status',
key: 'status',
format: (val) => (Number(val) === ActivityStatus.Active ? 'Active' : 'Inactive'),
},
{ header: 'Rule Description', key: 'description', format: (val) => val || '—' },
{ header: 'Created Date', key: 'createdAt', format: (val) => (val ? formatDate(val) : '—') },
],
[programmeMap]
);
return (
<div className="space-y-4">
{/* Top Filter and Actions Bar */}
@@ -291,25 +315,37 @@ export const RulesSection: React.FC<RulesSectionProps> = ({
</div>
</div>
{/* Action Buttons */}
{/* Action Buttons: Refresh, Export & Add */}
<div className="flex items-center gap-2 self-end lg:self-center">
<button
type="button"
onClick={() => refetch()}
onClick={() => {
refetch();
success('Refreshed', 'Programme rules updated from API.');
}}
disabled={isFetching}
className="inline-flex items-center gap-1.5 px-3 py-2 text-xs font-semibold text-slate-300 bg-slate-800/80 hover:bg-slate-700 rounded-xl border border-slate-700 transition cursor-pointer"
title="Refresh Rules"
className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-slate-300 bg-[#0d121f] hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
title="Refresh Programme Rules from API"
>
<RefreshCw className={`w-3.5 h-3.5 ${isFetching ? 'animate-spin' : ''}`} />
Refresh
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetching ? 'animate-spin' : ''}`} />
<span>Refresh</span>
</button>
<ExportButton
title="Programme Rules Registry"
subtitle="Statutory, regulatory, and eligibility rules governing strategic funding programmes"
filename={`pfm-programme-rules-${new Date().toISOString().split('T')[0]}`}
columns={ruleExportColumns}
data={filteredRules}
/>
<button
type="button"
onClick={() => openCreateModal()}
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"
>
<Plus className="w-4 h-4" />
Add Rule
<span>Add Rule</span>
</button>
</div>
</div>
@@ -0,0 +1,139 @@
import React, { useState, useRef, useEffect } from 'react';
import { Download, FileSpreadsheet, FileText, ChevronDown } from 'lucide-react';
import { ExportColumn, exportToSpreadsheet, exportToPdf } from '../../utils/exportUtils';
import { useAuth } from '../../auth/AuthProvider';
export interface ExportButtonProps<T = any> {
title: string;
subtitle?: string;
filename: string;
columns: ExportColumn<T>[];
data: T[];
label?: string;
size?: 'sm' | 'md';
className?: string;
}
export function ExportButton<T>({
title,
subtitle,
filename,
columns,
data,
label = 'Export',
size = 'md',
className = '',
}: ExportButtonProps<T>) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const { user, claims, tenantId } = useAuth();
const isDisabled = !data || data.length === 0;
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
const handleExportSpreadsheet = () => {
if (isDisabled) return;
setIsOpen(false);
exportToSpreadsheet({
title,
subtitle,
filename,
columns,
data,
tenantName: (claims as any)?.tenant_name || (claims as any)?.tenant || `Tenant #${tenantId || 1}`,
userEmail: user?.profile?.email || (claims as any)?.email || '',
});
};
const handleExportPdf = () => {
if (isDisabled) return;
setIsOpen(false);
exportToPdf({
title,
subtitle,
filename,
columns,
data,
tenantName: (claims as any)?.tenant_name || (claims as any)?.tenant || `Tenant #${tenantId || 1}`,
userEmail: user?.profile?.email || (claims as any)?.email || '',
});
};
return (
<div className={`relative inline-block ${className}`} ref={dropdownRef}>
<button
type="button"
onClick={() => !isDisabled && setIsOpen(!isOpen)}
disabled={isDisabled}
title={
isDisabled
? 'No data available to export in current view'
: `Export ${data.length} records to Spreadsheet or PDF`
}
className={`inline-flex items-center justify-center gap-1.5 font-semibold rounded-xl border transition cursor-pointer shadow-xs ${
size === 'sm' ? 'px-3 py-1.5 text-xs' : 'px-3.5 py-2 text-xs'
} ${
isDisabled
? 'text-slate-600 bg-slate-900/40 border-slate-800/80 cursor-not-allowed opacity-60'
: 'text-slate-300 bg-[#0d121f] hover:bg-slate-800 border-slate-700/80 hover:text-white'
}`}
>
<Download className={`w-3.5 h-3.5 ${isDisabled ? 'text-slate-600' : 'text-emerald-400'}`} />
<span>{label}</span>
<ChevronDown className={`w-3 h-3 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && !isDisabled && (
<div className="absolute right-0 mt-1.5 w-52 bg-[#0d121f] border border-slate-700/90 rounded-xl shadow-2xl z-50 overflow-hidden backdrop-blur-md animate-in fade-in-50 zoom-in-95">
<div className="p-2 border-b border-slate-800/80">
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-400 px-2 block">
Export Format ({data.length} items)
</span>
</div>
<div className="p-1 space-y-0.5">
<button
type="button"
onClick={handleExportSpreadsheet}
className="w-full flex items-center gap-2.5 px-3 py-2 text-xs text-left text-slate-200 hover:text-white hover:bg-emerald-500/15 rounded-lg transition-colors cursor-pointer group"
>
<div className="w-6 h-6 rounded-md bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center shrink-0 group-hover:bg-emerald-500/20">
<FileSpreadsheet className="w-3.5 h-3.5 text-emerald-400" />
</div>
<div>
<span className="font-semibold block leading-tight">Spreadsheet (.xlsx)</span>
<span className="text-[10px] text-slate-400">Excel / Google Sheets</span>
</div>
</button>
<button
type="button"
onClick={handleExportPdf}
className="w-full flex items-center gap-2.5 px-3 py-2 text-xs text-left text-slate-200 hover:text-white hover:bg-blue-500/15 rounded-lg transition-colors cursor-pointer group"
>
<div className="w-6 h-6 rounded-md bg-blue-500/10 border border-blue-500/20 flex items-center justify-center shrink-0 group-hover:bg-blue-500/20">
<FileText className="w-3.5 h-3.5 text-blue-400" />
</div>
<div>
<span className="font-semibold block leading-tight">PDF Document (.pdf)</span>
<span className="text-[10px] text-slate-400">Landscape with PFM Logo</span>
</div>
</button>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,302 @@
import { jsPDF } from 'jspdf';
import autoTable from 'jspdf-autotable';
export interface ExportColumn {
header: string;
key: string;
format?: (value: any, row: any) => string;
}
export interface ExportDataOptions {
title: string;
subtitle?: string;
filename: string;
columns: ExportColumn[];
data: any[];
}
/**
* Converts dataset into standard CSV string and triggers browser download
*/
export function exportToSpreadsheet({ filename, columns, data }: ExportDataOptions): void {
if (!data || data.length === 0) return;
const headers = columns.map((col) => `"${(col.header || '').replace(/"/g, '""')}"`).join(',');
const rows = data.map((row) =>
columns
.map((col) => {
let val: any;
if (col.format) {
val = col.format(row[col.key], row);
} else {
val = row[col.key];
}
if (val === null || val === undefined) val = '';
const strVal = String(val).replace(/"/g, '""');
return `"${strVal}"`;
})
.join(',')
);
const csvContent = '\uFEFF' + [headers, ...rows].join('\r\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('href', url);
link.setAttribute('download', `${filename.replace(/\.csv$/i, '')}.csv`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
/**
* Draws the high-visibility official PFM vector brand insignia onto the jsPDF document.
*/
function drawPfmLogo(doc: jsPDF, x: number, y: number, size: number): void {
const scale = size / 64;
doc.saveGraphicsState();
// Dark foundation disc
doc.setFillColor(9, 13, 22);
doc.circle(x + 32 * scale, y + 32 * scale, 31 * scale, 'F');
doc.setDrawColor(30, 41, 59);
doc.setLineWidth(1 * scale);
doc.circle(x + 32 * scale, y + 32 * scale, 30 * scale, 'S');
// Cyan/Blue Outer hexagonal shield ring
doc.setDrawColor(59, 130, 246);
doc.setLineWidth(1.2 * scale);
doc.polygon(
[
{ x: x + 32 * scale, y: y + 6.5 * scale },
{ x: x + 53.5 * scale, y: y + 19 * scale },
{ x: x + 56 * scale, y: y + 23.2 * scale },
{ x: x + 56 * scale, y: y + 40.8 * scale },
{ x: x + 53.5 * scale, y: y + 45 * scale },
{ x: x + 32 * scale, y: y + 57.5 * scale },
{ x: x + 27 * scale, y: y + 57.5 * scale },
{ x: x + 5.5 * scale, y: y + 45 * scale },
{ x: x + 3 * scale, y: y + 40.8 * scale },
{ x: x + 3 * scale, y: y + 23.2 * scale },
{ x: x + 5.5 * scale, y: y + 19 * scale },
{ x: x + 27 * scale, y: y + 6.5 * scale },
],
'S'
);
// Top Facet - Indigo/Violet Governance Canopy
doc.setFillColor(99, 102, 241);
doc.triangle(
x + 32 * scale,
y + 9 * scale,
x + 51.5 * scale,
y + 20.2 * scale,
x + 43 * scale,
y + 25.2 * scale,
'F'
);
doc.triangle(
x + 32 * scale,
y + 9 * scale,
x + 43 * scale,
y + 25.2 * scale,
x + 21 * scale,
y + 25.2 * scale,
'F'
);
doc.triangle(
x + 32 * scale,
y + 9 * scale,
x + 21 * scale,
y + 25.2 * scale,
x + 12.5 * scale,
y + 20.2 * scale,
'F'
);
// Left Facet - Electric Sapphire Capital Vault
doc.setFillColor(37, 99, 235);
doc.triangle(
x + 10 * scale,
y + 23 * scale,
x + 19.2 * scale,
y + 28.4 * scale,
x + 19.2 * scale,
y + 44.5 * scale,
'F'
);
doc.triangle(
x + 10 * scale,
y + 23 * scale,
x + 19.2 * scale,
y + 44.5 * scale,
x + 30 * scale,
y + 50.8 * scale,
'F'
);
// Right Facet - Cyan/Emerald Performance Wing
doc.setFillColor(6, 182, 212);
doc.triangle(
x + 54 * scale,
y + 23 * scale,
x + 44.8 * scale,
y + 28.4 * scale,
x + 44.8 * scale,
y + 44.5 * scale,
'F'
);
doc.triangle(
x + 54 * scale,
y + 23 * scale,
x + 44.8 * scale,
y + 44.5 * scale,
x + 34 * scale,
y + 50.8 * scale,
'F'
);
// Center Plate
doc.setFillColor(15, 23, 42);
doc.setDrawColor(51, 65, 85);
doc.polygon(
[
{ x: x + 22 * scale, y: y + 28.5 * scale },
{ x: x + 32 * scale, y: y + 22.8 * scale },
{ x: x + 42 * scale, y: y + 28.5 * scale },
{ x: x + 42 * scale, y: y + 35.5 * scale },
{ x: x + 32 * scale, y: y + 41.2 * scale },
{ x: x + 22 * scale, y: y + 35.5 * scale },
],
'FD'
);
// Center Diamond Spark
doc.setFillColor(255, 255, 255);
doc.circle(x + 32 * scale, y + 32 * scale, 2.5 * scale, 'F');
doc.restoreGraphicsState();
}
/**
* Generates an executive-grade PDF document in LANDSCAPE mode with clear PFM logo visibility,
* metadata header, and structured autoTable data.
*/
export function exportToPdf({ title, subtitle, filename, columns, data }: ExportDataOptions): void {
if (!data || data.length === 0) return;
// Enforce Landscape orientation
const doc = new jsPDF({
orientation: 'landscape',
unit: 'pt',
format: 'a4',
});
const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
const margin = 40;
// Header background banner
doc.setFillColor(13, 18, 31);
doc.rect(0, 0, pageWidth, 72, 'F');
doc.setDrawColor(30, 41, 59);
doc.setLineWidth(1);
doc.line(0, 72, pageWidth, 72);
// Draw PFM Logo with clear visibility
const logoSize = 44;
drawPfmLogo(doc, margin, 14, logoSize);
// PFM Branding Text next to Logo
doc.setTextColor(255, 255, 255);
doc.setFont('helvetica', 'bold');
doc.setFontSize(14);
doc.text('PFM Console', margin + logoSize + 12, 33);
doc.setFont('helvetica', 'normal');
doc.setFontSize(8);
doc.setTextColor(56, 189, 248); // Cyan
doc.text('POST-FUND MANAGEMENT SYSTEM • OFFICIAL REPORT', margin + logoSize + 12, 46);
// Document Title & Subtitle in Header (Right aligned or center)
doc.setFont('helvetica', 'bold');
doc.setFontSize(13);
doc.setTextColor(248, 250, 252);
doc.text(title, pageWidth - margin, 32, { align: 'right' });
doc.setFont('helvetica', 'normal');
doc.setFontSize(8);
doc.setTextColor(148, 163, 184);
const nowStr = new Date().toLocaleString('en-GB', {
dateStyle: 'medium',
timeStyle: 'short',
});
doc.text(
`${subtitle || 'Authoritative Register Export'} • Generated: ${nowStr}`,
pageWidth - margin,
46,
{ align: 'right' }
);
// Prepare table headers and rows
const tableHeaders = [columns.map((c) => c.header)];
const tableRows = data.map((row) =>
columns.map((col) => {
let val: any;
if (col.format) {
val = col.format(row[col.key], row);
} else {
val = row[col.key];
}
if (val === null || val === undefined) return '—';
return String(val);
})
);
// Render autoTable in landscape
(autoTable as any)(doc, {
head: tableHeaders,
body: tableRows,
startY: 86,
margin: { left: margin, right: margin, bottom: 45 },
theme: 'grid',
styles: {
fontSize: 8,
cellPadding: 6,
textColor: [30, 41, 59],
lineColor: [226, 232, 240],
lineWidth: 0.5,
overflow: 'linebreak',
},
headStyles: {
fillColor: [15, 23, 42],
textColor: [255, 255, 255],
fontStyle: 'bold',
fontSize: 8,
},
alternateRowStyles: {
fillColor: [248, 250, 252],
},
didDrawPage: (hookData: any) => {
// Footer on every page
const currentPg = hookData.pageNumber;
doc.setFont('helvetica', 'normal');
doc.setFontSize(8);
doc.setTextColor(100, 116, 139);
doc.text(
`Post Fund Management (PFM) • Confidential & Authoritative • Page ${currentPg}`,
margin,
pageHeight - 20
);
doc.text(
`Total Records: ${data.length} • Landscape Master Report`,
pageWidth - margin,
pageHeight - 20,
{ align: 'right' }
);
},
});
doc.save(`${filename.replace(/\.pdf$/i, '')}.pdf`);
}
@@ -29,6 +29,7 @@ import {
updateOrganisation,
getAllPortfolios,
getPortfolios,
getOrganisationPortfolios,
getPortfolio,
createPortfolio,
updatePortfolio,
@@ -39,8 +40,8 @@ import {
updateProgramme,
getInstruments,
getInstructions,
createInstruction,
updateInstruction,
createInstrument,
updateInstrument,
getRules,
createRule,
updateRule,
@@ -51,8 +52,10 @@ import {
import { Modal } from '../components/ui/Modal';
import { HelpTooltip } from '../components/ui/HelpTooltip';
import { useToast } from '../components/ui/Toast';
import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils';
import { useAuth } from '../auth/AuthProvider';
import { InstructionsSection } from '../components/governance/InstructionsSection';
import { FundingInstrumentsSection, InstructionsSection } from '../components/governance/InstructionsSection';
import { RulesSection } from '../components/governance/RulesSection';
import {
Organisation,
@@ -133,26 +136,27 @@ export const GovernancePage: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const tabParam = searchParams.get('tab');
const getValidTab = (val: string | null): 'organisations' | 'portfolios' | 'programmes' | 'instructions' | 'rules' => {
if (val === 'portfolios' || val === 'programmes' || val === 'organisations' || val === 'instructions' || val === 'rules') {
return val;
const getValidTab = (val: string | null): 'organisations' | 'portfolios' | 'programmes' | 'instruments' | 'instructions' | 'rules' => {
if (val === 'portfolios' || val === 'programmes' || val === 'organisations' || val === 'instruments' || val === 'instructions' || val === 'rules') {
return val === 'instructions' ? 'instruments' : val;
}
return 'organisations';
};
const [activeTab, setActiveTab] = useState<'organisations' | 'portfolios' | 'programmes' | 'instructions' | 'rules'>(() => getValidTab(tabParam));
const [activeTab, setActiveTab] = useState<'organisations' | 'portfolios' | 'programmes' | 'instruments' | 'instructions' | 'rules'>(() => getValidTab(tabParam));
useEffect(() => {
if (tabParam && (tabParam === 'organisations' || tabParam === 'portfolios' || tabParam === 'programmes' || tabParam === 'instructions' || tabParam === 'rules')) {
setActiveTab(tabParam);
if (tabParam && (tabParam === 'organisations' || tabParam === 'portfolios' || tabParam === 'programmes' || tabParam === 'instruments' || tabParam === 'instructions' || tabParam === 'rules')) {
setActiveTab(tabParam === 'instructions' ? 'instruments' : tabParam);
}
}, [tabParam]);
const handleTabChange = (tab: 'organisations' | 'portfolios' | 'programmes' | 'instructions' | 'rules') => {
setActiveTab(tab);
const handleTabChange = (tab: 'organisations' | 'portfolios' | 'programmes' | 'instruments' | 'instructions' | 'rules') => {
const canonicalTab = tab === 'instructions' ? 'instruments' : tab;
setActiveTab(canonicalTab);
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set('tab', tab);
next.set('tab', canonicalTab);
return next;
});
};
@@ -219,17 +223,32 @@ export const GovernancePage: React.FC = () => {
staleTime: 5 * 60 * 1000,
});
const { data: organisations = [], isLoading: loadingOrgs } = useQuery({
const {
data: organisations = [],
isLoading: loadingOrgs,
isFetching: isFetchingOrgs,
refetch: refetchOrgs,
} = useQuery({
queryKey: ['organisations'],
queryFn: () => getOrganisations(),
});
const { data: portfolios = [], isLoading: loadingPortfolios } = useQuery({
const {
data: portfolios = [],
isLoading: loadingPortfolios,
isFetching: isFetchingPortfolios,
refetch: refetchPortfolios,
} = useQuery({
queryKey: ['portfolios'],
queryFn: () => getAllPortfolios(),
});
const { data: programmes = [], isLoading: loadingProgrammes } = useQuery({
const {
data: programmes = [],
isLoading: loadingProgrammes,
isFetching: isFetchingProgrammes,
refetch: refetchProgrammes,
} = useQuery({
queryKey: ['programmes'],
queryFn: () => getAllProgrammes(),
});
@@ -278,7 +297,7 @@ export const GovernancePage: React.FC = () => {
const { data: orgPortfolios = [], isLoading: loadingOrgPortfolios } = useQuery({
queryKey: ['organisation-portfolios', selectedOrgForDetail?.id],
queryFn: () => (selectedOrgForDetail ? getPortfolios(selectedOrgForDetail.id) : Promise.resolve([])),
queryFn: () => (selectedOrgForDetail ? getOrganisationPortfolios(selectedOrgForDetail.id) : Promise.resolve([])),
enabled: !!selectedOrgForDetail && isOrgDetailModalOpen,
});
@@ -575,9 +594,81 @@ export const GovernancePage: React.FC = () => {
(o) =>
o.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
o.registrationNo?.toLowerCase().includes(searchTerm.toLowerCase()) ||
o.email?.toLowerCase().includes(searchTerm.toLowerCase())
o.email?.toLowerCase().includes(searchTerm.toLowerCase()) ||
String(o.id).includes(searchTerm)
);
const filteredPortfolios = portfolios.filter((p) => {
if (!searchTerm.trim()) return true;
const q = searchTerm.toLowerCase();
const org = organisations.find((o) => String(o.id) === String(p.organisationId));
const orgName = org?.name?.toLowerCase() || '';
const name = (p.name || '').toLowerCase();
const desc = (p.description || '').toLowerCase();
const id = String(p.id);
const owner = getUserEmail(p.ownedBy).toLowerCase();
return name.includes(q) || desc.includes(q) || id.includes(q) || orgName.includes(q) || owner.includes(q);
});
const filteredProgrammes = programmes.filter((pr) => {
if (!searchTerm.trim()) return true;
const q = searchTerm.toLowerCase();
const port = portfolios.find((p) => String(p.id) === String(pr.portfolioId));
const portName = port?.name?.toLowerCase() || '';
const name = (pr.name || '').toLowerCase();
const id = String(pr.id);
const owner = getUserEmail(pr.ownedBy).toLowerCase();
return name.includes(q) || id.includes(q) || portName.includes(q) || owner.includes(q);
});
// Export Columns Definitions
const orgExportColumns: ExportColumn<Organisation>[] = [
{ header: 'Organisation ID', key: 'id' },
{ header: 'Legal Entity Name', key: 'name' },
{ header: 'Registration / CIPC No', key: 'registrationNo', format: (val) => val || '—' },
{ header: 'Entity Classification', key: 'type', format: (val) => getOrganisationTypeLabel(val) },
{ header: 'Official Email', key: 'email', format: (val) => val || '—' },
{ header: 'Status', key: 'status', format: (val) => getOrganisationStatusBadge({ status: val }).label },
{ header: 'Registration Date', key: 'createdAt', format: (val) => formatDate(val) },
];
const portfolioExportColumns: ExportColumn<Portfolio>[] = [
{ header: 'Portfolio ID', key: 'id' },
{ header: 'Portfolio Name', key: 'name' },
{
header: 'Organisation',
key: 'organisationId',
format: (val) => {
const org = organisations.find((o) => String(o.id) === String(val));
return org ? org.name : `Org #${val}`;
},
},
{
header: 'Programmes Count',
key: 'id',
format: (id) => programmes.filter((pr) => String(pr.portfolioId) === String(id)).length,
},
{ header: 'Owner Email', key: 'ownedBy', format: (val) => getUserEmail(val) },
{ header: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) },
{ header: 'Description', key: 'description', format: (val) => val || '—' },
];
const programmeExportColumns: ExportColumn<Programme>[] = [
{ header: 'Programme ID', key: 'id' },
{ header: 'Programme Name', key: 'name' },
{
header: 'Parent Portfolio',
key: 'portfolioId',
format: (val) => {
const port = portfolios.find((p) => String(p.id) === String(val));
return port ? port.name : `Portfolio #${val}`;
},
},
{ header: 'Governance Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label },
{ header: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) },
{ header: 'Owner Email', key: 'ownedBy', format: (val) => getUserEmail(val) },
];
return (
<div className="space-y-6 max-w-7xl mx-auto">
{/* Header */}
@@ -597,7 +688,7 @@ export const GovernancePage: React.FC = () => {
/>
</div>
<p className="text-xs text-slate-400 mt-1">
Maintain Master Organisation Registry, Funder Portfolios, Strategic Programmes, Funding Instructions, and Programme Rules.
Maintain Master Organisation Registry, Funder Portfolios, Strategic Programmes, Funding Instruments, and Programme Rules.
</p>
</div>
@@ -630,13 +721,13 @@ export const GovernancePage: React.FC = () => {
Create Programme
</button>
)}
{activeTab === 'instructions' && (
{(activeTab === 'instruments' || activeTab === 'instructions') && (
<button
onClick={() => setIsInstructionCreateTriggered(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-md shadow-blue-500/20 transition cursor-pointer"
>
<Plus className="w-4 h-4" />
Add Instruction
Add Funding Instrument
</button>
)}
{activeTab === 'rules' && (
@@ -705,18 +796,18 @@ export const GovernancePage: React.FC = () => {
/>
</button>
<button
onClick={() => handleTabChange('instructions')}
onClick={() => handleTabChange('instruments')}
className={`py-3.5 px-4 text-xs font-semibold border-b-2 transition-colors flex items-center gap-2 cursor-pointer whitespace-nowrap ${
activeTab === 'instructions'
activeTab === 'instruments' || activeTab === 'instructions'
? 'border-blue-500 text-blue-400'
: 'border-transparent text-slate-400 hover:text-slate-200'
}`}
>
<FileText className="w-4 h-4" />
<span>Funding Instructions</span>
<Coins className="w-4 h-4" />
<span>Funding Instruments</span>
<HelpTooltip
title="Funding Instructions"
content="Operational directives and funding instrument guidelines attached to specific strategic programmes."
title="Funding Instruments"
content="Binding funding instruments and financing directives attached to specific strategic funding programmes."
size="sm"
placement="top"
/>
@@ -740,31 +831,41 @@ export const GovernancePage: React.FC = () => {
</button>
</div>
{/* Search Bar & Actions */}
{/* Search Bar & Actions for Organisations */}
{activeTab === 'organisations' && (
<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 organisation name, reg no, or email..."
placeholder="Search by organisation name, reg no, email, 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>
<button
type="button"
onClick={() => {
queryClient.invalidateQueries({ queryKey: ['organisations'] });
success('Refreshed', 'Organisation directory refreshed from database.');
}}
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"
title="Force refresh organisation directory from database"
>
<RefreshCw className="w-3.5 h-3.5 text-blue-400" />
Refresh Directory
</button>
<div className="flex items-center gap-2 self-end sm:self-center">
<button
type="button"
onClick={() => {
refetchOrgs();
success('Refreshed', 'Organisation directory refreshed from API.');
}}
disabled={isFetchingOrgs}
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 organisation directory from API"
>
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetchingOrgs ? 'animate-spin' : ''}`} />
<span>Refresh Directory</span>
</button>
<ExportButton
title="Organisation Master Registry"
subtitle="Official directory of verified recipient and funder entities"
filename={`pfm-organisations-${new Date().toISOString().split('T')[0]}`}
columns={orgExportColumns}
data={filteredOrgs}
/>
</div>
</div>
)}
@@ -855,13 +956,57 @@ export const GovernancePage: React.FC = () => {
</div>
)}
{/* Search Bar & Actions for Portfolios */}
{activeTab === 'portfolios' && (
<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 portfolios by name, ID, organisation, or owner..."
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={() => {
refetchPortfolios();
success('Refreshed', 'Funder portfolios refreshed from API.');
}}
disabled={isFetchingPortfolios}
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 portfolios from API"
>
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetchingPortfolios ? 'animate-spin' : ''}`} />
<span>Refresh Portfolios</span>
</button>
<ExportButton
title="Funder Portfolios Registry"
subtitle="High-level funding groupings and thematic capital allocations"
filename={`pfm-portfolios-${new Date().toISOString().split('T')[0]}`}
columns={portfolioExportColumns}
data={filteredPortfolios}
/>
</div>
</div>
)}
{/* Tab 2: Portfolios List */}
{activeTab === 'portfolios' && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{loadingPortfolios ? (
<p className="text-xs text-slate-500 col-span-3">Loading portfolios...</p>
<p className="text-xs text-slate-500 col-span-3 text-center py-8">Loading portfolios...</p>
) : filteredPortfolios.length === 0 ? (
<div className="col-span-3 py-12 text-center bg-[#0f1523]/80 rounded-2xl border border-slate-800/80 p-8">
<FolderKanban className="w-8 h-8 text-slate-600 mx-auto mb-2" />
<p className="text-sm font-semibold text-slate-300">No portfolios found matching query</p>
<p className="text-xs text-slate-500 mt-1">Try refining your search keywords or create a new portfolio.</p>
</div>
) : (
portfolios.map((p) => {
filteredPortfolios.map((p) => {
const org = organisations.find((o) => String(o.id) === String(p.organisationId));
const progCount = programmes.filter((pr) => String(pr.portfolioId) === String(p.id)).length;
const isHighlighted = String(highlightedPortfolioId) === String(p.id);
@@ -942,6 +1087,44 @@ export const GovernancePage: React.FC = () => {
</div>
)}
{/* Search Bar & Actions for Programmes */}
{activeTab === 'programmes' && (
<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 programmes by name, ID, parent portfolio, or owner..."
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={() => {
refetchProgrammes();
success('Refreshed', 'Programmes hierarchy refreshed from API.');
}}
disabled={isFetchingProgrammes}
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 programmes from API"
>
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetchingProgrammes ? 'animate-spin' : ''}`} />
<span>Refresh Programmes</span>
</button>
<ExportButton
title="Strategic Programmes Hierarchy"
subtitle="Operational funding vehicles linked to funder portfolios"
filename={`pfm-programmes-${new Date().toISOString().split('T')[0]}`}
columns={programmeExportColumns}
data={filteredProgrammes}
/>
</div>
</div>
)}
{/* Tab 3: Programmes Hierarchy */}
{activeTab === 'programmes' && (
<div className="bg-[#0f1523]/80 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm overflow-hidden">
@@ -964,8 +1147,14 @@ export const GovernancePage: React.FC = () => {
Loading programmes...
</td>
</tr>
) : filteredProgrammes.length === 0 ? (
<tr>
<td colSpan={6} className="py-8 text-center text-slate-500">
No programmes found matching query.
</td>
</tr>
) : (
programmes.map((pr) => {
filteredProgrammes.map((pr) => {
const port = portfolios.find((p) => String(p.id) === String(pr.portfolioId));
const statusBadge = getApprovalStatusBadge(pr.status);
return (
@@ -1007,10 +1196,10 @@ export const GovernancePage: React.FC = () => {
setIsInstrumentsModalOpen(true);
}}
className="inline-flex items-center gap-1 text-xs font-semibold text-blue-400 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
title="View Funding Instructions"
title="View Funding Instruments"
>
<FileText className="w-3 h-3" />
Instructions
<Coins className="w-3 h-3" />
Instruments
</button>
<button
type="button"
@@ -1044,9 +1233,9 @@ export const GovernancePage: React.FC = () => {
</div>
)}
{/* Tab 4: Funding Instructions */}
{activeTab === 'instructions' && (
<InstructionsSection
{/* Tab 4: Funding Instruments */}
{(activeTab === 'instruments' || activeTab === 'instructions') && (
<FundingInstrumentsSection
programmes={programmes}
currentUserId={currentUserId}
preselectedProgrammeId={preselectedProgForInstruction}
@@ -1476,26 +1665,26 @@ export const GovernancePage: React.FC = () => {
</form>
</Modal>
{/* Modal: View Funding Instructions */}
{/* Modal: View Funding Instruments */}
<Modal
isOpen={isInstrumentsModalOpen}
onClose={() => {
setIsInstrumentsModalOpen(false);
setSelectedProgrammeForInstruments(null);
}}
title={`Funding Instructions: ${selectedProgrammeForInstruments?.name || ''}`}
subtitle={`Authoritative funding instructions linked to Programme #${selectedProgrammeForInstruments?.id || ''}`}
title={`Funding Instruments: ${selectedProgrammeForInstruments?.name || ''}`}
subtitle={`Authoritative funding instruments linked to Programme #${selectedProgrammeForInstruments?.id || ''}`}
>
<div className="space-y-4">
{loadingInstruments ? (
<div className="py-8 text-center text-xs text-slate-400 flex items-center justify-center gap-2">
<RefreshCw className="w-4 h-4 animate-spin text-blue-400" />
<span>Loading funding instructions...</span>
<span>Loading funding instruments...</span>
</div>
) : programmeInstruments.length === 0 ? (
<div className="py-8 text-center text-xs text-slate-400 space-y-3">
<div className="text-slate-500">
No funding instructions registered under this programme yet.
No funding instruments registered under this programme yet.
</div>
<button
type="button"
@@ -1505,12 +1694,12 @@ export const GovernancePage: React.FC = () => {
setSelectedProgrammeForInstruments(null);
setPreselectedProgForInstruction(pId || null);
setIsInstructionCreateTriggered(true);
handleTabChange('instructions');
handleTabChange('instruments');
}}
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl transition cursor-pointer shadow-md shadow-blue-500/20"
>
<Plus className="w-3.5 h-3.5" />
Add Instruction to this Programme
Add Instrument to this Programme
</button>
</div>
) : (
@@ -1540,10 +1729,10 @@ export const GovernancePage: React.FC = () => {
setIsInstrumentsModalOpen(false);
setSelectedProgrammeForInstruments(null);
setPreselectedProgForInstruction(pId || null);
handleTabChange('instructions');
handleTabChange('instruments');
}}
className="inline-flex items-center gap-1 text-[11px] font-semibold text-slate-300 hover:text-white bg-slate-800 hover:bg-slate-700 border border-slate-700 px-2 py-0.5 rounded cursor-pointer"
title="Manage in Instructions Tab"
title="Manage in Funding Instruments Tab"
>
<Edit2 className="w-3 h-3" />
Manage
@@ -1571,12 +1760,12 @@ export const GovernancePage: React.FC = () => {
setSelectedProgrammeForInstruments(null);
setPreselectedProgForInstruction(pId || null);
setIsInstructionCreateTriggered(true);
handleTabChange('instructions');
handleTabChange('instruments');
}}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-blue-400 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 rounded-xl transition cursor-pointer"
>
<Plus className="w-3.5 h-3.5" />
Add Instruction
Add Funding Instrument
</button>
<button
type="button"
@@ -1765,10 +1954,10 @@ export const GovernancePage: React.FC = () => {
setIsInstrumentsModalOpen(true);
}}
className="inline-flex items-center gap-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 px-2 py-1 rounded-lg cursor-pointer"
title="View Funding Instructions"
title="View Funding Instruments"
>
<FileText className="w-3 h-3" />
Instructions
<Coins className="w-3 h-3" />
Instruments
</button>
<button
type="button"
@@ -2067,12 +2256,12 @@ export const GovernancePage: React.FC = () => {
</button>
</div>
{/* Total Instructions */}
{/* Total Funding Instruments */}
<div className="p-3 bg-gradient-to-br from-blue-500/10 to-blue-500/5 rounded-xl border border-blue-500/20 flex flex-col justify-between">
<div className="flex items-center justify-between mb-1">
<span className="text-[11px] font-semibold text-blue-300 flex items-center gap-1.5">
<FileText className="w-3.5 h-3.5 text-blue-400" />
Total Instructions
<Coins className="w-3.5 h-3.5 text-blue-400" />
Total Funding Instruments
</span>
<span className="text-lg font-bold font-mono text-blue-400">
{loadingDetailInstructions ? (
@@ -2089,11 +2278,11 @@ export const GovernancePage: React.FC = () => {
setIsProgrammeDetailModalOpen(false);
setSelectedProgrammeForDetail(null);
setPreselectedProgForInstruction(pId || null);
handleTabChange('instructions');
handleTabChange('instruments');
}}
className="text-[11px] font-semibold text-blue-400 hover:text-blue-300 inline-flex items-center gap-1 mt-1 transition cursor-pointer self-start"
>
View in Instructions Tab &rarr;
View in Funding Instruments Tab &rarr;
</button>
</div>
</div>
+299 -144
View File
@@ -20,6 +20,8 @@ import {
CreateProgrammeRequest,
UpdateProgrammeRequest,
Instrument,
CreateInstrumentRequest,
UpdateInstrumentRequest,
Instruction,
CreateInstructionRequest,
UpdateInstructionRequest,
@@ -225,7 +227,7 @@ export const updateOrganisation = async (payload: UpdateOrganisationRequest): Pr
return res.data;
};
export const getPortfolios = async (organisationId?: string | number): Promise<Portfolio[]> => {
export const getOrganisationPortfolios = async (organisationId?: string | number): Promise<Portfolio[]> => {
const targetOrgId =
organisationId !== undefined && organisationId !== '' && organisationId !== null
? organisationId
@@ -235,8 +237,31 @@ export const getPortfolios = async (organisationId?: string | number): Promise<P
return [];
}
const hashedOrgId = await toolsService.resolveOrganisationId(targetOrgId);
const res = await apiClient.get<Portfolio[]>(`/v1/api/governance/organisations/${hashedOrgId}/portfolio`);
return res.data || [];
try {
const res = await apiClient.get<Portfolio[]>(`/v1/api/governance/organisations/${hashedOrgId}/portfolio`);
return res.data || [];
} catch (err) {
// If hashed ID fails or is not recognized, attempt with raw targetOrgId
if (String(targetOrgId) !== String(hashedOrgId)) {
try {
const fallbackRes = await apiClient.get<Portfolio[]>(`/v1/api/governance/organisations/${targetOrgId}/portfolio`);
return fallbackRes.data || [];
} catch {
// ignore
}
}
console.warn('[getOrganisationPortfolios] Failed to retrieve portfolios for organisation:', err);
return [];
}
};
export const getPortfolios = async (
arg?: string | number | Pagination
): Promise<Portfolio[]> => {
if (arg === undefined || arg === null || typeof arg === 'object') {
return getAllPortfolios(typeof arg === 'object' ? arg : undefined);
}
return getOrganisationPortfolios(arg);
};
export const getPortfolio = async (portfolioId: string | number): Promise<Portfolio> => {
@@ -245,23 +270,42 @@ export const getPortfolio = async (portfolioId: string | number): Promise<Portfo
return Array.isArray(res.data) ? res.data[0] : res.data;
};
export const getAllPortfolios = async (): Promise<Portfolio[]> => {
export const getAllPortfolios = async (
pagination: Pagination = { page: 1, pageSize: 100, index: 0 }
): Promise<Portfolio[]> => {
try {
const orgs = await getOrganisations(1, 10);
if (orgs.length > 0) {
const results = await Promise.allSettled(
orgs.map((org) => getPortfolios(org.id))
);
const combined = results
.filter((r): r is PromiseFulfilledResult<Portfolio[]> => r.status === 'fulfilled')
.flatMap((r) => r.value);
if (combined.length > 0) return combined;
const res = await apiClient.post<Portfolio[]>('/v1/api/governance/organisations/portfolios', {
index: pagination.index ? toIntegerId(pagination.index) : 0,
page: pagination.page || 1,
pageSize: pagination.pageSize || 100,
});
if (Array.isArray(res.data)) {
return res.data;
}
} catch (err) {
console.warn('[getAllPortfolios] POST /v1/api/governance/organisations/portfolios failed, attempting fallback:', err);
try {
const getRes = await apiClient.get<Portfolio[]>('/v1/api/governance/organisations/portfolios', {
params: { page: pagination.page || 1, pageSize: pagination.pageSize || 100 },
});
if (Array.isArray(getRes.data)) {
return getRes.data;
}
} catch {
// Fallback to iterating organisations if bulk endpoint is unavailable
const orgs = await getOrganisations(1, 10);
if (orgs.length > 0) {
const results = await Promise.allSettled(
orgs.map((org) => getOrganisationPortfolios(org.id))
);
const combined = results
.filter((r): r is PromiseFulfilledResult<Portfolio[]> => r.status === 'fulfilled')
.flatMap((r) => r.value);
if (combined.length > 0) return combined;
}
}
const res = await apiClient.get<Portfolio[]>('/v1/api/governance/organisations/portfolio');
return res.data || [];
} catch {
return [];
}
return [];
};
export const getPortfolioProgrammes = async (portfolioId: string | number): Promise<Programme[]> => {
@@ -314,7 +358,30 @@ export const getProgrammes = async (portfolioId: string | number): Promise<Progr
const LOCAL_INSTRUCTIONS_KEY = 'pfm_local_instructions';
export const getStoredInstructions = (): Instruction[] => {
export const purgeStoredInstructionDuplicates = (apiItems: Instrument[]): void => {
if (typeof window === 'undefined' || !Array.isArray(apiItems) || apiItems.length === 0) return;
try {
const raw = localStorage.getItem(LOCAL_INSTRUCTIONS_KEY);
if (!raw) return;
const local: Instrument[] = JSON.parse(raw);
if (!Array.isArray(local) || local.length === 0) return;
const apiIds = new Set(apiItems.map((i) => String(i.id)));
const apiKeys = new Set(apiItems.map((i) => `${i.programmeId}::${(i.name || '').toLowerCase().trim()}`));
const remaining = local.filter(
(l) => !apiIds.has(String(l.id)) && !apiKeys.has(`${l.programmeId}::${(l.name || '').toLowerCase().trim()}`)
);
if (remaining.length !== local.length) {
localStorage.setItem(LOCAL_INSTRUCTIONS_KEY, JSON.stringify(remaining));
}
} catch (e) {
console.warn('Failed to purge stored instruction duplicates', e);
}
};
export const getStoredInstructions = (): Instrument[] => {
if (typeof window === 'undefined') return [];
try {
const raw = localStorage.getItem(LOCAL_INSTRUCTIONS_KEY);
@@ -324,11 +391,14 @@ export const getStoredInstructions = (): Instruction[] => {
}
};
export const saveStoredInstruction = (inst: Instruction): void => {
export const saveStoredInstruction = (inst: Instrument): void => {
if (typeof window === 'undefined') return;
try {
const current = getStoredInstructions();
const idx = current.findIndex((i) => String(i.id) === String(inst.id));
const idx = current.findIndex(
(i) => String(i.id) === String(inst.id) ||
(String(i.programmeId) === String(inst.programmeId) && (i.name || '').trim().toLowerCase() === (inst.name || '').trim().toLowerCase())
);
if (idx >= 0) {
current[idx] = { ...current[idx], ...inst, updatedAt: new Date().toISOString() };
} else {
@@ -340,31 +410,35 @@ export const saveStoredInstruction = (inst: Instruction): void => {
}
};
export const getInstructions = async (programmeId: string | number): Promise<Instruction[]> => {
let apiItems: Instruction[] = [];
export const getInstruments = async (programmeId: string | number): Promise<Instrument[]> => {
let apiItems: Instrument[] = [];
let apiSucceeded = false;
try {
const hashedProgId = await toolsService.resolveProgrammeId(programmeId);
const res = await apiClient.get<Instruction[]>(`/v1/api/governance/instruments/${hashedProgId}`);
apiItems = res.data || [];
const res = await apiClient.get<Instrument[]>(`/v1/api/governance/instruments/${hashedProgId}`);
if (Array.isArray(res.data)) {
apiItems = res.data;
apiSucceeded = true;
}
} catch (err) {
console.warn(`[getInstructions] Could not fetch instruments from API for prog ${programmeId}:`, err);
console.warn(`[getInstruments] Could not fetch instruments from API for prog ${programmeId}:`, err);
}
if (apiSucceeded) {
purgeStoredInstructionDuplicates(apiItems);
return apiItems;
}
// Fallback to local items only when API failed
const localItems = getStoredInstructions().filter(
(item) => String(item.programmeId) === String(programmeId)
);
const map = new Map<string, Instruction>();
apiItems.forEach((item) => map.set(String(item.id), item));
localItems.forEach((item) => map.set(String(item.id), item));
return Array.from(map.values());
return localItems;
};
export const getInstruments = getInstructions;
export const getInstructions = getInstruments;
export const getAllInstructions = async (programmesList?: Programme[]): Promise<Instruction[]> => {
const localItems = getStoredInstructions();
export const getAllInstruments = async (programmesList?: Programme[]): Promise<Instrument[]> => {
let progList = programmesList;
if (!progList || progList.length === 0) {
try {
@@ -374,66 +448,103 @@ export const getAllInstructions = async (programmesList?: Programme[]): Promise<
}
}
const map = new Map<string, Instruction>();
localItems.forEach((item) => map.set(String(item.id), item));
const map = new Map<string, Instrument>();
let hasApiData = false;
if (progList && progList.length > 0) {
const results = await Promise.allSettled(
progList.map((pr) => getInstructions(pr.id))
progList.map((pr) => getInstruments(pr.id))
);
results.forEach((res) => {
if (res.status === 'fulfilled' && Array.isArray(res.value)) {
if (res.status === 'fulfilled' && Array.isArray(res.value) && res.value.length > 0) {
hasApiData = true;
res.value.forEach((inst) => map.set(String(inst.id), inst));
}
});
}
return Array.from(map.values());
if (hasApiData || map.size > 0) {
const allApiInstruments = Array.from(map.values());
purgeStoredInstructionDuplicates(allApiInstruments);
return allApiInstruments;
}
return getStoredInstructions();
};
export const createInstruction = async (payload: CreateInstructionRequest): Promise<Instruction> => {
const newId = Date.now();
const newInstruction: Instruction = {
id: newId,
export const getAllInstructions = getAllInstruments;
export const createInstrument = async (payload: CreateInstrumentRequest): Promise<Instrument> => {
const code = payload.code || `INST-${Date.now().toString().slice(-4)}`;
const body = {
programmeId: toIntegerId(payload.programmeId),
code: payload.code || `INST-${String(newId).slice(-4)}`,
name: payload.name,
code: code,
description: payload.description,
};
try {
const res = await apiClient.post<Instrument>('/v1/api/governance/instruments', body);
if (res.data && res.data.id) {
purgeStoredInstructionDuplicates([res.data]);
return res.data;
}
} catch (err: any) {
console.warn('[createInstrument] API create failed, falling back to local storage:', err);
const newId = Date.now();
const fallbackInstruction: Instrument = {
id: newId,
programmeId: toIntegerId(payload.programmeId),
code: code,
name: payload.name,
description: payload.description,
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
createdBy: payload.userId || 'system',
createdAt: new Date().toISOString(),
};
saveStoredInstruction(fallbackInstruction);
return fallbackInstruction;
}
// If API returned 200 without full body, return formatted item without saving duplicate to localStorage
return {
id: Date.now(),
programmeId: toIntegerId(payload.programmeId),
code: code,
name: payload.name,
description: payload.description,
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
createdBy: payload.userId || 'system',
createdAt: new Date().toISOString(),
};
try {
await apiClient.post('/v1/api/governance/instruments', {
programmeId: toIntegerId(payload.programmeId),
code: newInstruction.code,
name: newInstruction.name,
description: newInstruction.description,
status: newInstruction.status,
});
} catch (err: any) {
try {
await apiClient.post('/v1/api/governance/instructions', {
programmeId: toIntegerId(payload.programmeId),
code: newInstruction.code,
name: newInstruction.name,
description: newInstruction.description,
status: newInstruction.status,
});
} catch (e) {
console.warn('[createInstruction] API create failed, persisted locally:', e);
}
}
saveStoredInstruction(newInstruction);
return newInstruction;
};
export const updateInstruction = async (payload: UpdateInstructionRequest): Promise<Instruction> => {
const existing = getStoredInstructions().find((i) => String(i.id) === String(payload.instructionId));
const updatedInstruction: Instruction = {
id: payload.instructionId,
export const createInstruction = createInstrument;
export const updateInstrument = async (payload: UpdateInstrumentRequest): Promise<Instrument> => {
const targetId = payload.instructionId ?? payload.instrumentId ?? payload.id;
const numId = toIntegerId(targetId);
const body = {
instructionId: numId,
code: payload.code || '',
name: payload.name,
description: payload.description,
status: Number(payload.status),
};
try {
const res = await apiClient.put<Instrument>('/v1/api/governance/instruments', body);
if (res.data && res.data.id) {
return res.data;
}
} catch (err) {
console.warn('[updateInstrument] API update failed:', err);
}
const existing = getStoredInstructions().find((i) => String(i.id) === String(targetId));
const updatedInstrument: Instrument = {
id: targetId!,
programmeId: payload.programmeId ? toIntegerId(payload.programmeId) : (existing?.programmeId || 0),
code: payload.code || existing?.code || null,
name: payload.name,
@@ -445,35 +556,14 @@ export const updateInstruction = async (payload: UpdateInstructionRequest): Prom
updatedAt: new Date().toISOString(),
};
try {
await apiClient.put('/v1/api/governance/instruments', {
instrumentId: toIntegerId(payload.instructionId),
instructionId: toIntegerId(payload.instructionId),
programmeId: payload.programmeId ? toIntegerId(payload.programmeId) : undefined,
code: payload.code,
name: payload.name,
description: payload.description,
status: Number(payload.status),
});
} catch (err) {
try {
await apiClient.put('/v1/api/governance/instructions', {
instructionId: toIntegerId(payload.instructionId),
programmeId: payload.programmeId ? toIntegerId(payload.programmeId) : undefined,
code: payload.code,
name: payload.name,
description: payload.description,
status: Number(payload.status),
});
} catch (e) {
console.warn('[updateInstruction] API update failed, updated locally:', e);
}
if (existing) {
saveStoredInstruction(updatedInstrument);
}
saveStoredInstruction(updatedInstruction);
return updatedInstruction;
return updatedInstrument;
};
export const updateInstruction = updateInstrument;
// ==========================================
// 3. Project & Milestone Management APIs
// ==========================================
@@ -1610,6 +1700,29 @@ export const updateSiteVisit = async (payload: UpdateSiteVisitRequest): Promise<
const LOCAL_RULES_KEY = 'pfm_local_rules';
export const purgeStoredRuleDuplicates = (apiRules: Rule[]): void => {
if (typeof window === 'undefined' || !Array.isArray(apiRules) || apiRules.length === 0) return;
try {
const raw = localStorage.getItem(LOCAL_RULES_KEY);
if (!raw) return;
const local: Rule[] = JSON.parse(raw);
if (!Array.isArray(local) || local.length === 0) return;
const apiIds = new Set(apiRules.map((r) => String(r.id)));
const apiKeys = new Set(apiRules.map((r) => `${r.programmeId}::${(r.name || '').toLowerCase().trim()}`));
const remaining = local.filter(
(l) => !apiIds.has(String(l.id)) && !apiKeys.has(`${l.programmeId}::${(l.name || '').toLowerCase().trim()}`)
);
if (remaining.length !== local.length) {
localStorage.setItem(LOCAL_RULES_KEY, JSON.stringify(remaining));
}
} catch (e) {
console.warn('Failed to purge stored rule duplicates', e);
}
};
export const getStoredRules = (): Rule[] => {
if (typeof window === 'undefined') return [];
try {
@@ -1624,7 +1737,10 @@ export const saveStoredRule = (rule: Rule): void => {
if (typeof window === 'undefined') return;
try {
const current = getStoredRules();
const idx = current.findIndex((r) => String(r.id) === String(rule.id));
const idx = current.findIndex(
(r) => String(r.id) === String(rule.id) ||
(String(r.programmeId) === String(rule.programmeId) && (r.name || '').trim().toLowerCase() === (rule.name || '').trim().toLowerCase())
);
if (idx >= 0) {
current[idx] = { ...current[idx], ...rule, updatedAt: new Date().toISOString() };
} else {
@@ -1640,15 +1756,28 @@ export const saveStoredRule = (rule: Rule): void => {
// 15. Execution: Programme Rules APIs
// ==========================================
export const getRule = async (ruleId: string | number): Promise<Rule> => {
const localRule = getStoredRules().find((r) => String(r.id) === String(ruleId));
try {
const hashedRuleId = await toolsService.encodeId(ruleId);
const res = await apiClient.get<Rule>(`/v1/api/execution/rules/${hashedRuleId}`);
return res.data || localRule!;
if (res.data) {
purgeStoredRuleDuplicates([res.data]);
return res.data;
}
} catch {
const localRule = getStoredRules().find((r) => String(r.id) === String(ruleId));
if (localRule) return localRule;
throw new Error('Rule not found');
}
throw new Error('Rule not found');
};
const toValidUuid = (userId?: string | null): string => {
if (!userId) return '00000000-0000-0000-0000-000000000000';
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (uuidPattern.test(userId)) return userId;
const userSub = getCurrentUserSub();
if (userSub && uuidPattern.test(userSub)) return userSub;
return '00000000-0000-0000-0000-000000000000';
};
export const getRules = async (
@@ -1656,23 +1785,33 @@ export const getRules = async (
programmeId?: string | number
): Promise<Rule[]> => {
let apiRules: Rule[] = [];
let apiSucceeded = false;
try {
const res = await apiClient.post<Rule[]>('/v1/api/execution/programmes/rules', {
index: pagination.index ? toIntegerId(pagination.index) : 0,
page: pagination.page || 1,
pageSize: pagination.pageSize || 100,
});
apiRules = res.data || [];
if (Array.isArray(res.data)) {
apiRules = res.data;
apiSucceeded = true;
}
} catch (err) {
console.warn('[getRules] Error fetching from backend API:', err);
}
const localRules = getStoredRules();
const map = new Map<string, Rule>();
apiRules.forEach((r) => map.set(String(r.id), r));
localRules.forEach((r) => map.set(String(r.id), r));
if (apiSucceeded) {
purgeStoredRuleDuplicates(apiRules);
let result = apiRules;
if (programmeId != null && programmeId !== '' && programmeId !== 'all') {
result = result.filter((r) => String(r.programmeId) === String(programmeId));
}
return result;
}
let combined = Array.from(map.values());
// Fallback to local storage only if API call failed
const localRules = getStoredRules();
let combined = localRules;
if (programmeId != null && programmeId !== '' && programmeId !== 'all') {
combined = combined.filter((r) => String(r.programmeId) === String(programmeId));
}
@@ -1680,60 +1819,76 @@ export const getRules = async (
};
export const createRule = async (payload: CreateRuleRequest): Promise<Rule> => {
const newId = Date.now();
const newRule: Rule = {
id: newId,
const validUserId = toValidUuid(payload.userId);
const body = {
programmeId: toIntegerId(payload.programmeId),
userId: validUserId,
name: payload.name,
description: payload.description,
};
try {
const res = await apiClient.post<Rule>('/v1/api/execution/rules', body);
if (res.data && res.data.id) {
purgeStoredRuleDuplicates([res.data]);
return res.data;
}
} catch (err) {
console.warn('[createRule] API create failed, falling back to local storage:', err);
const newId = Date.now();
const fallbackRule: Rule = {
id: newId,
programmeId: toIntegerId(payload.programmeId),
name: payload.name,
description: payload.description,
status: ActivityStatus.Active,
version: 1,
createdBy: validUserId,
createdAt: new Date().toISOString(),
};
saveStoredRule(fallbackRule);
return fallbackRule;
}
return {
id: Date.now(),
programmeId: toIntegerId(payload.programmeId),
name: payload.name,
description: payload.description,
status: ActivityStatus.Active,
version: 1,
createdBy: payload.userId,
createdBy: validUserId,
createdAt: new Date().toISOString(),
};
try {
await apiClient.post('/v1/api/execution/rules', {
programmeId: toIntegerId(payload.programmeId),
userId: payload.userId,
name: payload.name,
description: payload.description,
});
} catch (err) {
console.warn('[createRule] API create failed, persisted locally:', err);
}
saveStoredRule(newRule);
return newRule;
};
export const updateRule = async (payload: UpdateRuleRequest): Promise<void> => {
const validUserId = toValidUuid(payload.userId);
try {
await apiClient.put('/v1/api/execution/rules', {
ruleId: toIntegerId(payload.ruleId),
userId: payload.userId,
userId: validUserId,
name: payload.name,
description: payload.description,
status: Number(payload.status),
version: Number(payload.version),
version: Number(payload.version || 1),
...(payload.programmeId ? { programmeId: toIntegerId(payload.programmeId) } : {}),
});
} catch (err) {
console.warn('[updateRule] API update failed, updated locally:', err);
console.warn('[updateRule] API update failed:', err);
const existing = getStoredRules().find((r) => String(r.id) === String(payload.ruleId));
const updatedRule: Rule = {
id: payload.ruleId,
programmeId: payload.programmeId ? toIntegerId(payload.programmeId) : (existing?.programmeId || 0),
name: payload.name,
description: payload.description,
status: Number(payload.status),
version: Number(payload.version || 1),
createdBy: existing?.createdBy || validUserId,
createdAt: existing?.createdAt || new Date().toISOString(),
updatedBy: validUserId,
updatedAt: new Date().toISOString(),
};
saveStoredRule(updatedRule);
}
const existing = getStoredRules().find((r) => String(r.id) === String(payload.ruleId));
const updatedRule: Rule = {
id: payload.ruleId,
programmeId: payload.programmeId ? toIntegerId(payload.programmeId) : (existing?.programmeId || 0),
name: payload.name,
description: payload.description,
status: Number(payload.status),
version: Number(payload.version),
createdBy: existing?.createdBy || payload.userId,
createdAt: existing?.createdAt || new Date().toISOString(),
updatedBy: payload.userId,
updatedAt: new Date().toISOString(),
};
saveStoredRule(updatedRule);
};
+9 -5
View File
@@ -385,9 +385,7 @@ export interface Instrument {
updatedAt?: string | null;
}
export type Instruction = Instrument;
export interface CreateInstructionRequest {
export interface CreateInstrumentRequest {
programmeId: number | string;
code?: string;
name: string;
@@ -396,8 +394,10 @@ export interface CreateInstructionRequest {
userId?: string;
}
export interface UpdateInstructionRequest {
instructionId: number | string;
export interface UpdateInstrumentRequest {
instructionId?: number | string;
instrumentId?: number | string;
id?: number | string;
programmeId?: number | string;
code?: string;
name: string;
@@ -406,6 +406,10 @@ export interface UpdateInstructionRequest {
userId?: string;
}
export type Instruction = Instrument;
export type CreateInstructionRequest = CreateInstrumentRequest;
export type UpdateInstructionRequest = UpdateInstrumentRequest;
export interface Project {
id: number | string;
programmeId: number | string;
@@ -0,0 +1,247 @@
import * as XLSX from 'xlsx';
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
export interface ExportColumn<T = any> {
header: string;
key: keyof T | string;
format?: (value: any, row: T) => string | number;
}
export interface ExportOptions<T = any> {
title: string;
subtitle?: string;
filename: string;
columns: ExportColumn<T>[];
data: T[];
tenantName?: string;
userEmail?: string;
}
/**
* Draws the official PFM Shield and Brand Header on jsPDF in landscape mode
*/
function drawPfmPdfHeader(
doc: jsPDF,
title: string,
subtitle?: string,
tenantName?: string,
userEmail?: string
): number {
const pageWidth = doc.internal.pageSize.getWidth();
// Dark header banner
doc.setFillColor(13, 18, 31); // #0d121f
doc.rect(0, 0, pageWidth, 68, 'F');
// Cyan-to-indigo accent bottom border line
doc.setFillColor(37, 99, 235); // #2563eb
doc.rect(0, 66, pageWidth, 2, 'F');
// Draw PFM Logo Shield Icon (Vector art)
const logoX = 24;
const logoY = 16;
// Background circle for logo
doc.setFillColor(15, 23, 42); // slate-900
doc.circle(logoX + 16, logoY + 16, 16, 'F');
doc.setDrawColor(56, 189, 248); // sky-400
doc.setLineWidth(1);
doc.circle(logoX + 16, logoY + 16, 16, 'S');
// Top facet (Violet/Indigo)
doc.setFillColor(99, 102, 241);
doc.triangle(
logoX + 16, logoY + 6,
logoX + 8, logoY + 14,
logoX + 24, logoY + 14,
'F'
);
// Left facet (Sky/Sapphire)
doc.setFillColor(14, 165, 233);
doc.triangle(
logoX + 8, logoY + 14,
logoX + 16, logoY + 26,
logoX + 16, logoY + 14,
'F'
);
// Right facet (Emerald/Cyan)
doc.setFillColor(16, 185, 129);
doc.triangle(
logoX + 24, logoY + 14,
logoX + 16, logoY + 26,
logoX + 16, logoY + 14,
'F'
);
// Core Diamond
doc.setFillColor(255, 255, 255);
doc.circle(logoX + 16, logoY + 14, 2.5, 'F');
// Brand Name & Subtitle
doc.setTextColor(255, 255, 255);
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.text('POST FUND MANAGEMENT (PFM)', logoX + 40, logoY + 13);
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
doc.setTextColor(148, 163, 184); // slate-400
doc.text('National Grants & Institutional Governance Platform', logoX + 40, logoY + 24);
// Right-aligned Metadata (Date, Tenant, User)
doc.setFontSize(8);
doc.setTextColor(203, 213, 225);
const nowStr = new Date().toLocaleString();
doc.text(`Generated: ${nowStr}`, pageWidth - 24, logoY + 8, { align: 'right' });
if (tenantName) {
doc.text(`Tenant: ${tenantName}`, pageWidth - 24, logoY + 18, { align: 'right' });
}
if (userEmail) {
doc.text(`Exported by: ${userEmail}`, pageWidth - 24, logoY + 28, { align: 'right' });
}
// Section Title on page
doc.setFontSize(13);
doc.setFont('helvetica', 'bold');
doc.setTextColor(15, 23, 42); // slate-900
doc.text(title, 24, 90);
let currentY = 94;
if (subtitle) {
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
doc.setTextColor(100, 116, 139); // slate-500
doc.text(subtitle, 24, 104);
currentY = 110;
} else {
currentY = 100;
}
return currentY;
}
/**
* Export tabular data to an Excel / Spreadsheet file (.xlsx)
*/
export function exportToSpreadsheet<T>(options: ExportOptions<T>): void {
const { title, filename, columns, data } = options;
if (!data || data.length === 0) {
console.warn('No data available to export to spreadsheet.');
return;
}
// Format table rows based on columns config
const rows = data.map((item) => {
const rowObj: Record<string, any> = {};
columns.forEach((col) => {
const rawVal = (item as any)[col.key];
const formatted = col.format ? col.format(rawVal, item) : (rawVal ?? '');
rowObj[col.header] = formatted;
});
return rowObj;
});
const worksheet = XLSX.utils.json_to_sheet(rows);
// Set column widths based on longest cell content
const colWidths = columns.map((col) => {
const headerLen = col.header.length;
const maxDataLen = data.reduce((max, item) => {
const rawVal = (item as any)[col.key];
const formatted = col.format ? String(col.format(rawVal, item)) : String(rawVal ?? '');
return Math.max(max, formatted.length);
}, headerLen);
return { wch: Math.min(Math.max(maxDataLen + 4, 12), 50) };
});
worksheet['!cols'] = colWidths;
const workbook = XLSX.utils.book_new();
const safeSheetName = title.replace(/[:\\/?*[\]]/g, ' ').substring(0, 31).trim() || 'Data';
XLSX.utils.book_append_sheet(workbook, worksheet, safeSheetName);
const cleanFilename = filename.endsWith('.xlsx') ? filename : `${filename}.xlsx`;
XLSX.writeFile(workbook, cleanFilename);
}
/**
* Export tabular data to a landscape PDF file with PFM Logo and branding
*/
export function exportToPdf<T>(options: ExportOptions<T>): void {
const { title, subtitle, filename, columns, data, tenantName, userEmail } = options;
if (!data || data.length === 0) {
console.warn('No data available to export to PDF.');
return;
}
// Landscape A4 doc: width: 841.89 pt, height: 595.28 pt
const doc = new jsPDF({
orientation: 'landscape',
unit: 'pt',
format: 'a4',
});
const startY = drawPfmPdfHeader(doc, title, subtitle, tenantName, userEmail);
// Build table headers and rows
const headers = [columns.map((c) => c.header)];
const body = data.map((item) => {
return columns.map((col) => {
const rawVal = (item as any)[col.key];
return col.format ? String(col.format(rawVal, item)) : String(rawVal ?? '');
});
});
autoTable(doc, {
head: headers,
body: body,
startY: startY + 6,
margin: { left: 24, right: 24, bottom: 32 },
styles: {
font: 'helvetica',
fontSize: 8,
cellPadding: 6,
textColor: [30, 41, 59], // slate-800
lineColor: [226, 232, 240], // slate-200
lineWidth: 0.5,
overflow: 'linebreak',
},
headStyles: {
fillColor: [15, 23, 42], // slate-900
textColor: [255, 255, 255],
fontStyle: 'bold',
fontSize: 8.5,
},
alternateRowStyles: {
fillColor: [248, 250, 252], // slate-50
},
didDrawPage: (dataArg) => {
// Footer on all pages
const pageCount = (doc.internal as any).getNumberOfPages();
const currentPage = dataArg.pageNumber;
const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
doc.setFontSize(7.5);
doc.setTextColor(148, 163, 184); // slate-400
doc.text(
'CONFIDENTIAL & PROPRIETARY — POST FUND MANAGEMENT (PFM) PLATFORM',
24,
pageHeight - 14
);
doc.text(
`Page ${currentPage} of ${pageCount}`,
pageWidth - 24,
pageHeight - 14,
{ align: 'right' }
);
},
});
const cleanFilename = filename.endsWith('.pdf') ? filename : `${filename}.pdf`;
doc.save(cleanFilename);
}