diff --git a/PostFundManagement.ConsoleUI/.env.example b/PostFundManagement.ConsoleUI/.env.example index feed193..d2c8f7c 100644 --- a/PostFundManagement.ConsoleUI/.env.example +++ b/PostFundManagement.ConsoleUI/.env.example @@ -10,14 +10,13 @@ APP_URL="MY_APP_URL" # OIDC SecuritySettings VITE_OIDC_AUTHORITY="https://sts.security.khongisa.co.za" -VITE_OIDC_AUDIENCE="change-me" -VITE_OIDC_CLIENT_ID="change-me" -VITE_OIDC_CLIENT_SECRET="change-me" +VITE_OIDC_AUDIENCE="pfm-api-dev" +VITE_OIDC_CLIENT_ID="pfm-scalar-dev" +VITE_OIDC_CLIENT_SECRET="secret_71f88570a46eb22eb5270b47f69e3332c84d8db85d008f0276cb7431c8872d27" # PFM Backend API Base URL VITE_API_BASE_URL="https://api.ifri.khongisa.co.za/" # HasherSettings for HashIds compatibility with backend pfm-api VITE_HASHER_MIN_LENGTH="11" -VITE_HASHER_SALT="change-me" - \ No newline at end of file +VITE_HASHER_SALT="TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==" diff --git a/PostFundManagement.ConsoleUI/.gitignore b/PostFundManagement.ConsoleUI/.gitignore new file mode 100644 index 0000000..5a86d2a --- /dev/null +++ b/PostFundManagement.ConsoleUI/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +build/ +dist/ +coverage/ +.DS_Store +*.log +.env* +!.env.example diff --git a/PostFundManagement.ConsoleUI/package.json b/PostFundManagement.ConsoleUI/package.json index 0f2bb0c..bf986c9 100644 --- a/PostFundManagement.ConsoleUI/package.json +++ b/PostFundManagement.ConsoleUI/package.json @@ -32,11 +32,11 @@ "zod": "^4.5.4" }, "devDependencies": { - "@types/express": "^4.17.21", "@types/node": "^22.14.0", "autoprefixer": "^10.4.21", "esbuild": "^0.25.0", "tsx": "^4.21.0", - "typescript": "~5.8.2" + "typescript": "~5.8.2", + "@types/express": "^4.17.21" } } diff --git a/PostFundManagement.ConsoleUI/src/auth/AuthProvider.tsx b/PostFundManagement.ConsoleUI/src/auth/AuthProvider.tsx index b23f18b..c833437 100644 --- a/PostFundManagement.ConsoleUI/src/auth/AuthProvider.tsx +++ b/PostFundManagement.ConsoleUI/src/auth/AuthProvider.tsx @@ -4,6 +4,23 @@ import { Navigate, useLocation } from 'react-router-dom'; import { getSecuritySettings, loadRuntimeConfig, onConfigChanged } from '../config'; import { parseJwtClaims } from './jwt'; import { BrandLogo } from '../components/ui/BrandLogo'; +import { + syncUserAccount, + isUserSyncedInBrowser, + isActivationPending, + getActivationPendingReason, + clearUserSyncInBrowser, + UserSyncResult, +} from '../services/userSyncService'; +import { AccountActivationModal } from '../components/auth/AccountActivationModal'; +import { SessionExpiredModal } from '../components/auth/SessionExpiredModal'; +import { + onSessionExpired, + triggerSessionExpired, + clearSessionAndRedirectToLanding, + cancelSessionExpiration, + dismissSubordinateModals, +} from '../services/sessionExpiration'; export interface AuthUserProfile { id: string; @@ -25,8 +42,14 @@ interface AuthContextType { accessToken: string | null; isAuthenticated: boolean; isLoading: boolean; + isSynced: boolean; + isPendingActivation: boolean; + activationError: string | null; + isSessionExpired: boolean; + triggerSessionExpired: (reason?: string) => void; login: () => Promise; logout: () => Promise; + syncUserSession: (force?: boolean) => Promise; authError: string | null; } @@ -98,9 +121,58 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => const [claims, setClaims] = useState({}); const [accessToken, setAccessToken] = useState(null); const [isLoading, setIsLoading] = useState(true); + const [isSynced, setIsSynced] = useState(() => isUserSyncedInBrowser()); + const [isPendingActivation, setIsPendingActivation] = useState(() => isActivationPending()); + const [activationError, setActivationError] = useState(() => getActivationPendingReason()); const [authError, setAuthError] = useState(null); + const [isSessionExpired, setIsSessionExpired] = useState(false); + const [sessionExpiredReason, setSessionExpiredReason] = useState(null); - // Initialize auth state and check for OIDC callback + // Subscribe to global HTTP 401 session expiration events + useEffect(() => { + const unsubscribe = onSessionExpired((reason) => { + // Subordination: If account activation lockout is active, session expiration is completely avoided and bypassed + if (isPendingActivation || isActivationPending()) { + return; + } + // Only display modal if user currently has an active token or authenticated state + const token = typeof window !== 'undefined' ? localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY) : null; + if (token || accessToken) { + setSessionExpiredReason(reason || 'Session expired or token invalidated (HTTP 401)'); + setIsSessionExpired(true); + } + }); + + return () => { + unsubscribe(); + }; + }, [accessToken, isPendingActivation]); + + // Priority Subordination: When activation process is active, dismiss all other modals, + // cancel session expiration and avoid its processes so that there is no overlapping. + useEffect(() => { + if (isPendingActivation) { + setIsSessionExpired(false); + setSessionExpiredReason(null); + cancelSessionExpiration(); + dismissSubordinateModals(); + } + }, [isPendingActivation]); + + // When session expiration modal is displayed, dismiss all other subordinates + useEffect(() => { + if (isSessionExpired) { + dismissSubordinateModals(); + } + }, [isSessionExpired]); + + const handleSessionExpiredDismiss = () => { + setIsSessionExpired(false); + setSessionExpiredReason(null); + clearSessionAndRedirectToLanding(); + }; + + // Initialize auth state, check for OIDC callback, and perform browser-cached user sync useEffect(() => { const initAuth = async () => { try { @@ -148,6 +220,38 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => // Clean up code and state from URL window.history.replaceState({}, document.title, window.location.pathname); + + // Execute user sync endpoint upon login. + // If HTTP 401 -> routes to landing page + // If non-401 failure -> displays non-dismissable activation modal + // If success -> saved in browser to prevent endpoint hotspot + try { + const syncResult = await syncUserAccount(userSub, token, profile, true); + if (syncResult.isUnauthorized) { + await logout(); + window.location.replace('/'); + return; + } + if (syncResult.isActivationPending) { + setIsPendingActivation(true); + setIsSynced(false); + setActivationError(syncResult.errorMessage || null); + } else if (syncResult.success) { + setIsPendingActivation(false); + setIsSynced(true); + setActivationError(null); + } + } catch (syncErr: any) { + if (syncErr?.response?.status === 401) { + await logout(); + window.location.replace('/'); + return; + } + setIsPendingActivation(true); + setIsSynced(false); + setActivationError(syncErr?.message || 'Account activation pending'); + } + setIsLoading(false); return; } @@ -181,6 +285,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => setSub(storedSub); } + let resolvedUser: AuthUserProfile | null = null; if (storedUser) { try { const parsedUser = JSON.parse(storedUser); @@ -188,8 +293,48 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => parsedUser.tenant = parsedClaims.tenant; } setUser(parsedUser); + resolvedUser = parsedUser; } catch {} } + + // Check if session is already marked pending activation or needs sync check + const wasPending = isActivationPending(); + setIsPendingActivation(wasPending); + setActivationError(getActivationPendingReason()); + + try { + // Verify sync with browser cache priority (hotspot prevention) + const syncResult = await syncUserAccount( + storedSub || '', + storedToken, + resolvedUser || undefined, + false + ); + + if (syncResult.isUnauthorized) { + // HTTP 401: Normal process of routing user to landing page + await logout(); + return; + } + + if (syncResult.isActivationPending) { + setIsPendingActivation(true); + setIsSynced(false); + setActivationError(syncResult.errorMessage || null); + } else if (syncResult.success) { + setIsPendingActivation(false); + setIsSynced(true); + setActivationError(null); + } + } catch (syncErr: any) { + if (syncErr?.response?.status === 401) { + await logout(); + return; + } + setIsPendingActivation(true); + setIsSynced(false); + setActivationError(syncErr?.message || 'Account activation pending'); + } } } catch (err: unknown) { console.warn('OIDC initialization notice:', err); @@ -202,6 +347,36 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => initAuth(); }, []); + const syncUserSession = async (force = true): Promise => { + try { + const result = await syncUserAccount(sub, accessToken, user || undefined, force); + if (result.isUnauthorized) { + await logout(); + return result; + } + if (result.success) { + setIsPendingActivation(false); + setIsSynced(true); + setActivationError(null); + } else if (result.isActivationPending) { + setIsPendingActivation(true); + setIsSynced(false); + setActivationError(result.errorMessage || null); + } + return result; + } catch (err: any) { + if (err?.response?.status === 401) { + await logout(); + return { success: false, isUnauthorized: true }; + } + const msg = err?.message || 'Sync failed'; + setIsPendingActivation(true); + setIsSynced(false); + setActivationError(msg); + return { success: false, isActivationPending: true, errorMessage: msg }; + } + }; + const login = async () => { try { setAuthError(null); @@ -211,7 +386,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => await mgr.signinRedirect(); } catch (err: unknown) { console.warn('OIDC Redirect encountered:', err); - // In sandboxed preview iframe, navigation to external identity provider domain can be blocked by CSP/frame-ancestors. if (typeof window !== 'undefined' && window.self !== window.top) { const errorMsg = 'Identity Provider redirect was restricted inside this preview frame. To complete single sign-on authentication, open the application in a new browser tab.'; @@ -228,10 +402,14 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => localStorage.removeItem(LOCAL_STORAGE_USER_KEY); localStorage.removeItem(LOCAL_STORAGE_SUB_KEY); localStorage.removeItem(LOCAL_STORAGE_CLAIMS_KEY); + clearUserSyncInBrowser(); setUser(null); setSub(''); setClaims({}); setAccessToken(null); + setIsSynced(false); + setIsPendingActivation(false); + setActivationError(null); try { const mgr = getUserManager(); @@ -253,12 +431,24 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => accessToken, isAuthenticated: !!accessToken, isLoading, + isSynced, + isPendingActivation, + activationError, + isSessionExpired, + triggerSessionExpired, login, logout, + syncUserSession, authError, }} > {children} + ); }; @@ -273,7 +463,17 @@ export const useAuth = () => { // Route Guard export const ProtectedRoute: React.FC<{ children: ReactNode }> = ({ children }) => { - const { isAuthenticated, isLoading } = useAuth(); + const { + isAuthenticated, + isLoading, + isPendingActivation, + user, + claims, + sub, + logout, + syncUserSession, + activationError, + } = useAuth(); const location = useLocation(); if (isLoading) { @@ -294,5 +494,20 @@ export const ProtectedRoute: React.FC<{ children: ReactNode }> = ({ children }) return ; } + // If user is authenticated but account has not yet been enabled by an administrator: + // Render the non-dismissable activation modal, blocking all downstream layout and queries. + if (isPendingActivation) { + return ( + + ); + } + return <>{children}; }; diff --git a/PostFundManagement.ConsoleUI/src/components/auth/AccountActivationModal.tsx b/PostFundManagement.ConsoleUI/src/components/auth/AccountActivationModal.tsx new file mode 100644 index 0000000..fbdf998 --- /dev/null +++ b/PostFundManagement.ConsoleUI/src/components/auth/AccountActivationModal.tsx @@ -0,0 +1,269 @@ +import React, { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + ShieldAlert, + RefreshCw, + LogOut, + UserCheck, + CheckCircle2, + AlertTriangle, + ArrowLeft, + Mail, + Building2, + Fingerprint, +} from 'lucide-react'; +import { BrandLogo } from '../ui/BrandLogo'; +import { AuthUserProfile, UserClaims } from '../../auth/AuthProvider'; +import { cancelSessionExpiration, dismissSubordinateModals } from '../../services/sessionExpiration'; + +interface AccountActivationModalProps { + user: AuthUserProfile | null; + claims: UserClaims; + sub: string; + onSync: (force?: boolean) => Promise<{ success: boolean; isUnauthorized?: boolean; errorMessage?: string }>; + onLogout: () => Promise; + initialError?: string | null; +} + +export const AccountActivationModal: React.FC = ({ + user, + claims, + sub, + onSync, + onLogout, + initialError, +}) => { + const navigate = useNavigate(); + const [isSyncing, setIsSyncing] = useState(false); + const [errorMessage, setErrorMessage] = useState(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. + useEffect(() => { + cancelSessionExpiration(); + dismissSubordinateModals(); + }, []); + + const displayName = + user?.name || + (claims.name as string) || + (claims.preferred_username as string) || + 'Registered User'; + + const displayEmail = + user?.email || + (claims.email as string) || + (claims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] as string) || + ''; + + const tenant = user?.tenant || (claims.tenant as string) || (claims.org as string) || 'Standard Enterprise'; + const role = user?.role || (claims.role as string) || 'User'; + + const handleSyncClick = async () => { + if (isSyncing) return; + setIsSyncing(true); + setErrorMessage(null); + + try { + const result = await onSync(true); + + if (result.isUnauthorized) { + // HTTP 401: Routine process of routing user to landing page + await onLogout(); + navigate('/', { replace: true }); + return; + } + + if (!result.success) { + setSyncAttemptCount((prev) => prev + 1); + setErrorMessage( + result.errorMessage || + 'Account is not yet enabled. The administrator has not activated your account in the system directory.' + ); + } + // If result.success is true, AuthProvider updates state, and this modal unmounts automatically! + } catch (err: any) { + if (err?.response?.status === 401) { + await onLogout(); + navigate('/', { replace: true }); + return; + } + setErrorMessage( + err?.message || + 'Account activation check encountered an error. Please confirm with your administrator and try again.' + ); + } finally { + setIsSyncing(false); + } + }; + + const handleSignOut = async () => { + await onLogout(); + navigate('/', { replace: true }); + }; + + const handleNavigateLanding = () => { + navigate('/'); + }; + + return ( +
+ {/* Non-dismissable Card Container: No X button, no escape dismiss, no backdrop click */} +
e.stopPropagation()} + > + {/* Amber accent top bar */} +
+ + {/* Modal Header */} +
+
+
+
+ + + + + +
+
+ + Access Restricted • Awaiting Enablement + +

+ Account Pending Administrator Activation +

+
+
+ + +
+
+ + {/* Modal Body */} +
+ {/* Status notification block */} +
+
+ + Identity Verified & Signed In Successfully +
+

+ You are a registered user and have successfully authenticated via Enterprise Single Sign-On. + However, your account has not yet been enabled in the system directory by a system administrator. +

+

+ If your administrator informs you that your account has been activated, please click the{' '} + Sync Account button below to refresh your status and unlock the system. +

+
+ + {/* User Profile Card */} +
+
+ Authenticated User Profile + + Lockout Active + +
+ +
+
+ +
+ Name + {displayName} +
+
+ +
+ +
+ Email + {displayEmail || 'Not specified'} +
+
+ +
+ +
+ Tenant / Role + + {tenant} ({role}) + +
+
+ +
+ +
+ Subject ID + {sub || 'SSO-Principal'} +
+
+
+
+ + {/* Sync Attempt Feedback / Error Notice */} + {errorMessage && ( +
+ +
+

Account Not Activated Yet

+

{errorMessage}

+ {syncAttemptCount > 0 && ( +

+ Attempt #{syncAttemptCount} checked at {new Date().toLocaleTimeString()} +

+ )} +
+
+ )} +
+ + {/* Modal Actions */} +
+
+ + + +
+ + +
+
+
+ ); +}; diff --git a/PostFundManagement.ConsoleUI/src/components/auth/SessionExpiredModal.tsx b/PostFundManagement.ConsoleUI/src/components/auth/SessionExpiredModal.tsx new file mode 100644 index 0000000..a2711c4 --- /dev/null +++ b/PostFundManagement.ConsoleUI/src/components/auth/SessionExpiredModal.tsx @@ -0,0 +1,186 @@ +import React, { useEffect, useState, useRef } from 'react'; +import { ShieldAlert, Clock, ArrowRight, X, AlertTriangle, LogOut } from 'lucide-react'; +import { clearSessionAndRedirectToLanding, dismissSubordinateModals } from '../../services/sessionExpiration'; + +interface SessionExpiredModalProps { + isOpen: boolean; + onDismiss?: () => void; + reason?: string | null; + /** Total countdown time in seconds (defaults to 8 seconds) */ + countdownSeconds?: number; +} + +export const SessionExpiredModal: React.FC = ({ + isOpen, + onDismiss, + reason, + countdownSeconds = 8, +}) => { + const [timeLeftMs, setTimeLeftMs] = useState(countdownSeconds * 1000); + const [isPaused, setIsPaused] = useState(false); + const totalDurationMs = countdownSeconds * 1000; + const isClearingRef = useRef(false); + + // When session expiration modal is displayed, dismiss all subordinate modals + useEffect(() => { + if (isOpen) { + dismissSubordinateModals(); + } + }, [isOpen]); + + // Listen for cancellation event (e.g., when activation lockout takes priority) + useEffect(() => { + const handleCancelled = () => { + setTimeLeftMs(countdownSeconds * 1000); + setIsPaused(false); + isClearingRef.current = false; + }; + window.addEventListener('pfm:session-expired-cancelled', handleCancelled); + return () => { + window.removeEventListener('pfm:session-expired-cancelled', handleCancelled); + }; + }, [countdownSeconds]); + + // Reset or start countdown when modal opens + useEffect(() => { + if (!isOpen) { + setTimeLeftMs(countdownSeconds * 1000); + setIsPaused(false); + isClearingRef.current = false; + return; + } + + const intervalMs = 50; + const interval = setInterval(() => { + setTimeLeftMs((prev) => { + if (isPaused) return prev; + const next = prev - intervalMs; + if (next <= 0) { + clearInterval(interval); + handleAction(); + return 0; + } + return next; + }); + }, intervalMs); + + return () => clearInterval(interval); + }, [isOpen, isPaused, countdownSeconds]); + + const handleAction = () => { + if (isClearingRef.current) return; + isClearingRef.current = true; + + if (onDismiss) { + onDismiss(); + } else { + clearSessionAndRedirectToLanding(); + } + }; + + if (!isOpen) return null; + + const secondsRemaining = Math.max(0, Math.ceil(timeLeftMs / 1000)); + const progressPercent = Math.max(0, Math.min(100, (timeLeftMs / totalDurationMs) * 100)); + + return ( +
setIsPaused(true)} + onMouseLeave={() => setIsPaused(false)} + role="dialog" + aria-modal="true" + aria-labelledby="session-expired-title" + > +
+ {/* Subtle decorative top accent bar */} +
+ + {/* Top bar with close button */} +
+
+
+ +
+
+
+

+ Session Expired +

+ + HTTP 401 + +
+

Authorization token invalidated

+
+
+ + +
+ + {/* Informative Explanation (non-technical and clear) */} +
+

+ Your active working session has ended or your security credentials have expired. To protect your data and prevent unauthorized changes, your session has been closed. +

+ {reason && ( +
+ + {reason} +
+ )} +
+ + {/* Progress Bar Countdown */} +
+
+ + + Returning to landing page in{' '} + {secondsRemaining}s + + {isPaused && ( + + Paused on hover + + )} +
+ + {/* Progress bar line */} +
+
+
+
+ + {/* Actions */} +
+ +
+
+
+ ); +}; diff --git a/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx b/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx index ea712cd..b7a0da8 100644 --- a/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx +++ b/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx @@ -18,6 +18,9 @@ import { } from 'lucide-react'; import { useAuth } from '../../auth/AuthProvider'; import { BrandLogo } from '../ui/BrandLogo'; +import { useQuery } from '@tanstack/react-query'; +import { getUsers } from '../../services/api'; +import { getUserEmail, registerUserEmail } from '../../lib/formatters'; const navItems = [ { @@ -62,6 +65,18 @@ export const AppLayout: React.FC = () => { await logout(); }; + // Pre-populate user directory cache so all components and modals resolve user emails instantly + useQuery({ + queryKey: ['users-directory'], + queryFn: () => getUsers(1, 200), + staleTime: 5 * 60 * 1000, + }); + + // 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)); + } + // Extract name and email directly from validated token claims const tokenUserName = user?.name || @@ -75,7 +90,7 @@ export const AppLayout: React.FC = () => { (claims.email as string) || (claims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] as string) || (claims.upn as string) || - (sub ? `sub:${sub}` : ''); + (sub ? getUserEmail(sub) : ''); // Extract tenant name directly from the user claim named 'tenant' const tenantName = @@ -252,8 +267,7 @@ export const AppLayout: React.FC = () => { >

{tokenUserName}

-

{tokenUserEmail}

- {sub &&

sub: {sub}

} +

{tokenUserEmail || (sub ? getUserEmail(sub) : '')}

= ({ const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + const handleDismissSubordinates = () => { + onClose(); + }; + if (isOpen) { document.body.style.overflow = 'hidden'; window.addEventListener('keydown', handleKeyDown); + window.addEventListener('pfm:dismiss-subordinates', handleDismissSubordinates); } return () => { document.body.style.overflow = 'unset'; window.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('pfm:dismiss-subordinates', handleDismissSubordinates); }; }, [isOpen, onClose]); diff --git a/PostFundManagement.ConsoleUI/src/components/ui/RiskHeatmapMatrix.tsx b/PostFundManagement.ConsoleUI/src/components/ui/RiskHeatmapMatrix.tsx new file mode 100644 index 0000000..57b9857 --- /dev/null +++ b/PostFundManagement.ConsoleUI/src/components/ui/RiskHeatmapMatrix.tsx @@ -0,0 +1,588 @@ +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'; + +interface RiskHeatmapMatrixProps { + risks: Risk[]; + projects?: Project[]; + compact?: boolean; + onSelectRisk?: (risk: Risk) => void; + title?: string; + subtitle?: string; +} + +// Standard 5-point likelihood and impact levels +const LIKELIHOOD_LEVELS = [ + { val: 1, label: 'Rare', short: '1 - Rare' }, + { val: 2, label: 'Unlikely', short: '2 - Unlikely' }, + { val: 3, label: 'Possible', short: '3 - Possible' }, + { val: 4, label: 'Likely', short: '4 - Likely' }, + { val: 5, label: 'Almost Certain', short: '5 - Almost Certain' }, +]; + +const IMPACT_LEVELS = [ + { val: 5, label: 'Severe', short: '5 - Severe' }, + { val: 4, label: 'Critical', short: '4 - Critical' }, + { val: 3, label: 'Major', short: '3 - Major' }, + { val: 2, label: 'Moderate', short: '2 - Moderate' }, + { val: 1, label: 'Minor', short: '1 - Minor' }, +]; + +export const getCellSeverity = (score: number) => { + if (score >= 15) { + return { + name: 'Critical', + bgClass: 'bg-rose-950/70 border-rose-600/70 text-rose-100 hover:bg-rose-900/90', + badgeClass: 'bg-rose-500/20 text-rose-300 border-rose-500/40', + textColor: 'text-rose-400', + }; + } + if (score >= 10) { + return { + name: 'High', + bgClass: 'bg-orange-950/60 border-orange-700/60 text-orange-200 hover:bg-orange-900/80', + badgeClass: 'bg-orange-500/20 text-orange-300 border-orange-500/40', + textColor: 'text-orange-400', + }; + } + if (score >= 5) { + return { + name: 'Moderate', + bgClass: 'bg-amber-950/50 border-amber-800/50 text-amber-300 hover:bg-amber-900/70', + badgeClass: 'bg-amber-500/20 text-amber-300 border-amber-500/40', + textColor: 'text-amber-400', + }; + } + return { + name: 'Low', + bgClass: 'bg-emerald-950/40 border-emerald-800/40 text-emerald-300 hover:bg-emerald-900/60', + badgeClass: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40', + textColor: 'text-emerald-400', + }; +}; + +export const RiskHeatmapMatrix: React.FC = ({ + risks, + projects = [], + compact = false, + onSelectRisk, + title, + subtitle, +}) => { + const [matrixMode, setMatrixMode] = useState<'5x5' | '3x3'>('5x5'); + const [selectedCell, setSelectedCell] = useState<{ + impactLabel: string; + likelihoodLabel: string; + score: number; + risks: Risk[]; + } | null>(null); + + // Map project names for easy lookup + const projectMap = useMemo(() => { + const map: Record = {}; + projects.forEach((p) => { + map[String(p.id)] = p.name; + }); + return map; + }, [projects]); + + // Pre-calculate risk counts by exact coordinates (1-5) + const matrix5x5 = useMemo(() => { + const grid: Record = {}; + for (let imp = 1; imp <= 5; imp++) { + for (let lik = 1; lik <= 5; lik++) { + grid[`${imp}-${lik}`] = []; + } + } + + risks.forEach((r) => { + const imp = Math.min(5, Math.max(1, Number(r.impact) || 1)); + const lik = Math.min(5, Math.max(1, Number(r.likelihood) || 1)); + grid[`${imp}-${lik}`].push(r); + }); + + return grid; + }, [risks]); + + // Pre-calculate 3x3 summary matrix + const matrix3x3 = useMemo(() => { + const buckets: Record = { + 'high-low': [], + 'high-med': [], + 'high-high': [], + 'med-low': [], + 'med-med': [], + 'med-high': [], + 'low-low': [], + 'low-med': [], + 'low-high': [], + }; + + risks.forEach((r) => { + const imp = Number(r.impact) || 1; + const lik = Number(r.likelihood) || 1; + + const impKey = imp >= 4 ? 'high' : imp === 3 ? 'med' : 'low'; + const likKey = lik >= 4 ? 'high' : lik === 3 ? 'med' : 'low'; + + buckets[`${impKey}-${likKey}`].push(r); + }); + + return buckets; + }, [risks]); + + // Overall severity summary counters + const summaryCounters = useMemo(() => { + let low = 0; + let moderate = 0; + let high = 0; + let critical = 0; + + risks.forEach((r) => { + const score = (Number(r.impact) || 1) * (Number(r.likelihood) || 1); + if (score >= 15) critical++; + else if (score >= 10) high++; + else if (score >= 5) moderate++; + else low++; + }); + + return { low, moderate, high, critical }; + }, [risks]); + + const handleCellClick = (impactLabel: string, likelihoodLabel: string, score: number, cellRisks: Risk[]) => { + if (cellRisks.length === 0) { + if (selectedCell) setSelectedCell(null); + return; + } + setSelectedCell({ + impactLabel, + likelihoodLabel, + score, + risks: cellRisks, + }); + }; + + return ( +
+ {/* Header & Controls */} +
+
+ {title &&

{title}

} + {subtitle &&

{subtitle}

} +
+ +
+ {/* Mode Switcher */} +
+ + +
+
+
+ + {/* Summary Score Badges */} +
+
+ Critical (15–25) + {summaryCounters.critical} +
+
+ High (10–14) + {summaryCounters.high} +
+
+ Moderate (5–9) + {summaryCounters.moderate} +
+
+ Low (1–4) + {summaryCounters.low} +
+
+ + {/* 5x5 Matrix Mode */} + {matrixMode === '5x5' && ( +
+
+ {/* Top axis label: Likelihood */} +
+ Likelihood / Probability → +
+ +
+ {/* Left Y-Axis Label: Impact */} +
+ + ↑ Impact / Severity + +
+ + {/* Grid with Row Headers */} +
+ {/* Column Headers */} +
+
+ Impact \ Lik. +
+ {LIKELIHOOD_LEVELS.map((lik) => ( +
+ {lik.short} +
+ ))} +
+ + {/* Rows (Impact 5 down to 1) */} +
+ {IMPACT_LEVELS.map((imp) => ( +
+ {/* Row Header */} +
+ {imp.short} +
+ + {/* 5 Cells */} + {LIKELIHOOD_LEVELS.map((lik) => { + const cellRisks = matrix5x5[`${imp.val}-${lik.val}`] || []; + const score = imp.val * lik.val; + const sev = getCellSeverity(score); + const hasRisks = cellRisks.length > 0; + const isSelected = + selectedCell?.impactLabel === imp.label && selectedCell?.likelihoodLabel === lik.label; + + return ( + + ); + })} +
+ ))} +
+
+
+
+
+ )} + + {/* 3x3 Summary Mode */} + {matrixMode === '3x3' && ( +
+
+ {/* Top axis label: Likelihood */} +
+ Likelihood / Probability → +
+ +
+ {/* Left Y-Axis Label: Impact */} +
+ + ↑ Impact / Severity + +
+ + {/* 3x3 Table */} +
+ {/* Column Headers */} +
+
+ Impact \ Lik. +
+
+ Low (1–2 Rare/Unlikely) +
+
+ Medium (3 Possible) +
+
+ High (4–5 Likely/Certain) +
+
+ + {/* Row 1: High Impact (4-5) */} +
+
+ High (4–5 Major/Crit.) +
+ + {/* High Imp - Low Lik */} + {(() => { + const cRisks = matrix3x3['high-low']; + return ( + + ); + })()} + + {/* High Imp - Med Lik */} + {(() => { + const cRisks = matrix3x3['high-med']; + return ( + + ); + })()} + + {/* High Imp - High Lik */} + {(() => { + const cRisks = matrix3x3['high-high']; + return ( + + ); + })()} +
+ + {/* Row 2: Medium Impact (3) */} +
+
+ Medium (3 Moderate) +
+ + {/* Med Imp - Low Lik */} + {(() => { + const cRisks = matrix3x3['med-low']; + return ( + + ); + })()} + + {/* Med Imp - Med Lik */} + {(() => { + const cRisks = matrix3x3['med-med']; + return ( + + ); + })()} + + {/* Med Imp - High Lik */} + {(() => { + const cRisks = matrix3x3['med-high']; + return ( + + ); + })()} +
+ + {/* Row 3: Low Impact (1-2) */} +
+
+ Low (1–2 Minor) +
+ + {/* Low Imp - Low Lik */} + {(() => { + const cRisks = matrix3x3['low-low']; + return ( + + ); + })()} + + {/* Low Imp - Med Lik */} + {(() => { + const cRisks = matrix3x3['low-med']; + return ( + + ); + })()} + + {/* Low Imp - High Lik */} + {(() => { + const cRisks = matrix3x3['low-high']; + return ( + + ); + })()} +
+
+
+
+
+ )} + + {/* Selected Cell Inspector (Drill-down) */} + {selectedCell && selectedCell.risks.length > 0 && ( +
+
+
+
+ +
+
+

+ Risks in Box: Impact [{selectedCell.impactLabel}] × Likelihood [{selectedCell.likelihoodLabel}] +

+

+ Residual Score: {selectedCell.score} / 25 • Total Items: {selectedCell.risks.length} +

+
+
+ +
+ +
+ {selectedCell.risks.map((rk) => ( +
onSelectRisk && onSelectRisk(rk)} + className={`pt-2 first:pt-0 ${onSelectRisk ? 'cursor-pointer hover:bg-slate-800/30 p-2 rounded-xl transition' : ''}`} + > +
+ {rk.name} + + Project #{rk.projectId} {projectMap[String(rk.projectId)] ? `(${projectMap[String(rk.projectId)]})` : ''} + +
+ {rk.description && ( +

{rk.description}

+ )} +
+ Likelihood: {rk.likelihood} + Impact: {rk.impact} + Owner: {getUserEmail(rk.owner)} +
+ {rk.mitigationAction && ( +
+ Mitigation: {rk.mitigationAction} +
+ )} +
+ ))} +
+
+ )} +
+ ); +}; diff --git a/PostFundManagement.ConsoleUI/src/components/ui/Toast.tsx b/PostFundManagement.ConsoleUI/src/components/ui/Toast.tsx index 5a25733..a07afbd 100644 --- a/PostFundManagement.ConsoleUI/src/components/ui/Toast.tsx +++ b/PostFundManagement.ConsoleUI/src/components/ui/Toast.tsx @@ -1,24 +1,230 @@ -import React, { createContext, useContext, useState, ReactNode } from 'react'; -import { CheckCircle2, AlertCircle, Info, X } from 'lucide-react'; +import React, { createContext, useContext, useState, useEffect, useRef, ReactNode } from 'react'; +import { + CheckCircle2, + AlertCircle, + Info, + X, + Copy, + Check, + ChevronDown, + ChevronUp, +} from 'lucide-react'; +import { parseFriendlyError, ErrorCategory } from '../../utils/apiErrorParser'; export type ToastType = 'success' | 'error' | 'info'; -interface ToastItem { +export interface ToastItem { id: string; type: ToastType; title: string; message?: string; + duration: number; // in milliseconds + createdAt: number; + statusCode?: number; + category?: ErrorCategory; + categoryLabel?: string; + technicalDetails?: string; + fieldErrors?: Array<{ field: string; message: string }>; +} + +export interface ToastOptions { + duration?: number; + statusCode?: number; + category?: ErrorCategory; + categoryLabel?: string; + technicalDetails?: string; + fieldErrors?: Array<{ field: string; message: string }>; } interface ToastContextType { - toast: (type: ToastType, title: string, message?: string) => void; - success: (title: string, message?: string) => void; - error: (title: string, message?: string) => void; - info: (title: string, message?: string) => void; + toast: (type: ToastType, title: string, message?: string, options?: ToastOptions) => void; + success: (title: string, message?: string, options?: ToastOptions) => void; + error: (titleOrError: unknown, messageOrError?: unknown, options?: ToastOptions) => void; + info: (title: string, message?: string, options?: ToastOptions) => void; } const ToastContext = createContext(undefined); +// Default durations (in ms): +// Errors linger significantly longer (14s) so users have sufficient time to read and note the failure. +const DEFAULT_DURATIONS: Record = { + success: 4500, + info: 6000, + error: 14000, +}; + +const ToastCard: React.FC<{ + toast: ToastItem; + onRemove: (id: string) => void; +}> = ({ toast, onRemove }) => { + const [isPaused, setIsPaused] = useState(false); + const [remainingTime, setRemainingTime] = useState(toast.duration); + const [copied, setCopied] = useState(false); + const [showTechnical, setShowTechnical] = useState(false); + const intervalRef = useRef | null>(null); + + useEffect(() => { + if (isPaused) { + if (intervalRef.current) clearInterval(intervalRef.current); + return; + } + + const step = 100; + intervalRef.current = setInterval(() => { + setRemainingTime((prev) => { + if (prev <= step) { + if (intervalRef.current) clearInterval(intervalRef.current); + onRemove(toast.id); + return 0; + } + return prev - step; + }); + }, step); + + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [isPaused, toast.id, onRemove]); + + const handleCopy = () => { + const textToCopy = [ + `PFM Error Notice: ${toast.title}`, + toast.message ? `Details: ${toast.message}` : '', + toast.statusCode ? `Status Code: ${toast.statusCode}` : '', + toast.technicalDetails ? `Technical Info: ${toast.technicalDetails}` : '', + `Timestamp: ${new Date(toast.createdAt).toISOString()}`, + ] + .filter(Boolean) + .join('\n'); + + navigator.clipboard.writeText(textToCopy).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }; + + const progressPercent = Math.max(0, Math.min(100, (remainingTime / toast.duration) * 100)); + + return ( +
setIsPaused(true)} + onMouseLeave={() => setIsPaused(false)} + className={`pointer-events-auto relative flex flex-col rounded-xl border shadow-2xl transition-all duration-200 overflow-hidden bg-[#111622] ${ + toast.type === 'success' + ? 'border-emerald-500/30 text-gray-200' + : toast.type === 'error' + ? 'border-rose-500/40 text-gray-200 shadow-rose-950/20' + : 'border-blue-500/30 text-gray-200' + }`} + > +
+ {toast.type === 'success' && } + {toast.type === 'error' && } + {toast.type === 'info' && } + +
+
+

{toast.title}

+ {toast.categoryLabel && ( + + {toast.categoryLabel} + + )} + {toast.statusCode && ( + + HTTP {toast.statusCode} + + )} +
+ + {toast.message && ( +

+ {toast.message} +

+ )} + + {/* Optional collapsible technical reference */} + {toast.technicalDetails && ( +
+ + + {showTechnical && ( +
+ {toast.technicalDetails} +
+ )} +
+ )} + + {/* Action bar for error toasts (Copy info & linger helper) */} + {toast.type === 'error' && ( +
+ + {isPaused ? 'Timer paused (hovering)' : 'Notification lingers for review'} + + +
+ )} +
+ + +
+ + {/* Visual countdown progress bar */} +
+
+
+
+ ); +}; + export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const [toasts, setToasts] = useState([]); @@ -26,45 +232,89 @@ export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) = setToasts((prev) => prev.filter((t) => t.id !== id)); }; - const addToast = (type: ToastType, title: string, message?: string) => { + const addToast = ( + type: ToastType, + title: string, + message?: string, + options?: ToastOptions + ) => { const id = Math.random().toString(36).substring(2, 9); - setToasts((prev) => [...prev, { id, type, title, message }]); - setTimeout(() => removeToast(id), 5000); + const duration = options?.duration || DEFAULT_DURATIONS[type]; + + setToasts((prev) => [ + ...prev, + { + id, + type, + title, + message, + duration, + createdAt: Date.now(), + statusCode: options?.statusCode, + category: options?.category, + categoryLabel: options?.categoryLabel, + technicalDetails: options?.technicalDetails, + fieldErrors: options?.fieldErrors, + }, + ]); }; - const success = (title: string, message?: string) => addToast('success', title, message); - const error = (title: string, message?: string) => addToast('error', title, message); - const info = (title: string, message?: string) => addToast('info', title, message); + const success = (title: string, message?: string, options?: ToastOptions) => + addToast('success', title, message, options); + + const info = (title: string, message?: string, options?: ToastOptions) => + addToast('info', title, message, options); + + /** + * Intelligently formats errors using ProblemDetails and non-technical language. + * Differentiates BadRequest errors for input validation, creation failures, and update failures. + * Accepts flexible argument patterns: + * - error("Action Failed", err) + * - error("Action Failed", "Custom message") + * - error(err, "Action Failed") + * - error(err) + */ + const error = ( + titleOrError: unknown, + messageOrError?: unknown, + options?: ToastOptions + ) => { + let rawError: unknown = null; + let actionTitle = 'Action Failed'; + + if (typeof titleOrError === 'string') { + actionTitle = titleOrError; + rawError = messageOrError; + } else { + rawError = titleOrError; + if (typeof messageOrError === 'string') { + actionTitle = messageOrError; + } + } + + // Parse friendly non-technical error with ProblemDetails awareness + const parsed = parseFriendlyError(rawError, actionTitle); + + addToast('error', parsed.title, parsed.message, { + duration: options?.duration || DEFAULT_DURATIONS.error, + statusCode: parsed.statusCode || options?.statusCode, + category: options?.category || parsed.category, + categoryLabel: options?.categoryLabel || parsed.categoryLabel, + technicalDetails: parsed.technicalDetails || options?.technicalDetails, + fieldErrors: parsed.fieldErrors || options?.fieldErrors, + }); + }; return ( {children} -
+
{toasts.map((t) => ( -
- {t.type === 'success' && } - {t.type === 'error' && } - {t.type === 'info' && } -
-

{t.title}

- {t.message &&

{t.message}

} -
- -
+ ))}
diff --git a/PostFundManagement.ConsoleUI/src/lib/formatters.ts b/PostFundManagement.ConsoleUI/src/lib/formatters.ts index a02f597..07fef42 100644 --- a/PostFundManagement.ConsoleUI/src/lib/formatters.ts +++ b/PostFundManagement.ConsoleUI/src/lib/formatters.ts @@ -307,7 +307,52 @@ export const getContractStatusBadge = (status: ContractStatus | number) => { } }; -export const getActivityStatusBadge = (status: ActivityStatus | number) => { +export const getActivityStatusBadge = (status: ActivityStatus | number | string | boolean | null | undefined) => { + if (status === null || status === undefined) { + return { + label: 'Active', + className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30 font-semibold', + }; + } + + if (typeof status === 'boolean') { + return status + ? { label: 'Active', className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30 font-semibold' } + : { label: 'Inactive', className: 'bg-slate-800/80 text-slate-400 border-slate-700/80 font-semibold' }; + } + + const str = String(status).trim().toLowerCase(); + if (str === '1' || str === 'active' || str === 'enabled') { + return { + label: 'Active', + className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30 font-semibold', + }; + } + if (str === '2' || str === 'inactive') { + return { + label: 'Inactive', + className: 'bg-slate-800/80 text-slate-400 border-slate-700/80 font-semibold', + }; + } + if (str === '3' || str === 'disabled' || str === 'suspended' || str === 'terminated') { + return { + label: str === 'suspended' ? 'Suspended' : 'Disabled', + className: 'bg-rose-500/10 text-rose-400 border-rose-500/30 font-semibold', + }; + } + if (str === 'approved') { + return { + label: 'Approved', + className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30 font-semibold', + }; + } + if (str === 'pending' || str === 'review') { + return { + label: 'Pending', + className: 'bg-amber-500/10 text-amber-400 border-amber-500/30 font-semibold', + }; + } + const s = Number(status); switch (s) { case ActivityStatus.Active: @@ -321,14 +366,44 @@ export const getActivityStatusBadge = (status: ActivityStatus | number) => { className: 'bg-slate-800/80 text-slate-400 border-slate-700/80 font-semibold', }; case ActivityStatus.Disabled: - default: return { label: 'Disabled', className: 'bg-rose-500/10 text-rose-400 border-rose-500/30 font-semibold', }; + default: + return { + label: String(status), + className: 'bg-slate-800/80 text-slate-300 border-slate-700/80 font-semibold', + }; } }; +/** + * Robust Status Badge resolver for Organisation Directory. + * Inspects status, Status, activityStatus, approvalStatus, statusId, isActive, or state, + * parsing string, boolean, or numeric database representations. + */ +export const getOrganisationStatusBadge = (statusOrOrg: any) => { + let val: any = statusOrOrg; + if (statusOrOrg !== null && typeof statusOrOrg === 'object') { + val = + statusOrOrg.status ?? + statusOrOrg.Status ?? + statusOrOrg.activityStatus ?? + statusOrOrg.ActivityStatus ?? + statusOrOrg.approvalStatus ?? + statusOrOrg.ApprovalStatus ?? + statusOrOrg.statusId ?? + statusOrOrg.StatusId ?? + statusOrOrg.state ?? + statusOrOrg.State ?? + (statusOrOrg.isActive !== undefined ? (statusOrOrg.isActive ? 1 : 2) : undefined) ?? + (statusOrOrg.IsActive !== undefined ? (statusOrOrg.IsActive ? 1 : 2) : undefined); + } + + return getActivityStatusBadge(val); +}; + export const getPriorityBadge = (priority: Priority | number) => { const p = Number(priority); switch (p) { @@ -419,14 +494,17 @@ export const getNotificationStatusBadge = (status?: NotificationStatus | number }; export const getRiskScoreColor = (likelihood: RiskLikelihood | number, impact: RiskImpact | number) => { - const score = Number(likelihood) * Number(impact); + const score = (Number(likelihood) || 1) * (Number(impact) || 1); if (score >= 15) { - return { level: 'High', color: 'bg-rose-600 text-white', badge: 'bg-rose-50 text-rose-800 border-rose-300 font-semibold' }; + return { level: 'Critical', color: 'bg-rose-600 text-white', badge: 'bg-rose-500/20 text-rose-300 border border-rose-500/40 font-bold' }; } - if (score >= 8) { - return { level: 'Medium', color: 'bg-amber-500 text-white', badge: 'bg-amber-50 text-amber-900 border-amber-300 font-semibold' }; + if (score >= 10) { + return { level: 'High', color: 'bg-orange-500 text-white', badge: 'bg-orange-500/20 text-orange-300 border border-orange-500/40 font-bold' }; } - return { level: 'Low', color: 'bg-emerald-600 text-white', badge: 'bg-emerald-50 text-emerald-800 border-emerald-300 font-semibold' }; + if (score >= 5) { + return { level: 'Moderate', color: 'bg-amber-500 text-white', badge: 'bg-amber-500/20 text-amber-300 border border-amber-500/40 font-semibold' }; + } + return { level: 'Low', color: 'bg-emerald-600 text-white', badge: 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 font-semibold' }; }; // ========================================== @@ -435,15 +513,30 @@ export const getRiskScoreColor = (likelihood: RiskLikelihood | number, impact: R const USER_EMAIL_CACHE: Record = {}; -export const registerUserEmail = (userId: string, email: string) => { - if (userId && email) { - USER_EMAIL_CACHE[userId] = email; +export const registerUserEmail = (userId: string | number, email: string) => { + if (userId && email && typeof email === 'string' && email.trim()) { + const cleanId = String(userId).trim(); + const cleanEmail = email.trim(); + USER_EMAIL_CACHE[cleanId] = cleanEmail; + USER_EMAIL_CACHE[cleanId.toLowerCase()] = cleanEmail; } }; -export const getUserEmail = (userIdOrEmail?: string | null): string => { - if (!userIdOrEmail) return '—'; - if (userIdOrEmail.includes('@')) return userIdOrEmail; - if (USER_EMAIL_CACHE[userIdOrEmail]) return USER_EMAIL_CACHE[userIdOrEmail]; - return userIdOrEmail; +export const registerUserEmails = (users: Array<{ id: string | number; email?: string | null }>) => { + if (!Array.isArray(users)) return; + for (const u of users) { + if (u && u.id && u.email) { + registerUserEmail(u.id, u.email); + } + } +}; + +export const getUserEmail = (userIdOrEmail?: string | number | null): string => { + if (!userIdOrEmail) return '—'; + const str = String(userIdOrEmail).trim(); + if (!str) return '—'; + 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()]; + return str; }; diff --git a/PostFundManagement.ConsoleUI/src/pages/ExecutiveDashboard.tsx b/PostFundManagement.ConsoleUI/src/pages/ExecutiveDashboard.tsx index 3de5946..20b32e5 100644 --- a/PostFundManagement.ConsoleUI/src/pages/ExecutiveDashboard.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/ExecutiveDashboard.tsx @@ -1,5 +1,5 @@ import React, { useState, useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Link } from 'react-router-dom'; import { TrendingUp, @@ -23,11 +23,14 @@ import { getBudgets, getDisbursements, getAllRisks, + getAllIndicators, } from '../services/api'; import { formatCompactZAR, formatZAR, getIndustrySectorLabel } from '../lib/formatters'; import { ApprovalStatus } from '../types'; +import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix'; export const ExecutiveDashboard: React.FC = () => { + const queryClient = useQueryClient(); const [selectedProgramme, setSelectedProgramme] = useState('All'); const [selectedSector, setSelectedSector] = useState('All'); const [selectedPeriod, setSelectedPeriod] = useState('FY 2026/27'); @@ -73,61 +76,140 @@ export const ExecutiveDashboard: React.FC = () => { queryFn: () => getAllRisks(), }); + const { data: indicators = [] } = useQuery({ + queryKey: ['indicators'], + queryFn: () => getAllIndicators(), + }); + const refetchAll = () => { refetchDashboard(); + queryClient.invalidateQueries(); }; - // Headline metrics sourced directly from live API models + // Filtered projects according to active Programme and Sector dropdown selections + const filteredProjects = useMemo(() => { + return projects.filter((p) => { + if (selectedProgramme !== 'All' && String(p.programmeId) !== selectedProgramme) { + return false; + } + if (selectedSector !== 'All') { + const sectorLabel = getIndustrySectorLabel(p.sector); + if (sectorLabel !== selectedSector) return false; + } + return true; + }); + }, [projects, selectedProgramme, selectedSector]); + + // Filtered risks associated with the filtered project set + const filteredRisks = useMemo(() => { + if (selectedProgramme === 'All' && selectedSector === 'All') { + return risks; + } + const projectIds = new Set(filteredProjects.map((p) => String(p.id))); + return risks.filter((r) => projectIds.has(String(r.projectId))); + }, [risks, filteredProjects, selectedProgramme, selectedSector]); + + // Headline metrics sourced directly from live API models with real filtering const portfolioValue = useMemo(() => { + if (selectedProgramme !== 'All' || selectedSector !== 'All') { + const projectIds = new Set(filteredProjects.map((p) => String(p.id))); + const filteredBudgets = budgets.filter((b) => projectIds.has(String(b.projectId))); + const budgetTotal = filteredBudgets.reduce((sum, b) => sum + (Number(b.amount) || 0), 0); + if (budgetTotal > 0) return budgetTotal; + const filteredAwards = awards.filter((a) => projectIds.has(String(a.projectId))); + const awardsTotal = filteredAwards.reduce((sum, a) => sum + (Number(a.amount) || 0), 0); + return awardsTotal; + } if (dashboard?.portfolioValue && dashboard.portfolioValue > 0) { return dashboard.portfolioValue; } - const budgetTotal = budgets.reduce((sum, b) => sum + (b.amount || 0), 0); + const budgetTotal = budgets.reduce((sum, b) => sum + (Number(b.amount) || 0), 0); if (budgetTotal > 0) return budgetTotal; - const awardsTotal = awards.reduce((sum, a) => sum + (a.amount || 0), 0); + const awardsTotal = awards.reduce((sum, a) => sum + (Number(a.amount) || 0), 0); return awardsTotal; - }, [dashboard, budgets, awards]); + }, [dashboard, budgets, awards, filteredProjects, selectedProgramme, selectedSector]); - const activeAwards = dashboard?.activeAwards ?? (awards.length || projects.length); + const activeAwards = useMemo(() => { + if (selectedProgramme !== 'All' || selectedSector !== 'All') { + const projectIds = new Set(filteredProjects.map((p) => String(p.id))); + const matched = awards.filter((a) => projectIds.has(String(a.projectId))); + return matched.length || filteredProjects.length; + } + if (awards.length > 0) return awards.length; + return dashboard?.activeAwards ?? projects.length; + }, [dashboard, awards, filteredProjects, projects, selectedProgramme, selectedSector]); const disbursed = useMemo(() => { - if (dashboard?.disbursed && dashboard.disbursed > 0) { - return dashboard.disbursed; - } - return disbursements + const relevantDisbursements = disbursements.filter((d) => { + if (selectedProgramme !== 'All' || selectedSector !== 'All') { + const projectIds = new Set(filteredProjects.map((p) => String(p.id))); + return projectIds.has(String(d.projectId)); + } + return true; + }); + + const approvedTotal = relevantDisbursements .filter((d) => d.status === ApprovalStatus.Approved || (d.status as number) === 3) - .reduce((sum, d) => sum + (d.amount || 0), 0); - }, [dashboard, disbursements]); + .reduce((sum, d) => sum + (Number(d.amount) || 0), 0); + + if (approvedTotal > 0) return approvedTotal; + + const allTotal = relevantDisbursements.reduce((sum, d) => sum + (Number(d.amount) || 0), 0); + if (allTotal > 0 && (!dashboard?.disbursed || dashboard.disbursed === 0)) { + return allTotal; + } + + return dashboard?.disbursed || 0; + }, [dashboard, disbursements, filteredProjects, selectedProgramme, selectedSector]); const onTrackPercentage = useMemo(() => { - if (dashboard?.onTrackPercentage !== undefined && dashboard.onTrackPercentage > 0) { + const targetProjects = filteredProjects.length > 0 ? filteredProjects : projects; + if (targetProjects.length === 0) return 0; + + const onTrack = targetProjects.filter( + (p) => p.status === ApprovalStatus.Approved || (p.status as number) === 3 || (p.status as unknown as string) === 'Active' + ).length; + + if (onTrack === 0 && dashboard?.onTrackPercentage && dashboard.onTrackPercentage > 0) { return dashboard.onTrackPercentage; } - if (projects.length === 0) return 0; - const activeOnTrack = projects.filter((p) => p.status === ApprovalStatus.Approved).length; - return Math.round((activeOnTrack / projects.length) * 100); - }, [dashboard, projects]); + return Math.round((onTrack / targetProjects.length) * 100); + }, [dashboard, filteredProjects, projects]); + // Dynamically calculate At Risk count directly from live registered risks const atRiskCount = useMemo(() => { - if (dashboard?.atRiskCount !== undefined) { - return dashboard.atRiskCount; - } - return risks.filter((r) => r.likelihood * r.impact >= 12).length; - }, [dashboard, risks]); + const targetRisks = filteredRisks; + const highRisks = targetRisks.filter((r) => { + const score = (Number(r.likelihood) || 1) * (Number(r.impact) || 1); + return score >= 12 || ((Number(r.impact) || 1) >= 4 && (Number(r.likelihood) || 1) >= 3); + }); - const beneficiariesReached = dashboard?.beneficiariesReached ?? 0; + if (targetRisks.length > 0) { + return highRisks.length; + } + return dashboard?.atRiskCount ?? 0; + }, [dashboard, filteredRisks]); + + const beneficiariesReached = useMemo(() => { + if (dashboard?.beneficiariesReached && dashboard.beneficiariesReached > 0) { + return dashboard.beneficiariesReached; + } + const sumActual = indicators.reduce((sum, ind) => sum + (Number(ind.actualAmount) || 0), 0); + return sumActual > 0 ? sumActual : 0; + }, [dashboard, indicators]); // Real sector breakdown calculated dynamically from actual project records const sectorData = useMemo(() => { - if (!projects || projects.length === 0) return []; + const targetProjects = filteredProjects.length > 0 ? filteredProjects : projects; + if (targetProjects.length === 0) return []; const counts: Record = {}; - projects.forEach((p) => { + targetProjects.forEach((p) => { const label = getIndustrySectorLabel(p.sector); counts[label] = (counts[label] || 0) + 1; }); const colors = ['bg-emerald-500', 'bg-teal-500', 'bg-blue-500', 'bg-indigo-500', 'bg-purple-500', 'bg-amber-500']; - const totalProjects = projects.length; + const totalProjects = targetProjects.length; return Object.entries(counts).map(([name, count], index) => { const percentage = Math.round((count / totalProjects) * 100); @@ -140,27 +222,7 @@ export const ExecutiveDashboard: React.FC = () => { color: colors[index % colors.length], }; }); - }, [projects, portfolioValue]); - - // Risk heatmap matrix computed directly from registered risk records - const riskHeatmap = useMemo(() => { - const matrix = { - highImpHighLik: risks.filter((r) => r.impact >= 4 && r.likelihood >= 4).length, - highImpMedLik: risks.filter((r) => r.impact >= 4 && r.likelihood === 3).length, - highImpLowLik: risks.filter((r) => r.impact >= 4 && r.likelihood <= 2).length, - - medImpHighLik: risks.filter((r) => r.impact === 3 && r.likelihood >= 4).length, - medImpMedLik: risks.filter((r) => r.impact === 3 && r.likelihood === 3).length, - medImpLowLik: risks.filter((r) => r.impact === 3 && r.likelihood <= 2).length, - - lowImpHighLik: risks.filter((r) => r.impact <= 2 && r.likelihood >= 4).length, - lowImpMedLik: risks.filter((r) => r.impact <= 2 && r.likelihood === 3).length, - lowImpLowLik: risks.filter((r) => r.impact <= 2 && r.likelihood <= 2).length, - - flaggedCount: risks.filter((r) => r.impact * r.likelihood >= 12).length, - }; - return matrix; - }, [risks]); + }, [projects, filteredProjects, portfolioValue]); // Quarterly disbursements distribution computed from real disbursement items const quarterlyTrend = useMemo(() => { @@ -198,26 +260,59 @@ export const ExecutiveDashboard: React.FC = () => { })); }, [disbursements]); - // Attention Projects derived directly from live project data + // Attention Projects derived directly from live project data & risk scores const attentionProjects = useMemo(() => { - if (!projects || projects.length === 0) return []; - return projects - .filter((p) => p.status === ApprovalStatus.Rejected || p.status === ApprovalStatus.Pending || p.status === ApprovalStatus.Review) - .slice(0, 5) + const targetProjects = filteredProjects.length > 0 ? filteredProjects : projects; + if (targetProjects.length === 0) return []; + + // Map each project to its highest residual risk score + const projectMaxRisk: Record = {}; + risks.forEach((r) => { + const pid = String(r.projectId); + const score = (Number(r.likelihood) || 1) * (Number(r.impact) || 1); + if (!projectMaxRisk[pid] || score > projectMaxRisk[pid]) { + projectMaxRisk[pid] = score; + } + }); + + return targetProjects .map((p) => { + const maxScore = projectMaxRisk[String(p.id)] || 0; const isRejected = p.status === ApprovalStatus.Rejected; const isPending = p.status === ApprovalStatus.Pending || p.status === ApprovalStatus.Review; + + let riskLevel: 'RED' | 'AMBER' | 'GREEN' = 'GREEN'; + let statusLabel = 'Active'; + let variance = '0%'; + let varianceType: 'negative' | 'positive' = 'positive'; + + if (isRejected || maxScore >= 15) { + riskLevel = 'RED'; + statusLabel = 'At Risk'; + variance = '-15%'; + varianceType = 'negative'; + } else if (isPending || maxScore >= 10) { + riskLevel = 'AMBER'; + statusLabel = isPending ? 'Pending' : 'Review'; + variance = '-5%'; + varianceType = 'negative'; + } + return { id: p.id, name: p.name, programme: p.programmeName || `Programme ${p.programmeId}`, - status: isRejected ? 'At Risk' : isPending ? 'Pending' : 'Active', - risk: isRejected ? 'RED' : isPending ? 'AMBER' : 'GREEN', - financialVariance: isRejected ? '-15%' : '0%', - varianceType: isRejected ? 'negative' : 'positive', + status: statusLabel, + risk: riskLevel, + financialVariance: variance, + varianceType, + maxScore, }; - }); - }, [projects]); + }) + .filter((p) => p.risk === 'RED' || p.risk === 'AMBER') + .sort((a, b) => b.maxScore - a.maxScore) + .slice(0, 5); + }, [projects, filteredProjects, risks]); return (
@@ -384,15 +479,15 @@ export const ExecutiveDashboard: React.FC = () => {
- {/* Visual Models Section (Sector, Heatmap, Impact Trend) */} -
+ {/* Visual Models Section (Sector Breakdown & Disbursements) */} +
{/* Panel 1: Funding by Sector */}

Funding by Sector

- API Calculated + Live Portfolio
@@ -436,75 +531,7 @@ export const ExecutiveDashboard: React.FC = () => {
- {/* Panel 2: Portfolio Risk Heatmap */} -
-
-
-

Portfolio Risk Heatmap

- - {riskHeatmap.flaggedCount} Flagged - -
- -

- Matrix distribution of project risks by Likelihood (1–5) and Impact (1–5). -

- - {/* 3x3 Heatmap Matrix with real counted risks */} -
- {/* Row 1 - High Impact */} -
- Moderate - {riskHeatmap.highImpLowLik} {riskHeatmap.highImpLowLik === 1 ? 'Risk' : 'Risks'} -
-
- High - {riskHeatmap.highImpMedLik} {riskHeatmap.highImpMedLik === 1 ? 'Risk' : 'Risks'} -
-
- Critical - {riskHeatmap.highImpHighLik} {riskHeatmap.highImpHighLik === 1 ? 'Risk' : 'Risks'} -
- - {/* Row 2 - Medium Impact */} -
- Low - {riskHeatmap.medImpLowLik} {riskHeatmap.medImpLowLik === 1 ? 'Risk' : 'Risks'} -
-
- Moderate - {riskHeatmap.medImpMedLik} {riskHeatmap.medImpMedLik === 1 ? 'Risk' : 'Risks'} -
-
- High - {riskHeatmap.medImpHighLik} {riskHeatmap.medImpHighLik === 1 ? 'Risk' : 'Risks'} -
- - {/* Row 3 - Low Impact */} -
- Trivial - {riskHeatmap.lowImpLowLik} {riskHeatmap.lowImpLowLik === 1 ? 'Risk' : 'Risks'} -
-
- Low - {riskHeatmap.lowImpMedLik} {riskHeatmap.lowImpMedLik === 1 ? 'Risk' : 'Risks'} -
-
- Moderate - {riskHeatmap.lowImpHighLik} {riskHeatmap.lowImpHighLik === 1 ? 'Risk' : 'Risks'} -
-
-
- -
- Total Registered Risks: {risks.length} - - Risk Register - -
-
- - {/* Panel 3: Funding-to-Impact Trend */} + {/* Panel 2: Disbursement Distribution */}
@@ -558,6 +585,14 @@ export const ExecutiveDashboard: React.FC = () => {
+ {/* Panel 3: Portfolio Risk Heatmap & Matrix */} + + {/* Projects Requiring Attention Table */}
diff --git a/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx b/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx index 4de67c8..58b0209 100644 --- a/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx @@ -15,6 +15,7 @@ import { Search, Coins, Eye, + RefreshCw, } from 'lucide-react'; import { getOrganisations, @@ -49,6 +50,7 @@ import { import { getOrganisationTypeLabel, getActivityStatusBadge, + getOrganisationStatusBadge, getApprovalStatusBadge, formatDate, getUserEmail, @@ -192,7 +194,7 @@ export const GovernancePage: React.FC = () => { orgForm.reset(); }, onError: (err: unknown) => { - toastError('Failed to save organisation', String(err)); + toastError('Failed to save organisation', err); }, }); @@ -217,7 +219,7 @@ export const GovernancePage: React.FC = () => { portfolioForm.reset(); }, onError: (err: unknown) => { - toastError('Failed to save portfolio', String(err)); + toastError('Failed to save portfolio', err); }, }); @@ -242,7 +244,7 @@ export const GovernancePage: React.FC = () => { programmeForm.reset(); }, onError: (err: unknown) => { - toastError('Failed to save programme', String(err)); + toastError('Failed to save programme', err); }, }); @@ -409,17 +411,31 @@ export const GovernancePage: React.FC = () => {
- {/* Search Bar */} + {/* Search Bar & Actions */} {activeTab === 'organisations' && ( -
- - setSearchTerm(e.target.value)} - className="w-full pl-9 pr-4 py-2.5 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-slate-200 placeholder:text-slate-500 transition shadow-xs" - /> +
+
+ + setSearchTerm(e.target.value)} + className="w-full pl-9 pr-4 py-2.5 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-slate-200 placeholder:text-slate-500 transition shadow-xs" + /> +
+
)} @@ -453,7 +469,7 @@ export const GovernancePage: React.FC = () => { ) : ( filteredOrgs.map((org) => { - const statusBadge = getActivityStatusBadge(org.status); + const statusBadge = getOrganisationStatusBadge(org); return ( diff --git a/PostFundManagement.ConsoleUI/src/pages/GrantsPage.tsx b/PostFundManagement.ConsoleUI/src/pages/GrantsPage.tsx index 7a0bcfa..d6cb2b5 100644 --- a/PostFundManagement.ConsoleUI/src/pages/GrantsPage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/GrantsPage.tsx @@ -21,6 +21,7 @@ import { Download, Upload, Link as LinkIcon, + Edit2, } from 'lucide-react'; import { getAwards, @@ -39,6 +40,7 @@ import { getDisbursements, getDisbursement, createDisbursement, + updateDisbursement, approveDisbursement, getOrganisations, getAllProgrammes, @@ -122,6 +124,7 @@ export const GrantsPage: React.FC = () => { const [selectedContractForExecution, setSelectedContractForExecution] = useState(null); const [isBudgetModalOpen, setIsBudgetModalOpen] = useState(false); const [isDisbursementModalOpen, setIsDisbursementModalOpen] = useState(false); + const [editingDisbursement, setEditingDisbursement] = useState(null); // Queries const { data: awards = [], isLoading: loadingAwards } = useQuery({ @@ -227,7 +230,7 @@ export const GrantsPage: React.FC = () => { awardForm.reset(); }, onError: (err: unknown) => { - toastError('Award registration failed', String(err)); + toastError('Award registration failed', err); }, }); @@ -240,7 +243,7 @@ export const GrantsPage: React.FC = () => { contractForm.reset(); }, onError: (err: unknown) => { - toastError('Contract creation failed', String(err)); + toastError('Contract creation failed', err); }, }); @@ -256,7 +259,7 @@ export const GrantsPage: React.FC = () => { executeForm.reset(); }, onError: (err: unknown) => { - toastError('Execution trigger failed', String(err)); + toastError('Execution trigger failed', err); }, }); @@ -269,23 +272,60 @@ export const GrantsPage: React.FC = () => { budgetForm.reset(); }, onError: (err: unknown) => { - toastError('Budget creation failed', String(err)); + toastError('Budget creation failed', err); }, }); const disbursementMutation = useMutation({ - mutationFn: (data: CreateDisbursementRequest) => createDisbursement(data), + mutationFn: async (data: DisbursementFormValues) => { + if (editingDisbursement) { + return updateDisbursement({ + disbursementId: editingDisbursement.id, + milestoneId: data.milestoneId ? Number(data.milestoneId) : null, + amount: Number(data.amount), + }); + } + return createDisbursement({ + projectId: Number(data.projectId), + milestoneId: data.milestoneId ? Number(data.milestoneId) : null, + amount: Number(data.amount), + }); + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['disbursements'] }); - success('Disbursement Scheduled', 'Payment tranche added in Pending Review state'); + success( + editingDisbursement ? 'Disbursement Updated' : 'Disbursement Scheduled', + editingDisbursement ? 'Payment tranche details updated' : 'Payment tranche added in Pending Review state' + ); setIsDisbursementModalOpen(false); + setEditingDisbursement(null); disbursementForm.reset(); }, onError: (err: unknown) => { - toastError('Disbursement creation failed', String(err)); + toastError(editingDisbursement ? 'Disbursement update failed' : 'Disbursement creation failed', err); }, }); + const openCreateDisbursement = () => { + setEditingDisbursement(null); + disbursementForm.reset({ + projectId: projects[0]?.id ? Number(projects[0].id) : 401, + milestoneId: undefined, + amount: 150000, + }); + setIsDisbursementModalOpen(true); + }; + + const openEditDisbursement = (d: Disbursement) => { + setEditingDisbursement(d); + disbursementForm.reset({ + projectId: Number(d.projectId) || (projects[0]?.id ? Number(projects[0].id) : 401), + milestoneId: d.milestoneId ? Number(d.milestoneId) : undefined, + amount: d.amount, + }); + setIsDisbursementModalOpen(true); + }; + const approveMutation = useMutation({ mutationFn: (id: string | number) => approveDisbursement(id), onSuccess: () => { @@ -297,7 +337,7 @@ export const GrantsPage: React.FC = () => { ); }, onError: (err: unknown) => { - toastError('Disbursement approval failed', String(err)); + toastError('Disbursement approval failed', err); }, }); @@ -321,7 +361,7 @@ export const GrantsPage: React.FC = () => { toastError('Download Error', 'No active secure download link generated.'); } } catch (err: any) { - toastError('Link Error', err?.message || 'Failed to fetch contract secure link.'); + toastError('Link Error', err); } }; @@ -331,7 +371,7 @@ export const GrantsPage: React.FC = () => { success('Document Attached', `Contract #${contractId} document uploaded successfully.`); queryClient.invalidateQueries({ queryKey: ['contracts'] }); } catch (err: any) { - toastError('Upload Failed', err?.message || 'Contract file upload failed.'); + toastError('Upload Failed', err); } }; @@ -341,7 +381,7 @@ export const GrantsPage: React.FC = () => { success('Contract Updated', `Contract status updated to ${getContractStatusBadge(status).label}.`); queryClient.invalidateQueries({ queryKey: ['contracts'] }); } catch (err: any) { - toastError('Update Failed', err?.message || 'Failed to update contract status.'); + toastError('Update Failed', err); } }; @@ -398,7 +438,7 @@ export const GrantsPage: React.FC = () => { )} {activeTab === 'disbursements' && ( - )} +
+ {isApproved ? ( + + + Released + + ) : ( + <> + + + + )} +
); @@ -1052,27 +1104,36 @@ export const GrantsPage: React.FC = () => { - {/* Modal: Schedule Disbursement */} + {/* Modal: Schedule or Edit Disbursement */} setIsDisbursementModalOpen(false)} - title="Schedule Disbursement Tranche" - subtitle="Link disbursement to milestone verification and conditions precedent" + onClose={() => { + setIsDisbursementModalOpen(false); + setEditingDisbursement(null); + }} + title={editingDisbursement ? 'Edit Disbursement Tranche' : 'Schedule Disbursement Tranche'} + subtitle={ + editingDisbursement + ? `Updating payment tranche DSB-${editingDisbursement.id}` + : 'Link disbursement to milestone verification and conditions precedent' + } >
disbursementMutation.mutate(data as any))} className="space-y-4"> -
- - -
+ {!editingDisbursement && ( +
+ + +
+ )}
@@ -1101,7 +1162,10 @@ export const GrantsPage: React.FC = () => {
diff --git a/PostFundManagement.ConsoleUI/src/pages/LandingPage.tsx b/PostFundManagement.ConsoleUI/src/pages/LandingPage.tsx index 0bf8d65..85768c3 100644 --- a/PostFundManagement.ConsoleUI/src/pages/LandingPage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/LandingPage.tsx @@ -18,7 +18,7 @@ import { useAuth } from '../auth/AuthProvider'; import { BrandLogo } from '../components/ui/BrandLogo'; export const LandingPage: React.FC = () => { - const { isAuthenticated, user, claims, sub, login, logout, authError, isLoading } = useAuth(); + const { isAuthenticated, user, claims, sub, login, logout, authError, isLoading, isPendingActivation } = useAuth(); const navigate = useNavigate(); const handleEnterConsole = async () => { @@ -70,9 +70,13 @@ export const LandingPage: React.FC = () => { ) : ( @@ -191,19 +195,36 @@ export const LandingPage: React.FC = () => {
{/* Guidance Message */} -
-

Session Verified

- You are authenticated with validated enterprise security credentials. Your token claims are active and verified. You can proceed to the PFM Console to manage projects, reviews, and disbursements, or sign out of your session. -
+ {isPendingActivation ? ( +
+

+ + Account Pending Administrator Activation +

+

+ You are authenticated, but your account has not yet been enabled in the directory by an administrator. + Click below to enter the console and check your activation status. +

+
+ ) : ( +
+

Session Verified

+ You are authenticated with validated enterprise security credentials. Your token claims are active and verified. You can proceed to the PFM Console to manage projects, reviews, and disbursements, or sign out of your session. +
+ )} {/* Navigation and Sign Out Buttons */}
@@ -222,9 +243,9 @@ export const LandingPage: React.FC = () => { OAuth2 / OpenID Connect - - - Verified Session + + + {isPendingActivation ? 'Awaiting Admin Enablement' : 'Verified Session'}
@@ -233,9 +254,9 @@ export const LandingPage: React.FC = () => {
-
-
- +
+
+

Sign In

@@ -275,12 +296,8 @@ export const LandingPage: React.FC = () => { {/* Footer */} -
-
- - Post-Fund Management, Performance & Impact System -
-

Enterprise Governance & Audit Console

+
+

Post-Fund Management, Performance & Impact System • Enterprise Governance Console

); diff --git a/PostFundManagement.ConsoleUI/src/pages/MonitoringPage.tsx b/PostFundManagement.ConsoleUI/src/pages/MonitoringPage.tsx index 051cc06..949ef8c 100644 --- a/PostFundManagement.ConsoleUI/src/pages/MonitoringPage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/MonitoringPage.tsx @@ -19,6 +19,7 @@ import { Check, Filter, Link as LinkIcon, + Edit2, } from 'lucide-react'; import { getAllIndicators, @@ -31,11 +32,13 @@ import { updateEvidence, getAllRisks, createRisk, + updateRisk, getProjects, getAllMilestones, } from '../services/api'; import { Modal } from '../components/ui/Modal'; import { useToast } from '../components/ui/Toast'; +import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix'; import { Indicator, Evidence, @@ -90,6 +93,7 @@ export const MonitoringPage: React.FC = () => { // Modals & States const [isIndicatorModalOpen, setIsIndicatorModalOpen] = useState(false); const [isRiskModalOpen, setIsRiskModalOpen] = useState(false); + const [editingRisk, setEditingRisk] = useState(null); const [selectedIndicatorForActual, setSelectedIndicatorForActual] = useState(null); const [actualInput, setActualInput] = useState(0); const [uploadEvidenceType, setUploadEvidenceType] = useState(EvidenceType.CompletionCertificate); @@ -160,7 +164,7 @@ export const MonitoringPage: React.FC = () => { indicatorForm.reset(); }, onError: (err: unknown) => { - toastError('Failed to create indicator', String(err)); + toastError('Failed to create indicator', err); }, }); @@ -173,7 +177,7 @@ export const MonitoringPage: React.FC = () => { setSelectedIndicatorForActual(null); }, onError: (err: unknown) => { - toastError('Failed to record actual', String(err)); + toastError('Failed to record actual', err); }, }); @@ -184,7 +188,7 @@ export const MonitoringPage: React.FC = () => { success('Evidence uploaded', 'Supporting file registered for reviewer inspection (Figure 10 / M14)'); }, onError: (err: unknown) => { - toastError('Upload failed', String(err)); + toastError('Upload failed', err); }, }); @@ -209,21 +213,66 @@ export const MonitoringPage: React.FC = () => { setReviewerComment(''); }, onError: (err: unknown) => { - toastError('Review update failed', String(err)); + toastError('Review update failed', err); }, }); + const openCreateRisk = () => { + setEditingRisk(null); + riskForm.reset({ + projectId: projects[0]?.id ? Number(projects[0].id) : 401, + name: '', + description: '', + likelihood: RiskLikelihood.Medium, + impact: RiskImpact.Moderate, + }); + setIsRiskModalOpen(true); + }; + + const openEditRisk = (rk: Risk) => { + setEditingRisk(rk); + riskForm.reset({ + projectId: Number(rk.projectId) || (projects[0]?.id ? Number(projects[0].id) : 401), + name: rk.name || '', + description: rk.description || '', + likelihood: (rk.likelihood as RiskLikelihood) ?? RiskLikelihood.Medium, + impact: (rk.impact as RiskImpact) ?? RiskImpact.Moderate, + }); + setIsRiskModalOpen(true); + }; + const riskMutation = useMutation({ - mutationFn: (data: CreateRiskRequest) => createRisk(data), + mutationFn: async (data: RiskFormValues) => { + if (editingRisk) { + return updateRisk({ + riskId: editingRisk.id, + name: data.name, + description: data.description, + likelihood: Number(data.likelihood), + impact: Number(data.impact), + }); + } + return createRisk({ + projectId: Number(data.projectId), + name: data.name, + description: data.description, + likelihood: Number(data.likelihood), + impact: Number(data.impact), + }); + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['risks'] }); queryClient.invalidateQueries({ queryKey: ['dashboard'] }); - success('Risk registered', 'Risk item added to project register'); + success( + editingRisk ? 'Risk updated' : 'Risk registered', + editingRisk ? 'Risk profile updated in project register' : 'Risk item added to project register' + ); setIsRiskModalOpen(false); + setEditingRisk(null); riskForm.reset(); }, onError: (err: unknown) => { - toastError('Failed to log risk', String(err)); + toastError(editingRisk ? 'Failed to update risk' : 'Failed to log risk', err); }, }); @@ -249,7 +298,7 @@ export const MonitoringPage: React.FC = () => { toastError('Download Error', 'No active secure download link generated.'); } } catch (err: any) { - toastError('Link Error', err?.message || 'Failed to retrieve evidence secure link.'); + toastError('Link Error', err); } }; @@ -288,7 +337,7 @@ export const MonitoringPage: React.FC = () => { )} {activeTab === 'risks' && (
)} - {/* Tab 3: Risk Register (M09) */} + {/* Tab 3: Risk Register & Heatmap (M09) */} {activeTab === 'risks' && ( -
-
- - - - - - - - - - - - - - {loadingRisks ? ( - - +
+ {/* Live Heatmap Matrix with cell click inspection */} + openEditRisk(r)} + title="Project Risk Heatmap & Matrix" + subtitle="ISO 31000 standard matrix — click on any cell to filter and inspect risks or edit mitigation controls" + /> + + {/* Risk Register Table */} +
+
+
+

Project Risk Register

+

Complete itemized audit log of identified risks and residual scores

+
+ +
+
+
Risk Title & DescriptionProject IDLikelihood (1–5)Impact (1–5)Residual ScoreRisk OwnerMitigation Control
- Loading risk register... -
+ + + + + + + + + + - ) : ( - risks.map((rk) => { - const score = rk.likelihood * rk.impact; - const color = getRiskScoreColor(rk.likelihood, rk.impact); - return ( - - - - - - - - - - ); - }) - )} - -
Risk Title & DescriptionProject IDLikelihood (1–5)Impact (1–5)Residual ScoreRisk OwnerMitigation ControlActions
- {rk.name} - {rk.description} - Project #{rk.projectId}{rk.likelihood}{rk.impact} - - {score} / 25 ({color.level}) - - {getUserEmail(rk.owner)} - {rk.mitigationAction || 'Standard internal audit controls applied.'} -
+ + + {loadingRisks ? ( + + + Loading risk register... + + + ) : risks.length === 0 ? ( + + + No risks recorded in the register. Click "Add Risk" to log your first risk item. + + + ) : ( + risks.map((rk) => { + const score = rk.likelihood * rk.impact; + const color = getRiskScoreColor(rk.likelihood, rk.impact); + return ( + + + {rk.name} + {rk.description} + + Project #{rk.projectId} + {rk.likelihood} + {rk.impact} + + + {score} / 25 ({color.level}) + + + {getUserEmail(rk.owner)} + + {rk.mitigationAction || 'Standard internal audit controls applied.'} + + + + + + ); + }) + )} + + +
)} @@ -803,27 +893,32 @@ export const MonitoringPage: React.FC = () => {
- {/* Modal: Add Risk */} + {/* Modal: Add or Edit Risk */} setIsRiskModalOpen(false)} - title="Add Risk to Risk Register" - subtitle="Identify and score project delivery risks" + onClose={() => { + setIsRiskModalOpen(false); + setEditingRisk(null); + }} + title={editingRisk ? 'Edit Project Risk' : 'Add Risk to Risk Register'} + subtitle={editingRisk ? `Updating risk item #${editingRisk.id}` : 'Identify and score project delivery risks'} >
riskMutation.mutate(data as any))} className="space-y-4"> -
- - -
+ {!editingRisk && ( +
+ + +
+ )}
@@ -877,7 +972,10 @@ export const MonitoringPage: React.FC = () => {
diff --git a/PostFundManagement.ConsoleUI/src/pages/ProjectsPage.tsx b/PostFundManagement.ConsoleUI/src/pages/ProjectsPage.tsx index 3b1ac7b..b5f66bf 100644 --- a/PostFundManagement.ConsoleUI/src/pages/ProjectsPage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/ProjectsPage.tsx @@ -17,15 +17,19 @@ import { FileCheck, TrendingUp, X, + Edit2, } from 'lucide-react'; import { getProjects, createProject, + updateProject, updateProjectStatus, getActivities, createActivity, + updateActivity, getMilestones, createMilestone, + updateMilestone, getAllProgrammes, getProgrammeProjects, getActivity, @@ -37,6 +41,8 @@ import { Modal } from '../components/ui/Modal'; import { useToast } from '../components/ui/Toast'; import { Project, + Activity, + Milestone, IndustrySector, ProjectType, ApprovalStatus, @@ -44,6 +50,8 @@ import { CreateProjectRequest, CreateActivityRequest, CreateMilestoneRequest, + UpdateActivityRequest, + UpdateMilestoneRequest, } from '../types'; import { getIndustrySectorLabel, @@ -52,6 +60,7 @@ import { getPriorityBadge, formatDate, formatZAR, + getUserEmail, INDUSTRY_SECTOR_OPTIONS, PROJECT_TYPE_OPTIONS, PRIORITY_OPTIONS, @@ -71,12 +80,14 @@ const activitySchema = z.object({ description: z.string().min(5, 'Description required'), category: z.string().min(2, 'Category required'), amount: z.coerce.number().min(1, 'Amount must be positive'), + milestoneId: z.string().optional().nullable(), }); const milestoneSchema = z.object({ name: z.string().min(4, 'Milestone deliverable required'), dueAt: z.string().min(4, 'Due date is mandatory'), priority: z.coerce.number().min(1).max(4), + status: z.coerce.number().optional(), }); type ProjectFormValues = z.infer; @@ -91,10 +102,15 @@ export const ProjectsPage: React.FC = () => { const [detailTab, setDetailTab] = useState<'activities' | 'milestones' | 'indicators' | 'risks'>('activities'); const [selectedProgrammeFilter, setSelectedProgrammeFilter] = useState('all'); - // Modals + // Modals & Editing State const [isProjectModalOpen, setIsProjectModalOpen] = useState(false); + const [editingProject, setEditingProject] = useState(null); + const [isActivityModalOpen, setIsActivityModalOpen] = useState(false); + const [editingActivity, setEditingActivity] = useState(null); + const [isMilestoneModalOpen, setIsMilestoneModalOpen] = useState(false); + const [editingMilestone, setEditingMilestone] = useState(null); // Queries const { data: programmes = [] } = useQuery({ @@ -156,6 +172,7 @@ export const ProjectsPage: React.FC = () => { description: '', category: '', amount: 0, + milestoneId: '', }, }); @@ -165,21 +182,108 @@ export const ProjectsPage: React.FC = () => { name: '', dueAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10), priority: Priority.High, + status: ApprovalStatus.Pending, }, }); + // Open Handlers + const openCreateProject = () => { + setEditingProject(null); + projectForm.reset({ + name: '', + description: '', + programmeId: programmes[0]?.id ? Number(programmes[0].id) : 0, + sector: IndustrySector.AgriTech, + projectType: ProjectType.Innovation, + }); + setIsProjectModalOpen(true); + }; + + const openEditProject = (p: Project) => { + setEditingProject(p); + projectForm.reset({ + name: p.name || '', + description: p.description || '', + programmeId: p.programmeId ? Number(p.programmeId) : (programmes[0]?.id ? Number(programmes[0].id) : 0), + sector: p.sector || IndustrySector.AgriTech, + projectType: p.projectType || ProjectType.Innovation, + }); + setIsProjectModalOpen(true); + }; + + const openCreateActivity = () => { + setEditingActivity(null); + activityForm.reset({ + title: '', + description: '', + category: '', + amount: 0, + milestoneId: '', + }); + setIsActivityModalOpen(true); + }; + + const openEditActivity = (a: Activity) => { + setEditingActivity(a); + activityForm.reset({ + title: a.title || '', + description: a.description || '', + category: a.category || '', + amount: a.amount || 0, + milestoneId: a.milestoneId ? String(a.milestoneId) : '', + }); + setIsActivityModalOpen(true); + }; + + const openCreateMilestone = () => { + setEditingMilestone(null); + milestoneForm.reset({ + name: '', + dueAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10), + priority: Priority.High, + status: ApprovalStatus.Pending, + }); + setIsMilestoneModalOpen(true); + }; + + const openEditMilestone = (m: Milestone) => { + setEditingMilestone(m); + milestoneForm.reset({ + name: m.name || '', + dueAt: m.dueAt ? m.dueAt.slice(0, 10) : new Date().toISOString().slice(0, 10), + priority: m.priority || Priority.High, + status: m.status || ApprovalStatus.Pending, + }); + setIsMilestoneModalOpen(true); + }; + // Mutations const projectMutation = useMutation({ - mutationFn: (data: CreateProjectRequest) => createProject(data), - onSuccess: (newProj) => { + mutationFn: (data: ProjectFormValues) => { + if (editingProject) { + return updateProject({ + projectId: editingProject.id, + name: data.name, + description: data.description, + sector: Number(data.sector), + projectType: Number(data.projectType), + }); + } + return createProject(data as CreateProjectRequest); + }, + onSuccess: (savedProj) => { queryClient.invalidateQueries({ queryKey: ['projects'] }); - success('Project registered', `Project "${newProj.name}" created successfully`); + success( + editingProject ? 'Project updated' : 'Project registered', + `Project "${savedProj.name}" saved successfully` + ); setIsProjectModalOpen(false); + setEditingProject(null); projectForm.reset(); - setSelectedProject(newProj); + setSelectedProject(savedProj); }, onError: (err: unknown) => { - toastError('Failed to create project', String(err)); + toastError(editingProject ? 'Failed to update project' : 'Failed to create project', err); }, }); @@ -194,33 +298,88 @@ export const ProjectsPage: React.FC = () => { success('Status updated', 'Project governance status changed successfully'); }, onError: (err: unknown) => { - toastError('Failed to update status', String(err)); + toastError('Failed to update status', err); }, }); const activityMutation = useMutation({ - mutationFn: (data: CreateActivityRequest) => createActivity(data), + mutationFn: (data: ActivityFormValues) => { + const parsedMilestoneId = + data.milestoneId && String(data.milestoneId).trim() !== '' + ? Number(data.milestoneId) + : null; + + if (editingActivity) { + return updateActivity({ + activityId: editingActivity.id, + projectId: activeProjectId!, + title: data.title, + description: data.description, + category: data.category, + amount: Number(data.amount), + milestoneId: parsedMilestoneId, + }); + } + return createActivity({ + projectId: activeProjectId!, + title: data.title, + description: data.description, + category: data.category, + amount: Number(data.amount), + milestoneId: parsedMilestoneId, + }); + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['activities', activeProjectId] }); - success('Activity added', 'Activity registered to project work breakdown'); + success( + editingActivity ? 'Activity updated' : 'Activity added', + editingActivity + ? 'Activity updated in project work breakdown' + : 'Activity registered to project work breakdown' + ); setIsActivityModalOpen(false); + setEditingActivity(null); activityForm.reset(); }, onError: (err: unknown) => { - toastError('Failed to add activity', String(err)); + toastError(editingActivity ? 'Failed to update activity' : 'Failed to add activity', err); }, }); const milestoneMutation = useMutation({ - mutationFn: (data: CreateMilestoneRequest) => createMilestone(data), + mutationFn: async (data: MilestoneFormValues): Promise => { + if (editingMilestone) { + await updateMilestone({ + milestoneId: editingMilestone.id, + name: data.name, + dueAt: data.dueAt, + priority: Number(data.priority), + status: Number(data.status ?? editingMilestone.status), + }); + } else { + await createMilestone({ + projectId: activeProjectId!, + name: data.name, + dueAt: data.dueAt, + priority: Number(data.priority), + }); + } + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['milestones', activeProjectId] }); - success('Milestone created', 'Contractual deliverable added to implementation schedule'); + queryClient.invalidateQueries({ queryKey: ['projects'] }); + success( + editingMilestone ? 'Milestone updated' : 'Milestone created', + editingMilestone + ? 'Contractual deliverable updated successfully' + : 'Contractual deliverable added to implementation schedule' + ); setIsMilestoneModalOpen(false); + setEditingMilestone(null); milestoneForm.reset(); }, onError: (err: unknown) => { - toastError('Failed to add milestone', String(err)); + toastError(editingMilestone ? 'Failed to update milestone' : 'Failed to add milestone', err); }, }); @@ -247,7 +406,7 @@ export const ProjectsPage: React.FC = () => {
- {/* Status Change Selector */} -
- - + + Edit Project + +
+ + +
@@ -433,7 +612,7 @@ export const ProjectsPage: React.FC = () => {
Project Work Breakdown +
+
+

{a.description}

+
+ {a.category} + Date: {formatDate(a.performedAt)} +
-

{a.description}

-
- {a.category} - Date: {formatDate(a.performedAt)} -
-
- ))} + ); + })}
)}
@@ -469,7 +673,7 @@ export const ProjectsPage: React.FC = () => {
Contractual Milestones +
@@ -574,12 +790,15 @@ export const ProjectsPage: React.FC = () => {
- {/* Modal: Create Project */} + {/* Modal: Create or Edit Project */} setIsProjectModalOpen(false)} - title="Create Funded Project" - subtitle="Operational entity under an approved Programme" + onClose={() => { + setIsProjectModalOpen(false); + setEditingProject(null); + }} + title={editingProject ? 'Edit Funded Project' : 'Create Funded Project'} + subtitle={editingProject ? `Updating project #${editingProject.id}` : 'Operational entity under an approved Programme'} >
projectMutation.mutate(data as any))} className="space-y-4">
@@ -595,19 +814,21 @@ export const ProjectsPage: React.FC = () => { )}
-
- - -
+ {!editingProject && ( +
+ + +
+ )}
@@ -652,7 +873,10 @@ export const ProjectsPage: React.FC = () => {
- {/* Modal: Add Activity */} + {/* Modal: Add or Edit Activity */} setIsActivityModalOpen(false)} - title="Add Activity to Implementation Plan" - subtitle={`Registering activity under Project #${activeProjectId}`} + onClose={() => { + setIsActivityModalOpen(false); + setEditingActivity(null); + }} + title={editingActivity ? 'Edit Activity in Implementation Plan' : 'Add Activity to Implementation Plan'} + subtitle={editingActivity ? `Updating activity #${editingActivity.id}` : `Registering activity under Project #${activeProjectId}`} >
- activityMutation.mutate({ - projectId: activeProjectId!, - ...data, - } as any) - )} + onSubmit={activityForm.handleSubmit((data) => activityMutation.mutate(data as any))} className="space-y-4" >
@@ -694,6 +916,24 @@ export const ProjectsPage: React.FC = () => { />
+ {/* Associated Milestone Dropdown (FR: Passing milestoneId to PUT/POST request) */} +
+ + +
+
@@ -726,7 +966,10 @@ export const ProjectsPage: React.FC = () => {
- {/* Modal: Add Milestone */} + {/* Modal: Add or Edit Milestone */} setIsMilestoneModalOpen(false)} - title="Define Contractual Milestone" - subtitle={`Deliverable schedule for Project #${activeProjectId}`} + onClose={() => { + setIsMilestoneModalOpen(false); + setEditingMilestone(null); + }} + title={editingMilestone ? 'Edit Contractual Milestone' : 'Define Contractual Milestone'} + subtitle={editingMilestone ? `Updating milestone #${editingMilestone.id}` : `Deliverable schedule for Project #${activeProjectId}`} >
- milestoneMutation.mutate({ - projectId: activeProjectId!, - ...data, - } as any) - )} + onSubmit={milestoneForm.handleSubmit((data) => milestoneMutation.mutate(data as any))} className="space-y-4" >
@@ -792,10 +1033,29 @@ export const ProjectsPage: React.FC = () => {
+ {editingMilestone && ( +
+ + +
+ )} +
diff --git a/PostFundManagement.ConsoleUI/src/pages/SettingsPage.tsx b/PostFundManagement.ConsoleUI/src/pages/SettingsPage.tsx index 311a4b3..7dfb362 100644 --- a/PostFundManagement.ConsoleUI/src/pages/SettingsPage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/SettingsPage.tsx @@ -27,6 +27,8 @@ import { getUser, updateUserStatus, syncIntegration, + syncUser, + getUserSyncRecord, getInvites, sendInvite, getApplicantInvites, @@ -38,6 +40,7 @@ import { Modal } from '../components/ui/Modal'; import { useToast } from '../components/ui/Toast'; import { useAuth } from '../auth/AuthProvider'; import { getAppConfig } from '../config'; +import { triggerSessionExpired } from '../services/sessionExpiration'; import { EmailStatuses, NotificationStatus, @@ -98,13 +101,13 @@ export const SettingsPage: React.FC = () => { }); const syncMutation = useMutation({ - mutationFn: () => syncIntegration(), + mutationFn: () => syncUser(undefined, true), onSuccess: () => { - success('Integration Sync Complete', 'Background user and tenant directory synchronized successfully.'); + success('User Sync Complete', 'Authenticated user enabled and directory synced. Browser cache updated to prevent endpoint hotspot.'); queryClient.invalidateQueries({ queryKey: ['users'] }); }, onError: (err: any) => { - toastError('Sync Failed', err?.message || 'Failed to trigger background integration sync.'); + toastError('Sync Failed', err); }, }); @@ -119,7 +122,7 @@ export const SettingsPage: React.FC = () => { } }, onError: (err: any) => { - toastError('Update Failed', err?.message || 'Could not update user status.'); + toastError('Update Failed', err); }, }); @@ -170,7 +173,7 @@ export const SettingsPage: React.FC = () => { setInviteMessage(''); }, onError: (err: any) => { - toastError('Dispatch Error', err?.message || 'Failed to dispatch invitation.'); + toastError('Dispatch Error', err); }, }); @@ -183,7 +186,7 @@ export const SettingsPage: React.FC = () => { setApplicantInvitesResults(res); success('Lookup Complete', `Retrieved ${res.length} invitation records for applicant.`); } catch (err: any) { - toastError('Lookup Error', err?.message || 'Applicant invitation lookup failed.'); + toastError('Lookup Error', err); setApplicantInvitesResults([]); } finally { setSearchingApplicant(false); @@ -224,14 +227,24 @@ export const SettingsPage: React.FC = () => {
{activeTab === 'users' && ( - +
+ + + Browser Sync Saved + + +
)} {activeTab === 'invites' && ( @@ -366,8 +379,8 @@ export const SettingsPage: React.FC = () => { - - + + @@ -391,11 +404,11 @@ export const SettingsPage: React.FC = () => { const badge = getActivityStatusBadge(u.status); return ( - -
User ID (UUID)EmailUser EmailUser Identifier Status Last Login Actions
- {u.id} + + {u.email || getUserEmail(u.id) || No email recorded} - {u.email || No email recorded} + + {u.id} @@ -778,6 +791,31 @@ export const SettingsPage: React.FC = () => { + +
+
+
+

+ + Session Expiration vs Lockout Architecture (HTTP 401) +

+

+ To disambiguate expired sessions from the session lockout process, the session expiration handler monitors all active business endpoints for HTTP 401 other than the user sync endpoint. The user sync endpoint remains dedicated to the session lockout & activation check, ensuring both features act in harmony where one is subordinated by call order and priority. +

+
+ +
+
)} @@ -939,12 +977,12 @@ export const SettingsPage: React.FC = () => {
- User UUID: - {selectedUserForDetail.id} + User Email: + {selectedUserForDetail.email || getUserEmail(selectedUserForDetail.id) || 'Not specified'}
- Email: - {selectedUserForDetail.email || 'Not specified'} + User Identifier: + {selectedUserForDetail.id}
diff --git a/PostFundManagement.ConsoleUI/src/services/api.ts b/PostFundManagement.ConsoleUI/src/services/api.ts index 46c330a..ab9e29a 100644 --- a/PostFundManagement.ConsoleUI/src/services/api.ts +++ b/PostFundManagement.ConsoleUI/src/services/api.ts @@ -20,6 +20,7 @@ import { UpdateProjectStatusRequest, Activity, CreateActivityRequest, + UpdateActivityRequest, Milestone, CreateMilestoneRequest, UpdateMilestoneRequest, @@ -51,26 +52,29 @@ import { ActivityStatus, } from '../types'; -// Helper to extract ProblemDetails from Axios error +import { parseFriendlyError, FriendlyError } from '../utils/apiErrorParser'; +import { registerUserEmail, registerUserEmails } from '../lib/formatters'; + +// Helper to extract ProblemDetails from Axios error or unknown error export function parseApiError(error: unknown): ProblemDetails { if (axios.isAxiosError(error)) { const axiosErr = error as AxiosError; - if (axiosErr.response?.data) { + if (axiosErr.response?.data && typeof axiosErr.response.data === 'object') { return axiosErr.response.data; } - return { - title: 'API Request Error', - detail: axiosErr.message || 'Unable to communicate with the remote PFM service.', - status: axiosErr.response?.status || 500, - }; } + + const friendly = parseFriendlyError(error); return { - title: 'Unexpected Application Error', - detail: String(error), - status: 500, + title: friendly.title, + detail: friendly.message, + status: friendly.statusCode || 500, }; } +export { parseFriendlyError }; +export type { FriendlyError }; + // ========================================== // 1. Executive Dashboard APIs // ========================================== @@ -82,17 +86,53 @@ export const getDashboard = async (): Promise => { // ========================================== // 2. Governance & Structure APIs // ========================================== + +/** + * Normalizes an Organisation object from backend responses. + * Robustly maps status across variations (status, Status, activityStatus, approvalStatus, statusId, isActive). + */ +export const normalizeOrganisation = (raw: any): Organisation => { + if (!raw) return raw; + const status = + raw.status ?? + raw.Status ?? + raw.activityStatus ?? + raw.ActivityStatus ?? + raw.approvalStatus ?? + raw.ApprovalStatus ?? + raw.statusId ?? + raw.StatusId ?? + raw.state ?? + raw.State ?? + (raw.isActive !== undefined ? (raw.isActive ? 1 : 2) : undefined) ?? + (raw.IsActive !== undefined ? (raw.IsActive ? 1 : 2) : undefined) ?? + ActivityStatus.Active; + + return { + ...raw, + id: raw.id ?? raw.Id ?? raw.organisationId ?? raw.OrganisationId, + name: raw.name ?? raw.Name ?? null, + registrationNo: raw.registrationNo ?? raw.RegistrationNo ?? raw.regNo ?? raw.RegNo ?? null, + email: raw.email ?? raw.Email ?? null, + type: raw.type ?? raw.Type ?? raw.organisationType ?? raw.OrganisationType ?? 5, + status, + createdAt: raw.createdAt ?? raw.CreatedAt ?? new Date().toISOString(), + createdBy: raw.createdBy ?? raw.CreatedBy ?? '', + }; +}; + export const getOrganisations = async (page = 1, pageSize = 100): Promise => { - const res = await apiClient.get('/v1/api/governance/organisations', { + const res = await apiClient.get('/v1/api/governance/organisations', { params: { page, pageSize }, }); - return res.data || []; + const list = res.data || []; + return list.map(normalizeOrganisation); }; export const getOrganisation = async (organisationId: string | number): Promise => { const hashedOrgId = await toolsService.resolveOrganisationId(organisationId); - const res = await apiClient.get(`/v1/api/governance/organisations/${hashedOrgId}`); - return res.data; + const res = await apiClient.get(`/v1/api/governance/organisations/${hashedOrgId}`); + return normalizeOrganisation(res.data); }; export const createOrganisation = async (payload: CreateOrganisationRequest): Promise => { @@ -275,11 +315,46 @@ export const getActivity = async (activityId: string | number): Promise => { const userSub = getCurrentUserSub(); - const res = await apiClient.post('/v1/api/projects/activities', { + const body: any = { ...payload, + milestoneId: + payload.milestoneId !== undefined && payload.milestoneId !== null && payload.milestoneId !== '' + ? Number(payload.milestoneId) + : null, createdBy: userSub || undefined, - }); - return res.data; + }; + 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; + } +}; + +export const updateActivity = async (payload: UpdateActivityRequest): Promise => { + const userSub = getCurrentUserSub(); + const body: any = { + ...payload, + milestoneId: + payload.milestoneId !== undefined && payload.milestoneId !== null && payload.milestoneId !== '' + ? Number(payload.milestoneId) + : null, + updatedBy: userSub || undefined, + }; + try { + const res = await apiClient.put('/v1/api/projects/activities', body); + return res.data; + } catch (err: any) { + if (err?.response?.status === 404 || err?.response?.status === 405) { + const res = await apiClient.put('/v1/api/projects/activity', body); + return res.data; + } + throw err; + } }; export const getMilestones = async (projectId: string | number): Promise => { @@ -324,8 +399,9 @@ export const getProjectIndicators = async (projectId: string | number): Promise< export const getProjectRisks = async (projectId: string | number): Promise => { const hashedProjectId = await toolsService.resolveProjectId(projectId); - const res = await apiClient.get(`/v1/api/projects/${hashedProjectId}/risks`); - return res.data || []; + const res = await apiClient.get(`/v1/api/projects/${hashedProjectId}/risks`); + const list = res.data || []; + return list.map(normalizeRisk); }; // ========================================== @@ -470,32 +546,85 @@ export const approveDisbursement = async (disbursementId: string | number): Prom }); }; +/** + * Normalizes a Risk record from backend responses. + * Robustly parses Likelihood (1-5) and Impact (1-5), supporting strings, numbers, and case variations. + */ +export const normalizeRisk = (raw: any): Risk => { + if (!raw) return raw; + + const rawLik = raw.likelihood ?? raw.Likelihood ?? raw.riskLikelihood ?? raw.RiskLikelihood; + const rawImp = raw.impact ?? raw.Impact ?? raw.riskImpact ?? raw.RiskImpact; + + const parseScore = (val: any, defaultVal: number): number => { + if (typeof val === 'number' && !isNaN(val)) { + return Math.min(5, Math.max(1, Math.round(val))); + } + if (typeof val === 'string') { + const parsed = parseInt(val, 10); + if (!isNaN(parsed) && parsed >= 1 && parsed <= 5) { + return parsed; + } + const lower = val.toLowerCase().trim(); + if (lower.includes('rare') || lower.includes('insignificant') || lower.includes('trivial') || lower === '1') return 1; + if (lower.includes('unlikely') || lower.includes('minor') || lower === '2') return 2; + if (lower.includes('possible') || lower.includes('moderate') || lower.includes('medium') || lower === '3') return 3; + if (lower.includes('likely') || lower.includes('major') || lower.includes('high') || lower === '4') return 4; + if (lower.includes('almost') || lower.includes('certain') || lower.includes('severe') || lower.includes('critical') || lower.includes('catastrophic') || lower === '5') return 5; + } + return defaultVal; + }; + + const likelihood = parseScore(rawLik, 3); + const impact = parseScore(rawImp, 2); + + return { + ...raw, + id: raw.id ?? raw.Id ?? raw.riskId ?? raw.RiskId, + projectId: raw.projectId ?? raw.ProjectId, + name: raw.name ?? raw.Name ?? null, + description: raw.description ?? raw.Description ?? null, + likelihood, + impact, + createdBy: raw.createdBy ?? raw.CreatedBy ?? '', + createdAt: raw.createdAt ?? raw.CreatedAt ?? new Date().toISOString(), + owner: raw.owner ?? raw.Owner ?? raw.riskOwner ?? raw.RiskOwner ?? raw.createdBy ?? raw.CreatedBy ?? '', + mitigationAction: raw.mitigationAction ?? raw.MitigationAction ?? raw.mitigation ?? raw.Mitigation ?? '', + status: raw.status ?? raw.Status, + }; +}; + export const getAllRisks = async (page = 1, pageSize = 100): Promise => { - const res = await apiClient.get('/v1/api/execution/risks', { + const res = await apiClient.get('/v1/api/execution/risks', { params: { page, pageSize }, }); - return res.data || []; + const list = res.data || []; + return list.map(normalizeRisk); }; export const getRisk = async (riskId: string | number): Promise => { const hashedRiskId = await toolsService.encodeId(riskId); - const res = await apiClient.get(`/v1/api/execution/risks/${hashedRiskId}`); - return res.data; + const res = await apiClient.get(`/v1/api/execution/risks/${hashedRiskId}`); + return normalizeRisk(res.data); }; export const createRisk = async (payload: CreateRiskRequest): Promise => { const userSub = getCurrentUserSub(); const res = await apiClient.post('/v1/api/execution/risks', { ...payload, + likelihood: Number(payload.likelihood), + impact: Number(payload.impact), createdBy: userSub || undefined, }); - return res.data; + return normalizeRisk(res.data); }; export const updateRisk = async (payload: UpdateRiskRequest): Promise => { const userSub = getCurrentUserSub(); await apiClient.put('/v1/api/execution/risks', { ...payload, + likelihood: Number(payload.likelihood), + impact: Number(payload.impact), updatedBy: userSub || undefined, }); }; @@ -592,11 +721,16 @@ export const getUsers = async ( filter: UserFilterRequest = {} ): Promise => { const res = await apiClient.post(`/v1/api/users/${page}/${pageSize}`, filter); - return res.data || []; + const list = res.data || []; + registerUserEmails(list); + return list; }; export const getUser = async (userId: string): Promise => { const res = await apiClient.get(`/v1/api/users/${userId}`); + if (res.data?.id && res.data?.email) { + registerUserEmail(res.data.id, res.data.email); + } return res.data; }; @@ -604,7 +738,11 @@ export const getCurrentUser = async (): Promise => { const userSub = getCurrentUserSub(); if (userSub) { try { - return await getUser(userSub); + const u = await getUser(userSub); + if (u?.id && u?.email) { + registerUserEmail(u.id, u.email); + } + return u; } catch { // Fallback } @@ -621,8 +759,40 @@ export const updateUserStatus = async (userId: string, status: ActivityStatus | await apiClient.get(`/v1/api/users/${userId}/status/${status}`); }; -export const syncIntegration = async (): Promise => { - await apiClient.post('/v1/api/integration/sync'); +export { + syncUserAccount, + ensureUserSynced, + isUserSyncedInBrowser, + saveUserSyncInBrowser, + clearUserSyncInBrowser, + getUserSyncRecord, + isActivationPending, + setActivationPending, + getActivationPendingReason, +} from './userSyncService'; + +/** + * Synchronizes the authenticated user with the backend to enable the user in the system. + * By default, sync is saved in the browser to prevent hotspotting the sync endpoint. + */ +export const syncUser = async ( + userData?: { + id?: string; + sub?: string; + email?: string | null; + name?: string | null; + role?: string | null; + tenant?: string | null; + }, + force = false +): Promise => { + const { ensureUserSynced } = await import('./userSyncService'); + return ensureUserSynced(userData?.sub || userData?.id, undefined, userData, force); +}; + +export const syncIntegration = async (force = true): Promise => { + const { ensureUserSynced } = await import('./userSyncService'); + await ensureUserSynced(undefined, undefined, undefined, force); }; // ========================================== diff --git a/PostFundManagement.ConsoleUI/src/services/httpClient.ts b/PostFundManagement.ConsoleUI/src/services/httpClient.ts index caa176d..207cd4d 100644 --- a/PostFundManagement.ConsoleUI/src/services/httpClient.ts +++ b/PostFundManagement.ConsoleUI/src/services/httpClient.ts @@ -1,5 +1,6 @@ import axios, { InternalAxiosRequestConfig } from 'axios'; import { getApiBaseUrl } from '../config'; +import { triggerSessionExpired, isUserSyncEndpoint } from './sessionExpiration'; export const BASE_API_URL = getApiBaseUrl(); @@ -15,14 +16,69 @@ export const apiClient = axios.create({ // Request Interceptor to dynamically set baseURL and attach Bearer Token apiClient.interceptors.request.use( - (config: InternalAxiosRequestConfig) => { + async (config: InternalAxiosRequestConfig) => { // Dynamically synchronize baseURL from runtime configuration config.baseURL = getApiBaseUrl(); const token = localStorage.getItem('pfm_access_token'); if (token && config.headers) { config.headers.Authorization = `Bearer ${token}`; } + + // User Sync Guard: + // All API calls require an authenticated and enabled user in the backend system. + const isSyncRequest = isUserSyncEndpoint(config.url); + if (token && !isSyncRequest) { + const { isUserSyncedInBrowser, isActivationPending, syncUserAccount } = await import('./userSyncService'); + + // If user account is flagged as pending administrator activation, block downstream API calls + if (isActivationPending()) { + return Promise.reject(new Error('User account is pending administrator activation')); + } + + const storedSub = localStorage.getItem('pfm_user_sub') || ''; + if (!isUserSyncedInBrowser(storedSub, token)) { + const syncRes = await syncUserAccount(storedSub, token); + if (!syncRes.success) { + return Promise.reject(new Error(syncRes.errorMessage || 'User account sync pending activation')); + } + } + } + return config; }, (error) => Promise.reject(error) ); + +// Response Interceptor for global HTTP 401 detection +apiClient.interceptors.response.use( + (response) => response, + (error) => { + if (error?.response?.status === 401) { + const requestUrl = error.config?.url; + const isSyncEndpoint = isUserSyncEndpoint(requestUrl); + + // Disambiguation between Session Lockout and Session Expired: + // - Session Expired process monitors ALL OTHER endpoints for HTTP 401 other than the user sync endpoint. + // - User sync endpoint 401 is subordinated to the session lockout / auth initialization flow through call order and priority. + if (!isSyncEndpoint) { + // Active working endpoint returned HTTP 401: trigger modal with progress bar countdown and landing redirect + const detailMsg = + error?.response?.data?.detail || + error?.response?.data?.message || + 'Authentication session expired or security token invalidated (HTTP 401).'; + + triggerSessionExpired(detailMsg, requestUrl); + } else { + // User sync endpoint returned HTTP 401: clean up session data silently for landing navigation + if (typeof window !== 'undefined') { + localStorage.removeItem('pfm_access_token'); + localStorage.removeItem('pfm_access_user'); + localStorage.removeItem('pfm_user_sync_record'); + localStorage.removeItem('pfm_user_pending_activation'); + localStorage.removeItem('pfm_user_pending_activation_reason'); + } + } + } + return Promise.reject(error); + } +); diff --git a/PostFundManagement.ConsoleUI/src/services/sessionExpiration.ts b/PostFundManagement.ConsoleUI/src/services/sessionExpiration.ts new file mode 100644 index 0000000..ca30b9d --- /dev/null +++ b/PostFundManagement.ConsoleUI/src/services/sessionExpiration.ts @@ -0,0 +1,159 @@ +import { queryClient } from '../lib/queryClient'; + +export const LOCAL_STORAGE_TOKEN_KEY = 'pfm_access_token'; +export const LOCAL_STORAGE_USER_KEY = 'pfm_auth_user'; +export const LOCAL_STORAGE_SUB_KEY = 'pfm_user_sub'; +export const LOCAL_STORAGE_CLAIMS_KEY = 'pfm_user_claims'; +export const LOCAL_STORAGE_USER_SYNC_KEY = 'pfm_user_sync_record'; +export const LOCAL_STORAGE_ACTIVATION_PENDING_KEY = 'pfm_user_pending_activation'; +export const LOCAL_STORAGE_ACTIVATION_REASON_KEY = 'pfm_user_pending_activation_reason'; + +/** + * Determines whether a given URL or request path targets a user sync endpoint. + * This differentiates the user sync / lockout lifecycle from active session working endpoints. + */ +export function isUserSyncEndpoint(url?: string): boolean { + if (!url) return false; + const clean = url.toLowerCase().split('?')[0]; + return ( + clean.includes('/users/sync') || + clean.includes('/integration/sync') || + clean.includes('/user/sync') || + clean.endsWith('/sync') || + clean === '/sync' + ); +} + +type SessionExpiredListener = (reason?: string) => void; + +const listeners = new Set(); +let hasTriggeredSessionExpired = false; + +/** + * Dismisses all subordinate UI modals throughout the application. + */ +export function dismissSubordinateModals(): void { + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('pfm:dismiss-subordinates')); + } +} + +/** + * Cancels and avoids the session expiration process and resets its state. + */ +export function cancelSessionExpiration(): void { + hasTriggeredSessionExpired = false; + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('pfm:session-expired-cancelled')); + } +} + +/** + * Checks whether session expiration is currently active. + */ +export function isSessionExpiredState(): boolean { + return hasTriggeredSessionExpired; +} + +/** + * Triggers global session expiration when an endpoint returns HTTP 401. + * Prevents multiple modal popups if multiple parallel requests fail with 401. + * + * NOTE: As established by system call priority and subordination, this process + * strictly monitors all other endpoints EXCEPT the user sync endpoint, which is + * reserved for the session lockout / activation process. + */ +export function triggerSessionExpired(reason?: string, requestUrl?: string): void { + // Disambiguation: Ignore HTTP 401 if originating from the user sync endpoint + if (requestUrl && isUserSyncEndpoint(requestUrl)) { + return; + } + + // Priority Subordination: If user is in account activation lockout, session expiration + // is avoided and cancelled in favor of the higher-priority activation modal. + if (typeof window !== 'undefined' && localStorage.getItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY)) { + return; + } + + if (hasTriggeredSessionExpired) { + return; + } + + // Only trigger if a session was previously present in localStorage or state + const hasExistingToken = typeof window !== 'undefined' && !!localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY); + if (!hasExistingToken) { + return; + } + + hasTriggeredSessionExpired = true; + + // Session expiration modal dismisses all other subordinates when it is displayed + dismissSubordinateModals(); + + // Dispatch custom browser event + if (typeof window !== 'undefined') { + window.dispatchEvent( + new CustomEvent('pfm:session-expired', { + detail: { reason: reason || 'HTTP 401 Unauthorized' }, + }) + ); + } + + // Notify registered callbacks + listeners.forEach((callback) => { + try { + callback(reason); + } catch (err) { + console.warn('[SessionExpiration] Callback execution error:', err); + } + }); +} + +/** + * Subscribes a callback to session expiration events. + * Returns an unsubscribe function. + */ +export function onSessionExpired(listener: SessionExpiredListener): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** + * Resets the session expiration trigger guard. + */ +export function resetSessionExpiredState(): void { + hasTriggeredSessionExpired = false; +} + +/** + * Clears all persistent tokens, user claims, synchronization metadata, + * and react-query caches, then securely redirects to the landing page. + */ +export function clearSessionAndRedirectToLanding(): void { + if (typeof window === 'undefined') return; + + try { + localStorage.removeItem(LOCAL_STORAGE_TOKEN_KEY); + localStorage.removeItem(LOCAL_STORAGE_USER_KEY); + localStorage.removeItem(LOCAL_STORAGE_SUB_KEY); + localStorage.removeItem(LOCAL_STORAGE_CLAIMS_KEY); + localStorage.removeItem(LOCAL_STORAGE_USER_SYNC_KEY); + localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY); + localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY); + } catch (err) { + console.warn('[SessionExpiration] Storage cleanup warning:', err); + } + + try { + queryClient.clear(); + } catch (err) { + console.warn('[SessionExpiration] QueryClient clear warning:', err); + } + + resetSessionExpiredState(); + + // Redirect to landing page + window.location.replace('/'); +} diff --git a/PostFundManagement.ConsoleUI/src/services/userSyncService.ts b/PostFundManagement.ConsoleUI/src/services/userSyncService.ts new file mode 100644 index 0000000..f901eaa --- /dev/null +++ b/PostFundManagement.ConsoleUI/src/services/userSyncService.ts @@ -0,0 +1,337 @@ +import { apiClient } from './httpClient'; + +export const LOCAL_STORAGE_USER_SYNC_KEY = 'pfm_user_sync_record'; +export const LOCAL_STORAGE_ACTIVATION_PENDING_KEY = 'pfm_user_pending_activation'; +export const LOCAL_STORAGE_ACTIVATION_REASON_KEY = 'pfm_user_pending_activation_reason'; + +export interface UserSyncRecord { + sub: string; + syncedAt: number; + syncedEndpoint: string; + tokenSnippet: string; + status: 'synced'; + userData?: any; +} + +export interface UserSyncProfile { + id?: string; + sub?: string; + email?: string | null; + name?: string | null; + role?: string | null; + tenant?: string | null; + organisationName?: string | null; +} + +export interface UserSyncResult { + success: boolean; + isUnauthorized?: boolean; + isActivationPending?: boolean; + endpoint?: string; + data?: any; + errorMessage?: string; +} + +let inFlightSyncPromise: Promise | null = null; + +/** + * Checks if the user is currently flagged in the browser as pending administrator activation / lockout. + */ +export function isActivationPending(): boolean { + try { + return localStorage.getItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY) === 'true'; + } catch { + return false; + } +} + +/** + * Gets the reason/error message recorded when activation check was last pending. + */ +export function getActivationPendingReason(): string | null { + try { + return localStorage.getItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY); + } catch { + return null; + } +} + +/** + * Sets or clears the pending activation flag in browser persistent storage. + */ +export function setActivationPending(pending: boolean, reason?: string): void { + try { + if (pending) { + localStorage.setItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY, 'true'); + if (reason) { + localStorage.setItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY, reason); + } + } else { + localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY); + localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY); + } + } catch (err) { + console.warn('[UserSync] Failed to update activation flag:', err); + } +} + +/** + * Checks if the current authenticated user has already been synchronized + * and stored in the browser's persistent storage. + * + * This prevents subsequent API calls, page refreshes, and navigations + * from creating a hotspot on the backend sync user endpoint. + */ +export function isUserSyncedInBrowser(currentSub?: string, currentToken?: string | null): boolean { + try { + // If flagged as pending activation, they are NOT considered synced + if (isActivationPending()) { + return false; + } + + const raw = localStorage.getItem(LOCAL_STORAGE_USER_SYNC_KEY); + if (!raw) return false; + + const record: UserSyncRecord = JSON.parse(raw); + if (!record || record.status !== 'synced') return false; + + // Check if the stored sync matches the current user sub (if specified) + if (currentSub && record.sub && record.sub !== currentSub) { + return false; + } + + // Check if the token snippet matches (if token changed, e.g. new session login) + if (currentToken && record.tokenSnippet) { + const snippet = currentToken.slice(-16); + if (record.tokenSnippet !== snippet) { + return false; + } + } + + return true; + } catch { + return false; + } +} + +/** + * Persists the user sync record into browser storage. + */ +export function saveUserSyncInBrowser( + sub: string, + token: string | null | undefined, + endpoint: string, + userData?: any +): void { + try { + const record: UserSyncRecord = { + sub: sub || 'authenticated_user', + syncedAt: Date.now(), + syncedEndpoint: endpoint, + tokenSnippet: token ? token.slice(-16) : '', + status: 'synced', + userData: userData || undefined, + }; + localStorage.setItem(LOCAL_STORAGE_USER_SYNC_KEY, JSON.stringify(record)); + } catch (err) { + console.warn('[UserSync] Failed to write sync record to localStorage:', err); + } +} + +/** + * Clears the user sync record from browser storage (used on logout or when activation fails). + */ +export function clearUserSyncInBrowser(): void { + try { + localStorage.removeItem(LOCAL_STORAGE_USER_SYNC_KEY); + localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY); + localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY); + } catch {} +} + +/** + * Clears only the successful sync record, leaving activation pending intact. + */ +export function clearSuccessfulSyncRecord(): void { + try { + localStorage.removeItem(LOCAL_STORAGE_USER_SYNC_KEY); + } catch {} +} + +/** + * Retrieves the current sync record from browser storage if present. + */ +export function getUserSyncRecord(): UserSyncRecord | null { + try { + const raw = localStorage.getItem(LOCAL_STORAGE_USER_SYNC_KEY); + if (!raw) return null; + return JSON.parse(raw) as UserSyncRecord; + } catch { + return null; + } +} + +export const USER_SYNC_CANDIDATE_ENDPOINTS = [ + '/v1/api/users/sync', + '/v1/api/integration/sync', + '/v1/api/user/sync', +]; + +/** + * Determines whether a given URL or request path targets a user sync endpoint. + * This is used to differentiate user sync / lockout lifecycle calls from regular + * business working endpoints, ensuring established call priority and subordination. + */ +export function isUserSyncEndpoint(url?: string): boolean { + if (!url) return false; + const clean = url.toLowerCase().split('?')[0]; + return ( + clean.includes('/users/sync') || + clean.includes('/integration/sync') || + clean.includes('/user/sync') || + clean.endsWith('/sync') || + clean === '/sync' + ); +} + +/** + * Executes the backend user sync request. + * Tries the primary '/v1/api/users/sync' endpoint, falling back to + * '/v1/api/integration/sync' or '/v1/api/user/sync' if needed. + */ +async function executeBackendSync( + userSub?: string, + profile?: UserSyncProfile +): Promise<{ endpoint: string; data?: any }> { + const payload = { + id: userSub || profile?.id || profile?.sub, + sub: userSub || profile?.sub, + email: profile?.email || undefined, + name: profile?.name || undefined, + role: profile?.role || undefined, + tenant: profile?.tenant || undefined, + }; + + const candidateEndpoints = USER_SYNC_CANDIDATE_ENDPOINTS; + + let lastError: any = null; + + for (const endpoint of candidateEndpoints) { + try { + const res = await apiClient.post(endpoint, payload); + return { endpoint, data: res.data }; + } catch (err: any) { + lastError = err; + const status = err?.response?.status; + + // 1. If HTTP 401 Unauthorized: token expired or invalid, fail fast + if (status === 401) { + throw err; + } + + // 2. If HTTP 403 Forbidden, 423 Locked, or non-404 client error: + // The endpoint is valid, but the user is blocked/disabled/not activated by the administrator. + if (status === 403 || status === 423 || (status && status >= 400 && status !== 404 && status !== 405)) { + throw err; + } + + // If 404 or 405, continue to next fallback candidate + } + } + + throw lastError || new Error('Failed to reach user sync endpoint'); +} + +/** + * Synchronizes the user account with the backend system. + * Handles: + * - HTTP 401 -> Returns isUnauthorized: true (triggering redirection to landing page) + * - HTTP 403 / failure -> Flags isActivationPending: true (triggering non-dismissable activation modal) + * - Success -> Caches sync record in browser, clears pending activation flag + */ +export async function syncUserAccount( + userSub?: string, + token?: string | null, + profile?: UserSyncProfile, + force = false +): Promise { + const effectiveToken = token || (typeof window !== 'undefined' ? localStorage.getItem('pfm_access_token') : null); + const effectiveSub = userSub || (typeof window !== 'undefined' ? localStorage.getItem('pfm_user_sub') || '' : ''); + + // If already synced in the browser, no pending activation, and not forced, return immediately. + // Avoids hotspotting the sync endpoint. + if (!force && isUserSyncedInBrowser(effectiveSub, effectiveToken) && !isActivationPending()) { + return { + success: true, + endpoint: getUserSyncRecord()?.syncedEndpoint || '/v1/api/users/sync', + data: getUserSyncRecord()?.userData, + }; + } + + // Deduplicate concurrent sync calls + if (inFlightSyncPromise) { + return inFlightSyncPromise; + } + + inFlightSyncPromise = (async (): Promise => { + try { + const result = await executeBackendSync(effectiveSub, profile); + // Success! Clear any activation lockout, save valid sync record to browser + setActivationPending(false); + saveUserSyncInBrowser(effectiveSub, effectiveToken, result.endpoint, result.data); + return { + success: true, + endpoint: result.endpoint, + data: result.data, + }; + } catch (err: any) { + const status = err?.response?.status; + + // Handle HTTP 401: Normal process of routing user to landing page + if (status === 401) { + clearUserSyncInBrowser(); + setActivationPending(false); + return { + success: false, + isUnauthorized: true, + errorMessage: 'Authentication session expired or unauthorized (HTTP 401).', + }; + } + + // Non-401 error: Account is registered/authenticated, but administrator has not enabled it yet + clearSuccessfulSyncRecord(); + const serverMsg = + err?.response?.data?.message || + err?.response?.data?.detail || + err?.response?.data?.error || + err?.message || + 'Account is not yet enabled by the administrator.'; + + setActivationPending(true, serverMsg); + + return { + success: false, + isUnauthorized: false, + isActivationPending: true, + errorMessage: serverMsg, + }; + } finally { + inFlightSyncPromise = null; + } + })(); + + return inFlightSyncPromise; +} + +/** + * Backward-compatible helper returning boolean success. + */ +export async function ensureUserSynced( + userSub?: string, + token?: string | null, + profile?: UserSyncProfile, + force = false +): Promise { + const result = await syncUserAccount(userSub, token, profile, force); + return result.success; +} diff --git a/PostFundManagement.ConsoleUI/src/types.ts b/PostFundManagement.ConsoleUI/src/types.ts index 3e383cc..178007a 100644 --- a/PostFundManagement.ConsoleUI/src/types.ts +++ b/PostFundManagement.ConsoleUI/src/types.ts @@ -443,6 +443,18 @@ export interface CreateActivityRequest { performedAt?: string | null; } +export interface UpdateActivityRequest { + activityId: number | string; + projectId?: number | string; + milestoneId?: number | string | null; + category: string; + amount: number; + title: string; + description: string; + performedAt?: string | null; + status?: ApprovalStatus; +} + export interface Milestone { id: number | string; projectId: number | string; diff --git a/PostFundManagement.ConsoleUI/src/utils/apiErrorParser.ts b/PostFundManagement.ConsoleUI/src/utils/apiErrorParser.ts new file mode 100644 index 0000000..7fa67b4 --- /dev/null +++ b/PostFundManagement.ConsoleUI/src/utils/apiErrorParser.ts @@ -0,0 +1,494 @@ +import axios, { AxiosError } from 'axios'; +import { ProblemDetails } from '../types'; + +export type ErrorCategory = + | 'validation' + | 'creation' + | 'update' + | 'security' + | 'notFound' + | 'conflict' + | 'server' + | 'general'; + +export interface FriendlyError { + title: string; + message: string; + statusCode?: number; + isProblemDetails: boolean; + category: ErrorCategory; + categoryLabel?: string; + fieldErrors?: Array<{ field: string; message: string }>; + technicalDetails?: string; +} + +/** + * Converts camelCase or PascalCase field names to human-readable titles. + * e.g. "HashId" -> "Resource Identifier", "startDate" -> "Start Date" + */ +function humanizeFieldName(field: string): string { + if (!field) return 'Field'; + + const customMappings: Record = { + hashid: 'Resource Identifier (HashId)', + id: 'Record ID', + sub: 'User Identity', + orgid: 'Organisation', + organisationid: 'Organisation', + portfolioid: 'Portfolio', + programmeid: 'Programme', + projectid: 'Project', + grantid: 'Award', + awardid: 'Award', + contractid: 'Contract', + budgetid: 'Budget', + disbursementid: 'Disbursement', + indicatorid: 'Indicator', + riskid: 'Risk', + email: 'Email Address', + name: 'Name', + title: 'Title', + code: 'Reference Code', + startdate: 'Start Date', + enddate: 'End Date', + targetdate: 'Target Date', + actualdate: 'Actual Date', + amount: 'Amount', + budgetamount: 'Budget Amount', + targetvalue: 'Target Value', + actualvalue: 'Actual Value', + baselinevalue: 'Baseline Value', + status: 'Status', + governancestatus: 'Governance Status', + contractstatus: 'Contract Status', + disbursementstatus: 'Disbursement Status', + }; + + const lower = field.toLowerCase().replace(/[^a-z0-9]/g, ''); + if (customMappings[lower]) { + return customMappings[lower]; + } + + // Insert space before uppercase letters + const spaced = field.replace(/([a-z])([A-Z])/g, '$1 $2'); + return spaced.charAt(0).toUpperCase() + spaced.slice(1); +} + +/** + * Cleans technical jargon, stack traces, and framework namespaces from text strings. + */ +function cleanTechnicalJargon(text: string): string { + if (!text) return ''; + + let cleaned = text + .replace(/^AxiosError:\s*/i, '') + .replace(/^Error:\s*/i, '') + .replace(/Request failed with status code \d+/i, '') + .replace(/https?:\/\/[^\s]+/g, '') + .replace(/System\.[a-zA-Z0-9_.]+/g, '') + .replace(/Microsoft\.[a-zA-Z0-9_.]+/g, '') + .replace(/at\s+[a-zA-Z0-9_.]+\s*\(.*?\)/g, '') + .trim(); + + // If after cleaning it's empty or trivial, return empty + if (cleaned.length < 3 || cleaned === '[object Object]') { + return ''; + } + + return cleaned; +} + +/** + * Checks if a title is a standard framework generic title. + */ +function isGenericFrameworkTitle(title?: string): boolean { + if (!title) return true; + const lower = title.toLowerCase().trim(); + return ( + lower === 'bad request' || + lower === 'one or more validation errors occurred.' || + lower === 'one or more validation errors occurred' || + lower === 'forbidden' || + lower === 'not found' || + lower === 'internal server error' || + lower === 'an error occurred while processing your request.' || + lower === 'api request error' || + lower.startsWith('request failed with') + ); +} + +/** + * Checks if a response object conforms to RFC 7807 / RFC 9457 ProblemDetails. + */ +export function isProblemDetailsObject(data: any): data is ProblemDetails { + if (!data || typeof data !== 'object') return false; + return ( + 'title' in data || + 'detail' in data || + 'status' in data || + 'type' in data || + 'errors' in data + ); +} + +/** + * Detects whether the user action context was an entity creation or an update. + */ +function detectOperationIntent( + method?: string, + actionTitle?: string, + url?: string +): 'creation' | 'update' | 'deletion' | 'general' { + const normMethod = (method || '').toUpperCase(); + const text = `${actionTitle || ''} ${url || ''}`.toLowerCase(); + + if ( + normMethod === 'POST' || + text.includes('create') || + text.includes('registration') || + text.includes('register') || + text.includes('dispatch') || + text.includes('add ') || + text.includes('new ') || + text.includes('upload') || + text.includes('record actual') || + text.includes('log risk') + ) { + return 'creation'; + } + + if ( + normMethod === 'PUT' || + normMethod === 'PATCH' || + text.includes('update') || + text.includes('edit') || + text.includes('save') || + text.includes('status') || + text.includes('approve') || + text.includes('execute') || + text.includes('modify') + ) { + return 'update'; + } + + if (normMethod === 'DELETE' || text.includes('delete') || text.includes('remove')) { + return 'deletion'; + } + + return 'general'; +} + +/** + * Extracts and formats human-friendly error guidance from any error, + * prioritizing the standardised ProblemDetails model when present, + * and handling BadRequest specifically for its multiple purposes: + * - Input validation errors (field-level) + * - Creation errors (prerequisites, business rules, duplicate identifiers) + * - Update errors (lifecycle state restrictions, invalid transitions, permissions) + */ +export function parseFriendlyError( + error: unknown, + fallbackActionTitle = 'Action Failed' +): FriendlyError { + let statusCode: number | undefined; + let rawTitle: string | undefined; + let rawDetail: string | undefined; + let rawErrors: Record | undefined; + let isProblemDetails = false; + let rawResponseData: any = null; + let technicalSummary = ''; + let httpMethod = ''; + let requestUrl = ''; + + // 1. Inspect Axios error structure + if (axios.isAxiosError(error)) { + const axiosErr = error as AxiosError; + statusCode = axiosErr.response?.status; + rawResponseData = axiosErr.response?.data; + httpMethod = axiosErr.config?.method?.toUpperCase() || ''; + requestUrl = axiosErr.config?.url || ''; + technicalSummary = `${httpMethod || 'HTTP'} ${requestUrl} -> ${statusCode || axiosErr.code || 'ERR'}`; + + if (rawResponseData && typeof rawResponseData === 'object') { + if (isProblemDetailsObject(rawResponseData)) { + isProblemDetails = true; + rawTitle = rawResponseData.title || undefined; + rawDetail = rawResponseData.detail || undefined; + statusCode = rawResponseData.status || statusCode; + rawErrors = rawResponseData.errors; + } else if (rawResponseData.message || rawResponseData.error) { + rawDetail = rawResponseData.message || rawResponseData.error; + } + } else if (typeof rawResponseData === 'string') { + // Check if response data is stringified JSON containing ProblemDetails + try { + const parsed = JSON.parse(rawResponseData); + if (isProblemDetailsObject(parsed)) { + isProblemDetails = true; + rawTitle = parsed.title || undefined; + rawDetail = parsed.detail || undefined; + statusCode = parsed.status || statusCode; + rawErrors = parsed.errors; + } else { + rawDetail = cleanTechnicalJargon(rawResponseData); + } + } catch { + if (!rawResponseData.includes(' = []; + if (rawErrors && typeof rawErrors === 'object') { + for (const [key, val] of Object.entries(rawErrors)) { + const fieldTitle = humanizeFieldName(key); + if (Array.isArray(val)) { + val.forEach((msg) => { + if (msg) fieldErrors.push({ field: fieldTitle, message: cleanTechnicalJargon(String(msg)) }); + }); + } else if (typeof val === 'string' && val) { + fieldErrors.push({ field: fieldTitle, message: cleanTechnicalJargon(val) }); + } + } + } + + // Detect whether the operation is creation, update, deletion, or general + const operationIntent = detectOperationIntent(httpMethod, fallbackActionTitle, requestUrl); + + // Build user-friendly title, message, and category based on standard HTTP codes and ProblemDetails + let friendlyTitle = fallbackActionTitle; + let friendlyMessage = ''; + let category: ErrorCategory = 'general'; + let categoryLabel: string | undefined = undefined; + + switch (statusCode) { + case 403: + // Forbid: specifically occurs when the hashid used on a call is invalid or access denied + category = 'security'; + categoryLabel = 'Invalid Identifier / Forbid'; + friendlyTitle = 'Access Not Permitted or Invalid Identifier'; + friendlyMessage = + cleanTechnicalJargon(rawDetail || '') || + 'The reference identifier (hash ID) is invalid, expired, or you do not have permission to access this item. Please verify your selection or contact your system administrator.'; + break; + + case 404: + // NotFound: item could not be located + category = 'notFound'; + categoryLabel = 'Record Not Found'; + friendlyTitle = 'Record Not Found'; + friendlyMessage = + cleanTechnicalJargon(rawDetail || '') || + 'The requested record could not be found. It may have been archived, removed, or the link is outdated.'; + break; + + case 400: { + // BadRequest serves MULTIPLE purposes: + // 1) Input validation errors (field-level errors dictionary) + // 2) Creation errors (prerequisites, business rules, duplicate code, invalid entity state) + // 3) Update errors (status transition restrictions, record locked, state mismatch) + const hasSpecificTitle = rawTitle && !isGenericFrameworkTitle(rawTitle); + const cleanDetail = cleanTechnicalJargon(rawDetail || ''); + + if (fieldErrors.length > 0) { + // Purpose 1: Input Validation Errors + category = 'validation'; + categoryLabel = + operationIntent === 'creation' + ? 'Creation Validation' + : operationIntent === 'update' + ? 'Update Validation' + : 'Input Validation'; + + if (hasSpecificTitle) { + friendlyTitle = cleanTechnicalJargon(rawTitle!); + } else if (operationIntent === 'creation') { + friendlyTitle = 'Please Check New Record Details'; + } else if (operationIntent === 'update') { + friendlyTitle = 'Please Check Updated Values'; + } else { + friendlyTitle = 'Please Check Submitted Details'; + } + + const errorList = fieldErrors.map((f) => `• ${f.field}: ${f.message}`).join('\n'); + friendlyMessage = cleanDetail + ? `${cleanDetail}\n\n${errorList}` + : `The submitted details could not be validated:\n${errorList}`; + } else if (operationIntent === 'creation') { + // Purpose 2: Creation Error (non-field-validation: business rule, duplicate, or prerequisite failure) + category = 'creation'; + categoryLabel = 'Creation Error'; + + if (hasSpecificTitle) { + friendlyTitle = cleanTechnicalJargon(rawTitle!); + } else if (fallbackActionTitle && fallbackActionTitle !== 'Action Failed') { + friendlyTitle = fallbackActionTitle; + } else { + friendlyTitle = 'Unable to Create Record'; + } + + friendlyMessage = + cleanDetail || + 'The new record could not be created. This may be due to business rule prerequisites, existing duplicate identifiers, or missing related records.'; + } else if (operationIntent === 'update') { + // Purpose 3: Update Error (non-field-validation: status transition, record locked, lifecycle restriction) + category = 'update'; + categoryLabel = 'Update Error'; + + if (hasSpecificTitle) { + friendlyTitle = cleanTechnicalJargon(rawTitle!); + } else if (fallbackActionTitle && fallbackActionTitle !== 'Action Failed') { + friendlyTitle = fallbackActionTitle; + } else { + friendlyTitle = 'Unable to Update Record'; + } + + friendlyMessage = + cleanDetail || + 'The record could not be updated. It may be locked, in a status that does not allow modifications, or the requested status transition is not permitted.'; + } else { + // General Bad Request + category = 'general'; + categoryLabel = 'Request Error'; + + if (hasSpecificTitle) { + friendlyTitle = cleanTechnicalJargon(rawTitle!); + } else { + friendlyTitle = fallbackActionTitle || 'Request Could Not Be Processed'; + } + + friendlyMessage = + cleanDetail || + 'The request could not be processed due to invalid parameters or business rule requirements. Please review your input and try again.'; + } + break; + } + + case 409: + // Conflict + category = 'conflict'; + categoryLabel = 'Conflict'; + friendlyTitle = 'Update Conflict'; + friendlyMessage = + cleanTechnicalJargon(rawDetail || '') || + 'This action conflicts with the current status of the record (for instance, another user may have updated it or a duplicate entry exists). Please refresh the page and try again.'; + break; + + case 422: + // Unprocessable Entity + category = 'validation'; + categoryLabel = 'Incomplete Data'; + friendlyTitle = 'Information Incomplete'; + friendlyMessage = + cleanTechnicalJargon(rawDetail || '') || + 'The submitted information could not be processed by the system. Please verify that all required fields are complete.'; + break; + + case 429: + // Too Many Requests + category = 'server'; + categoryLabel = 'Rate Limit'; + friendlyTitle = 'System Busy'; + friendlyMessage = 'The system received too many requests in a short time. Please pause for a moment before trying again.'; + break; + + case 500: + case 502: + case 503: + case 504: + // Server error + category = 'server'; + categoryLabel = 'Service Issue'; + friendlyTitle = 'Service Temporarily Unavailable'; + friendlyMessage = + 'The central service encountered an unexpected issue while processing your request. Please try again in a few moments.'; + break; + + default: + if (rawTitle && isProblemDetails && !isGenericFrameworkTitle(rawTitle)) { + friendlyTitle = cleanTechnicalJargon(rawTitle) || fallbackActionTitle; + } + if (rawDetail) { + friendlyMessage = cleanTechnicalJargon(rawDetail); + } else if (fieldErrors.length > 0) { + category = 'validation'; + categoryLabel = 'Validation'; + friendlyMessage = fieldErrors.map((f) => `• ${f.field}: ${f.message}`).join('\n'); + } else { + friendlyMessage = 'An unexpected issue occurred while processing this request. Please try again or contact support.'; + } + break; + } + + // Ensure title is friendly and not an uncaught technical string + if (friendlyTitle.includes('AxiosError') || friendlyTitle.includes('status code')) { + friendlyTitle = fallbackActionTitle; + } + + return { + title: friendlyTitle, + message: friendlyMessage, + statusCode, + isProblemDetails, + category, + categoryLabel, + fieldErrors: fieldErrors.length > 0 ? fieldErrors : undefined, + technicalDetails: technicalSummary || (statusCode ? `HTTP Status ${statusCode}` : undefined), + }; +}