Remediated test #1 results

This commit is contained in:
2026-09-20 13:51:01 +02:00
parent 5829bffaa0
commit d428f6e17c
16 changed files with 8367 additions and 758 deletions
File diff suppressed because it is too large Load Diff
@@ -80,8 +80,12 @@ export const AppLayout: React.FC = () => {
});
// Ensure current logged-in user is registered in the email cache
if (sub && (user?.email || claims.email)) {
registerUserEmail(sub, ((user?.email || claims.email) as string));
const effectiveEmail = ((user?.email || claims?.email || claims?.['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] || claims?.upn) as string);
if (sub && effectiveEmail) {
registerUserEmail(sub, effectiveEmail);
}
if (user?.id && effectiveEmail) {
registerUserEmail(user.id, effectiveEmail);
}
// Extract name and email directly from validated token claims
@@ -148,31 +152,20 @@ export const AppLayout: React.FC = () => {
{navItems.map((item) => {
const Icon = item.icon;
return (
<div key={item.path} className="flex items-center justify-between group">
<NavLink
to={item.path}
className={({ isActive }) =>
`flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all flex-1 min-w-0 ${
isActive
? 'bg-gradient-to-r from-blue-600 to-indigo-600 text-white shadow-md shadow-blue-600/25 font-semibold'
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
}`
}
>
<Icon className="w-4 h-4 shrink-0" />
{sidebarOpen && <span className="truncate">{item.name}</span>}
</NavLink>
{sidebarOpen && (
<div className="pl-1">
<HelpTooltip
title={item.name}
content={item.help}
size="sm"
placement="right"
/>
</div>
)}
</div>
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
`flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all min-w-0 ${
isActive
? 'bg-gradient-to-r from-blue-600 to-indigo-600 text-white shadow-md shadow-blue-600/25 font-semibold'
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
}`
}
>
<Icon className="w-4 h-4 shrink-0" />
{sidebarOpen && <span className="truncate">{item.name}</span>}
</NavLink>
);
})}
</nav>
@@ -210,21 +203,6 @@ export const AppLayout: React.FC = () => {
{/* Top Header */}
<header className="h-16 bg-[#0d121f]/90 border-b border-slate-800/80 backdrop-blur-md px-4 sm:px-6 md:px-8 flex items-center justify-between shrink-0 z-20">
<div className="flex items-center gap-3">
{/* Brand Icon */}
<Link
to="/"
id="header-brand-logo-link"
className="flex items-center gap-2 group cursor-pointer"
title="PFM Console Home"
>
<BrandLogo size={28} showGlow={false} animateOnHover />
<span className="font-bold text-xs tracking-tight text-white hidden md:inline-block group-hover:text-blue-400 transition">
PFM
</span>
</Link>
<span className="h-4 w-px bg-slate-800 hidden md:block" />
{/* Home Navigation */}
<Link
to="/"
@@ -797,9 +797,8 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
{filteredAwardsForDropdown.map((award) => {
const org = getOrganisationForAward(award);
const isApproved = Number(award.status) === ApprovalStatus.Approved;
const hashId = toolsService.encodeIdSync(award.id);
return (
<option key={award.id} value={hashId || String(award.id)}>
<option key={award.id} value={String(award.id)}>
Award #{award.id} {award.referenceNo ? `(${award.referenceNo})` : ''} —{' '}
{org?.name || 'Organisation'} • {formatZAR(award.amount)}
{!isApproved ? ` [Status: ${getApprovalStatusBadge(award.status).label}]` : ' [Approved]'}
@@ -808,7 +807,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
})}
</select>
<p className="text-[11px] text-slate-500 mt-1">
The endpoint expects the award identifier hash; selecting an award links Programme, Project, and Organisation.
Selecting an approved award links Programme, Project, and Organisation automatically.
</p>
</div>
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,5 @@
import React, { useState, useRef, useEffect, ReactNode } from 'react';
import React, { useState, useRef, useEffect, useLayoutEffect, useCallback, ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { Info, X } from 'lucide-react';
export interface HelpTooltipProps {
@@ -25,14 +26,118 @@ export const HelpTooltip: React.FC<HelpTooltipProps> = ({
ariaLabel = 'Information and guidance',
}) => {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const [coords, setCoords] = useState<{ top: number; left: number } | null>(null);
// Close on Escape or click outside
const containerRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const updatePosition = useCallback(() => {
if (!buttonRef.current) return;
const triggerRect = buttonRef.current.getBoundingClientRect();
const tooltipEl = tooltipRef.current;
const tooltipWidth = tooltipEl ? tooltipEl.offsetWidth : 288;
const tooltipHeight = tooltipEl ? tooltipEl.offsetHeight : 120;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const PADDING = 12;
const GAP = 8;
const spaceAbove = triggerRect.top - GAP - PADDING;
const spaceBelow = viewportHeight - triggerRect.bottom - GAP - PADDING;
const spaceLeft = triggerRect.left - GAP - PADDING;
const spaceRight = viewportWidth - triggerRect.right - GAP - PADDING;
let chosenPlacement = placement;
if (placement === 'bottom') {
if (spaceBelow < tooltipHeight && spaceAbove > spaceBelow) {
chosenPlacement = 'top';
}
} else if (placement === 'top') {
if (spaceAbove < tooltipHeight && spaceBelow > spaceAbove) {
chosenPlacement = 'bottom';
}
} else if (placement === 'left') {
if (spaceLeft < tooltipWidth && spaceRight > spaceLeft) {
chosenPlacement = 'right';
}
} else if (placement === 'right') {
if (spaceRight < tooltipWidth && spaceLeft > spaceRight) {
chosenPlacement = 'left';
}
}
let top = 0;
let left = 0;
if (chosenPlacement === 'top') {
top = triggerRect.top - tooltipHeight - GAP;
left = triggerRect.left + triggerRect.width / 2 - tooltipWidth / 2;
} else if (chosenPlacement === 'bottom') {
top = triggerRect.bottom + GAP;
left = triggerRect.left + triggerRect.width / 2 - tooltipWidth / 2;
} else if (chosenPlacement === 'left') {
top = triggerRect.top + triggerRect.height / 2 - tooltipHeight / 2;
left = triggerRect.left - tooltipWidth - GAP;
} else {
// right
top = triggerRect.top + triggerRect.height / 2 - tooltipHeight / 2;
left = triggerRect.right + GAP;
}
// Viewport boundary clamping to guarantee tooltip is never cropped by screen edges
const maxLeft = Math.max(PADDING, viewportWidth - tooltipWidth - PADDING);
left = Math.max(PADDING, Math.min(left, maxLeft));
const maxTop = Math.max(PADDING, viewportHeight - tooltipHeight - PADDING);
top = Math.max(PADDING, Math.min(top, maxTop));
setCoords({ top, left });
}, [placement]);
// Position updates on open, resize, or scroll of any container
useLayoutEffect(() => {
if (!isOpen) {
setCoords(null);
return;
}
updatePosition();
let resizeObserver: ResizeObserver | null = null;
if (typeof ResizeObserver !== 'undefined' && tooltipRef.current) {
resizeObserver = new ResizeObserver(() => {
updatePosition();
});
resizeObserver.observe(tooltipRef.current);
}
window.addEventListener('resize', updatePosition, { passive: true });
window.addEventListener('scroll', updatePosition, { capture: true, passive: true });
return () => {
if (resizeObserver) {
resizeObserver.disconnect();
}
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
};
}, [isOpen, updatePosition]);
// Close on Escape or click outside both button and tooltip
useEffect(() => {
if (!isOpen) return;
const handleOutsideClick = (e: MouseEvent | TouchEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
const target = e.target as Node;
if (
buttonRef.current &&
!buttonRef.current.contains(target) &&
tooltipRef.current &&
!tooltipRef.current.contains(target)
) {
setIsOpen(false);
}
};
@@ -66,16 +171,53 @@ export const HelpTooltip: React.FC<HelpTooltipProps> = ({
lg: 'w-4 h-4',
}[size];
const placementClasses = {
top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
bottom: 'top-full left-1/2 -translate-x-1/2 mt-2',
left: 'right-full top-1/2 -translate-y-1/2 mr-2',
right: 'left-full top-1/2 -translate-y-1/2 ml-2',
}[placement];
const tooltipPortal =
isOpen && typeof document !== 'undefined'
? createPortal(
<div
ref={tooltipRef}
role="tooltip"
onClick={(e) => e.stopPropagation()}
style={{
position: 'fixed',
top: coords ? `${coords.top}px` : '-9999px',
left: coords ? `${coords.left}px` : '-9999px',
visibility: coords ? 'visible' : 'hidden',
}}
className="z-[9999] w-72 max-w-[calc(100vw-24px)] p-3.5 rounded-xl bg-[#0c121e] border border-blue-500/30 text-slate-200 shadow-2xl backdrop-blur-md animate-in fade-in zoom-in-95 duration-150"
>
<div className="flex items-start justify-between gap-2 border-b border-slate-800/80 pb-1.5 mb-2">
<div className="flex items-center gap-1.5 text-blue-400">
<Info className="w-3.5 h-3.5 shrink-0" />
<span className="font-semibold text-xs tracking-tight text-white">
{title || 'Guidance & Information'}
</span>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsOpen(false);
}}
className="rounded-md p-0.5 text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
aria-label="Close tooltip"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
<div className="text-[11px] leading-relaxed text-slate-300 font-normal space-y-1">
{content}
</div>
</div>,
document.body
)
: null;
return (
<div ref={containerRef} className={`relative inline-flex items-center align-middle ${className}`}>
<button
ref={buttonRef}
type="button"
aria-label={ariaLabel}
aria-expanded={isOpen}
@@ -89,37 +231,7 @@ export const HelpTooltip: React.FC<HelpTooltipProps> = ({
<Info className={`${iconSizes} shrink-0`} />
</button>
{isOpen && (
<div
role="tooltip"
onClick={(e) => e.stopPropagation()}
className={`absolute ${placementClasses} z-50 w-72 max-w-[calc(100vw-32px)] p-3 rounded-xl bg-[#0c121e] border border-blue-500/30 text-slate-200 shadow-2xl backdrop-blur-md animate-in fade-in zoom-in-95 duration-150`}
>
<div className="flex items-start justify-between gap-2 border-b border-slate-800/80 pb-1.5 mb-2">
<div className="flex items-center gap-1.5 text-blue-400">
<Info className="w-3.5 h-3.5 shrink-0" />
<span className="font-semibold text-xs tracking-tight text-white">
{title || 'Guidance & Information'}
</span>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsOpen(false);
}}
className="rounded-md p-0.5 text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
aria-label="Close tooltip"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
<div className="text-[11px] leading-relaxed text-slate-300 font-normal space-y-1">
{content}
</div>
</div>
)}
{tooltipPortal}
</div>
);
};
@@ -43,24 +43,28 @@ export const ProblemDetailsAlert: React.FC<ProblemDetailsAlertProps> = ({
)}
</div>
{/* Single error or fallback message displayed directly */}
{(!parsed.errorsList || parsed.errorsList.length <= 1) && parsed.message && (
{/* Direct error message or error list from ProblemDetails */}
{parsed.errorsList && parsed.errorsList.length > 0 ? (
<div className="mt-2 space-y-1.5">
{parsed.message && !parsed.errorsList.includes(parsed.message) && (
<p className="text-xs text-rose-200/90 leading-relaxed whitespace-pre-line">
{parsed.message}
</p>
)}
<ul className="space-y-1 bg-black/30 rounded-lg p-2.5 border border-rose-900/40">
{parsed.errorsList.map((err, idx) => (
<li key={idx} className="text-xs text-rose-200/90 flex items-start gap-2">
<span className="text-rose-400 mt-0.5 font-bold">•</span>
<span className="leading-snug">{err}</span>
</li>
))}
</ul>
</div>
) : parsed.message ? (
<p className="text-xs mt-1.5 text-rose-200/90 leading-relaxed whitespace-pre-line">
{parsed.message}
</p>
)}
{/* Multiple errors from ProblemDetails displayed directly */}
{parsed.errorsList && parsed.errorsList.length > 1 && (
<ul className="mt-2 space-y-1 bg-black/30 rounded-lg p-2.5 border border-rose-900/40">
{parsed.errorsList.map((err, idx) => (
<li key={idx} className="text-xs text-rose-200/90 flex items-start gap-2">
<span className="text-rose-400 mt-0.5">•</span>
<span className="leading-snug">{err}</span>
</li>
))}
</ul>
)}
) : null}
{parsed.technicalDetails && (
<p className="text-[10px] font-mono text-slate-500 mt-2">
@@ -155,16 +155,21 @@ const ToastCard: React.FC<{
)}
</div>
{/* Display errors directly if multiple, or single message directly */}
{toast.errorsList && toast.errorsList.length > 1 ? (
<ul className="mt-1.5 space-y-1">
{toast.errorsList.map((err, idx) => (
<li key={idx} className="text-xs text-rose-200/90 flex items-start gap-1.5">
<span className="text-rose-400 mt-0.5">•</span>
<span className="leading-snug break-words">{err}</span>
</li>
))}
</ul>
{/* Display errors from ProblemDetails errors member */}
{toast.errorsList && toast.errorsList.length > 0 ? (
<div className="mt-1.5 space-y-1">
{toast.message && !toast.errorsList.includes(toast.message) && (
<p className="text-xs text-slate-300 leading-relaxed">{toast.message}</p>
)}
<ul className="space-y-1">
{toast.errorsList.map((err, idx) => (
<li key={idx} className="text-xs text-rose-200/90 flex items-start gap-1.5">
<span className="text-rose-400 mt-0.5 font-bold">•</span>
<span className="leading-snug break-words">{err}</span>
</li>
))}
</ul>
</div>
) : toast.message ? (
<p className="text-xs mt-1.5 text-slate-300 leading-relaxed whitespace-pre-line break-words">
{toast.message}
@@ -512,6 +512,7 @@ export const getRiskScoreColor = (likelihood: RiskLikelihood | number, impact: R
// ==========================================
const USER_EMAIL_CACHE: Record<string, string> = {};
const EMAIL_TO_USER_ID_CACHE: Record<string, string> = {};
export const registerUserEmail = (userId: string | number, email: string) => {
if (userId && email && typeof email === 'string' && email.trim()) {
@@ -519,6 +520,7 @@ export const registerUserEmail = (userId: string | number, email: string) => {
const cleanEmail = email.trim();
USER_EMAIL_CACHE[cleanId] = cleanEmail;
USER_EMAIL_CACHE[cleanId.toLowerCase()] = cleanEmail;
EMAIL_TO_USER_ID_CACHE[cleanEmail.toLowerCase()] = cleanId;
}
};
@@ -531,6 +533,11 @@ export const registerUserEmails = (users: Array<{ id: string | number; email?: s
}
};
export const getUserIdByEmail = (email?: string | null): string | undefined => {
if (!email || typeof email !== 'string') return undefined;
return EMAIL_TO_USER_ID_CACHE[email.trim().toLowerCase()];
};
export const getUserEmail = (userIdOrEmail?: string | number | null): string => {
if (!userIdOrEmail) return '—';
const str = String(userIdOrEmail).trim();
@@ -538,5 +545,28 @@ export const getUserEmail = (userIdOrEmail?: string | number | null): string =>
if (str.includes('@')) return str;
if (USER_EMAIL_CACHE[str]) return USER_EMAIL_CACHE[str];
if (USER_EMAIL_CACHE[str.toLowerCase()]) return USER_EMAIL_CACHE[str.toLowerCase()];
// Synchronous fallback from stored auth session in browser
try {
const storedUserJson = localStorage.getItem('pfm_auth_user');
const storedSub = localStorage.getItem('pfm_user_sub');
if (storedUserJson) {
const parsed = JSON.parse(storedUserJson);
if (parsed?.email) {
const email = String(parsed.email).trim();
if (
(parsed.id && String(parsed.id).toLowerCase() === str.toLowerCase()) ||
(parsed.sub && String(parsed.sub).toLowerCase() === str.toLowerCase()) ||
(storedSub && String(storedSub).toLowerCase() === str.toLowerCase())
) {
registerUserEmail(str, email);
return email;
}
}
}
} catch {
// Ignore storage parse errors
}
return str;
};
@@ -13,6 +13,7 @@ import {
RefreshCw,
Clock,
ShieldAlert,
RotateCcw,
} from 'lucide-react';
import {
getDashboard,
@@ -36,6 +37,18 @@ export const ExecutiveDashboard: React.FC = () => {
const [selectedSector, setSelectedSector] = useState('All');
const [selectedPeriod, setSelectedPeriod] = useState('FY 2026/27');
const isFiltered = selectedProgramme !== 'All' || selectedSector !== 'All' || selectedPeriod !== 'FY 2026/27';
const activeFilterCount =
(selectedProgramme !== 'All' ? 1 : 0) +
(selectedSector !== 'All' ? 1 : 0) +
(selectedPeriod !== 'FY 2026/27' ? 1 : 0);
const resetFilters = () => {
setSelectedProgramme('All');
setSelectedSector('All');
setSelectedPeriod('FY 2026/27');
};
// Data queries
const { data: dashboard, isLoading: loadingDashboard, refetch: refetchDashboard, isFetching } = useQuery({
queryKey: ['dashboard'],
@@ -318,41 +331,79 @@ export const ExecutiveDashboard: React.FC = () => {
return (
<div className="space-y-6 max-w-7xl mx-auto">
{/* Page Title & Filter Bar */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-[#0d121f]/90 p-5 rounded-2xl border border-slate-800/80 backdrop-blur-md shadow-xl">
<div>
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold text-white tracking-tight">
Executive Portfolio Dashboard
</h2>
<HelpTooltip
title="Executive Dashboard Overview"
content="Macro-level command center showing aggregated national portfolio commitments, actual tranche disbursements, delivery pace, and critical project escalations."
size="sm"
placement="bottom"
/>
<span className="text-[11px] font-semibold text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-full border border-blue-500/30">
Live Portfolio Feed
</span>
<div className="bg-[#0d121f]/90 p-5 sm:p-6 rounded-2xl border border-slate-800/80 backdrop-blur-md shadow-xl space-y-4">
{/* Top Tier: Title, Status Badge, and Primary Actions */}
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2.5 flex-wrap">
<h2 className="text-xl sm:text-2xl font-bold text-white tracking-tight">
Executive Portfolio Dashboard
</h2>
<HelpTooltip
title="Executive Dashboard Overview"
content="Macro-level command center showing aggregated national portfolio commitments, actual tranche disbursements, delivery pace, and critical project escalations."
size="sm"
placement="bottom"
/>
<span className="inline-flex items-center gap-1.5 text-[11px] font-semibold text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-full border border-blue-500/30">
<span className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
Live Portfolio Feed
</span>
</div>
<p className="text-xs sm:text-sm text-slate-400 mt-1">
Real-time portfolio overview, disbursement tracking, risk heatmap, and projects requiring governance action.
</p>
</div>
<div className="flex items-center gap-2.5 shrink-0 self-start lg:self-center">
{isFiltered && (
<button
type="button"
onClick={resetFilters}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-amber-300 bg-amber-500/10 hover:bg-amber-500/20 border border-amber-500/30 rounded-xl transition cursor-pointer shadow-xs"
title="Reset all filters to default"
>
<RotateCcw className="w-3.5 h-3.5" />
<span>Reset Filters</span>
</button>
)}
<button
type="button"
onClick={refetchAll}
disabled={isFetching}
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900 hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
title="Refresh dashboard metrics"
>
<RefreshCw className={`w-3.5 h-3.5 ${isFetching ? 'animate-spin text-blue-400' : 'text-slate-400'}`} />
<span>{isFetching ? 'Refreshing...' : 'Refresh'}</span>
</button>
</div>
<p className="text-xs text-slate-400 mt-1">
Real-time portfolio overview, disbursement tracking, risk heatmap, and projects requiring governance action.
</p>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-1.5 text-xs text-slate-400">
<Filter className="w-3.5 h-3.5 text-slate-500" />
<span>Filter:</span>
{/* Dedicated Filter Toolbar */}
<div className="pt-4 border-t border-slate-800/80 flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2 text-xs font-semibold text-slate-300 pr-1">
<Filter className="w-3.5 h-3.5 text-blue-400" />
<span>Filters:</span>
{activeFilterCount > 0 && (
<span className="text-[10px] font-bold text-blue-300 bg-blue-500/20 border border-blue-500/40 px-2 py-0.5 rounded-full">
{activeFilterCount} Active
</span>
)}
</div>
<div className="flex items-center gap-1">
{/* Programme Filter */}
<div className="flex items-center bg-[#090d16] border border-slate-700/80 hover:border-slate-600 rounded-xl px-3 py-1.5 text-xs transition shadow-inner">
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider mr-2 shrink-0">
Programme
</span>
<select
value={selectedProgramme}
onChange={(e) => setSelectedProgramme(e.target.value)}
className="text-xs font-medium bg-[#090d16] border border-slate-700/80 rounded-xl px-3 py-1.5 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 transition cursor-pointer"
className="bg-transparent text-xs font-medium text-slate-100 focus:outline-none cursor-pointer pr-1 [&>option]:bg-slate-900 [&>option]:text-white max-w-[200px] truncate"
>
<option value="All">Programme: All</option>
<option value="All">All</option>
{programmes.map((pr) => (
<option key={pr.id} value={pr.id}>
{pr.name}
@@ -367,13 +418,17 @@ export const ExecutiveDashboard: React.FC = () => {
/>
</div>
<div className="flex items-center gap-1">
{/* Sector Filter */}
<div className="flex items-center bg-[#090d16] border border-slate-700/80 hover:border-slate-600 rounded-xl px-3 py-1.5 text-xs transition shadow-inner">
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider mr-2 shrink-0">
Sector
</span>
<select
value={selectedSector}
onChange={(e) => setSelectedSector(e.target.value)}
className="text-xs font-medium bg-[#090d16] border border-slate-700/80 rounded-xl px-3 py-1.5 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 transition cursor-pointer"
className="bg-transparent text-xs font-medium text-slate-100 focus:outline-none cursor-pointer pr-1 [&>option]:bg-slate-900 [&>option]:text-white max-w-[170px] truncate"
>
<option value="All">Sector: All</option>
<option value="All">All</option>
{sectorData.map((s) => (
<option key={s.name} value={s.name}>
{s.name}
@@ -388,14 +443,18 @@ export const ExecutiveDashboard: React.FC = () => {
/>
</div>
<div className="flex items-center gap-1">
{/* Financial Period Filter */}
<div className="flex items-center bg-[#090d16] border border-slate-700/80 hover:border-slate-600 rounded-xl px-3 py-1.5 text-xs transition shadow-inner">
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider mr-2 shrink-0">
Period
</span>
<select
value={selectedPeriod}
onChange={(e) => setSelectedPeriod(e.target.value)}
className="text-xs font-medium bg-[#090d16] border border-slate-700/80 rounded-xl px-3 py-1.5 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 transition cursor-pointer"
className="bg-transparent text-xs font-medium text-slate-100 focus:outline-none cursor-pointer pr-1 [&>option]:bg-slate-900 [&>option]:text-white"
>
<option value="FY 2026/27">Period: FY 2026/27</option>
<option value="FY 2025/26">Period: FY 2025/26</option>
<option value="FY 2026/27">FY 2026/27</option>
<option value="FY 2025/26">FY 2025/26</option>
<option value="All-Time">All-Time Cumulative</option>
</select>
<HelpTooltip
@@ -405,15 +464,6 @@ export const ExecutiveDashboard: React.FC = () => {
placement="bottom"
/>
</div>
<button
onClick={refetchAll}
disabled={isFetching}
className="p-2 text-slate-400 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs"
title="Refresh dashboard metrics"
>
<RefreshCw className={`w-4 h-4 ${isFetching ? 'animate-spin text-blue-400' : ''}`} />
</button>
</div>
</div>
@@ -612,7 +662,7 @@ export const ExecutiveDashboard: React.FC = () => {
<div className="pt-4 border-t border-slate-800/80 mt-4 text-[11px] text-slate-400 flex justify-between">
<span>Primary Focus: {sectorData[0]?.name || 'All Sectors'}</span>
<Link to="/governance" className="text-blue-400 font-medium hover:underline flex items-center gap-1">
<Link to="/governance?tab=portfolios" className="text-blue-400 font-medium hover:underline flex items-center gap-1">
View Portfolios <ArrowUpRight className="w-3 h-3" />
</Link>
</div>
@@ -1,4 +1,5 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -16,6 +17,7 @@ import {
Coins,
Eye,
RefreshCw,
ArrowRight,
} from 'lucide-react';
import {
getOrganisations,
@@ -34,6 +36,8 @@ import {
updateProgramme,
getInstruments,
getPortfolioProgrammes,
getUsers,
getUser,
} from '../services/api';
import { Modal } from '../components/ui/Modal';
import { HelpTooltip } from '../components/ui/HelpTooltip';
@@ -58,6 +62,8 @@ import {
getApprovalStatusBadge,
formatDate,
getUserEmail,
registerUserEmail,
getUserIdByEmail,
ORGANISATION_TYPE_OPTIONS,
ACTIVITY_STATUS_OPTIONS,
APPROVAL_STATUS_OPTIONS,
@@ -76,14 +82,14 @@ const portfolioSchema = z.object({
name: z.string().min(3, 'Portfolio name is required'),
description: z.string().min(5, 'Description is required'),
organisationId: z.coerce.number().min(1, 'Select a parent organisation'),
ownerId: z.string().min(1, 'Owner ID is required'),
ownerEmail: z.string().min(1, 'Owner email is required'),
});
const programmeSchema = z.object({
name: z.string().min(3, 'Programme name is required'),
portfolioId: z.coerce.number().min(1, 'Select a parent portfolio'),
status: z.coerce.number().optional(),
ownerId: z.string().min(1, 'Owner ID is required'),
ownerEmail: z.string().min(1, 'Owner email is required'),
});
type OrganisationFormValues = z.infer<typeof organisationSchema>;
@@ -92,11 +98,72 @@ type ProgrammeFormValues = z.infer<typeof programmeSchema>;
export const GovernancePage: React.FC = () => {
const queryClient = useQueryClient();
const { sub, user } = useAuth();
const { sub, user, claims } = useAuth();
const currentUserId = sub || user?.id || '';
const { success, error: toastError } = useToast();
const [activeTab, setActiveTab] = useState<'organisations' | 'portfolios' | 'programmes'>('organisations');
const effectiveUserEmail =
user?.email ||
(claims?.email as string) ||
(claims?.['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] as string) ||
(claims?.upn as string) ||
(sub ? getUserEmail(sub) : '') ||
'';
useEffect(() => {
if (effectiveUserEmail) {
if (sub) registerUserEmail(sub, effectiveUserEmail);
if (user?.id) registerUserEmail(user.id, effectiveUserEmail);
}
}, [sub, user, effectiveUserEmail]);
const [searchParams, setSearchParams] = useSearchParams();
const tabParam = searchParams.get('tab');
const getValidTab = (val: string | null): 'organisations' | 'portfolios' | 'programmes' => {
if (val === 'portfolios' || val === 'programmes' || val === 'organisations') {
return val;
}
return 'organisations';
};
const [activeTab, setActiveTab] = useState<'organisations' | 'portfolios' | 'programmes'>(() => getValidTab(tabParam));
useEffect(() => {
if (tabParam && (tabParam === 'organisations' || tabParam === 'portfolios' || tabParam === 'programmes')) {
setActiveTab(tabParam);
}
}, [tabParam]);
const handleTabChange = (tab: 'organisations' | 'portfolios' | 'programmes') => {
setActiveTab(tab);
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set('tab', tab);
return next;
});
};
const [searchTerm, setSearchTerm] = useState('');
const [highlightedPortfolioId, setHighlightedPortfolioId] = useState<number | string | null>(null);
const handleNavigateToPortfolioTab = (portfolioId?: number | string) => {
setIsOrgDetailModalOpen(false);
setSelectedOrgForDetail(null);
handleTabChange('portfolios');
if (portfolioId != null) {
setHighlightedPortfolioId(portfolioId);
setTimeout(() => {
const el = document.getElementById(`portfolio-card-${portfolioId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 150);
setTimeout(() => {
setHighlightedPortfolioId(null);
}, 3500);
}
};
// Modals & Editing State
const [isOrgModalOpen, setIsOrgModalOpen] = useState(false);
@@ -125,6 +192,12 @@ export const GovernancePage: React.FC = () => {
const [isProgrammeDetailModalOpen, setIsProgrammeDetailModalOpen] = useState(false);
// Queries
const { data: usersDirectory = [] } = useQuery({
queryKey: ['users-directory'],
queryFn: () => getUsers(1, 200),
staleTime: 5 * 60 * 1000,
});
const { data: organisations = [], isLoading: loadingOrgs } = useQuery({
queryKey: ['organisations'],
queryFn: () => getOrganisations(),
@@ -182,6 +255,92 @@ export const GovernancePage: React.FC = () => {
enabled: !!selectedProgrammeForDetail && isProgrammeDetailModalOpen,
});
// Owner resolution helpers
const resolveOwnerId = (emailOrId: string, fallbackOwnerId?: string | number | null): string => {
const trimmed = (emailOrId || '').trim();
if (!trimmed) {
return fallbackOwnerId ? String(fallbackOwnerId) : currentUserId;
}
// If already a valid UUID
if (/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(trimmed)) {
return trimmed;
}
const cleanEmail = trimmed.toLowerCase();
// 1. Current logged-in user match
if (effectiveUserEmail && cleanEmail === effectiveUserEmail.toLowerCase()) {
return sub || user?.id || (fallbackOwnerId ? String(fallbackOwnerId) : currentUserId);
}
// 2. Reverse cache lookup
const cachedId = getUserIdByEmail(cleanEmail);
if (cachedId) return cachedId;
// 3. Search loaded users directory
const matchedUser = usersDirectory.find(
(u) => u.email && u.email.trim().toLowerCase() === cleanEmail
);
if (matchedUser?.id) return String(matchedUser.id);
// 4. Fallback ID match check
if (fallbackOwnerId) {
const origEmail = getUserEmail(fallbackOwnerId);
if (origEmail && origEmail.toLowerCase() === cleanEmail) {
return String(fallbackOwnerId);
}
}
// 5. Default fallback
return fallbackOwnerId ? String(fallbackOwnerId) : currentUserId;
};
const resolveOwnerEmailForDisplay = async (rawOwnerId?: string | number | null): Promise<string> => {
if (!rawOwnerId) {
return effectiveUserEmail;
}
const raw = String(rawOwnerId).trim();
if (raw.includes('@')) {
return raw;
}
// Check if it's the current user
if (
(sub && raw.toLowerCase() === sub.toLowerCase()) ||
(user?.id && raw.toLowerCase() === user.id.toLowerCase())
) {
return effectiveUserEmail;
}
// Check cached email
const cached = getUserEmail(raw);
if (cached && cached.includes('@')) {
return cached;
}
// Check users directory
const found = usersDirectory.find((u) => String(u.id).toLowerCase() === raw.toLowerCase());
if (found?.email) {
return found.email;
}
// If it's a UUID, fetch user canonical record from API
if (/^[0-9a-fA-F-]{36}$/.test(raw)) {
try {
const u = await getUser(raw);
if (u?.email) {
registerUserEmail(u.id, u.email);
return u.email;
}
} catch {
// Fetch failed
}
}
return effectiveUserEmail;
};
// Forms
const orgForm = useForm<OrganisationFormValues>({
resolver: zodResolver(organisationSchema) as any,
@@ -200,7 +359,7 @@ export const GovernancePage: React.FC = () => {
name: '',
description: '',
organisationId: organisations[0]?.id ? Number(organisations[0].id) : 101,
ownerId: currentUserId,
ownerEmail: effectiveUserEmail,
},
});
@@ -210,7 +369,7 @@ export const GovernancePage: React.FC = () => {
name: '',
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
status: ApprovalStatus.Approved,
ownerId: currentUserId,
ownerEmail: effectiveUserEmail,
},
});
@@ -243,16 +402,22 @@ export const GovernancePage: React.FC = () => {
const portfolioMutation = useMutation({
mutationFn: (data: PortfolioFormValues) => {
const resolvedOwnerId = resolveOwnerId(data.ownerEmail, editingPortfolio?.ownedBy);
if (editingPortfolio) {
return updatePortfolio({
portfolioId: editingPortfolio.id,
name: data.name,
description: data.description,
organisationId: Number(data.organisationId),
ownerId: data.ownerId,
ownerId: resolvedOwnerId,
});
}
return createPortfolio(data as CreatePortfolioRequest);
return createPortfolio({
name: data.name,
description: data.description,
organisationId: Number(data.organisationId),
ownerId: resolvedOwnerId,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['portfolios'] });
@@ -268,16 +433,21 @@ export const GovernancePage: React.FC = () => {
const programmeMutation = useMutation({
mutationFn: (data: ProgrammeFormValues) => {
const resolvedOwnerId = resolveOwnerId(data.ownerEmail, editingProgramme?.ownedBy);
if (editingProgramme) {
return updateProgramme({
programmeId: editingProgramme.id,
name: data.name,
portfolioId: Number(data.portfolioId),
status: data.status !== undefined ? Number(data.status) : undefined,
ownerId: data.ownerId,
ownerId: resolvedOwnerId,
});
}
return createProgramme(data as CreateProgrammeRequest);
return createProgramme({
name: data.name,
portfolioId: Number(data.portfolioId),
ownerId: resolvedOwnerId,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['programmes'] });
@@ -321,18 +491,19 @@ export const GovernancePage: React.FC = () => {
name: '',
description: '',
organisationId: organisations[0]?.id ? Number(organisations[0].id) : 101,
ownerId: currentUserId,
ownerEmail: effectiveUserEmail,
});
setIsPortfolioModalOpen(true);
};
const openEditPortfolio = (p: Portfolio) => {
const openEditPortfolio = async (p: Portfolio) => {
setEditingPortfolio(p);
const email = await resolveOwnerEmailForDisplay(p.ownedBy);
portfolioForm.reset({
name: p.name || '',
description: p.description || '',
organisationId: Number(p.organisationId) || 101,
ownerId: p.ownedBy || currentUserId,
ownerEmail: email,
});
setIsPortfolioModalOpen(true);
};
@@ -343,18 +514,19 @@ export const GovernancePage: React.FC = () => {
name: '',
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
status: ApprovalStatus.Approved,
ownerId: currentUserId,
ownerEmail: effectiveUserEmail,
});
setIsProgrammeModalOpen(true);
};
const openEditProgramme = (pr: Programme) => {
const openEditProgramme = async (pr: Programme) => {
setEditingProgramme(pr);
const email = await resolveOwnerEmailForDisplay(pr.ownedBy);
programmeForm.reset({
name: pr.name || '',
portfolioId: Number(pr.portfolioId) || 201,
status: pr.status ?? ApprovalStatus.Approved,
ownerId: pr.ownedBy || currentUserId,
ownerEmail: email,
});
setIsProgrammeModalOpen(true);
};
@@ -425,7 +597,7 @@ export const GovernancePage: React.FC = () => {
{/* Tabs */}
<div className="flex border-b border-slate-800/80 bg-[#0d121f]/80 px-4 rounded-2xl backdrop-blur-sm">
<button
onClick={() => setActiveTab('organisations')}
onClick={() => handleTabChange('organisations')}
className={`py-3.5 px-4 text-xs font-semibold border-b-2 transition-colors flex items-center gap-2 cursor-pointer ${
activeTab === 'organisations'
? 'border-blue-500 text-blue-400'
@@ -442,7 +614,7 @@ export const GovernancePage: React.FC = () => {
/>
</button>
<button
onClick={() => setActiveTab('portfolios')}
onClick={() => handleTabChange('portfolios')}
className={`py-3.5 px-4 text-xs font-semibold border-b-2 transition-colors flex items-center gap-2 cursor-pointer ${
activeTab === 'portfolios'
? 'border-blue-500 text-blue-400'
@@ -459,7 +631,7 @@ export const GovernancePage: React.FC = () => {
/>
</button>
<button
onClick={() => setActiveTab('programmes')}
onClick={() => handleTabChange('programmes')}
className={`py-3.5 px-4 text-xs font-semibold border-b-2 transition-colors flex items-center gap-2 cursor-pointer ${
activeTab === 'programmes'
? 'border-blue-500 text-blue-400'
@@ -568,7 +740,7 @@ export const GovernancePage: React.FC = () => {
setIsOrgDetailModalOpen(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.5 rounded-lg transition shadow-xs cursor-pointer"
title="Inspect Organisation (GET /v1/api/governance/organisations/{id})"
title="Inspect Organisation"
>
<Eye className="w-3 h-3" />
Inspect
@@ -601,8 +773,17 @@ export const GovernancePage: React.FC = () => {
portfolios.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);
return (
<div key={p.id} className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm flex flex-col justify-between hover:border-slate-700/80 transition-all">
<div
key={p.id}
id={`portfolio-card-${p.id}`}
className={`bg-[#0f1523]/80 p-5 rounded-2xl border shadow-lg backdrop-blur-sm flex flex-col justify-between transition-all ${
isHighlighted
? 'border-blue-500 ring-2 ring-blue-500/50 shadow-blue-500/20 scale-[1.01]'
: 'border-slate-800/80 hover:border-slate-700/80'
}`}
>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-[10px] font-mono uppercase tracking-wider text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-md border border-blue-500/30">
@@ -614,48 +795,53 @@ export const GovernancePage: React.FC = () => {
<p className="text-xs text-slate-400 leading-relaxed">{p.description}</p>
</div>
<div className="mt-4 pt-3 border-t border-slate-800/80 space-y-2">
<div className="mt-4 pt-3 border-t border-slate-800/80 space-y-2.5">
<div className="flex items-center justify-between text-xs text-slate-400">
<span className="truncate max-w-[170px] font-medium text-slate-300">{org ? org.name : `Org #${p.organisationId}`}</span>
<span className="font-mono text-[11px] text-slate-500">{formatDate(p.createdAt)}</span>
</div>
<div className="flex items-center justify-between pt-1 text-xs">
<span className="text-slate-400 font-medium truncate max-w-[190px]">
Owner: <span className="text-slate-200 font-mono text-[11px]">{getUserEmail(p.ownedBy)}</span>
<span className="truncate max-w-[200px] font-medium text-slate-300" title={org ? org.name : `Org #${p.organisationId}`}>
{org ? org.name : `Org #${p.organisationId}`}
</span>
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => {
setSelectedPortfolioForDetail(p);
setIsPortfolioDetailModalOpen(true);
}}
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 Portfolio (GET /v1/api/governance/portfolios/{id})"
>
<Eye className="w-3 h-3" />
Inspect
</button>
<button
type="button"
onClick={() => {
setSelectedPortfolioForProgrammes(p);
setIsPortfolioProgrammesModalOpen(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"
>
<Layers className="w-3 h-3" />
Programmes
</button>
<button
type="button"
onClick={() => openEditPortfolio(p)}
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"
>
<Edit2 className="w-3 h-3" />
Edit
</button>
</div>
<span className="font-mono text-[11px] text-slate-500 shrink-0">{formatDate(p.createdAt)}</span>
</div>
<div className="flex items-center gap-1.5 text-xs text-slate-400 min-w-0">
<span className="font-medium shrink-0">Owner:</span>
<span className="text-slate-200 font-mono text-[11px] break-all">
{getUserEmail(p.ownedBy)}
</span>
</div>
<div className="flex items-center justify-end gap-1.5 pt-1.5 border-t border-slate-800/50">
<button
type="button"
onClick={() => {
setSelectedPortfolioForDetail(p);
setIsPortfolioDetailModalOpen(true);
}}
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 Portfolio"
>
<Eye className="w-3 h-3" />
Inspect
</button>
<button
type="button"
onClick={() => {
setSelectedPortfolioForProgrammes(p);
setIsPortfolioProgrammesModalOpen(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"
>
<Layers className="w-3 h-3" />
Programmes
</button>
<button
type="button"
onClick={() => openEditPortfolio(p)}
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"
>
<Edit2 className="w-3 h-3" />
Edit
</button>
</div>
</div>
</div>
@@ -718,7 +904,7 @@ export const GovernancePage: React.FC = () => {
setIsProgrammeDetailModalOpen(true);
}}
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 Programme (GET /v1/api/projects/programme/{id})"
title="Inspect Programme"
>
<Eye className="w-3 h-3" />
Inspect
@@ -972,11 +1158,22 @@ export const GovernancePage: React.FC = () => {
/>
</div>
<input
type="text"
{...portfolioForm.register('ownerId')}
type="email"
list="portfolio-owner-email-suggestions"
{...portfolioForm.register('ownerEmail')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 font-mono text-white bg-[#090d16]"
placeholder="khwezim@gmail.com"
placeholder="e.g. khwezim@gmail.com"
/>
<datalist id="portfolio-owner-email-suggestions">
{usersDirectory.filter((u) => u.email).map((u) => (
<option key={u.id} value={u.email!}>
{u.name ? `${u.name} (${u.email})` : u.email!}
</option>
))}
</datalist>
{portfolioForm.formState.errors.ownerEmail && (
<p className="text-[10px] text-rose-500 mt-1">{portfolioForm.formState.errors.ownerEmail.message}</p>
)}
</div>
<div>
@@ -1111,11 +1308,22 @@ export const GovernancePage: React.FC = () => {
/>
</div>
<input
type="text"
{...programmeForm.register('ownerId')}
type="email"
list="programme-owner-email-suggestions"
{...programmeForm.register('ownerEmail')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 font-mono text-white bg-[#090d16]"
placeholder="khwezim@gmail.com"
placeholder="e.g. khwezim@gmail.com"
/>
<datalist id="programme-owner-email-suggestions">
{usersDirectory.filter((u) => u.email).map((u) => (
<option key={u.id} value={u.email!}>
{u.name ? `${u.name} (${u.email})` : u.email!}
</option>
))}
</datalist>
{programmeForm.formState.errors.ownerEmail && (
<p className="text-[10px] text-rose-500 mt-1">{programmeForm.formState.errors.ownerEmail.message}</p>
)}
</div>
</div>
@@ -1260,7 +1468,7 @@ export const GovernancePage: React.FC = () => {
</div>
</Modal>
{/* Modal: View Organisation Details (GET /v1/api/governance/organisations/{id} & /portfolio) */}
{/* Modal: View Organisation Details & Linked Portfolio */}
<Modal
isOpen={isOrgDetailModalOpen}
onClose={() => {
@@ -1274,51 +1482,89 @@ export const GovernancePage: React.FC = () => {
{loadingOrgFreshDetail ? (
<div className="py-8 text-center text-slate-400">
<RefreshCw className="w-5 h-5 animate-spin mx-auto mb-2 text-blue-400" />
Fetching canonical record (GET /v1/api/governance/organisations/{selectedOrgForDetail?.id})...
Fetching organisation details...
</div>
) : (
<>
<div className="p-4 bg-[#0d121f] rounded-xl border border-slate-800 space-y-2">
<div className="grid grid-cols-2 gap-3">
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Legal Name:</span>
<span className="text-white font-medium">{orgFreshDetail?.name || selectedOrgForDetail?.name}</span>
<div className="p-4 bg-[#0d121f] rounded-xl border border-slate-800 space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="flex items-center gap-2 min-w-0">
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Legal Name:</span>
<span className="text-white font-medium truncate">{orgFreshDetail?.name || selectedOrgForDetail?.name}</span>
</div>
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Reg Number:</span>
<div className="flex items-center gap-2">
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Reg Number:</span>
<span className="font-mono text-slate-300">{orgFreshDetail?.registrationNo || selectedOrgForDetail?.registrationNo || '—'}</span>
</div>
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Official Email:</span>
<span className="font-mono text-slate-300">{orgFreshDetail?.email || selectedOrgForDetail?.email}</span>
<div className="flex items-center gap-2 min-w-0">
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Official Email:</span>
<span className="font-mono text-slate-300 truncate">{orgFreshDetail?.email || selectedOrgForDetail?.email}</span>
</div>
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Entity Type:</span>
<div className="flex items-center gap-2">
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Entity Type:</span>
<span className="text-slate-300">{getOrganisationTypeLabel(orgFreshDetail?.type ?? selectedOrgForDetail?.type ?? OrganisationType.Enterprise)}</span>
</div>
</div>
</div>
<div>
<h4 className="font-semibold text-slate-300 text-xs mb-2 flex items-center justify-between">
<span>Linked Portfolios (GET /v1/api/governance/organisations/{selectedOrgForDetail?.id}/portfolio)</span>
<span className="text-[10px] font-mono text-slate-400">{orgPortfolios.length} linked</span>
</h4>
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<h4 className="font-semibold text-slate-300 text-xs">Linked Portfolios</h4>
<span className="text-[10px] font-mono text-slate-400 bg-slate-800/80 border border-slate-700/60 px-2 py-0.5 rounded-full">
{orgPortfolios.length} linked
</span>
</div>
{orgPortfolios.length > 0 && (
<button
type="button"
onClick={() => handleNavigateToPortfolioTab()}
className="inline-flex items-center gap-1 text-[11px] font-semibold text-blue-400 hover:text-blue-300 transition cursor-pointer"
>
<span>View Portfolios tab</span>
<ArrowRight className="w-3 h-3" />
</button>
)}
</div>
{loadingOrgPortfolios ? (
<p className="text-xs text-slate-500">Loading portfolios...</p>
<p className="text-xs text-slate-500 py-3 text-center">Loading portfolios...</p>
) : orgPortfolios.length === 0 ? (
<p className="text-xs text-slate-500">No portfolios directly linked to this organisation.</p>
<div className="p-3.5 bg-slate-900/40 rounded-xl border border-slate-800/80 text-center">
<p className="text-xs text-slate-400">No portfolios directly linked to this organisation.</p>
</div>
) : (
<div className="space-y-2 max-h-[220px] overflow-y-auto">
<div className="space-y-2 max-h-[220px] overflow-y-auto pr-0.5">
{orgPortfolios.map((port) => (
<div key={port.id} className="p-3 bg-slate-900/60 rounded-xl border border-slate-800 flex items-center justify-between">
<div>
<span className="text-white font-medium block">{port.name}</span>
<span className="text-[10px] text-slate-500 font-mono">ID: {port.id}</span>
<div
key={port.id}
onClick={() => handleNavigateToPortfolioTab(port.id)}
className="p-3 bg-slate-900/60 hover:bg-slate-900 rounded-xl border border-slate-800 hover:border-blue-500/40 flex items-center justify-between gap-3 transition cursor-pointer group"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-white font-medium block group-hover:text-blue-300 transition-colors truncate">
{port.name}
</span>
<span className="text-[10px] text-blue-400 bg-blue-500/10 px-1.5 py-0.5 rounded border border-blue-500/20 font-mono shrink-0">
Portfolio #{port.id}
</span>
</div>
{port.description && (
<p className="text-[11px] text-slate-400 truncate mt-0.5">{port.description}</p>
)}
</div>
<span className="text-[10px] text-blue-400 bg-blue-500/10 px-2 py-0.5 rounded border border-blue-500/20 font-mono">
Portfolio
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleNavigateToPortfolioTab(port.id);
}}
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.5 py-1 rounded-lg transition shadow-xs shrink-0 cursor-pointer"
>
<span>Open</span>
<ArrowRight className="w-3 h-3 group-hover:translate-x-0.5 transition-transform" />
</button>
</div>
))}
</div>
@@ -1342,7 +1588,7 @@ export const GovernancePage: React.FC = () => {
</div>
</Modal>
{/* Modal: View Portfolio Details (GET /v1/api/governance/portfolios/{id}) */}
{/* Modal: View Portfolio Details */}
<Modal
isOpen={isPortfolioDetailModalOpen}
onClose={() => {
@@ -1356,7 +1602,7 @@ export const GovernancePage: React.FC = () => {
{loadingPortfolioFreshDetail ? (
<div className="py-8 text-center text-slate-400">
<RefreshCw className="w-5 h-5 animate-spin mx-auto mb-2 text-blue-400" />
Fetching canonical record (GET /v1/api/governance/portfolios/{selectedPortfolioForDetail?.id})...
Fetching portfolio details...
</div>
) : (
<div className="p-4 bg-[#0d121f] rounded-xl border border-slate-800 space-y-3">
@@ -1368,14 +1614,16 @@ export const GovernancePage: React.FC = () => {
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Description:</span>
<p className="text-slate-300 mt-0.5 leading-relaxed">{portfolioFreshDetail?.description || selectedPortfolioForDetail?.description || 'None provided'}</p>
</div>
<div className="grid grid-cols-2 gap-2 pt-2 border-t border-slate-800/80">
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Owner:</span>
<span className="font-mono text-slate-300">{getUserEmail(portfolioFreshDetail?.ownedBy || selectedPortfolioForDetail?.ownedBy)}</span>
<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>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Created:</span>
<span className="font-mono text-slate-300">
<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>
@@ -1398,7 +1646,7 @@ export const GovernancePage: React.FC = () => {
</div>
</Modal>
{/* Modal: View Programme Details (GET /v1/api/projects/programme/{id}) */}
{/* Modal: View Programme Details */}
<Modal
isOpen={isProgrammeDetailModalOpen}
onClose={() => {
@@ -1412,24 +1660,26 @@ export const GovernancePage: React.FC = () => {
{loadingProgrammeFreshDetail ? (
<div className="py-8 text-center text-slate-400">
<RefreshCw className="w-5 h-5 animate-spin mx-auto mb-2 text-blue-400" />
Fetching canonical record (GET /v1/api/projects/programme/{selectedProgrammeForDetail?.id})...
Fetching programme details...
</div>
) : (
<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">Programme Name:</span>
<span className="text-white font-medium text-sm">{programmeFreshDetail?.name || selectedProgrammeForDetail?.name}</span>
<div className="p-4 bg-[#0d121f] rounded-xl border border-slate-800/80 space-y-3.5">
<div className="pb-3 border-b border-slate-800/80">
<span className="text-slate-400 font-semibold block text-[11px] uppercase tracking-wider mb-1">Programme Name</span>
<span className="text-white font-semibold text-sm block">{programmeFreshDetail?.name || selectedProgrammeForDetail?.name}</span>
</div>
<div className="grid grid-cols-2 gap-2 pt-2">
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Approval Status:</span>
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).className}`}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 items-center">
<div className="flex items-center gap-2">
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Approval Status:</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>
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Owner:</span>
<span className="font-mono text-slate-300">{getUserEmail(programmeFreshDetail?.ownedBy || selectedProgrammeForDetail?.ownedBy)}</span>
<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(programmeFreshDetail?.ownedBy || selectedProgrammeForDetail?.ownedBy)}>
{getUserEmail(programmeFreshDetail?.ownedBy || selectedProgrammeForDetail?.ownedBy)}
</span>
</div>
</div>
</div>
@@ -1,4 +1,5 @@
import React, { useState, useRef } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -20,16 +21,12 @@ import {
Filter,
Link as LinkIcon,
Edit2,
ChevronRight,
} from 'lucide-react';
import {
getAllIndicators,
createIndicator,
recordIndicatorActual,
getAllEvidence,
getMilestoneEvidence,
getEvidenceSecureLink,
uploadEvidenceFile,
updateEvidence,
getAllRisks,
createRisk,
updateRisk,
@@ -40,10 +37,10 @@ import { Modal } from '../components/ui/Modal';
import { HelpTooltip } from '../components/ui/HelpTooltip';
import { useToast } from '../components/ui/Toast';
import { ContractsManagement } from '../components/monitoring/ContractsManagement';
import { EvidenceReviewQueue } from '../components/monitoring/EvidenceReviewQueue';
import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix';
import {
Indicator,
Evidence,
Risk,
ApprovalStatus,
UnitOfMeasure,
@@ -85,13 +82,40 @@ const riskSchema = z.object({
type IndicatorFormValues = z.infer<typeof indicatorSchema>;
type RiskFormValues = z.infer<typeof riskSchema>;
export const WORKFLOW_STAGES = [
{ id: 'planned', label: 'Planned', step: 1, description: 'Milestone scope agreed and deliverables planned' },
{ id: 'in_progress', label: 'In Progress', step: 2, description: 'Activity underway by recipient organisation' },
{ id: 'submitted', label: 'Submitted', step: 3, description: 'Evidence and deliverables submitted by recipient' },
{ id: 'under_review', label: 'Under Review', step: 4, description: 'Programme Manager verifying evidence against criteria' },
{ id: 'verified_paid', label: 'Verified & Paid', step: 5, description: 'Milestone verified and disbursement authorized' },
] as const;
export const MonitoringPage: React.FC = () => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const [searchParams, setSearchParams] = useSearchParams();
const [activeTab, setActiveTab] = useState<'contracts' | 'evidence' | 'indicators' | 'risks'>('contracts');
const tabParam = searchParams.get('tab');
const [activeTab, setActiveTab] = useState<'contracts' | 'evidence' | 'indicators' | 'risks'>(
tabParam === 'evidence' || tabParam === 'indicators' || tabParam === 'risks' ? tabParam : 'contracts'
);
const [isCreateContractModalOpen, setIsCreateContractModalOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const t = searchParams.get('tab');
if (t === 'evidence' || t === 'indicators' || t === 'risks' || t === 'contracts') {
setActiveTab(t);
}
}, [searchParams]);
const handleTabChange = (tab: 'contracts' | 'evidence' | 'indicators' | 'risks') => {
setActiveTab(tab);
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set('tab', tab);
return next;
});
};
// Modals & States
const [isIndicatorModalOpen, setIsIndicatorModalOpen] = useState(false);
@@ -99,11 +123,6 @@ export const MonitoringPage: React.FC = () => {
const [editingRisk, setEditingRisk] = useState<Risk | null>(null);
const [selectedIndicatorForActual, setSelectedIndicatorForActual] = useState<Indicator | null>(null);
const [actualInput, setActualInput] = useState<number>(0);
const [uploadEvidenceType, setUploadEvidenceType] = useState<number>(EvidenceType.CompletionCertificate);
// Reviewer commentary
const [reviewerComment, setReviewerComment] = useState('');
const [selectedMilestoneEvidenceFilter, setSelectedMilestoneEvidenceFilter] = useState<string | number | 'all'>('all');
// Queries
const { data: indicators = [], isLoading: loadingIndicators } = useQuery({
@@ -111,14 +130,6 @@ export const MonitoringPage: React.FC = () => {
queryFn: () => getAllIndicators(),
});
const { data: evidenceList = [], isLoading: loadingEvidence } = useQuery({
queryKey: ['evidence', selectedMilestoneEvidenceFilter],
queryFn: () =>
selectedMilestoneEvidenceFilter !== 'all'
? getMilestoneEvidence(selectedMilestoneEvidenceFilter)
: getAllEvidence(),
});
const { data: risks = [], isLoading: loadingRisks } = useQuery({
queryKey: ['risks'],
queryFn: () => getAllRisks(),
@@ -184,42 +195,6 @@ export const MonitoringPage: React.FC = () => {
},
});
const uploadMutation = useMutation({
mutationFn: (formData: FormData) => uploadEvidenceFile(formData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['evidence'] });
success('Evidence uploaded', 'Supporting file registered for reviewer inspection.');
},
onError: (err: unknown) => {
toastError('Upload failed', err);
},
});
const reviewEvidenceMutation = useMutation({
mutationFn: ({ id, status }: { id: string | number; status: ApprovalStatus }) =>
updateEvidence({
evidenceId: id,
version: 2,
evidenceType: EvidenceType.DeliverableProof,
status,
}),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['evidence'] });
if (variables.status === ApprovalStatus.Approved) {
success(
'Evidence Approved & Verified',
'Approval recorded. Auto-triggers payment eligibility check in Disbursements.'
);
} else {
success('Rework Requested', 'Evidence returned to recipient for correction.');
}
setReviewerComment('');
},
onError: (err: unknown) => {
toastError('Review update failed', err);
},
});
const openCreateRisk = () => {
setEditingRisk(null);
riskForm.reset({
@@ -279,40 +254,14 @@ export const MonitoringPage: React.FC = () => {
},
});
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const formData = new FormData();
formData.append('file', file);
formData.append('projectId', '401');
formData.append('milestoneId', '803');
formData.append('evidenceType', String(uploadEvidenceType));
uploadMutation.mutate(formData);
}
};
const handleGetEvidenceSecureLink = async (evidenceId: string | number) => {
try {
const url = await getEvidenceSecureLink(evidenceId);
if (url) {
window.open(url, '_blank', 'noopener,noreferrer');
success('Secure URL Retrieved', 'Signed evidence download link opened.');
} else {
toastError('Download Error', 'No active secure download link generated.');
}
} catch (err: any) {
toastError('Link Error', err);
}
};
// Performance indicator thresholds:
// Green >= 90%, Amber 70-89%, Red < 70%
const getIndicatorStatus = (actual: number, target: number) => {
if (!target) return { label: 'Pending', badge: 'bg-slate-800/60 text-slate-400 border-slate-700/60' };
const pct = (actual / target) * 100;
if (pct >= 90) return { label: 'Green (≥90%)', badge: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' };
if (pct >= 70) return { label: 'Amber (70–89%)', badge: 'bg-amber-500/10 text-amber-400 border-amber-500/20' };
return { label: 'Red (<70%)', badge: 'bg-rose-500/10 text-rose-400 border-rose-500/20' };
if (pct >= 90) return { label: '≥90%', badge: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' };
if (pct >= 70) return { label: '70–89%', badge: 'bg-amber-500/10 text-amber-400 border-amber-500/20' };
return { label: '<70%', badge: 'bg-rose-500/10 text-rose-400 border-rose-500/20' };
};
return (
@@ -367,8 +316,8 @@ export const MonitoringPage: React.FC = () => {
{/* Tabs */}
<div className="flex gap-2 border-b border-slate-800/80 pb-3 overflow-x-auto">
<button
onClick={() => setActiveTab('contracts')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${
onClick={() => handleTabChange('contracts')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 cursor-pointer ${
activeTab === 'contracts'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
@@ -384,25 +333,25 @@ export const MonitoringPage: React.FC = () => {
/>
</button>
<button
onClick={() => setActiveTab('evidence')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${
onClick={() => handleTabChange('evidence')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 cursor-pointer ${
activeTab === 'evidence'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`}
>
<Paperclip className="w-4 h-4" />
<span>Milestone Evidence & Review Queue</span>
<FileCheck className="w-4 h-4" />
<span>Evidence Review & Verification</span>
<HelpTooltip
title="Verification Workflow"
content="Evidence submission, file validation, and peer review sign-off gating milestone payments."
title="Evidence Review & Verification"
content="Peer review, document inspection, and verification sign-offs for deliverables submitted against projects and milestones."
size="sm"
placement="top"
/>
</button>
<button
onClick={() => setActiveTab('indicators')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${
onClick={() => handleTabChange('indicators')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 cursor-pointer ${
activeTab === 'indicators'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
@@ -418,8 +367,8 @@ export const MonitoringPage: React.FC = () => {
/>
</button>
<button
onClick={() => setActiveTab('risks')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${
onClick={() => handleTabChange('risks')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 cursor-pointer ${
activeTab === 'risks'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
@@ -444,220 +393,14 @@ export const MonitoringPage: React.FC = () => {
/>
)}
{/* Tab 1: Milestone Evidence Submission & Review */}
{/* Tab 1: Project & Milestone Evidence Review Hub */}
{activeTab === 'evidence' && (
<div className="space-y-6">
{/* Milestone Evidence Review Container */}
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
{/* Header Bar */}
<div className="bg-slate-900/90 text-white px-6 py-4 flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-slate-800/80">
<span className="text-xs font-mono font-semibold tracking-wider text-blue-400 uppercase">
RECIPIENT PORTAL &bull; Project XYZ &bull; Milestone 3: Prototype Delivery
</span>
<span className="text-[11px] text-slate-400 font-mono">Milestone Evidence Submission</span>
</div>
{/* Workflow Progress Steps */}
<div className="p-6 border-b border-slate-800/80 bg-slate-900/40">
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2 text-center text-xs font-semibold">
<div className="py-2.5 px-3 rounded-xl bg-slate-800/60 text-slate-400 border border-slate-700/50">Planned</div>
<div className="py-2.5 px-3 rounded-xl bg-slate-800/60 text-slate-400 border border-slate-700/50">In Progress</div>
<div className="py-2.5 px-3 rounded-xl bg-blue-600 text-white shadow-lg shadow-blue-600/30 ring-2 ring-blue-500/40">
Submitted
</div>
<div className="py-2.5 px-3 rounded-xl bg-amber-500/15 text-amber-300 border border-amber-500/30">
Under Review
</div>
<div className="py-2.5 px-3 rounded-xl bg-slate-900/40 text-slate-500 border border-dashed border-slate-800">
Verified & Paid
</div>
</div>
<div className="mt-4 flex items-center justify-between text-xs">
<div className="flex items-center gap-2 text-slate-300">
<span className="font-semibold">Current status:</span>
<span className="font-mono text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20 font-bold">
SUBMITTED — awaiting Programme Manager review
</span>
</div>
</div>
</div>
{/* Upload Evidence & Attached Evidence Panels */}
<div className="grid grid-cols-1 md:grid-cols-2 divide-y md:divide-y-0 md:divide-x divide-slate-800/80">
{/* Upload Box */}
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-bold text-white">Upload Evidence</h4>
<div className="flex items-center gap-1.5">
<label className="text-[11px] font-medium text-slate-400">Type:</label>
<select
value={uploadEvidenceType}
onChange={(e) => setUploadEvidenceType(Number(e.target.value))}
className="text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 max-w-[180px] [&>option]:bg-slate-900 [&>option]:text-white"
>
{EVIDENCE_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
<p className="text-xs text-slate-400">
Drag & drop or browse files. Accepted formats: PDF, JPG, DOCX, XLSX (Max 25MB).
</p>
<div
onClick={() => fileInputRef.current?.click()}
className="border-2 border-dashed border-slate-700/80 hover:border-blue-500/80 bg-slate-900/40 hover:bg-blue-500/5 rounded-2xl p-8 text-center cursor-pointer transition flex flex-col items-center justify-center space-y-3"
>
<Upload className="w-8 h-8 text-slate-400" />
<div>
<span className="text-xs font-bold text-white block">Click to Browse or Drag Files Here</span>
<span className="text-[10px] text-slate-500">Automated SHA-256 hash stamp applied on intake</span>
</div>
<button
type="button"
className="px-4 py-1.5 text-xs font-semibold text-white bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-xl shadow-xs transition"
>
Browse Files
</button>
</div>
<input
type="file"
ref={fileInputRef}
onChange={handleFileUpload}
className="hidden"
accept=".pdf,.jpg,.jpeg,.png,.docx,.xlsx,.zip"
/>
</div>
{/* Attached Evidence List */}
<div className="p-6 space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div>
<h4 className="text-sm font-bold text-white">Attached Evidence (v2)</h4>
<span className="text-[11px] text-slate-400 font-mono">{evidenceList.length} files loaded</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-400">Milestone:</span>
<select
value={selectedMilestoneEvidenceFilter}
onChange={(e) => setSelectedMilestoneEvidenceFilter(e.target.value === 'all' ? 'all' : e.target.value)}
className="text-xs bg-slate-900 border border-slate-700/80 rounded-xl px-2.5 py-1 text-white focus:outline-none focus:ring-1 focus:ring-blue-500 max-w-[200px] [&>option]:bg-slate-900 [&>option]:text-white"
>
<option value="all">All Milestones</option>
{milestones.map((m) => (
<option key={m.id} value={m.id}>
{m.name || `Milestone #${m.id}`}
</option>
))}
</select>
</div>
</div>
<div className="space-y-2.5">
{evidenceList.map((ev) => (
<div
key={ev.id}
className="p-3 rounded-xl border border-slate-800/80 bg-slate-900/60 flex items-center justify-between gap-3 text-xs"
>
<div className="flex items-center gap-2.5 min-w-0">
<FileText className="w-4 h-4 text-blue-400 shrink-0" />
<div className="min-w-0">
<span className="font-semibold text-slate-200 block truncate font-mono">
{ev.fileName || `evidence_file_${ev.id}.pdf`}
</span>
<span className="text-[10px] text-slate-400 font-mono">
{ev.fileSize || '3.2 MB'} &bull; v{ev.version} &bull; {getEvidenceTypeLabel(ev.type)}
</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-amber-500/10 text-amber-400 border border-amber-500/20">
Under Review
</span>
<button
type="button"
onClick={() => handleGetEvidenceSecureLink(ev.id)}
className="p-1 rounded text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Fetch Secure Signed Link"
>
<LinkIcon className="w-3.5 h-3.5" />
</button>
{ev.documentUrl && (
<a
href={ev.documentUrl}
target="_blank"
rel="noopener noreferrer"
className="text-slate-400 hover:text-white p-1 transition"
title="Download evidence file"
>
<Download className="w-3.5 h-3.5" />
</a>
)}
</div>
</div>
))}
</div>
</div>
</div>
{/* Programme Manager Review Section */}
<div className="p-6 border-t border-slate-800/80 bg-slate-900/40 space-y-4">
<h4 className="text-sm font-bold text-white">Programme Manager Review</h4>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
Reviewer comment / rejection reason
</label>
<input
type="text"
value={reviewerComment}
onChange={(e) => setReviewerComment(e.target.value)}
placeholder="Mandatory if requesting rework; optional for approvals..."
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition"
/>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pt-2">
<div className="flex items-center gap-3">
<button
onClick={() =>
reviewEvidenceMutation.mutate({
id: evidenceList[0]?.id || 1201,
status: ApprovalStatus.Rejected,
})
}
disabled={reviewEvidenceMutation.isPending}
className="px-4 py-2 text-xs font-semibold text-white bg-rose-600/90 hover:bg-rose-600 rounded-xl shadow-lg shadow-rose-600/20 transition"
>
Reject / Rework
</button>
<button
onClick={() =>
reviewEvidenceMutation.mutate({
id: evidenceList[0]?.id || 1201,
status: ApprovalStatus.Approved,
})
}
disabled={reviewEvidenceMutation.isPending}
className="px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition"
>
Approve & Verify
</button>
</div>
<p className="text-[11px] text-slate-400 italic">
Approval auto-triggers payment eligibility check.
</p>
</div>
</div>
</div>
</div>
<EvidenceReviewQueue
initialProjectId={searchParams.get('projectId') || 'all'}
initialMilestoneId={searchParams.get('milestoneId') || 'all'}
initialActivityId={searchParams.get('activityId') || 'all'}
initialUserId={searchParams.get('userId') || 'all'}
/>
)}
{/* Tab 2: Indicators Table (M08) */}
@@ -964,7 +707,7 @@ export const MonitoringPage: React.FC = () => {
<label className="block text-xs font-semibold text-slate-300">Actual Value to Record *</label>
<HelpTooltip
title="Monitored Measurement"
content="Current realized value for this reporting period. Automatically updates Green (>=90%), Amber (70-89%), or Red (<70%) status."
content="Current realized value for this reporting period. Automatically updates to ≥90%, 70–89%, or <70% performance threshold status."
size="sm"
placement="right"
/>
@@ -1,4 +1,5 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -520,15 +521,25 @@ export const ProjectsPage: React.FC = () => {
{/* Actions & Status Change Selector */}
<div className="shrink-0 text-right space-y-2">
<button
type="button"
onClick={() => openEditProject(selectedProject)}
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 border border-slate-700/80 rounded-xl transition cursor-pointer"
title="Edit project details"
>
<Edit2 className="w-3.5 h-3.5 text-blue-400" />
Edit Project
</button>
<div className="flex items-center justify-end gap-2">
<Link
to={`/monitoring?tab=evidence&projectId=${selectedProject.id}`}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-blue-300 hover:text-white bg-blue-500/15 hover:bg-blue-500/25 border border-blue-500/30 rounded-xl transition cursor-pointer"
title="Review submitted evidence for this project"
>
<FileCheck className="w-3.5 h-3.5 text-blue-400" />
Review Evidence
</Link>
<button
type="button"
onClick={() => openEditProject(selectedProject)}
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 border border-slate-700/80 rounded-xl transition cursor-pointer"
title="Edit project details"
>
<Edit2 className="w-3.5 h-3.5 text-blue-400" />
Edit Project
</button>
</div>
<div>
<div className="flex items-center gap-1 mb-1">
<label className="text-[10px] uppercase font-bold text-slate-400 block">Approval Status</label>
@@ -684,6 +695,14 @@ export const ProjectsPage: React.FC = () => {
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="font-mono text-xs font-bold text-blue-400">{formatZAR(a.amount)}</span>
<Link
to={`/monitoring?tab=evidence&projectId=${selectedProject.id}&activityId=${a.id}`}
className="inline-flex items-center gap-1 text-[10px] font-semibold text-cyan-300 hover:text-white bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/25 px-2 py-1 rounded-lg transition"
title="View & review evidence for this activity"
>
<FileCheck className="w-3 h-3 text-cyan-400" />
Evidence
</Link>
<button
onClick={() => openEditActivity(a)}
className="p-1.5 rounded-lg text-slate-400 hover:text-blue-400 hover:bg-slate-800 transition cursor-pointer"
@@ -736,16 +755,29 @@ export const ProjectsPage: React.FC = () => {
const statusBadge = getApprovalStatusBadge(m.status);
const priBadge = getPriorityBadge(m.priority);
return (
<div key={m.id} className="p-3.5 rounded-xl border border-slate-800/80 bg-[#0d121f]/90 space-y-2">
<div className="flex items-start justify-between gap-2">
<div key={m.id} className="p-3.5 rounded-xl border border-slate-800/80 bg-[#0d121f]/90 space-y-2.5">
<div className="flex items-start justify-between gap-3">
<div>
<span className="font-semibold text-xs text-white block">{m.name}</span>
<span className="text-[10px] text-slate-500 font-mono">Milestone #{m.id}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
{statusBadge.label}
</span>
<div className="flex items-center gap-1.5">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
{statusBadge.label}
</span>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${priBadge.className}`}>
{priBadge.label}
</span>
</div>
<Link
to={`/monitoring?tab=evidence&projectId=${selectedProject.id}&milestoneId=${m.id}`}
className="inline-flex items-center gap-1 text-[10px] font-semibold text-blue-300 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/25 px-2 py-1 rounded-lg transition"
title="View & review evidence for this milestone"
>
<FileCheck className="w-3 h-3 text-blue-400" />
Evidence
</Link>
<button
onClick={() => openEditMilestone(m)}
className="p-1.5 rounded-lg text-slate-400 hover:text-blue-400 hover:bg-slate-800 transition cursor-pointer"
@@ -755,14 +787,11 @@ export const ProjectsPage: React.FC = () => {
</button>
</div>
</div>
<div className="flex items-center justify-between text-[11px] text-slate-400">
<span className="flex items-center gap-1">
<Clock className="w-3 h-3 text-slate-500" />
<div className="flex items-center text-[11px] text-slate-400 pt-1.5 border-t border-slate-800/60">
<span className="flex items-center gap-1.5 text-slate-400">
<Clock className="w-3.5 h-3.5 text-slate-500" />
Due: {formatDate(m.dueAt)}
</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded-md font-medium border ${priBadge.className}`}>
{priBadge.label}
</span>
</div>
</div>
);
@@ -121,7 +121,7 @@ export const SettingsPage: React.FC = () => {
const syncIntegrationMutation = useMutation({
mutationFn: () => syncIntegration(true),
onSuccess: () => {
success('Backend Integration Synced', 'Direct POST /v1/api/integration/sync executed successfully.');
success('Backend Integration Synced', 'System records and telemetry successfully synchronized.');
queryClient.invalidateQueries({ queryKey: ['users'] });
},
onError: (err: any) => {
@@ -260,7 +260,7 @@ export const SettingsPage: React.FC = () => {
<div className="flex items-center gap-2">
<span
className="hidden sm:inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-[11px] font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/20"
title={getUserSyncRecord() ? `Endpoint: ${getUserSyncRecord()?.syncedEndpoint || '/v1/api/integration/sync'} (Synced in browser)` : 'Sync active in browser'}
title="User directory synchronization active in browser"
>
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
Browser Sync Saved
@@ -553,7 +553,7 @@ export const SettingsPage: React.FC = () => {
type="submit"
disabled={searchingApplicant || !applicantSearchId.trim()}
className="px-3.5 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl transition cursor-pointer disabled:opacity-50"
title="Lookup all invites (GET /v1/api/communications/invites/{applicantUserId})"
title="Lookup all invites"
>
{searchingApplicant ? 'Searching...' : 'All Invites'}
</button>
@@ -562,7 +562,7 @@ export const SettingsPage: React.FC = () => {
onClick={handleLookupSingleApplicantInvite}
disabled={searchingApplicant || !applicantSearchId.trim()}
className="px-3.5 py-2 text-xs font-semibold text-blue-300 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 rounded-xl transition cursor-pointer disabled:opacity-50"
title="Lookup primary invite (GET /v1/api/communications/invites/applicant/{applicantUserId})"
title="Lookup primary invite"
>
Primary Invite
</button>
@@ -721,10 +721,10 @@ export const SettingsPage: React.FC = () => {
<div>
<h3 className="text-sm font-bold text-white flex items-center gap-2">
<RefreshCw className="w-4 h-4 text-blue-400" />
Backend Integration Sync Engine (POST /v1/api/integration/sync)
Backend Integration Sync Engine
</h3>
<p className="text-xs text-slate-400 mt-1 max-w-2xl leading-relaxed">
The integration sync endpoint provisions and synchronizes authenticated accounts with the backend security directory. To avoid hotspotting the backend during frequent navigation, synchronizations are locally persisted in the browser and refreshed on demand or when roles update.
The integration sync service provisions and synchronizes authenticated accounts with the backend security directory. To avoid hotspotting the backend during frequent navigation, synchronizations are locally persisted in the browser and refreshed on demand or when roles update.
</p>
</div>
@@ -734,7 +734,7 @@ export const SettingsPage: React.FC = () => {
onClick={() => syncIntegrationMutation.mutate()}
disabled={syncIntegrationMutation.isPending}
className="inline-flex items-center gap-2 px-4 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl shadow-lg shadow-blue-500/20 transition cursor-pointer disabled:opacity-50"
title="Direct POST /v1/api/integration/sync call"
title="Trigger synchronization with directory"
>
<RefreshCw className={`w-3.5 h-3.5 ${syncIntegrationMutation.isPending ? 'animate-spin' : ''}`} />
{syncIntegrationMutation.isPending ? 'Syncing...' : 'Trigger Integration Sync'}
@@ -769,19 +769,19 @@ export const SettingsPage: React.FC = () => {
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-2">
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider block">
OpenAPI Target Route
Synchronization Service Profile
</span>
<div className="text-xs space-y-1.5 font-mono">
<div>
<span className="text-slate-500">HTTP Method:</span> <span className="text-emerald-400 font-bold">POST</span>
<span className="text-slate-500">Service Mode:</span> <span className="text-emerald-400 font-bold">Secure Directory Handshake</span>
</div>
<div>
<span className="text-slate-500">Endpoint Path:</span>{' '}
<span className="text-cyan-400">/v1/api/integration/sync</span>
<span className="text-slate-500">Sync Scope:</span>{' '}
<span className="text-cyan-400">Authoritative User Directory & Security Claims</span>
</div>
<div>
<span className="text-slate-500">Execution Target:</span>{' '}
<span className="text-white">Authoritative User Directory Synchronization</span>
<span className="text-slate-500">Service Status:</span>{' '}
<span className="text-white">Operational & Connected</span>
</div>
</div>
</div>
@@ -1129,7 +1129,7 @@ export const SettingsPage: React.FC = () => {
{loadingUserFreshDetail ? (
<div className="p-6 text-center text-slate-400">
<RefreshCw className="w-5 h-5 animate-spin mx-auto mb-2 text-blue-400" />
Querying backend endpoint (GET /v1/api/users/{selectedUserForDetail.id})...
Fetching user profile details...
</div>
) : (
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-2">
+192 -117
View File
@@ -54,7 +54,7 @@ import {
} from '../types';
import { parseFriendlyError, FriendlyError } from '../utils/apiErrorParser';
import { registerUserEmail, registerUserEmails } from '../lib/formatters';
import { registerUserEmail, registerUserEmails, getUserIdByEmail } from '../lib/formatters';
// Helper to extract ProblemDetails from Axios error or unknown error
export function parseApiError(error: unknown): ProblemDetails {
@@ -88,6 +88,20 @@ export const getDashboard = async (): Promise<Dashboard> => {
// 2. Governance & Structure APIs
// ==========================================
/**
* Ensures an ID is converted to a canonical int64 integer number.
* Decodes HashIDs if a HashID string is passed, ensuring request body schemas
* (pattern: ^-?(?:0|[1-9]\d*)$) receive numeric integers, never raw HashIDs.
*/
export const toIntegerId = (id: number | string | null | undefined): number => {
return toolsService.decodeToNumberSync(id);
};
export const toNullableIntegerId = (id: number | string | null | undefined): number | null => {
if (id === null || id === undefined || id === '') return null;
return toolsService.decodeToNumberSync(id);
};
/**
* Normalizes an Organisation object from backend responses.
* Robustly maps status across variations (status, Status, activityStatus, approvalStatus, statusId, isActive).
@@ -137,19 +151,22 @@ export const getOrganisation = async (organisationId: string | number): Promise<
};
export const createOrganisation = async (payload: CreateOrganisationRequest): Promise<Organisation> => {
const userSub = getCurrentUserSub();
const res = await apiClient.post('/v1/api/governance/organisations', {
...payload,
createdBy: userSub || undefined,
name: payload.name,
registrationNo: payload.registrationNo,
email: payload.email,
type: payload.type !== undefined ? Number(payload.type) : 5,
});
return res.data;
};
export const updateOrganisation = async (payload: UpdateOrganisationRequest): Promise<Organisation> => {
const userSub = getCurrentUserSub();
const res = await apiClient.put('/v1/api/governance/organisations', {
...payload,
updatedBy: userSub || undefined,
organisationId: toIntegerId(payload.organisationId),
name: payload.name,
registrationNo: payload.registrationNo,
email: payload.email,
type: Number(payload.type),
});
return res.data;
};
@@ -193,20 +210,30 @@ export const getPortfolioProgrammes = async (portfolioId: string | number): Prom
export const createPortfolio = async (payload: CreatePortfolioRequest): Promise<Portfolio> => {
const userSub = getCurrentUserSub();
let ownerId = payload.ownerId;
if (ownerId && typeof ownerId === 'string' && ownerId.includes('@')) {
ownerId = getUserIdByEmail(ownerId) || userSub || ownerId;
}
const res = await apiClient.post('/v1/api/governance/organisations/portfolio', {
...payload,
ownerId: payload.ownerId || userSub || undefined,
createdBy: userSub || undefined,
name: payload.name,
description: payload.description,
organisationId: toIntegerId(payload.organisationId),
ownerId: ownerId || userSub || '00000000-0000-0000-0000-000000000000',
});
return res.data;
};
export const updatePortfolio = async (payload: UpdatePortfolioRequest): Promise<Portfolio> => {
const userSub = getCurrentUserSub();
let ownerId = payload.ownerId;
if (ownerId && typeof ownerId === 'string' && ownerId.includes('@')) {
ownerId = getUserIdByEmail(ownerId) || userSub || ownerId;
}
const res = await apiClient.put('/v1/api/governance/organisations/portfolio', {
...payload,
ownerId: payload.ownerId || userSub || undefined,
updatedBy: userSub || undefined,
portfolioId: toIntegerId(payload.portfolioId),
name: payload.name,
description: payload.description,
ownerId: ownerId || userSub || '00000000-0000-0000-0000-000000000000',
});
return res.data;
};
@@ -245,20 +272,29 @@ export const getProgrammeProjects = async (programmeId: string | number): Promis
export const createProgramme = async (payload: CreateProgrammeRequest): Promise<Programme> => {
const userSub = getCurrentUserSub();
let ownerId = payload.ownerId;
if (ownerId && typeof ownerId === 'string' && ownerId.includes('@')) {
ownerId = getUserIdByEmail(ownerId) || userSub || ownerId;
}
const res = await apiClient.post('/v1/api/projects/programme', {
...payload,
ownerId: payload.ownerId || userSub || undefined,
createdBy: userSub || undefined,
portfolioId: toIntegerId(payload.portfolioId),
name: payload.name,
ownerId: ownerId || userSub || '00000000-0000-0000-0000-000000000000',
});
return res.data;
};
export const updateProgramme = async (payload: UpdateProgrammeRequest): Promise<Programme> => {
const userSub = getCurrentUserSub();
let ownerId = payload.ownerId;
if (ownerId && typeof ownerId === 'string' && ownerId.includes('@')) {
ownerId = getUserIdByEmail(ownerId) || userSub || ownerId;
}
const res = await apiClient.put('/v1/api/projects/programme', {
...payload,
ownerId: payload.ownerId || userSub || undefined,
updatedBy: userSub || undefined,
programmeId: toIntegerId(payload.programmeId),
name: payload.name,
ownerId: ownerId || userSub || '00000000-0000-0000-0000-000000000000',
status: Number(payload.status) || 1,
});
return res.data;
};
@@ -277,28 +313,40 @@ export const getProject = async (projectId: string | number): Promise<Project> =
};
export const createProject = async (payload: CreateProjectRequest): Promise<Project> => {
const userSub = getCurrentUserSub();
const res = await apiClient.post('/v1/api/projects', {
...payload,
createdBy: userSub || undefined,
programmeId: toIntegerId(payload.programmeId),
name: payload.name,
description: payload.description,
sector: Number(payload.sector),
projectType: Number(payload.projectType),
});
return res.data;
};
export const updateProject = async (payload: UpdateProjectRequest): Promise<Project> => {
const userSub = getCurrentUserSub();
const startedAt = payload.startedAt
? (payload.startedAt.includes('T') ? payload.startedAt : new Date(payload.startedAt).toISOString())
: null;
const endedAt = payload.endedAt
? (payload.endedAt.includes('T') ? payload.endedAt : new Date(payload.endedAt).toISOString())
: null;
const res = await apiClient.put('/v1/api/projects', {
...payload,
updatedBy: userSub || undefined,
projectId: toIntegerId(payload.projectId),
name: payload.name,
description: payload.description,
startedAt,
endedAt,
sector: Number(payload.sector),
projectType: Number(payload.projectType),
});
return res.data;
};
export const updateProjectStatus = async (payload: UpdateProjectStatusRequest): Promise<void> => {
const userSub = getCurrentUserSub();
await apiClient.put('/v1/api/projects/status', {
...payload,
updatedBy: userSub || undefined,
projectId: toIntegerId(payload.projectId),
status: Number(payload.status),
});
};
@@ -315,36 +363,36 @@ export const getActivity = async (activityId: string | number): Promise<Activity
};
export const createActivity = async (payload: CreateActivityRequest): Promise<Activity> => {
const userSub = getCurrentUserSub();
const body: any = {
...payload,
milestoneId:
payload.milestoneId !== undefined && payload.milestoneId !== null && payload.milestoneId !== ''
? Number(payload.milestoneId)
: null,
createdBy: userSub || undefined,
const performedAt = payload.performedAt
? (payload.performedAt.includes('T') ? payload.performedAt : new Date(payload.performedAt).toISOString())
: null;
const body = {
projectId: toIntegerId(payload.projectId),
milestoneId: toNullableIntegerId(payload.milestoneId),
category: payload.category || 'Operations',
amount: Number(payload.amount) || 0,
title: payload.title,
description: payload.description || '',
performedAt,
};
try {
const res = await apiClient.post('/v1/api/projects/activities', body);
return res.data;
} catch (err: any) {
if (err?.response?.status === 405) {
const res = await apiClient.put('/v1/api/projects/activities', body);
return res.data;
}
throw err;
}
const res = await apiClient.post('/v1/api/projects/activities', body);
return res.data;
};
export const updateActivity = async (payload: UpdateActivityRequest): Promise<Activity> => {
const userSub = getCurrentUserSub();
const body: any = {
...payload,
milestoneId:
payload.milestoneId !== undefined && payload.milestoneId !== null && payload.milestoneId !== ''
? Number(payload.milestoneId)
: null,
updatedBy: userSub || undefined,
const performedAt = payload.performedAt
? (payload.performedAt.includes('T') ? payload.performedAt : new Date(payload.performedAt).toISOString())
: null;
const body = {
projectId: toIntegerId(payload.projectId),
milestoneId: toNullableIntegerId(payload.milestoneId),
category: payload.category || 'Operations',
amount: Number(payload.amount) || 0,
title: payload.title,
description: payload.description || '',
performedAt,
};
try {
const res = await apiClient.put('/v1/api/projects/activities', body);
@@ -376,19 +424,30 @@ export const getMilestone = async (milestoneId: string | number): Promise<Milest
};
export const createMilestone = async (payload: CreateMilestoneRequest): Promise<Milestone> => {
const userSub = getCurrentUserSub();
const dueAtIso = payload.dueAt.includes('T')
? payload.dueAt
: new Date(payload.dueAt).toISOString();
const res = await apiClient.post('/v1/api/evidence/milestones', {
...payload,
createdBy: userSub || undefined,
projectId: toIntegerId(payload.projectId),
dueAt: dueAtIso,
name: payload.name,
priority: Number(payload.priority) || 1,
});
return res.data;
};
export const updateMilestone = async (payload: UpdateMilestoneRequest): Promise<void> => {
const userSub = getCurrentUserSub();
const dueAtIso = payload.dueAt.includes('T')
? payload.dueAt
: new Date(payload.dueAt).toISOString();
await apiClient.put('/v1/api/projects/milestone', {
...payload,
updatedBy: userSub || undefined,
milestoneId: toIntegerId(payload.milestoneId),
dueAt: dueAtIso,
name: payload.name,
priority: Number(payload.priority) || 1,
status: Number(payload.status) || 1,
});
};
@@ -422,10 +481,10 @@ export const getAward = async (awardId: string | number): Promise<Award> => {
};
export const createAward = async (payload: CreateAwardRequest): Promise<Award> => {
const userSub = getCurrentUserSub();
const res = await apiClient.post('/v1/api/grants/awards', {
...payload,
createdBy: userSub || undefined,
organisationId: toIntegerId(payload.organisationId),
programmeId: toIntegerId(payload.programmeId),
amount: Number(payload.amount),
});
return res.data;
};
@@ -456,12 +515,27 @@ export const getContract = async (contractId: string | number): Promise<Contract
};
export const createContract = async (payload: CreateContractRequest): Promise<Contract> => {
const userSub = getCurrentUserSub();
const hashedAwardId = await toolsService.resolveAwardId(payload.awardId);
// Ensure awardId is numeric int64 as required by OpenAPI schema (pattern: ^-?(?:0|[1-9]\d*)$)
let numericAwardId: number = Number(payload.awardId);
if (isNaN(numericAwardId) || !/^-?\d+$/.test(String(payload.awardId))) {
const decoded = toolsService.decodeIdSync(String(payload.awardId));
if (decoded && /^-?\d+$/.test(String(decoded))) {
numericAwardId = Number(decoded);
}
}
const effectiveAtIso = payload.effectiveAt.includes('T')
? payload.effectiveAt
: new Date(payload.effectiveAt).toISOString();
const expiresAtIso = payload.expiresAt.includes('T')
? payload.expiresAt
: new Date(payload.expiresAt).toISOString();
const res = await apiClient.post('/v1/api/grants/contracts', {
...payload,
awardId: hashedAwardId,
createdBy: userSub || undefined,
awardId: !isNaN(numericAwardId) ? numericAwardId : payload.awardId,
totalValue: Number(payload.totalValue),
effectiveAt: effectiveAtIso,
expiresAt: expiresAtIso,
});
// Extract ID from headers (e.g. Location or Content-Location) if present
@@ -472,6 +546,8 @@ export const createContract = async (payload: CreateContractRequest): Promise<Co
headerId = parts[parts.length - 1];
}
const hashedAwardId = toolsService.encodeIdSync(numericAwardId || payload.awardId);
// Case 1: Backend returns string contractId/hash directly
if (typeof res.data === 'string' && res.data.trim().length > 0) {
const rawStr = res.data.trim();
@@ -479,9 +555,9 @@ export const createContract = async (payload: CreateContractRequest): Promise<Co
id: rawStr,
contractId: rawStr,
awardId: hashedAwardId,
totalValue: payload.totalValue,
effectiveAt: payload.effectiveAt,
expiresAt: payload.expiresAt,
totalValue: Number(payload.totalValue),
effectiveAt: effectiveAtIso,
expiresAt: expiresAtIso,
status: ContractStatus.Draft,
} as unknown as Contract;
}
@@ -510,9 +586,9 @@ export const createContract = async (payload: CreateContractRequest): Promise<Co
id: headerId,
contractId: headerId,
awardId: hashedAwardId,
totalValue: payload.totalValue,
effectiveAt: payload.effectiveAt,
expiresAt: payload.expiresAt,
totalValue: Number(payload.totalValue),
effectiveAt: effectiveAtIso,
expiresAt: expiresAtIso,
status: ContractStatus.Draft,
} as unknown as Contract;
}
@@ -523,6 +599,7 @@ export const createContract = async (payload: CreateContractRequest): Promise<Co
const matched = existing.find(
(c) =>
String(c.awardId) === String(hashedAwardId) ||
String(c.awardId) === String(numericAwardId) ||
String(c.awardId) === String(payload.awardId)
);
if (matched) return matched;
@@ -532,18 +609,17 @@ export const createContract = async (payload: CreateContractRequest): Promise<Co
id: hashedAwardId,
contractId: hashedAwardId,
awardId: hashedAwardId,
totalValue: payload.totalValue,
effectiveAt: payload.effectiveAt,
expiresAt: payload.expiresAt,
totalValue: Number(payload.totalValue),
effectiveAt: effectiveAtIso,
expiresAt: expiresAtIso,
status: ContractStatus.Draft,
}) as unknown as Contract;
};
export const updateContract = async (payload: UpdateContractRequest): Promise<void> => {
const hashedContractId = await toolsService.resolveContractId(payload.contractId);
await apiClient.put('/v1/api/grants/contracts', {
...payload,
contractId: hashedContractId,
contractId: toIntegerId(payload.contractId),
status: Number(payload.status),
});
};
@@ -571,12 +647,10 @@ export const getContractSecureLink = async (contractId: string | number): Promis
};
export const executeContract = async (payload: ExecuteContractRequest): Promise<void> => {
const userSub = getCurrentUserSub();
const hashedContractId = await toolsService.resolveContractId(payload.contractId);
await apiClient.post('/v1/api/grants/contracts/execute', {
...payload,
contractId: hashedContractId,
executedBy: userSub || undefined,
contractId: toIntegerId(payload.contractId),
externalSignatureId: payload.externalSignatureId,
signedDocumentUrl: payload.signedDocumentUrl,
});
};
@@ -594,10 +668,10 @@ export const getBudget = async (budgetId: string | number): Promise<Budget> => {
};
export const createBudget = async (payload: CreateBudgetRequest): Promise<Budget> => {
const userSub = getCurrentUserSub();
const res = await apiClient.post('/v1/api/grants/budgets', {
...payload,
createdBy: userSub || undefined,
projectId: toIntegerId(payload.projectId),
name: payload.name,
amount: Number(payload.amount),
});
return res.data;
};
@@ -619,28 +693,25 @@ export const getDisbursement = async (disbursementId: string | number): Promise<
};
export const createDisbursement = async (payload: CreateDisbursementRequest): Promise<Disbursement> => {
const userSub = getCurrentUserSub();
const res = await apiClient.post('/v1/api/execution/disbursements', {
...payload,
createdBy: userSub || undefined,
projectId: toIntegerId(payload.projectId),
milestoneId: toNullableIntegerId(payload.milestoneId),
amount: Number(payload.amount),
});
return res.data;
};
export const updateDisbursement = async (payload: UpdateDisbursementRequest): Promise<void> => {
const userSub = getCurrentUserSub();
await apiClient.put('/v1/api/execution/disbursements', {
...payload,
updatedBy: userSub || undefined,
disbursementId: toIntegerId(payload.disbursementId),
milestoneId: toNullableIntegerId(payload.milestoneId),
amount: Number(payload.amount),
});
};
export const approveDisbursement = async (disbursementId: string | number): Promise<void> => {
const hashedDisbId = await toolsService.encodeId(disbursementId);
const userSub = getCurrentUserSub();
await apiClient.post(`/v1/api/execution/disbursements/${hashedDisbId}/approve`, {
approvedBy: userSub || undefined,
});
await apiClient.post(`/v1/api/execution/disbursements/${hashedDisbId}/approve`, {});
};
/**
@@ -706,23 +777,23 @@ export const getRisk = async (riskId: string | number): Promise<Risk> => {
};
export const createRisk = async (payload: CreateRiskRequest): Promise<Risk> => {
const userSub = getCurrentUserSub();
const res = await apiClient.post('/v1/api/execution/risks', {
...payload,
projectId: toIntegerId(payload.projectId),
likelihood: Number(payload.likelihood),
impact: Number(payload.impact),
createdBy: userSub || undefined,
name: payload.name,
description: payload.description || '',
});
return normalizeRisk(res.data);
};
export const updateRisk = async (payload: UpdateRiskRequest): Promise<void> => {
const userSub = getCurrentUserSub();
await apiClient.put('/v1/api/execution/risks', {
...payload,
riskId: toIntegerId(payload.riskId),
likelihood: Number(payload.likelihood),
impact: Number(payload.impact),
updatedBy: userSub || undefined,
name: payload.name,
description: payload.description || '',
});
};
@@ -741,20 +812,19 @@ export const getIndicator = async (indicatorId: string | number): Promise<Indica
};
export const createIndicator = async (payload: CreateIndicatorRequest): Promise<Indicator> => {
const userSub = getCurrentUserSub();
const res = await apiClient.post('/v1/api/evidence/indicators', {
...payload,
createdBy: userSub || undefined,
projectId: toIntegerId(payload.projectId),
name: payload.name,
unitOfMeasure: Number(payload.unitOfMeasure) || 1,
baselineAmount: Number(payload.baselineAmount) || 0,
targetAmount: Number(payload.targetAmount) || 0,
});
return res.data;
};
export const recordIndicatorActual = async (indicatorId: string | number, actualAmount: number): Promise<void> => {
const hashedIndId = await toolsService.encodeId(indicatorId);
const userSub = getCurrentUserSub();
await apiClient.post(`/v1/api/evidence/indicators/${hashedIndId}/actuals/${actualAmount}`, {
recordedBy: userSub || undefined,
});
await apiClient.post(`/v1/api/evidence/indicators/${hashedIndId}/actuals/${actualAmount}`, {});
};
export const getEvidenceFiles = async (page = 1, pageSize = 100): Promise<Evidence[]> => {
@@ -802,10 +872,11 @@ export const uploadEvidenceFile = async (formData: FormData): Promise<Evidence>
};
export const updateEvidence = async (payload: UpdateEvidenceRequest): Promise<void> => {
const userSub = getCurrentUserSub();
await apiClient.put('/v1/api/evidence', {
...payload,
updatedBy: userSub || undefined,
evidenceId: toIntegerId(payload.evidenceId),
version: Number(payload.version) || 1,
evidenceType: Number(payload.evidenceType),
status: Number(payload.status),
});
};
@@ -910,10 +981,14 @@ export const syncIntegration = async (force = true): Promise<void> => {
// 8. Communications & Invites APIs
// ==========================================
export const sendInvite = async (payload: InviteRequest): Promise<Invite> => {
const userSub = getCurrentUserSub();
const res = await apiClient.post<Invite>('/v1/api/communications/invite', {
...payload,
createdBy: userSub || undefined,
organisationId: toIntegerId(payload.organisationId),
invitedOrganisationId: toIntegerId(payload.invitedOrganisationId),
awardId: toIntegerId(payload.awardId),
contractId: toIntegerId(payload.contractId),
recipient: payload.recipient,
message: payload.message,
platform: payload.platform !== undefined ? Number(payload.platform) : 1,
});
return res.data;
};
@@ -99,11 +99,19 @@ export function encodeHashIdSync(id: number | string | null | undefined): string
* Deterministically decodes a HashID string back to original numeric ID or string.
* Synchronous and does not perform HTTP network round trips.
*/
export function decodeHashIdSync(hashId: string | null | undefined): number | string {
if (!hashId) return '';
export function decodeHashIdSync(hashId: string | number | null | undefined): number | string {
if (hashId === undefined || hashId === null || hashId === '') return '';
if (typeof hashId === 'number') return hashId;
const str = String(hashId).trim();
if (!str) return '';
// If already pure integer string
if (/^-?\d+$/.test(str)) {
const parsed = parseInt(str, 10);
return isNaN(parsed) ? str : parsed;
}
try {
const decoded = hasher.decode(str);
if (Array.isArray(decoded) && decoded.length > 0) {
@@ -116,6 +124,30 @@ export function decodeHashIdSync(hashId: string | null | undefined): number | st
return str;
}
/**
* Decodes a HashID or numeric value strictly to a canonical integer.
* Useful for request payloads expecting int64 integers.
*/
export function decodeToNumberSync(hashId: number | string | null | undefined): number {
if (hashId === null || hashId === undefined || hashId === '') return 0;
if (typeof hashId === 'number') {
return isNaN(hashId) ? 0 : Math.trunc(hashId);
}
const str = String(hashId).trim();
if (/^-?\d+$/.test(str)) {
const parsed = parseInt(str, 10);
return isNaN(parsed) ? 0 : parsed;
}
try {
const decoded = hasher.decode(str);
if (Array.isArray(decoded) && decoded.length > 0) {
return Number(decoded[0]);
}
} catch {}
const fallback = Number(str);
return isNaN(fallback) ? 0 : Math.trunc(fallback);
}
export const toolsService = {
/**
* Synchronous encoding
@@ -127,10 +159,17 @@ export const toolsService = {
/**
* Synchronous decoding
*/
decodeIdSync(hashId: string | null | undefined): number | string {
decodeIdSync(hashId: string | number | null | undefined): number | string {
return decodeHashIdSync(hashId);
},
/**
* Synchronous decoding strictly to canonical integer
*/
decodeToNumberSync(hashId: number | string | null | undefined): number {
return decodeToNumberSync(hashId);
},
/**
* Encodes a raw ID to a HashID (returns Promise for backward compatibility with existing callers)
*/
@@ -75,12 +75,53 @@ function humanizeFieldName(field: string): string {
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
/**
* Checks if a string contains raw HTTP request/response headers or dump.
*/
export function isRawHttpOutput(text: string): boolean {
if (!text || typeof text !== 'string') return false;
// If it has multiple header-like lines or standard HTTP request/response signatures
const headerPatterns = [
/^(?:accept|authorization|content-type|content-length|origin|referer|user-agent|sec-ch-ua|sec-fetch|x-forwarded|x-real-ip|host):/im,
/(?:authorization:\s*bearer|content-type:\s*application\/)/i,
/HTTP\/\d(?:\.\d)?\s+\d{3}/i,
];
let matches = 0;
const lines = text.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (/^[A-Za-z0-9-]+:\s*.+$/.test(trimmed)) {
if (headerPatterns.some((p) => p.test(trimmed))) {
matches++;
}
}
}
return matches >= 2 || (matches >= 1 && /(?:authorization:\s*bearer|content-type:\s*application)/i.test(text));
}
/**
* Redacts sensitive tokens and credentials from technical traces.
*/
export function sanitizeTechnicalDetails(text: string): string {
if (!text) return '';
return text
.replace(/(authorization:\s*bearer\s+)[a-zA-Z0-9._-]+/gi, '$1[REDACTED_TOKEN]')
.replace(/(bearer\s+)[a-zA-Z0-9._-]+/gi, '$1[REDACTED_TOKEN]')
.replace(/("?(?:token|access_token|password|secret|apiKey)"?\s*[:=]\s*)"[^"]+"/gi, '$1"[REDACTED]"');
}
/**
* Cleans technical jargon, stack traces, and framework namespaces from text strings.
*/
function cleanTechnicalJargon(text: string): string {
if (!text) return '';
// If text is a raw HTTP dump, do not return it as clean user text
if (isRawHttpOutput(text)) {
return '';
}
const cleaned = text
.replace(/^AxiosError:\s*/i, '')
.replace(/^Error:\s*/i, '')
@@ -205,23 +246,28 @@ export function parseFriendlyError(
rawErrors = dataObj;
}
} else if (typeof rawResponseData === 'string') {
// Check if response data is stringified JSON containing ProblemDetails
try {
const parsed = JSON.parse(rawResponseData) as any;
if (isProblemDetailsObject(parsed)) {
isProblemDetails = true;
rawTitle = parsed.title || parsed.Title || undefined;
rawDetail = parsed.detail || parsed.Detail || undefined;
statusCode = parsed.status || parsed.Status || statusCode;
rawErrors = parsed.errors ?? parsed.Errors ?? parsed.errorList ?? parsed.ErrorList;
} else if (Array.isArray(parsed)) {
rawErrors = parsed;
} else {
rawDetail = cleanTechnicalJargon(rawResponseData);
}
} catch {
if (!rawResponseData.includes('<html')) {
rawDetail = cleanTechnicalJargon(rawResponseData);
if (isRawHttpOutput(rawResponseData)) {
technicalSummary = sanitizeTechnicalDetails(rawResponseData);
rawDetail = undefined;
} else {
// Check if response data is stringified JSON containing ProblemDetails
try {
const parsed = JSON.parse(rawResponseData) as any;
if (isProblemDetailsObject(parsed)) {
isProblemDetails = true;
rawTitle = parsed.title || parsed.Title || undefined;
rawDetail = parsed.detail || parsed.Detail || undefined;
statusCode = parsed.status || parsed.Status || statusCode;
rawErrors = parsed.errors ?? parsed.Errors ?? parsed.errorList ?? parsed.ErrorList;
} else if (Array.isArray(parsed)) {
rawErrors = parsed;
} else {
rawDetail = cleanTechnicalJargon(rawResponseData);
}
} catch {
if (!rawResponseData.includes('<html')) {
rawDetail = cleanTechnicalJargon(rawResponseData);
}
}
}
}
@@ -481,6 +527,6 @@ export function parseFriendlyError(
categoryLabel,
fieldErrors: fieldErrors.length > 0 ? fieldErrors : undefined,
errorsList: errorsList.length > 0 ? errorsList : undefined,
technicalDetails: technicalSummary || (statusCode ? `HTTP Status ${statusCode}` : undefined),
technicalDetails: technicalSummary ? sanitizeTechnicalDetails(technicalSummary) : (statusCode ? `HTTP Status ${statusCode}` : undefined),
};
}