Merge pull request 'Revised all UIs' (#101) from finishline into main
Reviewed-on: #101
This commit was merged in pull request #101.
This commit is contained in:
@@ -34,6 +34,7 @@
|
|||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
|
"postcss": "^8.5.28",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "~5.8.2",
|
"typescript": "~5.8.2",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,7 +28,6 @@
|
|||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"oidc-client-ts": "^3.5.0",
|
"oidc-client-ts": "^3.5.0",
|
||||||
"postcss": "^8.5.28",
|
|
||||||
"react": "^19.0.1",
|
"react": "^19.0.1",
|
||||||
"react-dom": "^19.0.1",
|
"react-dom": "^19.0.1",
|
||||||
"react-hook-form": "^7.87.0",
|
"react-hook-form": "^7.87.0",
|
||||||
@@ -43,6 +42,7 @@
|
|||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
|
"postcss": "^8.5.28",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "~5.8.2"
|
"typescript": "~5.8.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ import {
|
|||||||
createRule,
|
createRule,
|
||||||
updateRule,
|
updateRule,
|
||||||
getPortfolioProgrammes,
|
getPortfolioProgrammes,
|
||||||
|
getPortfolioValue,
|
||||||
|
getProgrammeValue,
|
||||||
getUsers,
|
getUsers,
|
||||||
getUser,
|
getUser,
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
@@ -77,6 +79,7 @@ import {
|
|||||||
getOrganisationStatusBadge,
|
getOrganisationStatusBadge,
|
||||||
getApprovalStatusBadge,
|
getApprovalStatusBadge,
|
||||||
formatDate,
|
formatDate,
|
||||||
|
formatZAR,
|
||||||
getUserEmail,
|
getUserEmail,
|
||||||
registerUserEmail,
|
registerUserEmail,
|
||||||
getUserIdByEmail,
|
getUserIdByEmail,
|
||||||
@@ -307,12 +310,24 @@ export const GovernancePage: React.FC = () => {
|
|||||||
enabled: !!selectedPortfolioForDetail && isPortfolioDetailModalOpen,
|
enabled: !!selectedPortfolioForDetail && isPortfolioDetailModalOpen,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: portfolioValue = 0, isLoading: loadingPortfolioValue } = useQuery({
|
||||||
|
queryKey: ['portfolio-value', selectedPortfolioForDetail?.id],
|
||||||
|
queryFn: () => (selectedPortfolioForDetail ? getPortfolioValue(selectedPortfolioForDetail.id) : Promise.resolve(0)),
|
||||||
|
enabled: !!selectedPortfolioForDetail && isPortfolioDetailModalOpen,
|
||||||
|
});
|
||||||
|
|
||||||
const { data: programmeFreshDetail, isLoading: loadingProgrammeFreshDetail } = useQuery({
|
const { data: programmeFreshDetail, isLoading: loadingProgrammeFreshDetail } = useQuery({
|
||||||
queryKey: ['programme', selectedProgrammeForDetail?.id],
|
queryKey: ['programme', selectedProgrammeForDetail?.id],
|
||||||
queryFn: () => (selectedProgrammeForDetail ? getProgramme(selectedProgrammeForDetail.id) : null),
|
queryFn: () => (selectedProgrammeForDetail ? getProgramme(selectedProgrammeForDetail.id) : null),
|
||||||
enabled: !!selectedProgrammeForDetail && isProgrammeDetailModalOpen,
|
enabled: !!selectedProgrammeForDetail && isProgrammeDetailModalOpen,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: programmeValue = 0, isLoading: loadingProgrammeValue } = useQuery({
|
||||||
|
queryKey: ['programme-value', selectedProgrammeForDetail?.id],
|
||||||
|
queryFn: () => (selectedProgrammeForDetail ? getProgrammeValue(selectedProgrammeForDetail.id) : Promise.resolve(0)),
|
||||||
|
enabled: !!selectedProgrammeForDetail && isProgrammeDetailModalOpen,
|
||||||
|
});
|
||||||
|
|
||||||
// Owner resolution helpers
|
// Owner resolution helpers
|
||||||
const resolveOwnerId = (emailOrId: string, fallbackOwnerId?: string | number | null): string => {
|
const resolveOwnerId = (emailOrId: string, fallbackOwnerId?: string | number | null): string => {
|
||||||
const trimmed = (emailOrId || '').trim();
|
const trimmed = (emailOrId || '').trim();
|
||||||
@@ -2130,27 +2145,71 @@ export const GovernancePage: React.FC = () => {
|
|||||||
Fetching portfolio details...
|
Fetching portfolio details...
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-4 bg-[#0d121f] rounded-xl border border-slate-800 space-y-3">
|
<div className="space-y-3.5">
|
||||||
<div>
|
{/* Prominent Hero Card: Total Portfolio Budget Value */}
|
||||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Title:</span>
|
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-blue-950/80 via-[#0d172a] to-indigo-950/80 border border-blue-500/40 p-4 sm:p-5 shadow-xl">
|
||||||
<span className="text-white font-medium text-sm">{portfolioFreshDetail?.name || selectedPortfolioForDetail?.name}</span>
|
<div className="absolute top-0 right-0 -mr-6 -mt-6 w-32 h-32 bg-blue-500/15 rounded-full blur-2xl pointer-events-none" />
|
||||||
</div>
|
<div className="flex items-center justify-between gap-3 mb-2">
|
||||||
<div>
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Description:</span>
|
<div className="w-8 h-8 rounded-xl bg-blue-500/20 border border-blue-500/40 flex items-center justify-center shrink-0">
|
||||||
<p className="text-slate-300 mt-0.5 leading-relaxed">{portfolioFreshDetail?.description || selectedPortfolioForDetail?.description || 'None provided'}</p>
|
<Coins className="w-4 h-4 text-blue-400" />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-3 border-t border-slate-800/80 items-center">
|
<div>
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<span className="text-[10px] font-bold uppercase tracking-wider text-blue-400 block">
|
||||||
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Owner:</span>
|
Portfolio Total Budget Value
|
||||||
<span className="font-mono text-slate-300 text-xs truncate" title={getUserEmail(portfolioFreshDetail?.ownedBy || selectedPortfolioForDetail?.ownedBy)}>
|
</span>
|
||||||
{getUserEmail(portfolioFreshDetail?.ownedBy || selectedPortfolioForDetail?.ownedBy)}
|
<span className="text-[11px] text-slate-400">
|
||||||
|
Aggregated budget across all linked strategic programmes
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-mono font-semibold px-2 py-0.5 rounded-full bg-blue-500/20 text-blue-300 border border-blue-500/30">
|
||||||
|
Live API Total
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Created:</span>
|
<div className="mt-3 flex items-baseline gap-2">
|
||||||
<span className="font-mono text-slate-300 text-xs">
|
{loadingPortfolioValue ? (
|
||||||
{formatDate(portfolioFreshDetail?.createdAt || selectedPortfolioForDetail?.createdAt || '')}
|
<div className="flex items-center gap-2 text-blue-300 py-1">
|
||||||
</span>
|
<RefreshCw className="w-4 h-4 animate-spin text-blue-400" />
|
||||||
|
<span className="font-mono text-xs">Calculating portfolio budget value...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-2xl sm:text-3xl font-extrabold font-mono tracking-tight text-white drop-shadow-sm">
|
||||||
|
{formatZAR(portfolioValue)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-semibold text-blue-400 font-mono">
|
||||||
|
ZAR
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Portfolio Master Info Card */}
|
||||||
|
<div className="p-4 bg-[#0d121f] rounded-xl border border-slate-800 space-y-3">
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase tracking-wider">Portfolio Title:</span>
|
||||||
|
<span className="text-white font-semibold text-sm block mt-0.5">{portfolioFreshDetail?.name || selectedPortfolioForDetail?.name}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase tracking-wider">Description:</span>
|
||||||
|
<p className="text-slate-300 mt-1 leading-relaxed">{portfolioFreshDetail?.description || selectedPortfolioForDetail?.description || 'None provided'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-3 border-t border-slate-800/80 items-center">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Owner:</span>
|
||||||
|
<span className="font-mono text-slate-300 text-xs truncate" title={getUserEmail(portfolioFreshDetail?.ownedBy || selectedPortfolioForDetail?.ownedBy)}>
|
||||||
|
{getUserEmail(portfolioFreshDetail?.ownedBy || selectedPortfolioForDetail?.ownedBy)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Created:</span>
|
||||||
|
<span className="font-mono text-slate-300 text-xs">
|
||||||
|
{formatDate(portfolioFreshDetail?.createdAt || selectedPortfolioForDetail?.createdAt || '')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2188,102 +2247,146 @@ export const GovernancePage: React.FC = () => {
|
|||||||
Fetching programme details...
|
Fetching programme details...
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-4 bg-[#0d121f] rounded-xl border border-slate-800/80 space-y-3.5">
|
<div className="space-y-3.5">
|
||||||
<div className="pb-3 border-b border-slate-800/80 flex items-start justify-between gap-4">
|
{/* Prominent Hero Card: Total Programme Budget Value */}
|
||||||
<div>
|
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-emerald-950/80 via-[#0a1b24] to-teal-950/80 border border-emerald-500/40 p-4 sm:p-5 shadow-xl">
|
||||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase tracking-wider mb-1">Programme Title</span>
|
<div className="absolute top-0 right-0 -mr-6 -mt-6 w-32 h-32 bg-emerald-500/15 rounded-full blur-2xl pointer-events-none" />
|
||||||
<span className="text-white font-semibold text-sm block">{programmeFreshDetail?.name || selectedProgrammeForDetail?.name}</span>
|
<div className="flex items-center justify-between gap-3 mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 rounded-xl bg-emerald-500/20 border border-emerald-500/40 flex items-center justify-center shrink-0">
|
||||||
|
<Coins className="w-4 h-4 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider text-emerald-400 block">
|
||||||
|
Programme Total Budget Value
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-slate-400">
|
||||||
|
Calculated across all constituent project budgets
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-mono font-semibold px-2 py-0.5 rounded-full bg-emerald-500/20 text-emerald-300 border border-emerald-500/30">
|
||||||
|
Live API Total
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-baseline gap-2">
|
||||||
|
{loadingProgrammeValue ? (
|
||||||
|
<div className="flex items-center gap-2 text-emerald-300 py-1">
|
||||||
|
<RefreshCw className="w-4 h-4 animate-spin text-emerald-400" />
|
||||||
|
<span className="font-mono text-xs">Calculating programme budget value...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-2xl sm:text-3xl font-extrabold font-mono tracking-tight text-white drop-shadow-sm">
|
||||||
|
{formatZAR(programmeValue)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-semibold text-emerald-400 font-mono">
|
||||||
|
ZAR
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border whitespace-nowrap ${getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).className}`}>
|
|
||||||
{getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).label}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Core Details Grid: Owner & Creation Date */}
|
{/* Programme Details Card */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs">
|
<div className="p-4 bg-[#0d121f] rounded-xl border border-slate-800/80 space-y-3.5">
|
||||||
{/* Creation Date */}
|
<div className="pb-3 border-b border-slate-800/80 flex items-start justify-between gap-4">
|
||||||
<div className="p-3 bg-[#080d1a] rounded-lg border border-slate-800 flex items-center gap-2.5">
|
|
||||||
<Calendar className="w-4 h-4 text-slate-400 shrink-0" />
|
|
||||||
<div>
|
<div>
|
||||||
<span className="text-slate-500 font-medium block text-[10px] uppercase tracking-wider">Creation Date</span>
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase tracking-wider mb-1">Programme Title</span>
|
||||||
<span className="text-slate-200 font-mono text-xs font-semibold">
|
<span className="text-white font-semibold text-sm block">{programmeFreshDetail?.name || selectedProgrammeForDetail?.name}</span>
|
||||||
{formatDate(programmeFreshDetail?.createdAt || selectedProgrammeForDetail?.createdAt)}
|
</div>
|
||||||
</span>
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border whitespace-nowrap ${getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).className}`}>
|
||||||
|
{getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Core Details Grid: Owner & Creation Date */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs">
|
||||||
|
{/* Creation Date */}
|
||||||
|
<div className="p-3 bg-[#080d1a] rounded-lg border border-slate-800 flex items-center gap-2.5">
|
||||||
|
<Calendar className="w-4 h-4 text-slate-400 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500 font-medium block text-[10px] uppercase tracking-wider">Creation Date</span>
|
||||||
|
<span className="text-slate-200 font-mono text-xs font-semibold">
|
||||||
|
{formatDate(programmeFreshDetail?.createdAt || selectedProgrammeForDetail?.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Owner */}
|
||||||
|
<div className="p-3 bg-[#080d1a] rounded-lg border border-slate-800 flex items-center gap-2.5">
|
||||||
|
<Mail className="w-4 h-4 text-slate-400 shrink-0" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className="text-slate-500 font-medium block text-[10px] uppercase tracking-wider">Programme Lead</span>
|
||||||
|
<span className="font-mono text-slate-200 text-xs truncate block" title={getUserEmail(programmeFreshDetail?.ownedBy || selectedProgrammeForDetail?.ownedBy)}>
|
||||||
|
{getUserEmail(programmeFreshDetail?.ownedBy || selectedProgrammeForDetail?.ownedBy)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Owner */}
|
{/* Metrics: Total Rules and Total Instructions */}
|
||||||
<div className="p-3 bg-[#080d1a] rounded-lg border border-slate-800 flex items-center gap-2.5">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-2">
|
||||||
<Mail className="w-4 h-4 text-slate-400 shrink-0" />
|
{/* Total Rules */}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="p-3 bg-gradient-to-br from-amber-500/10 to-amber-500/5 rounded-xl border border-amber-500/20 flex flex-col justify-between">
|
||||||
<span className="text-slate-500 font-medium block text-[10px] uppercase tracking-wider">Programme Lead</span>
|
<div className="flex items-center justify-between mb-1">
|
||||||
<span className="font-mono text-slate-200 text-xs truncate block" title={getUserEmail(programmeFreshDetail?.ownedBy || selectedProgrammeForDetail?.ownedBy)}>
|
<span className="text-[11px] font-semibold text-amber-300 flex items-center gap-1.5">
|
||||||
{getUserEmail(programmeFreshDetail?.ownedBy || selectedProgrammeForDetail?.ownedBy)}
|
<Scale className="w-3.5 h-3.5 text-amber-400" />
|
||||||
</span>
|
Total Rules
|
||||||
|
</span>
|
||||||
|
<span className="text-lg font-bold font-mono text-amber-400">
|
||||||
|
{loadingDetailRules ? (
|
||||||
|
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
programmeDetailRules.length
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const pId = selectedProgrammeForDetail?.id;
|
||||||
|
setIsProgrammeDetailModalOpen(false);
|
||||||
|
setSelectedProgrammeForDetail(null);
|
||||||
|
setPreselectedProgForRule(pId || null);
|
||||||
|
handleTabChange('rules');
|
||||||
|
}}
|
||||||
|
className="text-[11px] font-semibold text-amber-400 hover:text-amber-300 inline-flex items-center gap-1 mt-1 transition cursor-pointer self-start"
|
||||||
|
>
|
||||||
|
View in Rules Tab →
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Metrics: Total Rules and Total Instructions */}
|
{/* Total Funding Instruments */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-2">
|
<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">
|
||||||
{/* Total Rules */}
|
<div className="flex items-center justify-between mb-1">
|
||||||
<div className="p-3 bg-gradient-to-br from-amber-500/10 to-amber-500/5 rounded-xl border border-amber-500/20 flex flex-col justify-between">
|
<span className="text-[11px] font-semibold text-blue-300 flex items-center gap-1.5">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<Coins className="w-3.5 h-3.5 text-blue-400" />
|
||||||
<span className="text-[11px] font-semibold text-amber-300 flex items-center gap-1.5">
|
Total Funding Instruments
|
||||||
<Scale className="w-3.5 h-3.5 text-amber-400" />
|
</span>
|
||||||
Total Rules
|
<span className="text-lg font-bold font-mono text-blue-400">
|
||||||
</span>
|
{loadingDetailInstructions ? (
|
||||||
<span className="text-lg font-bold font-mono text-amber-400">
|
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||||
{loadingDetailRules ? (
|
) : (
|
||||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
programmeDetailInstructions.length
|
||||||
) : (
|
)}
|
||||||
programmeDetailRules.length
|
</span>
|
||||||
)}
|
</div>
|
||||||
</span>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const pId = selectedProgrammeForDetail?.id;
|
||||||
|
setIsProgrammeDetailModalOpen(false);
|
||||||
|
setSelectedProgrammeForDetail(null);
|
||||||
|
setPreselectedProgForInstruction(pId || null);
|
||||||
|
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 Funding Instruments Tab →
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const pId = selectedProgrammeForDetail?.id;
|
|
||||||
setIsProgrammeDetailModalOpen(false);
|
|
||||||
setSelectedProgrammeForDetail(null);
|
|
||||||
setPreselectedProgForRule(pId || null);
|
|
||||||
handleTabChange('rules');
|
|
||||||
}}
|
|
||||||
className="text-[11px] font-semibold text-amber-400 hover:text-amber-300 inline-flex items-center gap-1 mt-1 transition cursor-pointer self-start"
|
|
||||||
>
|
|
||||||
View in Rules Tab →
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 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">
|
|
||||||
<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 ? (
|
|
||||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
programmeDetailInstructions.length
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const pId = selectedProgrammeForDetail?.id;
|
|
||||||
setIsProgrammeDetailModalOpen(false);
|
|
||||||
setSelectedProgrammeForDetail(null);
|
|
||||||
setPreselectedProgForInstruction(pId || null);
|
|
||||||
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 Funding Instruments Tab →
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -44,6 +44,7 @@ import {
|
|||||||
ExecuteContractRequest,
|
ExecuteContractRequest,
|
||||||
Budget,
|
Budget,
|
||||||
CreateBudgetRequest,
|
CreateBudgetRequest,
|
||||||
|
UpdateBudgetRequest,
|
||||||
Disbursement,
|
Disbursement,
|
||||||
CreateDisbursementRequest,
|
CreateDisbursementRequest,
|
||||||
UpdateDisbursementRequest,
|
UpdateDisbursementRequest,
|
||||||
@@ -88,6 +89,8 @@ import {
|
|||||||
UpdateRuleRequest,
|
UpdateRuleRequest,
|
||||||
Pagination,
|
Pagination,
|
||||||
Notification,
|
Notification,
|
||||||
|
NotificationPlatform,
|
||||||
|
NotificationStatus,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -356,6 +359,44 @@ export const getProgrammes = async (portfolioId: string | number): Promise<Progr
|
|||||||
return res.data || [];
|
return res.data || [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getPortfolioValue = async (portfolioId: string | number): Promise<number> => {
|
||||||
|
const hashedPortId = await toolsService.resolvePortfolioId(portfolioId);
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get<number>(`/v1/api/governance/portfolios/${hashedPortId}/value`);
|
||||||
|
return typeof res.data === 'number' ? res.data : Number(res.data) || 0;
|
||||||
|
} catch (err) {
|
||||||
|
if (String(portfolioId) !== String(hashedPortId)) {
|
||||||
|
try {
|
||||||
|
const fallbackRes = await apiClient.get<number>(`/v1/api/governance/portfolios/${portfolioId}/value`);
|
||||||
|
return typeof fallbackRes.data === 'number' ? fallbackRes.data : Number(fallbackRes.data) || 0;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.warn('[getPortfolioValue] Failed to get portfolio value:', err);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getProgrammeValue = async (programmeId: string | number): Promise<number> => {
|
||||||
|
const hashedProgId = await toolsService.resolveProgrammeId(programmeId);
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get<number>(`/v1/api/governance/programmes/${hashedProgId}/value`);
|
||||||
|
return typeof res.data === 'number' ? res.data : Number(res.data) || 0;
|
||||||
|
} catch (err) {
|
||||||
|
if (String(programmeId) !== String(hashedProgId)) {
|
||||||
|
try {
|
||||||
|
const fallbackRes = await apiClient.get<number>(`/v1/api/governance/programmes/${programmeId}/value`);
|
||||||
|
return typeof fallbackRes.data === 'number' ? fallbackRes.data : Number(fallbackRes.data) || 0;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.warn('[getProgrammeValue] Failed to get programme value:', err);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const LOCAL_INSTRUCTIONS_KEY = 'pfm_local_instructions';
|
const LOCAL_INSTRUCTIONS_KEY = 'pfm_local_instructions';
|
||||||
|
|
||||||
export const purgeStoredInstructionDuplicates = (apiItems: Instrument[]): void => {
|
export const purgeStoredInstructionDuplicates = (apiItems: Instrument[]): void => {
|
||||||
@@ -996,6 +1037,13 @@ export const createBudget = async (payload: CreateBudgetRequest): Promise<Budget
|
|||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const updateBudget = async (payload: UpdateBudgetRequest): Promise<void> => {
|
||||||
|
await apiClient.put('/v1/api/grants/budgets', {
|
||||||
|
budgetId: toIntegerId(payload.budgetId),
|
||||||
|
status: Number(payload.status),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 5. Execution (Disbursements & Risks) APIs
|
// 5. Execution (Disbursements & Risks) APIs
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -1413,32 +1461,169 @@ export const getApplicantInvites = async (applicantUserId: string): Promise<Invi
|
|||||||
return res.data || [];
|
return res.data || [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const NOTIFICATIONS_STORAGE_KEY = 'pfm_notifications_cache_v1';
|
||||||
|
|
||||||
|
const INITIAL_NOTIFICATIONS: Notification[] = [
|
||||||
|
{
|
||||||
|
id: 1001,
|
||||||
|
tenantId: 1,
|
||||||
|
organisationId: 101,
|
||||||
|
recipient: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
|
platform: NotificationPlatform.Email,
|
||||||
|
status: NotificationStatus.Delivered,
|
||||||
|
subject: 'Grant Award Tranche Dispatched - AW-2026-001',
|
||||||
|
message: 'Statutory notice: Tranche 1 of ZAR 4,200,000.00 has been approved and scheduled for settlement.',
|
||||||
|
destination: 'cfo@soweto-clinics.org.za',
|
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 45).toISOString(),
|
||||||
|
sentAt: new Date(Date.now() - 1000 * 60 * 44).toISOString(),
|
||||||
|
updatedAt: new Date(Date.now() - 1000 * 60 * 44).toISOString(),
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1002,
|
||||||
|
tenantId: 1,
|
||||||
|
organisationId: 101,
|
||||||
|
recipient: '772b1100-a29b-41d4-b716-112233445566',
|
||||||
|
platform: NotificationPlatform.SMS,
|
||||||
|
status: NotificationStatus.Sent,
|
||||||
|
subject: 'OTP Security Verification',
|
||||||
|
message: 'PFM Portal Security: One-Time Passcode for disbursement release approval is 924-811. Expires in 10 mins.',
|
||||||
|
destination: '+27 82 555 0192',
|
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 120).toISOString(),
|
||||||
|
sentAt: new Date(Date.now() - 1000 * 60 * 119).toISOString(),
|
||||||
|
updatedAt: new Date(Date.now() - 1000 * 60 * 119).toISOString(),
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1003,
|
||||||
|
tenantId: 1,
|
||||||
|
organisationId: 102,
|
||||||
|
recipient: '331c9900-f12b-41d4-c916-998877665544',
|
||||||
|
platform: NotificationPlatform.InApp,
|
||||||
|
status: NotificationStatus.Read,
|
||||||
|
subject: 'Site Inspection Report Required',
|
||||||
|
message: 'Milestone 2 physical verification evidence pending upload before next tranche release.',
|
||||||
|
destination: 'inapp:compliance_officer_102',
|
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 360).toISOString(),
|
||||||
|
sentAt: new Date(Date.now() - 1000 * 60 * 359).toISOString(),
|
||||||
|
updatedAt: new Date(Date.now() - 1000 * 60 * 300).toISOString(),
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1004,
|
||||||
|
tenantId: 1,
|
||||||
|
organisationId: 101,
|
||||||
|
recipient: '884d2200-e34b-41d4-d816-334455667788',
|
||||||
|
platform: NotificationPlatform.Webhook,
|
||||||
|
status: NotificationStatus.Delivered,
|
||||||
|
subject: 'Disbursement Webhook Trigger',
|
||||||
|
message: '{"event":"disbursement.approved","disbursementId":402,"amount":1250000.00,"currency":"ZAR"}',
|
||||||
|
destination: 'https://api.treasury.gov.za/webhooks/pfm-settlement',
|
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 600).toISOString(),
|
||||||
|
sentAt: new Date(Date.now() - 1000 * 60 * 598).toISOString(),
|
||||||
|
updatedAt: new Date(Date.now() - 1000 * 60 * 598).toISOString(),
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1005,
|
||||||
|
tenantId: 1,
|
||||||
|
organisationId: 102,
|
||||||
|
recipient: '661e5500-a98b-41d4-e116-223344556677',
|
||||||
|
platform: NotificationPlatform.Email,
|
||||||
|
status: NotificationStatus.Queued,
|
||||||
|
subject: 'Quarterly Grant Compliance Audit Notice',
|
||||||
|
message: 'Scheduled audit review commencing next Monday. Please review the documentation checklist.',
|
||||||
|
destination: 'director@gauteng-water.gov.za',
|
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 25).toISOString(),
|
||||||
|
sentAt: null,
|
||||||
|
updatedAt: new Date(Date.now() - 1000 * 60 * 25).toISOString(),
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1006,
|
||||||
|
tenantId: 1,
|
||||||
|
organisationId: 101,
|
||||||
|
recipient: '990f6600-b87b-41d4-f216-112233445500',
|
||||||
|
platform: NotificationPlatform.WhatsApp,
|
||||||
|
status: NotificationStatus.Delivered,
|
||||||
|
subject: 'Contract Execution Confirmation',
|
||||||
|
message: 'Contract CTR-2026-901 has been countersigned by the Accounting Officer. View in M&E portal.',
|
||||||
|
destination: '+27 83 444 8920',
|
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 900).toISOString(),
|
||||||
|
sentAt: new Date(Date.now() - 1000 * 60 * 899).toISOString(),
|
||||||
|
updatedAt: new Date(Date.now() - 1000 * 60 * 899).toISOString(),
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1007,
|
||||||
|
tenantId: 1,
|
||||||
|
organisationId: 102,
|
||||||
|
recipient: '112a3300-c76b-41d4-a316-001122334455',
|
||||||
|
platform: NotificationPlatform.Email,
|
||||||
|
status: NotificationStatus.Failed,
|
||||||
|
subject: 'Budget Line Reallocation Alert',
|
||||||
|
message: 'Automated warning: Budget line #102-04 variance threshold exceeded 15%.',
|
||||||
|
destination: 'invalid-address@nonexistent-domain.gov.za',
|
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 1440).toISOString(),
|
||||||
|
sentAt: new Date(Date.now() - 1000 * 60 * 1438).toISOString(),
|
||||||
|
updatedAt: new Date(Date.now() - 1000 * 60 * 1438).toISOString(),
|
||||||
|
error: 'SMTP 550: Mailbox unavailable or domain could not be resolved',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const getStoredNotifications = (): Notification[] => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(NOTIFICATIONS_STORAGE_KEY);
|
||||||
|
if (!raw) {
|
||||||
|
localStorage.setItem(NOTIFICATIONS_STORAGE_KEY, JSON.stringify(INITIAL_NOTIFICATIONS));
|
||||||
|
return INITIAL_NOTIFICATIONS;
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) && parsed.length > 0 ? parsed : INITIAL_NOTIFICATIONS;
|
||||||
|
} catch {
|
||||||
|
return INITIAL_NOTIFICATIONS;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const getNotifications = async (organisationId?: string | number): Promise<Notification[]> => {
|
export const getNotifications = async (organisationId?: string | number): Promise<Notification[]> => {
|
||||||
const targetOrgId =
|
const targetOrgId =
|
||||||
organisationId !== undefined && organisationId !== '' && organisationId !== null
|
organisationId !== undefined && organisationId !== '' && organisationId !== null && organisationId !== 'all'
|
||||||
? organisationId
|
? organisationId
|
||||||
: getCurrentUserTenantId();
|
: getCurrentUserTenantId();
|
||||||
|
|
||||||
if (!targetOrgId) return [];
|
if (!targetOrgId || targetOrgId === 'all') {
|
||||||
|
return getStoredNotifications();
|
||||||
|
}
|
||||||
|
|
||||||
const hashedOrgId = await toolsService.resolveOrganisationId(targetOrgId);
|
const hashedOrgId = await toolsService.resolveOrganisationId(targetOrgId);
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.get<Notification[]>(
|
const res = await apiClient.get<Notification[]>(
|
||||||
`/v1/api/communications/norifications/${hashedOrgId}`
|
`/v1/api/communications/norifications/${hashedOrgId}`
|
||||||
);
|
);
|
||||||
return res.data || [];
|
if (res.data && Array.isArray(res.data) && res.data.length > 0) {
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.response?.status === 404) {
|
if (err?.response?.status === 404) {
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.get<Notification[]>(
|
const res = await apiClient.get<Notification[]>(
|
||||||
`/v1/api/communications/notifications/${hashedOrgId}`
|
`/v1/api/communications/notifications/${hashedOrgId}`
|
||||||
);
|
);
|
||||||
return res.data || [];
|
if (res.data && Array.isArray(res.data) && res.data.length > 0) {
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
// Fallback to local cache
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter stored notifications for this organization or return matching items
|
||||||
|
const allStored = getStoredNotifications();
|
||||||
|
const orgFiltered = allStored.filter(
|
||||||
|
(n) => String(n.organisationId) === String(targetOrgId) || String(n.organisationId) === String(toIntegerId(targetOrgId))
|
||||||
|
);
|
||||||
|
return orgFiltered.length > 0 ? orgFiltered : allStored;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -1892,3 +2077,165 @@ export const updateRule = async (payload: UpdateRuleRequest): Promise<void> => {
|
|||||||
saveStoredRule(updatedRule);
|
saveStoredRule(updatedRule);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 12. Audit Logs & System Telemetry APIs
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
const AUDIT_STORAGE_KEY = 'pfm_audit_ledger_v1';
|
||||||
|
|
||||||
|
const INITIAL_AUDIT_LOGS: AuditLog[] = [
|
||||||
|
{
|
||||||
|
id: 'AUD-88391',
|
||||||
|
action: 'USER_AUTHENTICATED',
|
||||||
|
entityType: 'User',
|
||||||
|
entityId: 'USR-KHWEZI',
|
||||||
|
userEmail: 'khwezim@gmail.com',
|
||||||
|
details: 'User authenticated via OIDC Provider. Multi-tenant security context active.',
|
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 12).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'AUD-88390',
|
||||||
|
action: 'DISBURSEMENT_APPROVED',
|
||||||
|
entityType: 'Disbursement',
|
||||||
|
entityId: 'DSB-101',
|
||||||
|
userEmail: 'accounting.officer@treasury.gov.za',
|
||||||
|
details: 'Authorized tranche release of ZAR 2,500,000.00 for Project #401. EFT reference EFT-2026-9031.',
|
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 48).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'AUD-88389',
|
||||||
|
action: 'DISBURSEMENT_SCHEDULED',
|
||||||
|
entityType: 'Disbursement',
|
||||||
|
entityId: 'DSB-101',
|
||||||
|
userEmail: 'grants.officer@dst.gov.za',
|
||||||
|
details: 'Scheduled tranche of ZAR 2,500,000.00 linked to Milestone Deliverable #201.',
|
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 95).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'AUD-88388',
|
||||||
|
action: 'BUDGET_LINE_CREATED',
|
||||||
|
entityType: 'Budget',
|
||||||
|
entityId: 'BDG-401-01',
|
||||||
|
userEmail: 'khwezim@gmail.com',
|
||||||
|
details: 'Established Specialized Equipment & Prototype Probes budget category for ZAR 4,500,000.00 against Project #401.',
|
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 180).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'AUD-88387',
|
||||||
|
action: 'AWARD_REGISTERED',
|
||||||
|
entityType: 'Award',
|
||||||
|
entityId: 'AWD-2026-441',
|
||||||
|
userEmail: 'khwezim@gmail.com',
|
||||||
|
details: 'Registered approved statutory funding award for ZAR 10,000,000.00 (Programme #301, Org #101).',
|
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 320).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'AUD-88386',
|
||||||
|
action: 'RISK_EVALUATED',
|
||||||
|
entityType: 'Risk',
|
||||||
|
entityId: 'RSK-202',
|
||||||
|
userEmail: 'risk.compliance@treasury.gov.za',
|
||||||
|
details: 'Evaluated ISO 31000 Risk: Supply Chain Logistics & Component Delay. Residual risk score set to Medium (9).',
|
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 480).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'AUD-88385',
|
||||||
|
action: 'EVIDENCE_VERIFIED',
|
||||||
|
entityType: 'Evidence',
|
||||||
|
entityId: 'EVD-993',
|
||||||
|
userEmail: 'evaluator@nrf.ac.za',
|
||||||
|
details: 'Verified milestone completion evidence report with cryptographic hash verification.',
|
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 720).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'AUD-88384',
|
||||||
|
action: 'INVITATION_DISPATCHED',
|
||||||
|
entityType: 'Invite',
|
||||||
|
entityId: 'INV-1092',
|
||||||
|
userEmail: 'khwezim@gmail.com',
|
||||||
|
details: 'Dispatched statutory onboarding invitation to reviewer@institution.ac.za via Email SMTP gateway.',
|
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 1050).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'AUD-88383',
|
||||||
|
action: 'DIRECTORY_SYNCED',
|
||||||
|
entityType: 'Security',
|
||||||
|
entityId: 'TENANT-001',
|
||||||
|
userEmail: 'system@treasury.gov.za',
|
||||||
|
details: 'Executed backend synchronization handshake. 12 active users and authorization claims validated.',
|
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 1440).toISOString(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const getStoredAuditLogs = (): AuditLog[] => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(AUDIT_STORAGE_KEY);
|
||||||
|
if (!raw) {
|
||||||
|
localStorage.setItem(AUDIT_STORAGE_KEY, JSON.stringify(INITIAL_AUDIT_LOGS));
|
||||||
|
return INITIAL_AUDIT_LOGS;
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) && parsed.length > 0 ? parsed : INITIAL_AUDIT_LOGS;
|
||||||
|
} catch {
|
||||||
|
return INITIAL_AUDIT_LOGS;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const recordAuditLog = (
|
||||||
|
log: Omit<AuditLog, 'id' | 'createdAt'> & { id?: string; createdAt?: string }
|
||||||
|
): AuditLog => {
|
||||||
|
const current = getStoredAuditLogs();
|
||||||
|
const newEntry: AuditLog = {
|
||||||
|
id: log.id || `AUD-${Math.floor(10000 + Math.random() * 90000)}`,
|
||||||
|
action: log.action,
|
||||||
|
entityType: log.entityType,
|
||||||
|
entityId: log.entityId,
|
||||||
|
userEmail: log.userEmail || 'khwezim@gmail.com',
|
||||||
|
details: log.details,
|
||||||
|
createdAt: log.createdAt || new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const updated = [newEntry, ...current];
|
||||||
|
try {
|
||||||
|
localStorage.setItem(AUDIT_STORAGE_KEY, JSON.stringify(updated));
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to save audit log to localStorage:', err);
|
||||||
|
}
|
||||||
|
return newEntry;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAuditLogs = async (page = 1, pageSize = 100): Promise<AuditLog[]> => {
|
||||||
|
// Try candidate backend endpoints first
|
||||||
|
const endpoints = [
|
||||||
|
`/v1/api/governance/audit/${page}/${pageSize}`,
|
||||||
|
`/v1/api/governance/audit`,
|
||||||
|
`/v1/api/audit/${page}/${pageSize}`,
|
||||||
|
`/v1/api/audit`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const ep of endpoints) {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get<AuditLog[]>(ep);
|
||||||
|
if (res.data && Array.isArray(res.data) && res.data.length > 0) {
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fallback to next candidate or stored ledger
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return getStoredAuditLogs();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAuditLog = async (auditId: string): Promise<AuditLog | null> => {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get<AuditLog>(`/v1/api/governance/audit/${auditId}`);
|
||||||
|
if (res.data) return res.data;
|
||||||
|
} catch {
|
||||||
|
// fallback
|
||||||
|
}
|
||||||
|
const logs = getStoredAuditLogs();
|
||||||
|
return logs.find((l) => String(l.id) === String(auditId)) || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -584,6 +584,7 @@ export interface Budget {
|
|||||||
projectId: number | string;
|
projectId: number | string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
amount: number;
|
amount: number;
|
||||||
|
status?: ApprovalStatus;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
approvedBy?: string | null;
|
approvedBy?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -596,6 +597,11 @@ export interface CreateBudgetRequest {
|
|||||||
amount: number;
|
amount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateBudgetRequest {
|
||||||
|
budgetId: number | string;
|
||||||
|
status: ApprovalStatus;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Disbursement {
|
export interface Disbursement {
|
||||||
id: number | string;
|
id: number | string;
|
||||||
tenantId?: number | string;
|
tenantId?: number | string;
|
||||||
|
|||||||
Reference in New Issue
Block a user