Refactored UI endpoint usage

This commit is contained in:
2026-09-20 08:26:06 +02:00
parent b703b0c64d
commit 5829bffaa0
35 changed files with 17482 additions and 648 deletions
@@ -38,8 +38,7 @@ export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
const [errorMessage, setErrorMessage] = useState<string | null>(initialError || null);
const [syncAttemptCount, setSyncAttemptCount] = useState(0);
// Activation process dismisses all other modals so that it is displayed without overlapping.
// By extension, any active session expiration modal is dismissed and its processes cancelled.
// Clear any existing session prompt and close background dialogs
useEffect(() => {
cancelSessionExpiration();
dismissSubordinateModals();
@@ -21,14 +21,14 @@ export const SessionExpiredModal: React.FC<SessionExpiredModalProps> = ({
const totalDurationMs = countdownSeconds * 1000;
const isClearingRef = useRef(false);
// When session expiration modal is displayed, dismiss all subordinate modals
// Close background modals when session expires
useEffect(() => {
if (isOpen) {
dismissSubordinateModals();
}
}, [isOpen]);
// Listen for cancellation event (e.g., when activation lockout takes priority)
// Reset state if session expiration is cancelled
useEffect(() => {
const handleCancelled = () => {
setTimeLeftMs(countdownSeconds * 1000);
@@ -18,6 +18,7 @@ import {
} from 'lucide-react';
import { useAuth } from '../../auth/AuthProvider';
import { BrandLogo } from '../ui/BrandLogo';
import { HelpTooltip } from '../ui/HelpTooltip';
import { useQuery } from '@tanstack/react-query';
import { getUsers } from '../../services/api';
import { getUserEmail, registerUserEmail } from '../../lib/formatters';
@@ -27,31 +28,37 @@ const navItems = [
name: 'Executive Dashboard',
path: '/dashboard',
icon: LayoutDashboard,
help: 'Strategic macro metrics, portfolio value, disbursed funding, ISO 31000 risk heatmap, and projects requiring immediate intervention.',
},
{
name: 'Governance & Structure',
path: '/governance',
icon: Building2,
help: 'Master records for legal entities, holding structures, investment portfolios, and thematic programmes funded under statutory mandates.',
},
{
name: 'Projects & Milestones',
path: '/projects',
icon: FolderKanban,
help: 'Project Work Breakdown Structure (WBS), contractual deliverables, scheduled milestones, evidence verification, and execution tracking.',
},
{
name: 'Grant & Financials',
path: '/grants',
icon: Coins,
help: 'Financial management including statutory awards, multi-year budget line allocations, tranches, and scheduled payment disbursements.',
},
{
name: 'M&E, Evidence & Risks',
path: '/monitoring',
icon: ShieldAlert,
help: 'Monitoring & Evaluation (M&E) framework, indicator tracking, ISO 31000 risk registers, mitigation plans, and compliance audits.',
},
{
name: 'Settings & Communications',
path: '/settings',
icon: Settings,
help: 'User account provisioning, statutory sync, tenant environment parameters, and communication invite templates.',
},
];
@@ -109,7 +116,7 @@ export const AppLayout: React.FC = () => {
sidebarOpen ? 'w-64' : 'w-20'
} shrink-0 bg-[#0b101b]/95 text-slate-200 border-r border-slate-800/80 transition-all duration-300 flex flex-col z-30 shadow-2xl backdrop-blur-md`}
>
{/* Brand Header — Clickable link navigating to auth-aware Home page */}
{/* Brand Header */}
<div className="h-16 px-4 flex items-center justify-between border-b border-slate-800/80 bg-[#0d121f]/90">
<Link
to="/"
@@ -141,25 +148,36 @@ export const AppLayout: React.FC = () => {
{navItems.map((item) => {
const Icon = item.icon;
return (
<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 ${
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>
<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>
);
})}
</nav>
{/* User Card with Token-derived info & Identity Server Logout */}
{/* User Card */}
<div className="p-3 border-t border-slate-800/80 bg-[#0d121f]/90">
<div className="flex items-center gap-3">
<div className="h-9 w-9 shrink-0 rounded-full bg-blue-600/20 border border-blue-500/40 flex items-center justify-center font-bold text-xs text-blue-400">
@@ -192,7 +210,7 @@ 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">
{/* Top Bar Brand Icon & Direct Portal Link */}
{/* Brand Icon */}
<Link
to="/"
id="header-brand-logo-link"
@@ -207,7 +225,7 @@ export const AppLayout: React.FC = () => {
<span className="h-4 w-px bg-slate-800 hidden md:block" />
{/* Clickable Home Link */}
{/* Home Navigation */}
<Link
to="/"
id="header-home-link"
@@ -229,17 +247,31 @@ export const AppLayout: React.FC = () => {
<Database className="w-3.5 h-3.5 text-blue-400 shrink-0" />
<span className="truncate">{tenantName}</span>
</span>
<HelpTooltip
title="Tenant Context"
content="Identifies the multi-tenant organization partition and database boundary within the national PFM environment."
size="sm"
placement="bottom"
/>
</div>
<ChevronRight className="w-4 h-4 text-slate-600 hidden sm:block" />
<span className="text-xs font-medium text-emerald-400 bg-emerald-500/10 px-2.5 py-0.5 rounded-full border border-emerald-500/30 flex items-center gap-1.5 shadow-xs">
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
Authenticated Session
</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium text-emerald-400 bg-emerald-500/10 px-2.5 py-0.5 rounded-full border border-emerald-500/30 flex items-center gap-1.5 shadow-xs">
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
Authenticated Session
</span>
<HelpTooltip
title="Security Session Status"
content="Current session is verified against OIDC Identity Server with active cryptographic token and claims."
size="sm"
placement="bottom"
/>
</div>
</div>
{/* User Name & Email derived from token, plus Identity Server signout */}
{/* User Profile and Controls */}
<div className="relative flex items-center gap-3">
<div className="text-right hidden sm:block">
<p className="text-xs font-semibold text-white truncate max-w-[200px]" title={tokenUserName}>
@@ -292,7 +324,7 @@ export const AppLayout: React.FC = () => {
</div>
</header>
{/* Viewport with Landing Page ambient dot grid and glow */}
{/* Main Content Area */}
<main className="flex-1 overflow-y-auto p-6 md:p-8 bg-[#090d16] relative text-slate-100">
<div
className="absolute inset-0 pointer-events-none opacity-15"
File diff suppressed because it is too large Load Diff
@@ -17,12 +17,7 @@ const sizeMap = {
};
/**
* Modern, stylized SVG brand emblem representing Post Fund Management (PFM).
* Features an isometric kinetic nexus with three interlocking governance facets:
* 1. Top Canopy: Governance, Strategy & Portfolios (Radiant Indigo / Violet)
* 2. Left Vault: Capital, Grants & Disbursement Assurance (Electric Sapphire)
* 3. Right Wing: Milestones, Evidence & Impact Delivery (Cyan to Emerald)
* 4. Center Core: Luminous Floating Diamond Milestone Star
* SVG brand logo component for Post Fund Management (PFM).
*/
export const BrandLogo: React.FC<BrandLogoProps> = ({
size = 'md',
@@ -0,0 +1,125 @@
import React, { useState, useRef, useEffect, ReactNode } from 'react';
import { Info, X } from 'lucide-react';
export interface HelpTooltipProps {
/** Concise context-sensitive explanation of the section, control, metric, or dialog */
content: ReactNode;
/** Optional title for the tooltip popover */
title?: string;
/** Custom alignment: top, bottom, left, right (defaults to top) */
placement?: 'top' | 'bottom' | 'left' | 'right';
/** Size of the (i) icon: sm (13px), md (15px), lg (18px) */
size?: 'sm' | 'md' | 'lg';
/** Additional styling classes for the container or trigger button */
className?: string;
/** Accessible label or ARIA label */
ariaLabel?: string;
}
export const HelpTooltip: React.FC<HelpTooltipProps> = ({
content,
title,
placement = 'top',
size = 'md',
className = '',
ariaLabel = 'Information and guidance',
}) => {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Close on Escape or click outside
useEffect(() => {
if (!isOpen) return;
const handleOutsideClick = (e: MouseEvent | TouchEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleOutsideClick);
document.addEventListener('touchstart', handleOutsideClick);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleOutsideClick);
document.removeEventListener('touchstart', handleOutsideClick);
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen]);
const sizeClasses = {
sm: 'w-3.5 h-3.5 text-[11px]',
md: 'w-4 h-4 text-xs',
lg: 'w-5 h-5 text-sm',
}[size];
const iconSizes = {
sm: 'w-3 h-3',
md: 'w-3.5 h-3.5',
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];
return (
<div ref={containerRef} className={`relative inline-flex items-center align-middle ${className}`}>
<button
type="button"
aria-label={ariaLabel}
aria-expanded={isOpen}
onClick={(e) => {
e.stopPropagation();
setIsOpen((prev) => !prev);
}}
className={`inline-flex items-center justify-center rounded-full text-slate-400 hover:text-blue-400 bg-slate-800/80 hover:bg-slate-700/80 border border-slate-700/60 hover:border-blue-500/50 transition-all cursor-pointer shadow-xs focus:outline-none focus:ring-1 focus:ring-blue-500/60 ${sizeClasses} p-0.5`}
title="Click for context guidance"
>
<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>
)}
</div>
);
};
@@ -1,11 +1,13 @@
import React, { ReactNode, useEffect } from 'react';
import { X } from 'lucide-react';
import { HelpTooltip } from './HelpTooltip';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
subtitle?: string;
helpText?: ReactNode;
children: ReactNode;
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
}
@@ -15,6 +17,7 @@ export const Modal: React.FC<ModalProps> = ({
onClose,
title,
subtitle,
helpText,
children,
maxWidth = 'lg',
}) => {
@@ -56,7 +59,17 @@ export const Modal: React.FC<ModalProps> = ({
>
<div className="flex items-start justify-between p-5 border-b border-slate-800/80 bg-[#0d121f]/90">
<div>
<h3 className="text-lg font-semibold text-white tracking-tight">{title}</h3>
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-white tracking-tight">{title}</h3>
{helpText && (
<HelpTooltip
title={`${title} Guidance`}
content={helpText}
size="sm"
placement="bottom"
/>
)}
</div>
{subtitle && <p className="text-xs text-slate-400 mt-0.5">{subtitle}</p>}
</div>
<button
@@ -0,0 +1,85 @@
import React from 'react';
import { AlertCircle, AlertTriangle, X } from 'lucide-react';
import { parseFriendlyError } from '../../utils/apiErrorParser';
interface ProblemDetailsAlertProps {
error: unknown;
onDismiss?: () => void;
title?: string;
className?: string;
}
export const ProblemDetailsAlert: React.FC<ProblemDetailsAlertProps> = ({
error,
onDismiss,
title,
className = '',
}) => {
if (!error) return null;
const parsed = parseFriendlyError(error, title || 'Action Could Not Be Completed');
return (
<div
role="alert"
className={`rounded-xl border border-rose-500/40 bg-[#160d14] p-4 text-gray-200 shadow-lg shadow-rose-950/20 ${className}`}
>
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-rose-400 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h4 className="text-sm font-semibold text-white tracking-tight">
{parsed.title}
</h4>
{parsed.categoryLabel && (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded border bg-rose-950/60 text-rose-300 border-rose-800/60">
{parsed.categoryLabel}
</span>
)}
{parsed.statusCode && (
<span className="text-[10px] font-mono font-medium px-1.5 py-0.5 rounded bg-slate-800 text-slate-300 border border-slate-700">
HTTP {parsed.statusCode}
</span>
)}
</div>
{/* Single error or fallback message displayed directly */}
{(!parsed.errorsList || parsed.errorsList.length <= 1) && 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>
)}
{parsed.technicalDetails && (
<p className="text-[10px] font-mono text-slate-500 mt-2">
Ref: {parsed.technicalDetails}
</p>
)}
</div>
{onDismiss && (
<button
type="button"
onClick={onDismiss}
className="text-slate-400 hover:text-white p-1 rounded-lg hover:bg-slate-800/60 transition-colors shrink-0 cursor-pointer"
aria-label="Dismiss error"
>
<X className="w-4 h-4" />
</button>
)}
</div>
</div>
);
};
@@ -2,6 +2,7 @@ import React, { useState, useMemo } from 'react';
import { Risk, Project } from '../../types';
import { getUserEmail } from '../../lib/formatters';
import { AlertTriangle, ShieldAlert, CheckCircle2, ChevronRight, X, Info } from 'lucide-react';
import { HelpTooltip } from './HelpTooltip';
interface RiskHeatmapMatrixProps {
risks: Risk[];
@@ -168,7 +169,15 @@ export const RiskHeatmapMatrix: React.FC<RiskHeatmapMatrixProps> = ({
{/* Header & Controls */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
{title && <h3 className="text-sm font-semibold text-white">{title}</h3>}
<div className="flex items-center gap-2">
{title && <h3 className="text-sm font-semibold text-white">{title}</h3>}
<HelpTooltip
title="Risk Heatmap Guidance"
content="Visualizes project risk exposure based on ISO 31000 standards. Severity score = Likelihood (1–5) × Impact (1–5). Click any populated cell to drill into registered risks."
size="sm"
placement="bottom"
/>
</div>
{subtitle && <p className="text-xs text-slate-400 mt-0.5">{subtitle}</p>}
</div>
@@ -200,6 +209,12 @@ export const RiskHeatmapMatrix: React.FC<RiskHeatmapMatrixProps> = ({
3×3 Summary
</button>
</div>
<HelpTooltip
title="Matrix Resolution Switcher"
content="Toggle between 5×5 detailed granular risk distribution and 3×3 executive aggregated categories (Low, Medium, High)."
size="sm"
placement="left"
/>
</div>
</div>
@@ -25,6 +25,7 @@ export interface ToastItem {
categoryLabel?: string;
technicalDetails?: string;
fieldErrors?: Array<{ field: string; message: string }>;
errorsList?: string[];
}
export interface ToastOptions {
@@ -34,6 +35,7 @@ export interface ToastOptions {
categoryLabel?: string;
technicalDetails?: string;
fieldErrors?: Array<{ field: string; message: string }>;
errorsList?: string[];
}
interface ToastContextType {
@@ -153,11 +155,21 @@ const ToastCard: React.FC<{
)}
</div>
{toast.message && (
{/* 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>
) : toast.message ? (
<p className="text-xs mt-1.5 text-slate-300 leading-relaxed whitespace-pre-line break-words">
{toast.message}
</p>
)}
) : null}
{/* Optional collapsible technical reference */}
{toast.technicalDetails && (
@@ -255,6 +267,7 @@ export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) =
categoryLabel: options?.categoryLabel,
technicalDetails: options?.technicalDetails,
fieldErrors: options?.fieldErrors,
errorsList: options?.errorsList,
},
]);
};
@@ -302,6 +315,7 @@ export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) =
categoryLabel: options?.categoryLabel || parsed.categoryLabel,
technicalDetails: parsed.technicalDetails || options?.technicalDetails,
fieldErrors: parsed.fieldErrors || options?.fieldErrors,
errorsList: parsed.errorsList || options?.errorsList,
});
};