Implemented data export
This commit is contained in:
@@ -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 →
|
||||
View in Funding Instruments Tab →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user