Added PFM Console UI project

This commit is contained in:
2026-09-11 12:13:25 +02:00
parent a15b25248d
commit 99ab5b2ce6
35 changed files with 12231 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
# GEMINI_API_KEY: Required for Gemini AI API calls.
# AI Studio automatically injects this at runtime from user secrets.
# Users configure this via the Secrets panel in the AI Studio UI.
GEMINI_API_KEY="MY_GEMINI_API_KEY"
# APP_URL: The URL where this applet is hosted.
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
# Used for self-referential links, OAuth callbacks, and API endpoints.
APP_URL="MY_APP_URL"
# OIDC SecuritySettings
VITE_OIDC_AUTHORITY="https://sts.security.khongisa.co.za"
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/"
+8
View File
@@ -0,0 +1,8 @@
node_modules/
build/
dist/
coverage/
.DS_Store
*.log
.env*
!.env.example
+36
View File
@@ -0,0 +1,36 @@
# Stage 1: Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy dependency definitions
COPY package*.json ./
# Install dependencies
RUN npm ci || npm install
# Copy application source
COPY . .
# Build Vite application for production
RUN npm run build
# Stage 2: Production Nginx Server
FROM nginx:alpine
# Copy nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy compiled static assets from build stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy entrypoint script for dynamic runtime environment variables
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
# Expose port 3000
EXPOSE 3000
# Set entrypoint and default command
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,19 @@
version: '3.8'
services:
pfm-console:
build:
context: .
dockerfile: Dockerfile
container_name: pfm-console
ports:
- "3000:3000"
environment:
# OIDC Identity Provider Configuration
- OIDC_AUTHORITY=https://sts.security.khongisa.co.za
- OIDC_AUDIENCE=pfm-api-dev
- OIDC_CLIENT_ID=pfm-scalar-dev
- OIDC_CLIENT_SECRET=secret_71f88570a46eb22eb5270b47f69e3332c84d8db85d008f0276cb7431c8872d27
# Backend API Configuration
- API_BASE_URL=https://api.ifri.khongisa.co.za/
restart: unless-stopped
@@ -0,0 +1,4 @@
#!/bin/sh
set -e
exec "$@"
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Post Fund Management Console</title>
<meta name="description" content="Enterprise backoffice portal for Funders to manage portfolios, programmes, projects, grants, disbursements, and evidence workflows." />
<meta property="og:title" content="Post Fund Management Console" />
<meta property="og:description" content="Enterprise backoffice portal for Funders to manage portfolios, programmes, projects, grants, disbursements, and evidence workflows." />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,6 @@
{
"name": "Post Fund Management Console",
"description": "Enterprise backoffice portal for Funders to manage portfolios, programmes, projects, grants, disbursements, and evidence workflows.",
"requestFramePermissions": [],
"majorCapabilities": [ "MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API" ]
}
+37
View File
@@ -0,0 +1,37 @@
server {
listen 3000;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip Compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/json image/svg+xml;
# Cache control for static assets
location ~* \.(?:css|js|jpg|jpeg|gif|png|ico|cur|gz|svg|svgz|mp4|ogg|ogv|webm|htc)$ {
expires 1y;
access_log off;
add_header Cache-Control "public";
}
# Disable caching for appsettings.json so changes take effect immediately
location = /appsettings.json {
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate, post-check=0, pre-check=0";
}
# HTML5 SPA History Mode Support
location / {
try_files $uri $uri/ /index.html;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
+42
View File
@@ -0,0 +1,42 @@
{
"name": "react-example",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port=3000 --host=0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"clean": "rm -rf dist server.js",
"lint": "tsc --noEmit"
},
"dependencies": {
"@google/genai": "^2.4.0",
"@hookform/resolvers": "^5.9.1",
"@tailwindcss/vite": "^4.1.14",
"@tanstack/react-query": "^5.102.8",
"@vitejs/plugin-react": "^5.0.4",
"axios": "^1.20.0",
"dotenv": "^17.2.3",
"express": "^4.21.2",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"oidc-client-ts": "^3.5.0",
"react": "^19.0.1",
"react-dom": "^19.0.1",
"react-hook-form": "^7.87.0",
"react-router-dom": "^7.18.3",
"vite": "^6.2.3",
"zod": "^4.5.4"
},
"devDependencies": {
"@types/node": "^22.14.0",
"autoprefixer": "^10.4.21",
"esbuild": "^0.25.0",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.3",
"@types/express": "^4.17.21"
}
}
+51
View File
@@ -0,0 +1,51 @@
import React from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from './lib/queryClient';
import { AuthProvider, ProtectedRoute } from './auth/AuthProvider';
import { ToastProvider } from './components/ui/Toast';
import { AppLayout } from './components/layout/AppLayout';
import { LandingPage } from './pages/LandingPage';
import { ExecutiveDashboard } from './pages/ExecutiveDashboard';
import { GovernancePage } from './pages/GovernancePage';
import { ProjectsPage } from './pages/ProjectsPage';
import { GrantsPage } from './pages/GrantsPage';
import { MonitoringPage } from './pages/MonitoringPage';
import { SettingsPage } from './pages/SettingsPage';
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthProvider>
<ToastProvider>
<Routes>
{/* Public OIDC Login / Landing Page */}
<Route path="/" element={<LandingPage />} />
<Route path="/login" element={<LandingPage />} />
{/* Protected Backoffice Application Shell */}
<Route
element={
<ProtectedRoute>
<AppLayout />
</ProtectedRoute>
}
>
<Route path="/dashboard" element={<ExecutiveDashboard />} />
<Route path="/governance" element={<GovernancePage />} />
<Route path="/projects" element={<ProjectsPage />} />
<Route path="/grants" element={<GrantsPage />} />
<Route path="/monitoring" element={<MonitoringPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
{/* Fallback Redirect */}
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</ToastProvider>
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
);
}
@@ -0,0 +1,275 @@
import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { UserManager, WebStorageStateStore, User as OidcUser } from 'oidc-client-ts';
import { Navigate, useLocation } from 'react-router-dom';
import { getSecuritySettings, loadRuntimeConfig } from '../config';
import { parseJwtClaims } from './jwt';
export interface AuthUserProfile {
id: string;
sub: string;
name: string;
email: string;
role: string;
organisationName?: string;
avatarUrl?: string;
}
export type UserClaims = Record<string, any>;
interface AuthContextType {
user: AuthUserProfile | null;
sub: string;
claims: UserClaims;
accessToken: string | null;
isAuthenticated: boolean;
isLoading: boolean;
login: () => Promise<void>;
logout: () => Promise<void>;
authError: string | null;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
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 function getCurrentUserSub(): string {
if (typeof window !== 'undefined') {
return localStorage.getItem(LOCAL_STORAGE_SUB_KEY) || '';
}
return '';
}
export function getCurrentUserClaims(): UserClaims {
if (typeof window !== 'undefined') {
const raw = localStorage.getItem(LOCAL_STORAGE_CLAIMS_KEY);
if (raw) {
try {
return JSON.parse(raw);
} catch {
return {};
}
}
}
return {};
}
let userManagerInstance: UserManager | null = null;
function getUserManager(): UserManager {
if (!userManagerInstance && typeof window !== 'undefined') {
const sec = getSecuritySettings();
const origin = window.location.origin;
userManagerInstance = new UserManager({
authority: sec.Authority,
client_id: sec.ClientId,
client_secret: sec.ClientSecret,
redirect_uri: `${origin}/dashboard`,
post_logout_redirect_uri: `${origin}/`,
response_type: 'code',
scope: 'openid profile roles email address pfm-api-dev',
extraQueryParams: {
audience: sec.Audience,
},
userStore: new WebStorageStateStore({ store: window.localStorage }),
automaticSilentRenew: false,
});
}
return userManagerInstance!;
}
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [user, setUser] = useState<AuthUserProfile | null>(null);
const [sub, setSub] = useState<string>('');
const [claims, setClaims] = useState<UserClaims>({});
const [accessToken, setAccessToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [authError, setAuthError] = useState<string | null>(null);
// Initialize auth state and check for OIDC callback
useEffect(() => {
const initAuth = async () => {
try {
await loadRuntimeConfig();
// Check for URL query code indicating OIDC redirect callback
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('code') && urlParams.has('state')) {
const mgr = getUserManager();
const oidcUser: OidcUser = await mgr.signinCallback();
if (oidcUser && oidcUser.access_token) {
const token = oidcUser.access_token;
setAccessToken(token);
localStorage.setItem(LOCAL_STORAGE_TOKEN_KEY, token);
// Merge claims from profile and decoded JWT token
const decodedTokenClaims = parseJwtClaims(token);
const mergedClaims: UserClaims = {
...decodedTokenClaims,
...oidcUser.profile,
};
const userSub = (oidcUser.profile?.sub || mergedClaims.sub || '') as string;
setSub(userSub);
setClaims(mergedClaims);
localStorage.setItem(LOCAL_STORAGE_SUB_KEY, userSub);
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(mergedClaims));
const profile: AuthUserProfile = {
id: userSub || 'user-1',
sub: userSub,
name: oidcUser.profile.name || oidcUser.profile.preferred_username || (mergedClaims.name as string) || 'Authorized User',
email: oidcUser.profile.email || (mergedClaims.email as string) || '',
role: (oidcUser.profile.role as string) || (mergedClaims.role as string) || (Array.isArray(mergedClaims.roles) ? mergedClaims.roles[0] : 'Programme Sponsor'),
organisationName: (mergedClaims.organisation as string) || (mergedClaims.org as string) || undefined,
};
setUser(profile);
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile));
// Clean up code and state from URL
window.history.replaceState({}, document.title, window.location.pathname);
setIsLoading(false);
return;
}
}
// Check local storage for persisted session
const storedToken = localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY);
const storedUser = localStorage.getItem(LOCAL_STORAGE_USER_KEY);
let storedSub = localStorage.getItem(LOCAL_STORAGE_SUB_KEY);
const storedClaimsRaw = localStorage.getItem(LOCAL_STORAGE_CLAIMS_KEY);
if (storedToken) {
setAccessToken(storedToken);
let parsedClaims: UserClaims = {};
if (storedClaimsRaw) {
try {
parsedClaims = JSON.parse(storedClaimsRaw);
} catch { }
} else {
parsedClaims = parseJwtClaims(storedToken);
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(parsedClaims));
}
setClaims(parsedClaims);
if (!storedSub && parsedClaims.sub) {
storedSub = String(parsedClaims.sub);
localStorage.setItem(LOCAL_STORAGE_SUB_KEY, storedSub);
}
if (storedSub) {
setSub(storedSub);
}
if (storedUser) {
try {
setUser(JSON.parse(storedUser));
} catch { }
}
}
} catch (err: unknown) {
console.warn('OIDC initialization notice:', err);
setAuthError(err instanceof Error ? err.message : 'Auth initialization notice');
} finally {
setIsLoading(false);
}
};
initAuth();
}, []);
const login = async () => {
try {
setAuthError(null);
const mgr = getUserManager();
await mgr.signinRedirect();
} catch (err: unknown) {
console.warn('OIDC Redirect encountered:', err);
// In sandboxed preview iframe, navigation to third-party STS 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. For testing with Duende STS (https://sts.security.khongisa.co.za), open the application in a new tab or Docker container.';
setAuthError(errorMsg);
} else {
setAuthError(err instanceof Error ? err.message : 'Identity Provider redirect error');
}
}
};
const logout = async () => {
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);
setUser(null);
setSub('');
setClaims({});
setAccessToken(null);
try {
const mgr = getUserManager();
await mgr.signoutRedirect();
} catch {
// Local state cleared
}
} catch (err) {
console.error('Logout error:', err);
}
};
return (
<AuthContext.Provider
value={{
user,
sub,
claims,
accessToken,
isAuthenticated: !!accessToken,
isLoading,
login,
logout,
authError,
}}
>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
// Route Guard
export const ProtectedRoute: React.FC<{ children: ReactNode }> = ({ children }) => {
const { isAuthenticated, isLoading } = useAuth();
const location = useLocation();
if (isLoading) {
return (
<div className="flex h-screen w-full items-center justify-center bg-[#0d0d0d]">
<div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-indigo-500 border-t-transparent"></div>
<p className="text-xs font-medium text-gray-400">Verifying PFM Console Session...</p>
</div>
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/" state={{ from: location }} replace />;
}
return <>{children}</>;
};
@@ -0,0 +1,23 @@
/**
* Lightweight browser-safe JWT payload decoder without external native dependencies.
*/
export function parseJwtClaims(token: string): Record<string, any> {
if (!token || typeof token !== 'string') return {};
try {
const parts = token.split('.');
if (parts.length < 2) return {};
const base64Url = parts[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(
window
.atob(base64)
.split('')
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join('')
);
return JSON.parse(jsonPayload);
} catch (err) {
console.debug('Failed to decode JWT claims payload:', err);
return {};
}
}
@@ -0,0 +1,279 @@
import React, { useState } from 'react';
import { NavLink, Outlet, useNavigate, Link } from 'react-router-dom';
import {
LayoutDashboard,
Building2,
FolderKanban,
Coins,
ShieldAlert,
Settings,
LogOut,
Menu,
X,
ChevronRight,
ShieldCheck,
Layers,
Database,
Home,
User,
} from 'lucide-react';
import { useAuth } from '../../auth/AuthProvider';
const navItems = [
{
name: 'Executive Dashboard',
path: '/dashboard',
icon: LayoutDashboard,
},
{
name: 'Governance & Structure',
path: '/governance',
icon: Building2,
},
{
name: 'Projects & Milestones',
path: '/projects',
icon: FolderKanban,
},
{
name: 'Grant & Financials',
path: '/grants',
icon: Coins,
},
{
name: 'M&E, Evidence & Risks',
path: '/monitoring',
icon: ShieldAlert,
},
{
name: 'Settings & Communications',
path: '/settings',
icon: Settings,
},
];
export const AppLayout: React.FC = () => {
const { user, claims, sub, logout } = useAuth();
const [sidebarOpen, setSidebarOpen] = useState(true);
const [userMenuOpen, setUserMenuOpen] = useState(false);
const navigate = useNavigate();
const handleLogout = async () => {
await logout();
};
// Extract name and email directly from validated token claims
const tokenUserName =
user?.name ||
(claims.name as string) ||
(claims.preferred_username as string) ||
(claims.given_name ? `${claims.given_name} ${claims.family_name || ''}`.trim() : '') ||
'Authorized User';
const tokenUserEmail =
user?.email ||
(claims.email as string) ||
(claims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] as string) ||
(claims.upn as string) ||
(sub ? `sub:${sub}` : '');
return (
<div className="flex h-screen w-full overflow-hidden bg-[#090d16] font-sans text-slate-100 selection:bg-blue-600 selection:text-white">
{/* Sidebar */}
<aside
className={`${sidebarOpen ? 'w-64' : 'w-20'
} shrink-0 bg-[#0b101b]/95 text-slate-200 border-r border-slate-800/80 transition-all duration-300 flex flex-col z-30 shadow-2xl backdrop-blur-md`}
>
{/* Brand Header — Clickable link navigating to auth-aware Home page */}
<div className="h-16 px-4 flex items-center justify-between border-b border-slate-800/80 bg-[#0d121f]/90">
<Link
to="/"
id="nav-brand-home-link"
title="Go to Home page"
className="flex items-center gap-3 overflow-hidden group cursor-pointer"
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-blue-600 to-indigo-700 font-bold text-white shadow-md shadow-blue-500/20 border border-blue-400/20 group-hover:scale-105 transition">
<Layers className="w-5 h-5 text-white" />
</div>
{sidebarOpen && (
<div className="flex flex-col min-w-0">
<span className="font-semibold text-sm tracking-tight text-white truncate group-hover:text-blue-400 transition">
PFM Console
</span>
<span className="text-[11px] text-blue-400 font-medium tracking-tight">Enterprise Management</span>
</div>
)}
</Link>
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="rounded-lg p-1.5 text-slate-400 hover:text-white hover:bg-slate-800/60 transition cursor-pointer"
title={sidebarOpen ? 'Collapse sidebar' : 'Expand sidebar'}
>
{sidebarOpen ? <X className="w-4 h-4" /> : <Menu className="w-4 h-4" />}
</button>
</div>
{/* Navigation Items */}
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-1.5">
{navItems.map((item) => {
const Icon = item.icon;
return (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
`flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all ${isActive
? 'bg-gradient-to-r from-blue-600 to-indigo-600 text-white shadow-md shadow-blue-600/25 font-semibold'
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
}`
}
>
<Icon className="w-4 h-4 shrink-0" />
{sidebarOpen && <span className="truncate">{item.name}</span>}
</NavLink>
);
})}
</nav>
{/* User Card with Token-derived info & Identity Server Logout */}
<div className="p-3 border-t border-slate-800/80 bg-[#0d121f]/90">
<div className="flex items-center gap-3">
<div className="h-9 w-9 shrink-0 rounded-full bg-blue-600/20 border border-blue-500/40 flex items-center justify-center font-bold text-xs text-blue-400">
{tokenUserName ? tokenUserName.slice(0, 2).toUpperCase() : 'AU'}
</div>
{sidebarOpen && (
<div className="flex-1 min-w-0">
<p className="text-xs font-semibold text-white truncate" title={tokenUserName}>
{tokenUserName}
</p>
<p className="text-[11px] text-slate-400 truncate font-mono" title={tokenUserEmail}>
{tokenUserEmail || 'STS Token User'}
</p>
</div>
)}
<button
onClick={handleLogout}
id="sidebar-logout-btn"
className="rounded-lg p-1.5 text-slate-400 hover:text-rose-400 hover:bg-rose-950/30 transition cursor-pointer"
title="Sign Out via Identity Server"
>
<LogOut className="w-4 h-4" />
</button>
</div>
</div>
</aside>
{/* Main Content Area */}
<div className="flex-1 flex flex-col min-w-0 overflow-hidden relative">
{/* Top Header */}
<header className="h-16 bg-[#0d121f]/90 border-b border-slate-800/80 backdrop-blur-md px-6 md:px-8 flex items-center justify-between shrink-0 z-20">
<div className="flex items-center gap-3">
{/* Clickable Home Link */}
<Link
to="/"
id="header-home-link"
className="flex items-center gap-1.5 text-xs font-semibold text-slate-300 hover:text-white bg-slate-900/80 hover:bg-slate-800 px-3 py-1.5 rounded-lg border border-slate-800 transition cursor-pointer shadow-xs"
title="Return to Auth-Aware Home page"
>
<Home className="w-3.5 h-3.5 text-blue-400" />
<span>Home</span>
</Link>
<ChevronRight className="w-4 h-4 text-slate-600" />
<div className="hidden sm:flex items-center gap-2 text-xs font-medium text-slate-300">
<span className="text-slate-400">Tenant:</span>
<span className="bg-slate-900/80 text-slate-200 px-2.5 py-1 rounded-lg border border-slate-800 flex items-center gap-1.5 font-medium shadow-xs">
<Database className="w-3.5 h-3.5 text-blue-400" />
Department of Science, Innovation & Technology (DSI)
</span>
</div>
<ChevronRight className="w-4 h-4 text-slate-600 hidden sm:block" />
<span className="text-xs font-medium text-emerald-400 bg-emerald-500/10 px-2.5 py-0.5 rounded-full border border-emerald-500/30 flex items-center gap-1.5 shadow-xs">
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
Authenticated Session
</span>
</div>
{/* User Name & Email derived from token, plus Identity Server signout */}
<div className="relative flex items-center gap-3">
<div className="text-right hidden sm:block">
<p className="text-xs font-semibold text-white truncate max-w-[200px]" title={tokenUserName}>
{tokenUserName}
</p>
<p className="text-[11px] text-slate-400 font-mono truncate max-w-[200px]" title={tokenUserEmail}>
{tokenUserEmail}
</p>
</div>
<button
onClick={() => setUserMenuOpen(!userMenuOpen)}
id="header-user-menu-btn"
className="h-9 w-9 rounded-full bg-blue-600/20 border border-blue-500/40 hover:border-blue-400 flex items-center justify-center font-bold text-xs text-blue-400 transition cursor-pointer shadow-md shadow-blue-500/10"
title="User Account"
>
{tokenUserName ? tokenUserName.slice(0, 2).toUpperCase() : <User className="w-4 h-4" />}
</button>
{/* Dropdown Menu */}
{userMenuOpen && (
<div
className="absolute right-0 top-12 w-64 bg-[#0f1523] border border-slate-800 rounded-2xl shadow-2xl p-3 z-50 space-y-2 animate-in fade-in slide-in-from-top-2 backdrop-blur-md"
onMouseLeave={() => setUserMenuOpen(false)}
>
<div className="p-2.5 border-b border-slate-800">
<p className="text-xs font-semibold text-white">{tokenUserName}</p>
<p className="text-[11px] text-slate-400 font-mono truncate">{tokenUserEmail}</p>
{sub && <p className="text-[10px] text-slate-500 font-mono mt-1">sub: {sub}</p>}
</div>
<Link
to="/"
onClick={() => setUserMenuOpen(false)}
className="flex items-center gap-2 px-3 py-2 text-xs text-slate-300 hover:text-white hover:bg-slate-800/60 rounded-xl transition"
>
<Home className="w-3.5 h-3.5 text-blue-400" />
<span>Go to Home Page</span>
</Link>
<button
onClick={handleLogout}
id="dropdown-logout-btn"
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-rose-400 hover:bg-rose-950/30 rounded-xl transition cursor-pointer"
>
<LogOut className="w-3.5 h-3.5" />
<span>Sign Out via Identity Server</span>
</button>
</div>
)}
</div>
</header>
{/* Viewport with Landing Page ambient dot grid and glow */}
<main className="flex-1 overflow-y-auto p-6 md:p-8 bg-[#090d16] relative text-slate-100">
<div
className="absolute inset-0 pointer-events-none opacity-15"
style={{
backgroundImage: 'radial-gradient(circle at 1px 1px, rgba(255,255,255,0.15) 1px, transparent 0)',
backgroundSize: '32px 32px',
}}
/>
<div className="absolute -top-24 -left-24 w-80 h-80 bg-blue-600/10 rounded-full blur-3xl pointer-events-none" />
<div className="absolute top-1/2 -right-24 w-80 h-80 bg-indigo-600/10 rounded-full blur-3xl pointer-events-none" />
<div className="relative z-10">
<Outlet />
</div>
</main>
{/* Bottom Status Footer */}
<footer className="h-10 px-6 md:px-8 border-t border-slate-800/80 bg-[#0d121f]/90 flex items-center justify-between text-[11px] text-slate-400 shrink-0">
<span>Project Financial Management (PFM) System</span>
<span className="text-slate-400 font-medium">Duende STS OIDC Secure Session</span>
</footer>
</div>
</div>
);
};
@@ -0,0 +1,67 @@
import React, { ReactNode, useEffect } from 'react';
import { X } from 'lucide-react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
subtitle?: string;
children: ReactNode;
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
}
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
subtitle,
children,
maxWidth = 'lg',
}) => {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
if (isOpen) {
document.body.style.overflow = 'hidden';
window.addEventListener('keydown', handleKeyDown);
}
return () => {
document.body.style.overflow = 'unset';
window.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, onClose]);
if (!isOpen) return null;
const maxWidthClass = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl': 'max-w-2xl',
}[maxWidth];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-150">
<div
className={`relative w-full ${maxWidthClass} bg-[#0f1523] rounded-2xl shadow-2xl border border-slate-800/90 overflow-hidden flex flex-col max-h-[90vh] text-slate-200`}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start justify-between p-5 border-b border-slate-800/80 bg-[#0d121f]/90">
<div>
<h3 className="text-lg font-semibold text-white tracking-tight">{title}</h3>
{subtitle && <p className="text-xs text-slate-400 mt-0.5">{subtitle}</p>}
</div>
<button
onClick={onClose}
className="rounded-xl p-1.5 text-slate-400 hover:text-white hover:bg-slate-800/60 transition cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 overflow-y-auto flex-1 bg-[#0f1523]">{children}</div>
</div>
</div>
);
};
@@ -0,0 +1,79 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
import { CheckCircle2, AlertCircle, Info, X } from 'lucide-react';
export type ToastType = 'success' | 'error' | 'info';
interface ToastItem {
id: string;
type: ToastType;
title: 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;
}
const ToastContext = createContext<ToastContextType | undefined>(undefined);
export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const removeToast = (id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
};
const addToast = (type: ToastType, title: string, message?: string) => {
const id = Math.random().toString(36).substring(2, 9);
setToasts((prev) => [...prev, { id, type, title, message }]);
setTimeout(() => removeToast(id), 5000);
};
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);
return (
<ToastContext.Provider value={{ toast: addToast, success, error, info }}>
{children}
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-md w-full pointer-events-none p-2">
{toasts.map((t) => (
<div
key={t.id}
className={`pointer-events-auto flex items-start gap-3 p-4 rounded-xl border shadow-2xl transition-all duration-200 bg-[#141414] ${t.type === 'success'
? 'border-green-500/30 text-gray-200'
: t.type === 'error'
? 'border-rose-500/30 text-gray-200'
: 'border-indigo-500/30 text-gray-200'
}`}
>
{t.type === 'success' && <CheckCircle2 className="w-5 h-5 text-green-400 shrink-0 mt-0.5" />}
{t.type === 'error' && <AlertCircle className="w-5 h-5 text-rose-400 shrink-0 mt-0.5" />}
{t.type === 'info' && <Info className="w-5 h-5 text-indigo-400 shrink-0 mt-0.5" />}
<div className="flex-1">
<h4 className="text-sm font-semibold text-white">{t.title}</h4>
{t.message && <p className="text-xs mt-0.5 text-gray-400 leading-relaxed">{t.message}</p>}
</div>
<button
onClick={() => removeToast(t.id)}
className="text-gray-400 hover:text-white p-0.5 rounded transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
};
export const useToast = () => {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
};
@@ -0,0 +1,43 @@
export interface SecuritySettings {
Authority: string;
Audience: string;
ClientId: string;
ClientSecret: string;
}
export interface ApiSettings {
BaseUrl: string;
}
export interface AppConfig {
SecuritySettings: SecuritySettings;
ApiSettings: ApiSettings;
}
export const defaultSecuritySettings: SecuritySettings = {
Authority: (import.meta.env.VITE_OIDC_AUTHORITY as string) || 'https://sts.security.khongisa.co.za',
Audience: (import.meta.env.VITE_OIDC_AUDIENCE as string) || 'pfm-api-dev',
ClientId: (import.meta.env.VITE_OIDC_CLIENT_ID as string) || 'pfm-scalar-dev',
ClientSecret: (import.meta.env.VITE_OIDC_CLIENT_SECRET as string) || 'secret_71f88570a46eb22eb5270b47f69e3332c84d8db85d008f0276cb7431c8872d27',
};
export const defaultApiSettings: ApiSettings = {
BaseUrl: (import.meta.env.VITE_API_BASE_URL as string) || 'https://api.ifri.khongisa.co.za/',
};
const runtimeConfig: AppConfig = {
SecuritySettings: defaultSecuritySettings,
ApiSettings: defaultApiSettings,
};
export async function loadRuntimeConfig(): Promise<AppConfig> {
return runtimeConfig;
}
export function getSecuritySettings(): SecuritySettings {
return runtimeConfig.SecuritySettings;
}
export function getApiBaseUrl(): string {
return runtimeConfig.ApiSettings.BaseUrl;
}
+256
View File
@@ -0,0 +1,256 @@
@import "tailwindcss";
@layer base {
:root {
color-scheme: dark;
}
body {
background-color: #0a0a0a;
color: #e5e7eb;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
/* Form control color scheme */
input, select, textarea {
color-scheme: dark;
}
/* Custom dark scrollbar styling */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #0a0a0a;
}
::-webkit-scrollbar-thumb {
background: #262626;
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background: #3f3f46;
}
}
/* High Contrast Dark Theme Adaptations for existing utility classes */
.bg-white {
background-color: #111111 !important;
}
.bg-slate-50,
.bg-slate-50\/50,
.bg-slate-50\/60,
.bg-slate-50\/75,
.bg-slate-50\/80,
.bg-slate-50\/90 {
background-color: #161616 !important;
}
.bg-slate-100,
.bg-slate-200 {
background-color: #202020 !important;
}
.border-slate-100 {
border-color: #222222 !important;
}
.border-slate-200 {
border-color: #2d2d2d !important;
}
.border-slate-300 {
border-color: #3d3d3d !important;
}
.divide-slate-100 > :not([hidden]) ~ :not([hidden]),
.divide-slate-200 > :not([hidden]) ~ :not([hidden]) {
border-color: #242424 !important;
}
/* Text contrast enhancements */
.text-slate-900 {
color: #ffffff !important;
}
.text-slate-800 {
color: #f8fafc !important;
}
.text-slate-700 {
color: #f1f5f9 !important;
}
.text-slate-600 {
color: #e2e8f0 !important;
}
.text-slate-500 {
color: #cbd5e1 !important;
}
.text-slate-400 {
color: #94a3b8 !important;
}
.text-gray-900 {
color: #ffffff !important;
}
.text-gray-800 {
color: #f8fafc !important;
}
.text-gray-700 {
color: #f1f5f9 !important;
}
.text-gray-600 {
color: #e2e8f0 !important;
}
.text-gray-500 {
color: #cbd5e1 !important;
}
.text-gray-400 {
color: #94a3b8 !important;
}
/* High-contrast status badges and notification banners */
.bg-emerald-50,
.bg-emerald-50\/40,
.bg-emerald-50\/60 {
background-color: rgba(16, 185, 129, 0.16) !important;
}
.text-emerald-700,
.text-emerald-800,
.text-emerald-900 {
color: #6ee7b7 !important;
}
.border-emerald-200,
.border-emerald-300 {
border-color: rgba(110, 231, 183, 0.35) !important;
}
.bg-amber-50,
.bg-amber-50\/40,
.bg-amber-50\/60 {
background-color: rgba(245, 158, 11, 0.16) !important;
}
.text-amber-700,
.text-amber-800,
.text-amber-900 {
color: #fcd34d !important;
}
.border-amber-200,
.border-amber-300 {
border-color: rgba(252, 211, 77, 0.35) !important;
}
.bg-rose-50,
.bg-rose-50\/40,
.bg-rose-50\/60 {
background-color: rgba(244, 63, 94, 0.16) !important;
}
.text-rose-700,
.text-rose-800,
.text-rose-900 {
color: #fda4af !important;
}
.border-rose-200,
.border-rose-300 {
border-color: rgba(253, 164, 175, 0.35) !important;
}
.bg-purple-50,
.bg-indigo-50 {
background-color: rgba(139, 92, 246, 0.16) !important;
}
.text-purple-700,
.text-purple-800,
.text-indigo-700,
.text-indigo-800 {
color: #d8b4fe !important;
}
.border-purple-200,
.border-purple-300,
.border-indigo-200,
.border-indigo-300 {
border-color: rgba(216, 180, 254, 0.35) !important;
}
.bg-sky-50,
.bg-blue-50 {
background-color: rgba(59, 130, 246, 0.16) !important;
}
.text-sky-700,
.text-sky-800,
.text-blue-700,
.text-blue-800 {
color: #93c5fd !important;
}
.border-sky-200,
.border-sky-300,
.border-blue-200,
.border-blue-300 {
border-color: rgba(147, 197, 253, 0.35) !important;
}
/* Form inputs, textareas, and select controls */
input:not([type="checkbox"]):not([type="radio"]):not([type="submit"]):not([type="button"]),
select,
textarea {
background-color: #141414 !important;
color: #ffffff !important;
border-color: #383838 !important;
}
input::placeholder,
textarea::placeholder {
color: #94a3b8 !important;
}
/* Table header text high contrast */
th {
color: #cbd5e1 !important;
}
.hover\:bg-slate-50:hover,
.hover\:bg-slate-50\/80:hover,
.hover\:bg-slate-100:hover {
background-color: #1f1f1f !important;
}
.hover\:text-slate-800:hover,
.hover\:text-slate-900:hover {
color: #ffffff !important;
}
/* Primary actions matching the clean blue/indigo accent */
.bg-emerald-600 {
background-color: #2563eb !important;
}
.hover\:bg-emerald-500:hover,
.hover\:bg-emerald-700:hover {
background-color: #1d4ed8 !important;
}
.border-emerald-600 {
border-color: #3b82f6 !important;
}
@@ -0,0 +1,898 @@
import {
ApprovalStatus,
ContractStatus,
ActivityStatus,
Priority,
OrganisationType,
IndustrySector,
ProjectType,
UnitOfMeasure,
EvidenceType,
RiskLikelihood,
RiskImpact,
NotificationPlatform,
NotificationStatus,
EmailStatuses,
} from '../types';
export const formatZAR = (amount: number): string => {
return new Intl.NumberFormat('en-ZA', {
style: 'currency',
currency: 'ZAR',
maximumFractionDigits: 0,
}).format(amount);
};
export const formatCompactZAR = (amount: number): string => {
if (amount >= 1_000_000_000) {
return `R ${(amount / 1_000_000_000).toFixed(2)}bn`;
}
if (amount >= 1_000_000) {
return `R ${(amount / 1_000_000).toFixed(1)}m`;
}
if (amount >= 1_000) {
return `R ${(amount / 1_000).toFixed(0)}k`;
}
return formatZAR(amount);
};
export const formatDate = (dateStr?: string | null): string => {
if (!dateStr) return '—';
try {
const d = new Date(dateStr);
return d.toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
} catch {
return dateStr;
}
};
// ==========================================
// Dropdown Select Options with Integer Values
// ==========================================
export const EMAIL_STATUS_OPTIONS = [
{ value: EmailStatuses.GeneralError, label: 'General Error' },
{ value: EmailStatuses.AuthenticationError, label: 'Authentication Error' },
{ value: EmailStatuses.ProtocolError, label: 'Protocol Error' },
{ value: EmailStatuses.Connected, label: 'Connected' },
{ value: EmailStatuses.Disconnected, label: 'Disconnected' },
{ value: EmailStatuses.TooManyConnections, label: 'Too Many Connections' },
{ value: EmailStatuses.ConnectionAborted, label: 'Connection Aborted' },
{ value: EmailStatuses.Success, label: 'Success' },
];
export const EMAIL_STATUSES_OPTIONS = EMAIL_STATUS_OPTIONS;
export const NOTIFICATION_STATUS_OPTIONS = [
{ value: NotificationStatus.Pending, label: 'Pending' },
{ value: NotificationStatus.Queued, label: 'Queued' },
{ value: NotificationStatus.Processing, label: 'Processing' },
{ value: NotificationStatus.Sent, label: 'Sent' },
{ value: NotificationStatus.Delivered, label: 'Delivered' },
{ value: NotificationStatus.Read, label: 'Read' },
{ value: NotificationStatus.Failed, label: 'Failed' },
{ value: NotificationStatus.Cancelled, label: 'Cancelled' },
{ value: NotificationStatus.Bounced, label: 'Bounced' },
];
export const NOTIFICATION_PLATFORM_OPTIONS = [
{ value: NotificationPlatform.Email, label: 'Email' },
{ value: NotificationPlatform.SMS, label: 'SMS' },
{ value: NotificationPlatform.WhatsApp, label: 'WhatsApp' },
{ value: NotificationPlatform.PushNotification, label: 'Push Notification' },
{ value: NotificationPlatform.InApp, label: 'In App' },
{ value: NotificationPlatform.Teams, label: 'Teams' },
{ value: NotificationPlatform.Slack, label: 'Slack' },
{ value: NotificationPlatform.Webhook, label: 'Webhook' },
{ value: NotificationPlatform.Other, label: 'Other' },
];
export const EVIDENCE_TYPE_OPTIONS = [
{ value: EvidenceType.AttendanceRegister, label: 'Attendance Register' },
{ value: EvidenceType.BankStatement, label: 'Bank Statement' },
{ value: EvidenceType.CompletionCertificate, label: 'Completion Certificate' },
{ value: EvidenceType.FinancialInvoiceOrReceipt, label: 'Financial Invoice Or Receipt' },
{ value: EvidenceType.GeotaggedPhoto, label: 'Geotagged Photo' },
{ value: EvidenceType.InspectionReport, label: 'Inspection Report' },
{ value: EvidenceType.MeetingMinutes, label: 'Meeting Minutes' },
{ value: EvidenceType.PayrollOrStipendRegister, label: 'Payroll Or Stipend Register' },
{ value: EvidenceType.ProcurementDocument, label: 'Procurement Document' },
{ value: EvidenceType.TechnicalSpecificationOrDesign, label: 'Technical Specification Or Design' },
{ value: EvidenceType.VideoRecording, label: 'Video Recording' },
{ value: EvidenceType.Other, label: 'Other' },
];
export const PROJECT_TYPE_OPTIONS = [
{ value: ProjectType.CapacityBuildingAndTraining, label: 'Capacity Building And Training' },
{ value: ProjectType.CapitalExpenditureAndInfrastructure, label: 'Capital Expenditure And Infrastructure' },
{ value: ProjectType.CommercializationAndScaling, label: 'Commercialization And Scaling' },
{ value: ProjectType.FeasibilityAndTechnicalAssistance, label: 'Feasibility And Technical Assistance' },
{ value: ProjectType.OperationalGrantAndSubsidy, label: 'Operational Grant And Subsidy' },
{ value: ProjectType.ProductDevelopmentAndPrototyping, label: 'Product Development And Prototyping' },
{ value: ProjectType.ResearchAndDevelopment, label: 'Research And Development' },
{ value: ProjectType.SocialImpactAndCommunityDevelopment, label: 'Social Impact And Community Development' },
{ value: ProjectType.TechnologyTransferAndAdoption, label: 'Technology Transfer And Adoption' },
{ value: ProjectType.Innovation, label: 'Innovation' },
{ value: ProjectType.Other, label: 'Other' },
];
export const INDUSTRY_SECTOR_OPTIONS = [
{ value: IndustrySector.AgricultureAndAgroProcessing, label: 'Agriculture And Agro Processing' },
{ value: IndustrySector.AutomotiveAndTransportEquipment, label: 'Automotive And Transport Equipment' },
{ value: IndustrySector.ChemicalsAndPharmaceuticals, label: 'Chemicals And Pharmaceuticals' },
{ value: IndustrySector.ConstructionAndInfrastructure, label: 'Construction And Infrastructure' },
{ value: IndustrySector.CreativeIndustriesAndMedia, label: 'Creative Industries And Media' },
{ value: IndustrySector.DefenseAndAerospace, label: 'Defense And Aerospace' },
{ value: IndustrySector.EducationAndSkillsDevelopment, label: 'Education And Skills Development' },
{ value: IndustrySector.EnergyAndUtilities, label: 'Energy And Utilities' },
{ value: IndustrySector.FinancialServices, label: 'Financial Services' },
{ value: IndustrySector.HealthcareAndLifeSciences, label: 'Healthcare And Life Sciences' },
{ value: IndustrySector.InformationCommunicationTechnology, label: 'Information Communication Technology' },
{ value: IndustrySector.ManufacturingAndIndustrial, label: 'Manufacturing And Industrial' },
{ value: IndustrySector.MiningAndMetals, label: 'Mining And Metals' },
{ value: IndustrySector.RealEstateAndHousing, label: 'Real Estate And Housing' },
{ value: IndustrySector.RetailAndConsumerGoods, label: 'Retail And Consumer Goods' },
{ value: IndustrySector.TourismAndHospitality, label: 'Tourism And Hospitality' },
{ value: IndustrySector.WaterAndSanitation, label: 'Water And Sanitation' },
{ value: IndustrySector.Other, label: 'Other' },
];
export const ORGANISATION_TYPE_OPTIONS = [
{ value: OrganisationType.NonGovernmentalOrganisation, label: 'Non-Governmental Organisation' },
{ value: OrganisationType.CivilSocietyOrganisation, label: 'Civil Society Organisation' },
{ value: OrganisationType.Trust, label: 'Trust' },
{ value: OrganisationType.FaithBasedOrganisation, label: 'Faith-Based Organisation' },
{ value: OrganisationType.PrivateLimitedCompany, label: 'Private Limited Company' },
{ value: OrganisationType.PublicLimitedCompany, label: 'Public Limited Company' },
{ value: OrganisationType.SoleProprietorship, label: 'Sole Proprietorship' },
{ value: OrganisationType.Partnership, label: 'Partnership' },
{ value: OrganisationType.SocialEnterprise, label: 'Social Enterprise' },
{ value: OrganisationType.NationalGovernmentMinistry, label: 'National Government Ministry' },
{ value: OrganisationType.ProvincialGovernmentEntity, label: 'Provincial Government Entity' },
{ value: OrganisationType.LocalGovernmentMunicipality, label: 'Local Government Municipality' },
{ value: OrganisationType.StateOwnedEnterprise, label: 'State-Owned Enterprise' },
{ value: OrganisationType.PublicAgency, label: 'Public Agency' },
{ value: OrganisationType.PublicUniversity, label: 'Public University' },
{ value: OrganisationType.PrivateUniversity, label: 'Private University' },
{ value: OrganisationType.ResearchInstitute, label: 'Research Institute' },
{ value: OrganisationType.TechnicalVocationalCollege, label: 'Technical Vocational College' },
{ value: OrganisationType.Cooperative, label: 'Cooperative' },
{ value: OrganisationType.MicrofinanceInstitution, label: 'Microfinance Institution' },
{ value: OrganisationType.CommercialBank, label: 'Commercial Bank' },
{ value: OrganisationType.DevelopmentFinanceInstitution, label: 'Development Finance Institution' },
{ value: OrganisationType.UnitedNationsAgency, label: 'United Nations Agency' },
{ value: OrganisationType.IntergovernmentalOrganisation, label: 'Intergovernmental Organisation' },
{ value: OrganisationType.InternationalNGO, label: 'International NGO' },
{ value: OrganisationType.Consortium, label: 'Consortium' },
{ value: OrganisationType.SpecialPurposeVehicle, label: 'Special Purpose Vehicle' },
];
export const PRIORITY_OPTIONS = [
{ value: Priority.Low, label: 'Low' },
{ value: Priority.Medium, label: 'Medium' },
{ value: Priority.High, label: 'High' },
{ value: Priority.Critical, label: 'Critical' },
];
export const UNIT_OF_MEASURE_OPTIONS = [
{ value: UnitOfMeasure.Count, label: 'Count' },
{ value: UnitOfMeasure.Percentage, label: 'Percentage' },
{ value: UnitOfMeasure.Currency, label: 'Currency' },
{ value: UnitOfMeasure.Area, label: 'Area' },
{ value: UnitOfMeasure.Weight, label: 'Weight' },
{ value: UnitOfMeasure.Volume, label: 'Volume' },
{ value: UnitOfMeasure.DurationDays, label: 'Duration (Days)' },
{ value: UnitOfMeasure.DurationMonths, label: 'Duration (Months)' },
{ value: UnitOfMeasure.Ratio, label: 'Ratio' },
];
export const CONTRACT_STATUS_OPTIONS = [
{ value: ContractStatus.Draft, label: 'Draft' },
{ value: ContractStatus.PendingApproval, label: 'Pending Approval' },
{ value: ContractStatus.Active, label: 'Active' },
{ value: ContractStatus.Expired, label: 'Expired' },
{ value: ContractStatus.Terminated, label: 'Terminated' },
{ value: ContractStatus.Amended, label: 'Amended' },
];
export const APPROVAL_STATUS_OPTIONS = [
{ value: ApprovalStatus.Pending, label: 'Pending' },
{ value: ApprovalStatus.Review, label: 'Review' },
{ value: ApprovalStatus.Approved, label: 'Approved' },
{ value: ApprovalStatus.Rejected, label: 'Rejected' },
{ value: ApprovalStatus.Removed, label: 'Removed' },
];
export const ACTIVITY_STATUS_OPTIONS = [
{ value: ActivityStatus.Active, label: 'Active' },
{ value: ActivityStatus.Inactive, label: 'Inactive' },
{ value: ActivityStatus.Disabled, label: 'Disabled' },
];
export const RISK_LIKELIHOOD_OPTIONS = [
{ value: RiskLikelihood.Rare, label: 'Rare' },
{ value: RiskLikelihood.Unlikely, label: 'Unlikely' },
{ value: RiskLikelihood.Possible, label: 'Possible' },
{ value: RiskLikelihood.Likely, label: 'Likely' },
{ value: RiskLikelihood.AlmostCertain, label: 'Almost Certain' },
];
export const RISK_IMPACT_OPTIONS = [
{ value: RiskImpact.Minor, label: 'Minor' },
{ value: RiskImpact.Moderate, label: 'Moderate' },
{ value: RiskImpact.Major, label: 'Major' },
{ value: RiskImpact.Critical, label: 'Critical' },
{ value: RiskImpact.Severe, label: 'Severe' },
];
// ==========================================
// Status Badges & Labels
// ==========================================
export const getApprovalStatusBadge = (status: ApprovalStatus | number) => {
const s = Number(status);
switch (s) {
case ApprovalStatus.Approved:
return {
label: 'Approved',
className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30 font-semibold',
dot: 'bg-emerald-500',
};
case ApprovalStatus.Review:
return {
label: 'Review',
className: 'bg-amber-500/10 text-amber-400 border-amber-500/30 font-semibold',
dot: 'bg-amber-500',
};
case ApprovalStatus.Rejected:
return {
label: 'Rejected',
className: 'bg-rose-500/10 text-rose-400 border-rose-500/30 font-semibold',
dot: 'bg-rose-500',
};
case ApprovalStatus.Removed:
return {
label: 'Removed',
className: 'bg-slate-800/80 text-slate-400 border-slate-700/80 font-semibold',
dot: 'bg-slate-500',
};
case ApprovalStatus.Pending:
default:
return {
label: 'Pending',
className: 'bg-blue-500/10 text-blue-400 border-blue-500/30 font-semibold',
dot: 'bg-blue-500',
};
}
};
export const getContractStatusBadge = (status: ContractStatus | number) => {
const s = Number(status);
switch (s) {
case ContractStatus.Active:
return {
label: 'Active',
className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30 font-semibold',
};
case ContractStatus.PendingApproval:
return {
label: 'Pending Approval',
className: 'bg-amber-500/10 text-amber-400 border-amber-500/30 font-semibold',
};
case ContractStatus.Amended:
return {
label: 'Amended',
className: 'bg-indigo-500/10 text-indigo-400 border-indigo-500/30 font-semibold',
};
case ContractStatus.Expired:
return {
label: 'Expired',
className: 'bg-slate-800/80 text-slate-400 border-slate-700/80 font-semibold',
};
case ContractStatus.Terminated:
return {
label: 'Terminated',
className: 'bg-rose-500/10 text-rose-400 border-rose-500/30 font-semibold',
};
case ContractStatus.Draft:
default:
return {
label: 'Draft',
className: 'bg-slate-800/80 text-slate-300 border-slate-700/80 font-semibold',
};
}
};
export const getActivityStatusBadge = (status: ActivityStatus | number) => {
const s = Number(status);
switch (s) {
case ActivityStatus.Active:
return {
label: 'Active',
className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30 font-semibold',
};
case ActivityStatus.Inactive:
return {
label: 'Inactive',
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',
};
}
};
export const getPriorityBadge = (priority: Priority | number) => {
const p = Number(priority);
switch (p) {
case Priority.Critical:
return { label: 'Critical', className: 'bg-rose-500/10 text-rose-400 border-rose-500/30 font-semibold' };
case Priority.High:
return { label: 'High', className: 'bg-amber-500/10 text-amber-400 border-amber-500/30 font-semibold' };
case Priority.Medium:
return { label: 'Medium', className: 'bg-blue-500/10 text-blue-400 border-blue-500/30 font-semibold' };
case Priority.Low:
default:
return { label: 'Low', className: 'bg-slate-800/80 text-slate-400 border-slate-700/80 font-semibold' };
}
};
export const getOrganisationTypeLabel = (type: OrganisationType | number): string => {
const opt = ORGANISATION_TYPE_OPTIONS.find((o) => o.value === Number(type));
return opt ? opt.label : 'Organisation';
};
export const getIndustrySectorLabel = (sector: IndustrySector | number): string => {
const opt = INDUSTRY_SECTOR_OPTIONS.find((o) => o.value === Number(sector));
return opt ? opt.label : 'Industry Sector';
};
export const getProjectTypeLabel = (type: ProjectType | number): string => {
const opt = PROJECT_TYPE_OPTIONS.find((o) => o.value === Number(type));
return opt ? opt.label : 'Project Type';
};
export const getEvidenceTypeLabel = (type: EvidenceType | number): string => {
const opt = EVIDENCE_TYPE_OPTIONS.find((o) => o.value === Number(type));
return opt ? opt.label : 'Evidence';
};
export const getUnitOfMeasureLabel = (uom?: UnitOfMeasure | number | null): string => {
if (uom === null || uom === undefined) return 'Qty';
const opt = UNIT_OF_MEASURE_OPTIONS.find((o) => o.value === Number(uom));
return opt ? opt.label : 'Qty';
};
export const getNotificationPlatformLabel = (platform?: NotificationPlatform | number | null): string => {
const opt = NOTIFICATION_PLATFORM_OPTIONS.find((o) => o.value === Number(platform));
return opt ? opt.label : 'Platform';
};
export const getNotificationStatusLabel = (status?: NotificationStatus | number | null): string => {
const opt = NOTIFICATION_STATUS_OPTIONS.find((o) => o.value === Number(status));
return opt ? opt.label : 'Status';
};
export const getEmailStatusLabel = (status?: EmailStatuses | number | null): string => {
const opt = EMAIL_STATUS_OPTIONS.find((o) => o.value === Number(status));
return opt ? opt.label : 'Status';
};
export const getEmailStatusBadge = (status?: EmailStatuses | number | null) => {
const s = Number(status);
switch (s) {
case EmailStatuses.Connected:
case EmailStatuses.Success:
return { label: getEmailStatusLabel(s), className: 'bg-emerald-50 text-emerald-800 border-emerald-300 font-semibold' };
case EmailStatuses.Disconnected:
return { label: getEmailStatusLabel(s), className: 'bg-slate-100 text-slate-700 border-slate-300 font-semibold' };
default:
return { label: getEmailStatusLabel(s), className: 'bg-rose-50 text-rose-800 border-rose-300 font-semibold' };
}
};
export const getNotificationStatusBadge = (status?: NotificationStatus | number | null) => {
const s = Number(status);
switch (s) {
case NotificationStatus.Delivered:
case NotificationStatus.Read:
return { label: getNotificationStatusLabel(s), className: 'bg-emerald-50 text-emerald-800 border-emerald-300 font-semibold' };
case NotificationStatus.Sent:
case NotificationStatus.Queued:
case NotificationStatus.Processing:
return { label: getNotificationStatusLabel(s), className: 'bg-sky-50 text-sky-800 border-sky-300 font-semibold' };
case NotificationStatus.Pending:
return { label: getNotificationStatusLabel(s), className: 'bg-amber-50 text-amber-900 border-amber-300 font-semibold' };
case NotificationStatus.Failed:
case NotificationStatus.Cancelled:
case NotificationStatus.Bounced:
default:
return { label: getNotificationStatusLabel(s), className: 'bg-rose-50 text-rose-800 border-rose-300 font-semibold' };
}
};
export const getRiskScoreColor = (likelihood: RiskLikelihood | number, impact: RiskImpact | number) => {
const score = Number(likelihood) * Number(impact);
if (score >= 15) {
return { level: 'High', color: 'bg-rose-600 text-white', badge: 'bg-rose-50 text-rose-800 border-rose-300 font-semibold' };
}
if (score >= 8) {
return { level: 'Medium', color: 'bg-amber-500 text-white', badge: 'bg-amber-50 text-amber-900 border-amber-300 font-semibold' };
}
return { level: 'Low', color: 'bg-emerald-600 text-white', badge: 'bg-emerald-50 text-emerald-800 border-emerald-300 font-semibold' };
};
// ==========================================
// User to Email Resolver (UI shows only Email)
// ==========================================
const USER_EMAIL_CACHE: Record<string, string> = {};
export const registerUserEmail = (userId: string, email: string) => {
if (userId && email) {
USER_EMAIL_CACHE[userId] = email;
}
};
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;
};
import {
ApprovalStatus,
ContractStatus,
ActivityStatus,
Priority,
OrganisationType,
IndustrySector,
ProjectType,
UnitOfMeasure,
EvidenceType,
RiskLikelihood,
RiskImpact,
NotificationPlatform,
NotificationStatus,
EmailStatuses,
} from '../types';
export const formatZAR = (amount: number): string => {
return new Intl.NumberFormat('en-ZA', {
style: 'currency',
currency: 'ZAR',
maximumFractionDigits: 0,
}).format(amount);
};
export const formatCompactZAR = (amount: number): string => {
if (amount >= 1_000_000_000) {
return `R ${(amount / 1_000_000_000).toFixed(2)}bn`;
}
if (amount >= 1_000_000) {
return `R ${(amount / 1_000_000).toFixed(1)}m`;
}
if (amount >= 1_000) {
return `R ${(amount / 1_000).toFixed(0)}k`;
}
return formatZAR(amount);
};
export const formatDate = (dateStr?: string | null): string => {
if (!dateStr) return '—';
try {
const d = new Date(dateStr);
return d.toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
} catch {
return dateStr;
}
};
// ==========================================
// Dropdown Select Options with Integer Values
// ==========================================
export const EMAIL_STATUS_OPTIONS = [
{ value: EmailStatuses.GeneralError, label: 'General Error' },
{ value: EmailStatuses.AuthenticationError, label: 'Authentication Error' },
{ value: EmailStatuses.ProtocolError, label: 'Protocol Error' },
{ value: EmailStatuses.Connected, label: 'Connected' },
{ value: EmailStatuses.Disconnected, label: 'Disconnected' },
{ value: EmailStatuses.TooManyConnections, label: 'Too Many Connections' },
{ value: EmailStatuses.ConnectionAborted, label: 'Connection Aborted' },
{ value: EmailStatuses.Success, label: 'Success' },
];
export const EMAIL_STATUSES_OPTIONS = EMAIL_STATUS_OPTIONS;
export const NOTIFICATION_STATUS_OPTIONS = [
{ value: NotificationStatus.Pending, label: 'Pending' },
{ value: NotificationStatus.Queued, label: 'Queued' },
{ value: NotificationStatus.Processing, label: 'Processing' },
{ value: NotificationStatus.Sent, label: 'Sent' },
{ value: NotificationStatus.Delivered, label: 'Delivered' },
{ value: NotificationStatus.Read, label: 'Read' },
{ value: NotificationStatus.Failed, label: 'Failed' },
{ value: NotificationStatus.Cancelled, label: 'Cancelled' },
{ value: NotificationStatus.Bounced, label: 'Bounced' },
];
export const NOTIFICATION_PLATFORM_OPTIONS = [
{ value: NotificationPlatform.Email, label: 'Email' },
{ value: NotificationPlatform.SMS, label: 'SMS' },
{ value: NotificationPlatform.WhatsApp, label: 'WhatsApp' },
{ value: NotificationPlatform.PushNotification, label: 'Push Notification' },
{ value: NotificationPlatform.InApp, label: 'In App' },
{ value: NotificationPlatform.Teams, label: 'Teams' },
{ value: NotificationPlatform.Slack, label: 'Slack' },
{ value: NotificationPlatform.Webhook, label: 'Webhook' },
{ value: NotificationPlatform.Other, label: 'Other' },
];
export const EVIDENCE_TYPE_OPTIONS = [
{ value: EvidenceType.AttendanceRegister, label: 'Attendance Register' },
{ value: EvidenceType.BankStatement, label: 'Bank Statement' },
{ value: EvidenceType.CompletionCertificate, label: 'Completion Certificate' },
{ value: EvidenceType.FinancialInvoiceOrReceipt, label: 'Financial Invoice Or Receipt' },
{ value: EvidenceType.GeotaggedPhoto, label: 'Geotagged Photo' },
{ value: EvidenceType.InspectionReport, label: 'Inspection Report' },
{ value: EvidenceType.MeetingMinutes, label: 'Meeting Minutes' },
{ value: EvidenceType.PayrollOrStipendRegister, label: 'Payroll Or Stipend Register' },
{ value: EvidenceType.ProcurementDocument, label: 'Procurement Document' },
{ value: EvidenceType.TechnicalSpecificationOrDesign, label: 'Technical Specification Or Design' },
{ value: EvidenceType.VideoRecording, label: 'Video Recording' },
{ value: EvidenceType.Other, label: 'Other' },
];
export const PROJECT_TYPE_OPTIONS = [
{ value: ProjectType.CapacityBuildingAndTraining, label: 'Capacity Building And Training' },
{ value: ProjectType.CapitalExpenditureAndInfrastructure, label: 'Capital Expenditure And Infrastructure' },
{ value: ProjectType.CommercializationAndScaling, label: 'Commercialization And Scaling' },
{ value: ProjectType.FeasibilityAndTechnicalAssistance, label: 'Feasibility And Technical Assistance' },
{ value: ProjectType.OperationalGrantAndSubsidy, label: 'Operational Grant And Subsidy' },
{ value: ProjectType.ProductDevelopmentAndPrototyping, label: 'Product Development And Prototyping' },
{ value: ProjectType.ResearchAndDevelopment, label: 'Research And Development' },
{ value: ProjectType.SocialImpactAndCommunityDevelopment, label: 'Social Impact And Community Development' },
{ value: ProjectType.TechnologyTransferAndAdoption, label: 'Technology Transfer And Adoption' },
{ value: ProjectType.Innovation, label: 'Innovation' },
{ value: ProjectType.Other, label: 'Other' },
];
export const INDUSTRY_SECTOR_OPTIONS = [
{ value: IndustrySector.AgricultureAndAgroProcessing, label: 'Agriculture And Agro Processing' },
{ value: IndustrySector.AutomotiveAndTransportEquipment, label: 'Automotive And Transport Equipment' },
{ value: IndustrySector.ChemicalsAndPharmaceuticals, label: 'Chemicals And Pharmaceuticals' },
{ value: IndustrySector.ConstructionAndInfrastructure, label: 'Construction And Infrastructure' },
{ value: IndustrySector.CreativeIndustriesAndMedia, label: 'Creative Industries And Media' },
{ value: IndustrySector.DefenseAndAerospace, label: 'Defense And Aerospace' },
{ value: IndustrySector.EducationAndSkillsDevelopment, label: 'Education And Skills Development' },
{ value: IndustrySector.EnergyAndUtilities, label: 'Energy And Utilities' },
{ value: IndustrySector.FinancialServices, label: 'Financial Services' },
{ value: IndustrySector.HealthcareAndLifeSciences, label: 'Healthcare And Life Sciences' },
{ value: IndustrySector.InformationCommunicationTechnology, label: 'Information Communication Technology' },
{ value: IndustrySector.ManufacturingAndIndustrial, label: 'Manufacturing And Industrial' },
{ value: IndustrySector.MiningAndMetals, label: 'Mining And Metals' },
{ value: IndustrySector.RealEstateAndHousing, label: 'Real Estate And Housing' },
{ value: IndustrySector.RetailAndConsumerGoods, label: 'Retail And Consumer Goods' },
{ value: IndustrySector.TourismAndHospitality, label: 'Tourism And Hospitality' },
{ value: IndustrySector.WaterAndSanitation, label: 'Water And Sanitation' },
{ value: IndustrySector.Other, label: 'Other' },
];
export const ORGANISATION_TYPE_OPTIONS = [
{ value: OrganisationType.NonGovernmentalOrganisation, label: 'Non-Governmental Organisation' },
{ value: OrganisationType.CivilSocietyOrganisation, label: 'Civil Society Organisation' },
{ value: OrganisationType.Trust, label: 'Trust' },
{ value: OrganisationType.FaithBasedOrganisation, label: 'Faith-Based Organisation' },
{ value: OrganisationType.PrivateLimitedCompany, label: 'Private Limited Company' },
{ value: OrganisationType.PublicLimitedCompany, label: 'Public Limited Company' },
{ value: OrganisationType.SoleProprietorship, label: 'Sole Proprietorship' },
{ value: OrganisationType.Partnership, label: 'Partnership' },
{ value: OrganisationType.SocialEnterprise, label: 'Social Enterprise' },
{ value: OrganisationType.NationalGovernmentMinistry, label: 'National Government Ministry' },
{ value: OrganisationType.ProvincialGovernmentEntity, label: 'Provincial Government Entity' },
{ value: OrganisationType.LocalGovernmentMunicipality, label: 'Local Government Municipality' },
{ value: OrganisationType.StateOwnedEnterprise, label: 'State-Owned Enterprise' },
{ value: OrganisationType.PublicAgency, label: 'Public Agency' },
{ value: OrganisationType.PublicUniversity, label: 'Public University' },
{ value: OrganisationType.PrivateUniversity, label: 'Private University' },
{ value: OrganisationType.ResearchInstitute, label: 'Research Institute' },
{ value: OrganisationType.TechnicalVocationalCollege, label: 'Technical Vocational College' },
{ value: OrganisationType.Cooperative, label: 'Cooperative' },
{ value: OrganisationType.MicrofinanceInstitution, label: 'Microfinance Institution' },
{ value: OrganisationType.CommercialBank, label: 'Commercial Bank' },
{ value: OrganisationType.DevelopmentFinanceInstitution, label: 'Development Finance Institution' },
{ value: OrganisationType.UnitedNationsAgency, label: 'United Nations Agency' },
{ value: OrganisationType.IntergovernmentalOrganisation, label: 'Intergovernmental Organisation' },
{ value: OrganisationType.InternationalNGO, label: 'International NGO' },
{ value: OrganisationType.Consortium, label: 'Consortium' },
{ value: OrganisationType.SpecialPurposeVehicle, label: 'Special Purpose Vehicle' },
];
export const PRIORITY_OPTIONS = [
{ value: Priority.Low, label: 'Low' },
{ value: Priority.Medium, label: 'Medium' },
{ value: Priority.High, label: 'High' },
{ value: Priority.Critical, label: 'Critical' },
];
export const UNIT_OF_MEASURE_OPTIONS = [
{ value: UnitOfMeasure.Count, label: 'Count' },
{ value: UnitOfMeasure.Percentage, label: 'Percentage' },
{ value: UnitOfMeasure.Currency, label: 'Currency' },
{ value: UnitOfMeasure.Area, label: 'Area' },
{ value: UnitOfMeasure.Weight, label: 'Weight' },
{ value: UnitOfMeasure.Volume, label: 'Volume' },
{ value: UnitOfMeasure.DurationDays, label: 'Duration (Days)' },
{ value: UnitOfMeasure.DurationMonths, label: 'Duration (Months)' },
{ value: UnitOfMeasure.Ratio, label: 'Ratio' },
];
export const CONTRACT_STATUS_OPTIONS = [
{ value: ContractStatus.Draft, label: 'Draft' },
{ value: ContractStatus.PendingApproval, label: 'Pending Approval' },
{ value: ContractStatus.Active, label: 'Active' },
{ value: ContractStatus.Expired, label: 'Expired' },
{ value: ContractStatus.Terminated, label: 'Terminated' },
{ value: ContractStatus.Amended, label: 'Amended' },
];
export const APPROVAL_STATUS_OPTIONS = [
{ value: ApprovalStatus.Pending, label: 'Pending' },
{ value: ApprovalStatus.Review, label: 'Review' },
{ value: ApprovalStatus.Approved, label: 'Approved' },
{ value: ApprovalStatus.Rejected, label: 'Rejected' },
{ value: ApprovalStatus.Removed, label: 'Removed' },
];
export const ACTIVITY_STATUS_OPTIONS = [
{ value: ActivityStatus.Active, label: 'Active' },
{ value: ActivityStatus.Inactive, label: 'Inactive' },
{ value: ActivityStatus.Disabled, label: 'Disabled' },
];
export const RISK_LIKELIHOOD_OPTIONS = [
{ value: RiskLikelihood.Rare, label: 'Rare' },
{ value: RiskLikelihood.Unlikely, label: 'Unlikely' },
{ value: RiskLikelihood.Possible, label: 'Possible' },
{ value: RiskLikelihood.Likely, label: 'Likely' },
{ value: RiskLikelihood.AlmostCertain, label: 'Almost Certain' },
];
export const RISK_IMPACT_OPTIONS = [
{ value: RiskImpact.Minor, label: 'Minor' },
{ value: RiskImpact.Moderate, label: 'Moderate' },
{ value: RiskImpact.Major, label: 'Major' },
{ value: RiskImpact.Critical, label: 'Critical' },
{ value: RiskImpact.Severe, label: 'Severe' },
];
// ==========================================
// Status Badges & Labels
// ==========================================
export const getApprovalStatusBadge = (status: ApprovalStatus | number) => {
const s = Number(status);
switch (s) {
case ApprovalStatus.Approved:
return {
label: 'Approved',
className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30 font-semibold',
dot: 'bg-emerald-500',
};
case ApprovalStatus.Review:
return {
label: 'Review',
className: 'bg-amber-500/10 text-amber-400 border-amber-500/30 font-semibold',
dot: 'bg-amber-500',
};
case ApprovalStatus.Rejected:
return {
label: 'Rejected',
className: 'bg-rose-500/10 text-rose-400 border-rose-500/30 font-semibold',
dot: 'bg-rose-500',
};
case ApprovalStatus.Removed:
return {
label: 'Removed',
className: 'bg-slate-800/80 text-slate-400 border-slate-700/80 font-semibold',
dot: 'bg-slate-500',
};
case ApprovalStatus.Pending:
default:
return {
label: 'Pending',
className: 'bg-blue-500/10 text-blue-400 border-blue-500/30 font-semibold',
dot: 'bg-blue-500',
};
}
};
export const getContractStatusBadge = (status: ContractStatus | number) => {
const s = Number(status);
switch (s) {
case ContractStatus.Active:
return {
label: 'Active',
className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30 font-semibold',
};
case ContractStatus.PendingApproval:
return {
label: 'Pending Approval',
className: 'bg-amber-500/10 text-amber-400 border-amber-500/30 font-semibold',
};
case ContractStatus.Amended:
return {
label: 'Amended',
className: 'bg-indigo-500/10 text-indigo-400 border-indigo-500/30 font-semibold',
};
case ContractStatus.Expired:
return {
label: 'Expired',
className: 'bg-slate-800/80 text-slate-400 border-slate-700/80 font-semibold',
};
case ContractStatus.Terminated:
return {
label: 'Terminated',
className: 'bg-rose-500/10 text-rose-400 border-rose-500/30 font-semibold',
};
case ContractStatus.Draft:
default:
return {
label: 'Draft',
className: 'bg-slate-800/80 text-slate-300 border-slate-700/80 font-semibold',
};
}
};
export const getActivityStatusBadge = (status: ActivityStatus | number) => {
const s = Number(status);
switch (s) {
case ActivityStatus.Active:
return {
label: 'Active',
className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30 font-semibold',
};
case ActivityStatus.Inactive:
return {
label: 'Inactive',
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',
};
}
};
export const getPriorityBadge = (priority: Priority | number) => {
const p = Number(priority);
switch (p) {
case Priority.Critical:
return { label: 'Critical', className: 'bg-rose-500/10 text-rose-400 border-rose-500/30 font-semibold' };
case Priority.High:
return { label: 'High', className: 'bg-amber-500/10 text-amber-400 border-amber-500/30 font-semibold' };
case Priority.Medium:
return { label: 'Medium', className: 'bg-blue-500/10 text-blue-400 border-blue-500/30 font-semibold' };
case Priority.Low:
default:
return { label: 'Low', className: 'bg-slate-800/80 text-slate-400 border-slate-700/80 font-semibold' };
}
};
export const getOrganisationTypeLabel = (type: OrganisationType | number): string => {
const opt = ORGANISATION_TYPE_OPTIONS.find((o) => o.value === Number(type));
return opt ? opt.label : 'Organisation';
};
export const getIndustrySectorLabel = (sector: IndustrySector | number): string => {
const opt = INDUSTRY_SECTOR_OPTIONS.find((o) => o.value === Number(sector));
return opt ? opt.label : 'Industry Sector';
};
export const getProjectTypeLabel = (type: ProjectType | number): string => {
const opt = PROJECT_TYPE_OPTIONS.find((o) => o.value === Number(type));
return opt ? opt.label : 'Project Type';
};
export const getEvidenceTypeLabel = (type: EvidenceType | number): string => {
const opt = EVIDENCE_TYPE_OPTIONS.find((o) => o.value === Number(type));
return opt ? opt.label : 'Evidence';
};
export const getUnitOfMeasureLabel = (uom?: UnitOfMeasure | number | null): string => {
if (uom === null || uom === undefined) return 'Qty';
const opt = UNIT_OF_MEASURE_OPTIONS.find((o) => o.value === Number(uom));
return opt ? opt.label : 'Qty';
};
export const getNotificationPlatformLabel = (platform?: NotificationPlatform | number | null): string => {
const opt = NOTIFICATION_PLATFORM_OPTIONS.find((o) => o.value === Number(platform));
return opt ? opt.label : 'Platform';
};
export const getNotificationStatusLabel = (status?: NotificationStatus | number | null): string => {
const opt = NOTIFICATION_STATUS_OPTIONS.find((o) => o.value === Number(status));
return opt ? opt.label : 'Status';
};
export const getEmailStatusLabel = (status?: EmailStatuses | number | null): string => {
const opt = EMAIL_STATUS_OPTIONS.find((o) => o.value === Number(status));
return opt ? opt.label : 'Status';
};
export const getEmailStatusBadge = (status?: EmailStatuses | number | null) => {
const s = Number(status);
switch (s) {
case EmailStatuses.Connected:
case EmailStatuses.Success:
return { label: getEmailStatusLabel(s), className: 'bg-emerald-50 text-emerald-800 border-emerald-300 font-semibold' };
case EmailStatuses.Disconnected:
return { label: getEmailStatusLabel(s), className: 'bg-slate-100 text-slate-700 border-slate-300 font-semibold' };
default:
return { label: getEmailStatusLabel(s), className: 'bg-rose-50 text-rose-800 border-rose-300 font-semibold' };
}
};
export const getNotificationStatusBadge = (status?: NotificationStatus | number | null) => {
const s = Number(status);
switch (s) {
case NotificationStatus.Delivered:
case NotificationStatus.Read:
return { label: getNotificationStatusLabel(s), className: 'bg-emerald-50 text-emerald-800 border-emerald-300 font-semibold' };
case NotificationStatus.Sent:
case NotificationStatus.Queued:
case NotificationStatus.Processing:
return { label: getNotificationStatusLabel(s), className: 'bg-sky-50 text-sky-800 border-sky-300 font-semibold' };
case NotificationStatus.Pending:
return { label: getNotificationStatusLabel(s), className: 'bg-amber-50 text-amber-900 border-amber-300 font-semibold' };
case NotificationStatus.Failed:
case NotificationStatus.Cancelled:
case NotificationStatus.Bounced:
default:
return { label: getNotificationStatusLabel(s), className: 'bg-rose-50 text-rose-800 border-rose-300 font-semibold' };
}
};
export const getRiskScoreColor = (likelihood: RiskLikelihood | number, impact: RiskImpact | number) => {
const score = Number(likelihood) * Number(impact);
if (score >= 15) {
return { level: 'High', color: 'bg-rose-600 text-white', badge: 'bg-rose-50 text-rose-800 border-rose-300 font-semibold' };
}
if (score >= 8) {
return { level: 'Medium', color: 'bg-amber-500 text-white', badge: 'bg-amber-50 text-amber-900 border-amber-300 font-semibold' };
}
return { level: 'Low', color: 'bg-emerald-600 text-white', badge: 'bg-emerald-50 text-emerald-800 border-emerald-300 font-semibold' };
};
// ==========================================
// User to Email Resolver (UI shows only Email)
// ==========================================
const USER_EMAIL_CACHE: Record<string, string> = {};
export const registerUserEmail = (userId: string, email: string) => {
if (userId && email) {
USER_EMAIL_CACHE[userId] = email;
}
};
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;
};
@@ -0,0 +1,11 @@
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 3, // 3 minutes default cache
refetchOnWindowFocus: false,
retry: 1,
},
},
});
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
@@ -0,0 +1,655 @@
import React, { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import {
TrendingUp,
AlertTriangle,
Users,
Layers,
Coins,
CheckCircle,
ArrowUpRight,
Filter,
RefreshCw,
Clock,
ShieldAlert,
} from 'lucide-react';
import {
getDashboard,
getProjects,
getAllPortfolios,
getAllProgrammes,
getAwards,
getBudgets,
getDisbursements,
getAllRisks,
} from '../services/api';
import { formatCompactZAR, formatZAR, getIndustrySectorLabel } from '../lib/formatters';
import { ApprovalStatus } from '../types';
export const ExecutiveDashboard: React.FC = () => {
const [selectedProgramme, setSelectedProgramme] = useState('All');
const [selectedSector, setSelectedSector] = useState('All');
const [selectedPeriod, setSelectedPeriod] = useState('FY 2026/27');
// Real Queries from OpenAPI Endpoints
const { data: dashboard, isLoading: loadingDashboard, refetch: refetchDashboard, isFetching } = useQuery({
queryKey: ['dashboard'],
queryFn: getDashboard,
});
const { data: projects = [] } = useQuery({
queryKey: ['projects'],
queryFn: () => getProjects(),
});
const { data: portfolios = [] } = useQuery({
queryKey: ['portfolios'],
queryFn: () => getAllPortfolios(),
});
const { data: programmes = [] } = useQuery({
queryKey: ['programmes'],
queryFn: () => getAllProgrammes(),
});
const { data: awards = [] } = useQuery({
queryKey: ['awards'],
queryFn: () => getAwards(),
});
const { data: budgets = [] } = useQuery({
queryKey: ['budgets'],
queryFn: () => getBudgets(),
});
const { data: disbursements = [] } = useQuery({
queryKey: ['disbursements'],
queryFn: () => getDisbursements(),
});
const { data: risks = [] } = useQuery({
queryKey: ['risks'],
queryFn: () => getAllRisks(),
});
const refetchAll = () => {
refetchDashboard();
};
// Headline metrics sourced directly from live API models
const portfolioValue = useMemo(() => {
if (dashboard?.portfolioValue && dashboard.portfolioValue > 0) {
return dashboard.portfolioValue;
}
const budgetTotal = budgets.reduce((sum, b) => sum + (b.amount || 0), 0);
if (budgetTotal > 0) return budgetTotal;
const awardsTotal = awards.reduce((sum, a) => sum + (a.amount || 0), 0);
return awardsTotal;
}, [dashboard, budgets, awards]);
const activeAwards = dashboard?.activeAwards ?? (awards.length || projects.length);
const disbursed = useMemo(() => {
if (dashboard?.disbursed && dashboard.disbursed > 0) {
return dashboard.disbursed;
}
return disbursements
.filter((d) => d.status === ApprovalStatus.Approved || (d.status as number) === 3)
.reduce((sum, d) => sum + (d.amount || 0), 0);
}, [dashboard, disbursements]);
const onTrackPercentage = useMemo(() => {
if (dashboard?.onTrackPercentage !== undefined && 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]);
const atRiskCount = useMemo(() => {
if (dashboard?.atRiskCount !== undefined) {
return dashboard.atRiskCount;
}
return risks.filter((r) => r.likelihood * r.impact >= 12).length;
}, [dashboard, risks]);
const beneficiariesReached = dashboard?.beneficiariesReached ?? 0;
// Real sector breakdown calculated dynamically from actual project records
const sectorData = useMemo(() => {
if (!projects || projects.length === 0) return [];
const counts: Record<string, number> = {};
projects.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;
return Object.entries(counts).map(([name, count], index) => {
const percentage = Math.round((count / totalProjects) * 100);
const amount = portfolioValue > 0 ? Math.round((portfolioValue / totalProjects) * count) : 0;
return {
name,
count,
percentage,
amount,
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]);
// Quarterly disbursements distribution computed from real disbursement items
const quarterlyTrend = useMemo(() => {
if (!disbursements || disbursements.length === 0) {
return [
{ quarter: 'Q1', amount: 0, heightPct: 15 },
{ quarter: 'Q2', amount: 0, heightPct: 15 },
{ quarter: 'Q3', amount: 0, heightPct: 15 },
{ quarter: 'Q4', amount: 0, heightPct: 15 },
];
}
const qTotals: Record<string, number> = { Q1: 0, Q2: 0, Q3: 0, Q4: 0 };
disbursements.forEach((d) => {
const dateStr = d.createdAt || d.updatedAt;
if (dateStr) {
const month = new Date(dateStr).getMonth(); // 0-11
if (month <= 2) qTotals.Q1 += d.amount || 0;
else if (month <= 5) qTotals.Q2 += d.amount || 0;
else if (month <= 8) qTotals.Q3 += d.amount || 0;
else qTotals.Q4 += d.amount || 0;
} else {
qTotals.Q1 += (d.amount || 0) * 0.25;
qTotals.Q2 += (d.amount || 0) * 0.25;
qTotals.Q3 += (d.amount || 0) * 0.25;
qTotals.Q4 += (d.amount || 0) * 0.25;
}
});
const maxQ = Math.max(...Object.values(qTotals), 1);
return Object.entries(qTotals).map(([quarter, amount]) => ({
quarter,
amount,
heightPct: Math.max(15, Math.min(100, Math.round((amount / maxQ) * 100))),
}));
}, [disbursements]);
// Attention Projects derived directly from live project data
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)
.map((p) => {
const isRejected = p.status === ApprovalStatus.Rejected;
const isPending = p.status === ApprovalStatus.Pending || p.status === ApprovalStatus.Review;
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',
};
});
}, [projects]);
return (
<div className="space-y-6 max-w-7xl mx-auto">
{/* Page Title & Filter Bar */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-[#0d121f]/90 p-5 rounded-2xl border border-slate-800/80 backdrop-blur-md shadow-xl">
<div>
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold text-white tracking-tight">
Executive Portfolio Dashboard
</h2>
<span className="text-[11px] font-semibold text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-full border border-blue-500/30">
Live STS Feed
</span>
</div>
<p className="text-xs text-slate-400 mt-1">
Real-time portfolio overview, disbursement tracking, risk heatmap, and projects requiring governance action.
</p>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-1.5 text-xs text-slate-400">
<Filter className="w-3.5 h-3.5 text-slate-500" />
<span>Filter:</span>
</div>
<select
value={selectedProgramme}
onChange={(e) => setSelectedProgramme(e.target.value)}
className="text-xs font-medium bg-[#090d16] border border-slate-700/80 rounded-xl px-3 py-1.5 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 transition cursor-pointer"
>
<option value="All">Programme: All</option>
{programmes.map((pr) => (
<option key={pr.id} value={pr.id}>
{pr.name}
</option>
))}
</select>
<select
value={selectedSector}
onChange={(e) => setSelectedSector(e.target.value)}
className="text-xs font-medium bg-[#090d16] border border-slate-700/80 rounded-xl px-3 py-1.5 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 transition cursor-pointer"
>
<option value="All">Sector: All</option>
{sectorData.map((s) => (
<option key={s.name} value={s.name}>
{s.name}
</option>
))}
</select>
<select
value={selectedPeriod}
onChange={(e) => setSelectedPeriod(e.target.value)}
className="text-xs font-medium bg-[#090d16] border border-slate-700/80 rounded-xl px-3 py-1.5 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 transition cursor-pointer"
>
<option value="FY 2026/27">Period: FY 2026/27</option>
<option value="FY 2025/26">Period: FY 2025/26</option>
<option value="All-Time">All-Time Cumulative</option>
</select>
<button
onClick={refetchAll}
disabled={isFetching}
className="p-2 text-slate-400 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs"
title="Refresh dashboard metrics"
>
<RefreshCw className={`w-4 h-4 ${isFetching ? 'animate-spin text-blue-400' : ''}`} />
</button>
</div>
</div>
{/* 6 Headline Portfolio KPI Cards */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
{/* Card 1: Portfolio Value */}
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-blue-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
<div className="flex items-center justify-between text-slate-400">
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Portfolio Value</span>
<div className="p-1.5 rounded-lg bg-blue-500/10 text-blue-400 border border-blue-500/20 group-hover:scale-110 transition">
<Coins className="w-3.5 h-3.5" />
</div>
</div>
<div className="mt-3">
<p className="text-2xl font-bold text-white tracking-tight">{formatCompactZAR(portfolioValue)}</p>
<p className="text-[10px] text-slate-400 font-mono mt-1">{formatZAR(portfolioValue)}</p>
</div>
</div>
{/* Card 2: Active Awards */}
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-blue-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
<div className="flex items-center justify-between text-slate-400">
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Active Awards</span>
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 group-hover:scale-110 transition">
<Layers className="w-3.5 h-3.5" />
</div>
</div>
<div className="mt-3">
<p className="text-2xl font-bold text-white tracking-tight">{activeAwards}</p>
<p className="text-[10px] text-emerald-400 font-mono mt-1">
Across {portfolios.length} portfolio{portfolios.length === 1 ? '' : 's'}
</p>
</div>
</div>
{/* Card 3: Disbursed */}
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-teal-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
<div className="flex items-center justify-between text-slate-400">
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Disbursed</span>
<div className="p-1.5 rounded-lg bg-teal-500/10 text-teal-400 border border-teal-500/20 group-hover:scale-110 transition">
<TrendingUp className="w-3.5 h-3.5" />
</div>
</div>
<div className="mt-3">
<p className="text-2xl font-bold text-teal-400 tracking-tight">{formatCompactZAR(disbursed)}</p>
<p className="text-[10px] text-slate-400 font-mono mt-1">
{portfolioValue > 0 ? `${((disbursed / portfolioValue) * 100).toFixed(1)}% of commitments` : '0.0% of commitments'}
</p>
</div>
</div>
{/* Card 4: On Track */}
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-emerald-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
<div className="flex items-center justify-between text-slate-400">
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">On Track</span>
<div className="p-1.5 rounded-lg bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 group-hover:scale-110 transition">
<CheckCircle className="w-3.5 h-3.5" />
</div>
</div>
<div className="mt-3">
<p className="text-2xl font-bold text-emerald-400 tracking-tight">{onTrackPercentage}%</p>
<p className="text-[10px] text-slate-400 font-mono mt-1">Milestones verified</p>
</div>
</div>
{/* Card 5: At Risk */}
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-amber-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
<div className="flex items-center justify-between text-slate-400">
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">At Risk</span>
<div className="p-1.5 rounded-lg bg-amber-500/10 text-amber-400 border border-amber-500/20 group-hover:scale-110 transition">
<AlertTriangle className="w-3.5 h-3.5" />
</div>
</div>
<div className="mt-3">
<p className="text-2xl font-bold text-amber-400 tracking-tight">{atRiskCount}</p>
<p className="text-[10px] text-rose-400 font-mono mt-1">Requires PM action</p>
</div>
</div>
{/* Card 6: Beneficiaries Reached */}
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-indigo-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
<div className="flex items-center justify-between text-slate-400">
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Beneficiaries</span>
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 group-hover:scale-110 transition">
<Users className="w-3.5 h-3.5" />
</div>
</div>
<div className="mt-3">
<p className="text-2xl font-bold text-white tracking-tight">
{beneficiariesReached >= 1000 ? `${(beneficiariesReached / 1000).toFixed(0)}k` : beneficiariesReached}
</p>
<p className="text-[10px] text-slate-400 font-mono mt-1">Directly impacted</p>
</div>
</div>
</div>
{/* Visual Models Section (Sector, Heatmap, Impact Trend) */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Panel 1: Funding by Sector */}
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 flex flex-col justify-between shadow-lg backdrop-blur-sm">
<div>
<div className="flex items-center justify-between border-b border-slate-800/80 pb-3">
<h3 className="text-sm font-semibold text-white">Funding by Sector</h3>
<span className="text-[11px] font-mono text-blue-400 bg-blue-500/10 px-2 py-0.5 rounded-md border border-blue-500/30">
API Calculated
</span>
</div>
{/* Circular Graphic Simulation */}
<div className="py-6 flex items-center justify-center">
<div className="relative w-36 h-36 rounded-full border-8 border-slate-800 flex items-center justify-center">
<div className="w-24 h-24 rounded-full border-4 border-blue-500 flex flex-col items-center justify-center bg-[#090d16] shadow-inner">
<span className="text-xl font-bold text-white">{sectorData.length}</span>
<span className="text-[9px] uppercase tracking-wider text-slate-400">
{sectorData.length === 1 ? 'Sector' : 'Sectors'}
</span>
</div>
</div>
</div>
{/* Sector Progress Bars */}
<div className="space-y-2.5">
{sectorData.length === 0 ? (
<p className="text-xs text-center text-slate-500 py-3">No active project sectors assigned yet.</p>
) : (
sectorData.map((s) => (
<div key={s.name} className="space-y-1">
<div className="flex justify-between text-xs">
<span className="text-slate-300 font-medium truncate">{s.name} ({s.count})</span>
<span className="font-semibold text-white">{s.percentage}%</span>
</div>
<div className="w-full h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div className={`h-full ${s.color} rounded-full`} style={{ width: `${s.percentage}%` }}></div>
</div>
</div>
))
)}
</div>
</div>
<div className="pt-4 border-t border-slate-800/80 mt-4 text-[11px] text-slate-400 flex justify-between">
<span>Primary Focus: {sectorData[0]?.name || 'All Sectors'}</span>
<Link to="/governance" className="text-blue-400 font-medium hover:underline flex items-center gap-1">
View Portfolios <ArrowUpRight className="w-3 h-3" />
</Link>
</div>
</div>
{/* Panel 2: Portfolio Risk Heatmap */}
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 flex flex-col justify-between shadow-lg backdrop-blur-sm">
<div>
<div className="flex items-center justify-between border-b border-slate-800/80 pb-3">
<h3 className="text-sm font-semibold text-white">Portfolio Risk Heatmap</h3>
<span className="text-[11px] font-mono text-rose-400 bg-rose-500/10 px-2 py-0.5 rounded-md border border-rose-500/30 font-semibold">
{riskHeatmap.flaggedCount} Flagged
</span>
</div>
<p className="text-[11px] text-slate-400 my-2">
Matrix distribution of project risks by Likelihood (1–5) and Impact (1–5).
</p>
{/* 3x3 Heatmap Matrix with real counted risks */}
<div className="grid grid-cols-3 gap-2 my-4">
{/* Row 1 - High Impact */}
<div className="bg-amber-950/40 text-amber-200 p-2.5 rounded-xl text-center text-xs font-semibold border border-amber-800/40">
<span className="text-[10px] block text-amber-400 font-normal">Moderate</span>
{riskHeatmap.highImpLowLik} {riskHeatmap.highImpLowLik === 1 ? 'Risk' : 'Risks'}
</div>
<div className="bg-rose-950/50 text-rose-200 p-2.5 rounded-xl text-center text-xs font-semibold border border-rose-800/60">
<span className="text-[10px] block text-rose-400 font-normal">High</span>
{riskHeatmap.highImpMedLik} {riskHeatmap.highImpMedLik === 1 ? 'Risk' : 'Risks'}
</div>
<div className="bg-rose-900/80 text-rose-100 p-2.5 rounded-xl text-center text-xs font-bold border border-rose-500 shadow-md">
<span className="text-[10px] block text-rose-300 font-normal">Critical</span>
{riskHeatmap.highImpHighLik} {riskHeatmap.highImpHighLik === 1 ? 'Risk' : 'Risks'}
</div>
{/* Row 2 - Medium Impact */}
<div className="bg-emerald-950/40 text-emerald-200 p-2.5 rounded-xl text-center text-xs font-semibold border border-emerald-800/40">
<span className="text-[10px] block text-emerald-400 font-normal">Low</span>
{riskHeatmap.medImpLowLik} {riskHeatmap.medImpLowLik === 1 ? 'Risk' : 'Risks'}
</div>
<div className="bg-amber-950/40 text-amber-200 p-2.5 rounded-xl text-center text-xs font-semibold border border-amber-800/40">
<span className="text-[10px] block text-amber-400 font-normal">Moderate</span>
{riskHeatmap.medImpMedLik} {riskHeatmap.medImpMedLik === 1 ? 'Risk' : 'Risks'}
</div>
<div className="bg-rose-950/40 text-rose-200 p-2.5 rounded-xl text-center text-xs font-semibold border border-rose-800/50">
<span className="text-[10px] block text-rose-400 font-normal">High</span>
{riskHeatmap.medImpHighLik} {riskHeatmap.medImpHighLik === 1 ? 'Risk' : 'Risks'}
</div>
{/* Row 3 - Low Impact */}
<div className="bg-slate-900/80 text-slate-300 p-2.5 rounded-xl text-center text-xs font-semibold border border-slate-800">
<span className="text-[10px] block text-slate-500 font-normal">Trivial</span>
{riskHeatmap.lowImpLowLik} {riskHeatmap.lowImpLowLik === 1 ? 'Risk' : 'Risks'}
</div>
<div className="bg-emerald-950/40 text-emerald-200 p-2.5 rounded-xl text-center text-xs font-semibold border border-emerald-800/40">
<span className="text-[10px] block text-emerald-400 font-normal">Low</span>
{riskHeatmap.lowImpMedLik} {riskHeatmap.lowImpMedLik === 1 ? 'Risk' : 'Risks'}
</div>
<div className="bg-amber-950/40 text-amber-200 p-2.5 rounded-xl text-center text-xs font-semibold border border-amber-800/40">
<span className="text-[10px] block text-amber-400 font-normal">Moderate</span>
{riskHeatmap.lowImpHighLik} {riskHeatmap.lowImpHighLik === 1 ? 'Risk' : 'Risks'}
</div>
</div>
</div>
<div className="pt-4 border-t border-slate-800/80 text-[11px] text-slate-400 flex justify-between items-center">
<span>Total Registered Risks: {risks.length}</span>
<Link to="/monitoring" className="text-blue-400 font-medium hover:underline flex items-center gap-1">
Risk Register <ArrowUpRight className="w-3 h-3" />
</Link>
</div>
</div>
{/* Panel 3: Funding-to-Impact Trend */}
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 flex flex-col justify-between shadow-lg backdrop-blur-sm">
<div>
<div className="flex items-center justify-between border-b border-slate-800/80 pb-3">
<h3 className="text-sm font-semibold text-white">Disbursement Distribution</h3>
<span className="text-[11px] font-mono text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-md border border-emerald-500/30 font-semibold">
{disbursements.length} Tranche{disbursements.length === 1 ? '' : 's'}
</span>
</div>
<p className="text-[11px] text-slate-400 my-2">
Quarterly release distribution across active portfolios and milestone verifications.
</p>
{/* Graphic Trend Visualization from Live Disbursements */}
<div className="h-44 flex items-end gap-3 pt-6 pb-2 px-2 border-b border-slate-800/80">
{quarterlyTrend.map((q) => (
<div key={q.quarter} className="flex-1 flex flex-col items-center gap-1.5 h-full justify-end">
<span className="text-[10px] font-mono text-slate-400">{q.quarter}</span>
<div
className="w-full bg-slate-800 rounded-t-md"
style={{ height: `${Math.max(10, q.heightPct * 0.7)}%` }}
title={`Commitment: ${formatCompactZAR(q.amount)}`}
></div>
<div
className="w-full bg-gradient-to-t from-blue-600 to-indigo-500 rounded-t-md shadow-xs shadow-blue-500/20"
style={{ height: `${q.heightPct}%` }}
title={`Disbursed: ${formatCompactZAR(q.amount)}`}
></div>
</div>
))}
</div>
<div className="flex items-center justify-between text-[11px] text-slate-400 pt-3">
<div className="flex items-center gap-2">
<span className="h-2 w-3 bg-slate-800 rounded-xs"></span>
<span>Commitment Allocation</span>
</div>
<div className="flex items-center gap-2">
<span className="h-2 w-3 bg-blue-500 rounded-xs"></span>
<span>Actual Disbursed</span>
</div>
</div>
</div>
<div className="pt-4 border-t border-slate-800/80 text-[11px] text-slate-400 flex justify-between items-center">
<span>Total Disbursed: {formatCompactZAR(disbursed)}</span>
<Link to="/grants" className="text-blue-400 font-medium hover:underline flex items-center gap-1">
Financials <ArrowUpRight className="w-3 h-3" />
</Link>
</div>
</div>
</div>
{/* Projects Requiring Attention Table */}
<div className="bg-[#0f1523]/80 rounded-2xl border border-slate-800/80 overflow-hidden shadow-lg backdrop-blur-sm">
<div className="p-5 border-b border-slate-800/80 flex flex-col sm:flex-row sm:items-center justify-between gap-2 bg-[#0d121f]/90">
<div>
<h3 className="text-base font-semibold text-white">Projects Requiring Attention</h3>
<p className="text-xs text-slate-400 mt-0.5">
Prioritized by risk severity, delivery delay, or financial variance threshold breach.
</p>
</div>
<Link
to="/projects"
className="text-xs font-semibold text-blue-400 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 px-3.5 py-1.5 rounded-xl transition self-start sm:self-auto shadow-xs"
>
Open Project Directory &rarr;
</Link>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-[#0d121f]/95 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold text-[11px]">
<th className="py-3.5 px-5">Project</th>
<th className="py-3.5 px-4">Programme</th>
<th className="py-3.5 px-4">Status</th>
<th className="py-3.5 px-4 text-center">Risk</th>
<th className="py-3.5 px-4 text-right">Financial Variance</th>
<th className="py-3.5 px-5 text-right">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60">
{attentionProjects.length === 0 ? (
<tr>
<td colSpan={6} className="py-8 text-center text-xs text-slate-500">
No active projects currently flagged for executive attention. All projects are operating within target thresholds.
</td>
</tr>
) : (
attentionProjects.map((p) => (
<tr key={p.id} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-5">
<span className="font-semibold text-white block text-xs">{p.name}</span>
<span className="text-[10px] text-slate-400 font-mono">ID: {p.id}</span>
</td>
<td className="py-3.5 px-4 text-slate-300 font-medium">{p.programme}</td>
<td className="py-3.5 px-4">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${p.status === 'At Risk'
? 'bg-rose-500/10 text-rose-400 border-rose-500/30'
: p.status === 'Pending'
? 'bg-amber-500/10 text-amber-400 border-amber-500/30'
: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30'
}`}
>
{p.status}
</span>
</td>
<td className="py-3.5 px-4 text-center">
<span
className={`inline-flex items-center justify-center font-bold px-2.5 py-0.5 rounded-md text-[10px] shadow-xs ${p.risk === 'RED'
? 'bg-rose-600 text-white'
: p.risk === 'AMBER'
? 'bg-amber-500 text-white'
: 'bg-emerald-600 text-white'
}`}
>
{p.risk}
</span>
</td>
<td className="py-3.5 px-4 text-right">
<span
className={`font-mono font-bold text-xs ${p.varianceType === 'negative' ? 'text-rose-400' : 'text-emerald-400'
}`}
>
{p.financialVariance}
</span>
</td>
<td className="py-3.5 px-5 text-right">
<Link
to="/projects"
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-3 py-1.5 rounded-lg transition shadow-xs"
>
Review
<ArrowUpRight className="w-3.5 h-3.5" />
</Link>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
);
};
@@ -0,0 +1,860 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
Building2,
Plus,
FolderKanban,
Edit2,
Mail,
Shield,
Layers,
ChevronRight,
Search,
} from 'lucide-react';
import {
getOrganisations,
createOrganisation,
updateOrganisation,
getAllPortfolios,
createPortfolio,
updatePortfolio,
getAllProgrammes,
createProgramme,
updateProgramme,
} from '../services/api';
import { Modal } from '../components/ui/Modal';
import { useToast } from '../components/ui/Toast';
import { useAuth } from '../auth/AuthProvider';
import {
Organisation,
Portfolio,
Programme,
OrganisationType,
ActivityStatus,
ApprovalStatus,
CreateOrganisationRequest,
CreatePortfolioRequest,
CreateProgrammeRequest,
} from '../types';
import {
getOrganisationTypeLabel,
getActivityStatusBadge,
getApprovalStatusBadge,
formatDate,
getUserEmail,
ORGANISATION_TYPE_OPTIONS,
ACTIVITY_STATUS_OPTIONS,
APPROVAL_STATUS_OPTIONS,
} from '../lib/formatters';
// Validation Schemas matching API constraints
const organisationSchema = z.object({
name: z.string().min(3, 'Name must be at least 3 characters'),
registrationNo: z.string().min(4, 'Registration number is mandatory'),
email: z.string().email('Enter a valid email address'),
type: z.coerce.number().min(1).max(27),
status: z.coerce.number().optional(),
});
const portfolioSchema = z.object({
name: z.string().min(3, 'Portfolio name is required'),
description: z.string().min(5, 'Description is required'),
organisationId: z.coerce.number().min(1, 'Select a parent organisation'),
ownerId: z.string().min(1, 'Owner ID is required'),
});
const programmeSchema = z.object({
name: z.string().min(3, 'Programme name is required'),
portfolioId: z.coerce.number().min(1, 'Select a parent portfolio'),
status: z.coerce.number().optional(),
ownerId: z.string().min(1, 'Owner ID is required'),
});
type OrganisationFormValues = z.infer<typeof organisationSchema>;
type PortfolioFormValues = z.infer<typeof portfolioSchema>;
type ProgrammeFormValues = z.infer<typeof programmeSchema>;
export const GovernancePage: React.FC = () => {
const queryClient = useQueryClient();
const { sub, user } = useAuth();
const currentUserId = sub || user?.id || '';
const { success, error: toastError } = useToast();
const [activeTab, setActiveTab] = useState<'organisations' | 'portfolios' | 'programmes'>('organisations');
const [searchTerm, setSearchTerm] = useState('');
// Modals & Editing State
const [isOrgModalOpen, setIsOrgModalOpen] = useState(false);
const [editingOrg, setEditingOrg] = useState<Organisation | null>(null);
const [isPortfolioModalOpen, setIsPortfolioModalOpen] = useState(false);
const [editingPortfolio, setEditingPortfolio] = useState<Portfolio | null>(null);
const [isProgrammeModalOpen, setIsProgrammeModalOpen] = useState(false);
const [editingProgramme, setEditingProgramme] = useState<Programme | null>(null);
// Queries
const { data: organisations = [], isLoading: loadingOrgs } = useQuery({
queryKey: ['organisations'],
queryFn: () => getOrganisations(),
});
const { data: portfolios = [], isLoading: loadingPortfolios } = useQuery({
queryKey: ['portfolios'],
queryFn: () => getAllPortfolios(),
});
const { data: programmes = [], isLoading: loadingProgrammes } = useQuery({
queryKey: ['programmes'],
queryFn: () => getAllProgrammes(),
});
// Forms
const orgForm = useForm<OrganisationFormValues>({
resolver: zodResolver(organisationSchema) as any,
defaultValues: {
name: '',
registrationNo: '',
email: '',
type: OrganisationType.Enterprise,
status: ActivityStatus.Active,
},
});
const portfolioForm = useForm<PortfolioFormValues>({
resolver: zodResolver(portfolioSchema) as any,
defaultValues: {
name: '',
description: '',
organisationId: organisations[0]?.id ? Number(organisations[0].id) : 101,
ownerId: currentUserId,
},
});
const programmeForm = useForm<ProgrammeFormValues>({
resolver: zodResolver(programmeSchema) as any,
defaultValues: {
name: '',
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
status: ApprovalStatus.Approved,
ownerId: currentUserId,
},
});
// Mutations
const orgMutation = useMutation({
mutationFn: (data: OrganisationFormValues) => {
if (editingOrg) {
return updateOrganisation({
organisationId: editingOrg.id,
name: data.name,
registrationNo: data.registrationNo,
email: data.email,
type: Number(data.type),
status: data.status !== undefined ? Number(data.status) : undefined,
});
}
return createOrganisation(data as CreateOrganisationRequest);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['organisations'] });
success(editingOrg ? 'Organisation updated' : 'Organisation registered successfully');
setIsOrgModalOpen(false);
setEditingOrg(null);
orgForm.reset();
},
onError: (err: unknown) => {
toastError('Failed to save organisation', String(err));
},
});
const portfolioMutation = useMutation({
mutationFn: (data: PortfolioFormValues) => {
if (editingPortfolio) {
return updatePortfolio({
portfolioId: editingPortfolio.id,
name: data.name,
description: data.description,
organisationId: Number(data.organisationId),
ownerId: data.ownerId,
});
}
return createPortfolio(data as CreatePortfolioRequest);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['portfolios'] });
success(editingPortfolio ? 'Portfolio updated successfully' : 'Portfolio created successfully');
setIsPortfolioModalOpen(false);
setEditingPortfolio(null);
portfolioForm.reset();
},
onError: (err: unknown) => {
toastError('Failed to save portfolio', String(err));
},
});
const programmeMutation = useMutation({
mutationFn: (data: ProgrammeFormValues) => {
if (editingProgramme) {
return updateProgramme({
programmeId: editingProgramme.id,
name: data.name,
portfolioId: Number(data.portfolioId),
status: data.status !== undefined ? Number(data.status) : undefined,
ownerId: data.ownerId,
});
}
return createProgramme(data as CreateProgrammeRequest);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['programmes'] });
success(editingProgramme ? 'Programme updated successfully' : 'Programme created successfully');
setIsProgrammeModalOpen(false);
setEditingProgramme(null);
programmeForm.reset();
},
onError: (err: unknown) => {
toastError('Failed to save programme', String(err));
},
});
const openCreateOrg = () => {
setEditingOrg(null);
orgForm.reset({
name: '',
registrationNo: '',
email: '',
type: OrganisationType.Enterprise,
status: ActivityStatus.Active,
});
setIsOrgModalOpen(true);
};
const openEditOrg = (org: Organisation) => {
setEditingOrg(org);
orgForm.reset({
name: org.name || '',
registrationNo: org.registrationNo || '',
email: org.email || '',
type: org.type || OrganisationType.Enterprise,
status: org.status ?? ActivityStatus.Active,
});
setIsOrgModalOpen(true);
};
const openCreatePortfolio = () => {
setEditingPortfolio(null);
portfolioForm.reset({
name: '',
description: '',
organisationId: organisations[0]?.id ? Number(organisations[0].id) : 101,
ownerId: currentUserId,
});
setIsPortfolioModalOpen(true);
};
const openEditPortfolio = (p: Portfolio) => {
setEditingPortfolio(p);
portfolioForm.reset({
name: p.name || '',
description: p.description || '',
organisationId: Number(p.organisationId) || 101,
ownerId: p.ownedBy || currentUserId,
});
setIsPortfolioModalOpen(true);
};
const openCreateProgramme = () => {
setEditingProgramme(null);
programmeForm.reset({
name: '',
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
status: ApprovalStatus.Approved,
ownerId: currentUserId,
});
setIsProgrammeModalOpen(true);
};
const openEditProgramme = (pr: Programme) => {
setEditingProgramme(pr);
programmeForm.reset({
name: pr.name || '',
portfolioId: Number(pr.portfolioId) || 201,
status: pr.status ?? ApprovalStatus.Approved,
ownerId: pr.ownedBy || currentUserId,
});
setIsProgrammeModalOpen(true);
};
// Filtered lists
const filteredOrgs = organisations.filter(
(o) =>
o.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
o.registrationNo?.toLowerCase().includes(searchTerm.toLowerCase()) ||
o.email?.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div className="space-y-6 max-w-7xl mx-auto">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-[#0d121f]/90 p-5 rounded-2xl border border-slate-800/80 backdrop-blur-md shadow-xl">
<div>
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold text-white tracking-tight">
Governance & Structure
</h2>
<span className="text-[11px] font-semibold text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-full border border-blue-500/30">
Directory
</span>
</div>
<p className="text-xs text-slate-400 mt-1">
Maintain Master Organisation Registry, Funder Portfolios, and Strategic Programmes hierarchy.
</p>
</div>
{/* Tab Switcher & CTA */}
<div className="flex items-center gap-2">
{activeTab === 'organisations' && (
<button
onClick={openCreateOrg}
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 transition cursor-pointer"
>
<Plus className="w-4 h-4" />
Register Organisation
</button>
)}
{activeTab === 'portfolios' && (
<button
onClick={openCreatePortfolio}
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 transition cursor-pointer"
>
<Plus className="w-4 h-4" />
Create Portfolio
</button>
)}
{activeTab === 'programmes' && (
<button
onClick={openCreateProgramme}
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 transition cursor-pointer"
>
<Plus className="w-4 h-4" />
Create Programme
</button>
)}
</div>
</div>
{/* Tabs */}
<div className="flex border-b border-slate-800/80 bg-[#0d121f]/80 px-4 rounded-2xl backdrop-blur-sm">
<button
onClick={() => setActiveTab('organisations')}
className={`py-3.5 px-4 text-xs font-semibold border-b-2 transition-colors flex items-center gap-2 cursor-pointer ${activeTab === 'organisations'
? 'border-blue-500 text-blue-400'
: 'border-transparent text-slate-400 hover:text-slate-200'
}`}
>
<Building2 className="w-4 h-4" />
Organisations Directory ({organisations.length})
</button>
<button
onClick={() => setActiveTab('portfolios')}
className={`py-3.5 px-4 text-xs font-semibold border-b-2 transition-colors flex items-center gap-2 cursor-pointer ${activeTab === 'portfolios'
? 'border-blue-500 text-blue-400'
: 'border-transparent text-slate-400 hover:text-slate-200'
}`}
>
<FolderKanban className="w-4 h-4" />
Funder Portfolios ({portfolios.length})
</button>
<button
onClick={() => setActiveTab('programmes')}
className={`py-3.5 px-4 text-xs font-semibold border-b-2 transition-colors flex items-center gap-2 cursor-pointer ${activeTab === 'programmes'
? 'border-blue-500 text-blue-400'
: 'border-transparent text-slate-400 hover:text-slate-200'
}`}
>
<Layers className="w-4 h-4" />
Programmes Hierarchy ({programmes.length})
</button>
</div>
{/* Search Bar */}
{activeTab === 'organisations' && (
<div className="relative max-w-md">
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-3" />
<input
type="text"
placeholder="Search by organisation name, reg no, or email..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-9 pr-4 py-2.5 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-slate-200 placeholder:text-slate-500 transition shadow-xs"
/>
</div>
)}
{/* Tab 1: Organisations Table */}
{activeTab === 'organisations' && (
<div className="bg-[#0f1523]/80 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-[#0d121f]/95 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold text-[11px]">
<th className="py-3.5 px-5">Organisation Name</th>
<th className="py-3.5 px-4">Registration No</th>
<th className="py-3.5 px-4">Entity Type</th>
<th className="py-3.5 px-4">Contact Email</th>
<th className="py-3.5 px-4">Status</th>
<th className="py-3.5 px-5 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60">
{loadingOrgs ? (
<tr>
<td colSpan={6} className="py-8 text-center text-slate-500">
Loading registered organisations...
</td>
</tr>
) : filteredOrgs.length === 0 ? (
<tr>
<td colSpan={6} className="py-8 text-center text-slate-500">
No organisations found matching query.
</td>
</tr>
) : (
filteredOrgs.map((org) => {
const statusBadge = getActivityStatusBadge(org.status);
return (
<tr key={org.id} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-5">
<span className="font-semibold text-white block text-xs">{org.name}</span>
<span className="text-[10px] text-slate-500 font-mono">ID: {org.id}</span>
</td>
<td className="py-3.5 px-4 font-mono text-slate-300">{org.registrationNo || '—'}</td>
<td className="py-3.5 px-4">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-md text-[11px] font-medium bg-slate-900/80 text-slate-300 border border-slate-700/80">
{getOrganisationTypeLabel(org.type)}
</span>
</td>
<td className="py-3.5 px-4 text-slate-300">
<span className="flex items-center gap-1.5 font-mono text-[11px]">
<Mail className="w-3.5 h-3.5 text-slate-500" />
{org.email}
</span>
</td>
<td className="py-3.5 px-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
{statusBadge.label}
</span>
</td>
<td className="py-3.5 px-5 text-right">
<button
onClick={() => openEditOrg(org)}
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-3 py-1.5 rounded-lg transition shadow-xs cursor-pointer"
>
<Edit2 className="w-3 h-3" />
Edit
</button>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
)}
{/* Tab 2: Portfolios List */}
{activeTab === 'portfolios' && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{loadingPortfolios ? (
<p className="text-xs text-slate-500 col-span-3">Loading portfolios...</p>
) : (
portfolios.map((p) => {
const org = organisations.find((o) => String(o.id) === String(p.organisationId));
const progCount = programmes.filter((pr) => String(pr.portfolioId) === String(p.id)).length;
return (
<div key={p.id} className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm flex flex-col justify-between hover:border-slate-700/80 transition-all">
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-[10px] font-mono uppercase tracking-wider text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-md border border-blue-500/30">
Portfolio #{p.id}
</span>
<span className="text-[11px] text-slate-400 font-medium">{progCount} Programmes</span>
</div>
<h3 className="text-base font-bold text-white leading-snug">{p.name}</h3>
<p className="text-xs text-slate-400 leading-relaxed">{p.description}</p>
</div>
<div className="mt-4 pt-3 border-t border-slate-800/80 space-y-2">
<div className="flex items-center justify-between text-xs text-slate-400">
<span className="truncate max-w-[170px] font-medium text-slate-300">{org ? org.name : `Org #${p.organisationId}`}</span>
<span className="font-mono text-[11px] text-slate-500">{formatDate(p.createdAt)}</span>
</div>
<div className="flex items-center justify-between pt-1 text-xs">
<span className="text-slate-400 font-medium truncate max-w-[190px]">
Owner: <span className="text-slate-200 font-mono text-[11px]">{getUserEmail(p.ownedBy)}</span>
</span>
<button
onClick={() => openEditPortfolio(p)}
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
>
<Edit2 className="w-3 h-3" />
Edit
</button>
</div>
</div>
</div>
);
})
)}
</div>
)}
{/* Tab 3: Programmes Hierarchy */}
{activeTab === 'programmes' && (
<div className="bg-[#0f1523]/80 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-[#0d121f]/95 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold text-[11px]">
<th className="py-3.5 px-5">Programme Name</th>
<th className="py-3.5 px-4">Parent Portfolio</th>
<th className="py-3.5 px-4">Governance Status</th>
<th className="py-3.5 px-4">Creation Date</th>
<th className="py-3.5 px-4">Owner Email</th>
<th className="py-3.5 px-5 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60">
{loadingProgrammes ? (
<tr>
<td colSpan={6} className="py-8 text-center text-slate-500">
Loading programmes...
</td>
</tr>
) : (
programmes.map((pr) => {
const port = portfolios.find((p) => String(p.id) === String(pr.portfolioId));
const statusBadge = getApprovalStatusBadge(pr.status);
return (
<tr key={pr.id} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-5">
<span className="font-semibold text-white block text-xs">{pr.name}</span>
<span className="text-[10px] text-slate-500 font-mono">ID: {pr.id}</span>
</td>
<td className="py-3.5 px-4 text-slate-300 font-medium">
{port ? port.name : `Portfolio #${pr.portfolioId}`}
</td>
<td className="py-3.5 px-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
{statusBadge.label}
</span>
</td>
<td className="py-3.5 px-4 text-slate-400 font-mono text-[11px]">{formatDate(pr.createdAt)}</td>
<td className="py-3.5 px-4 font-mono text-slate-300 font-medium text-[11px]">
{getUserEmail(pr.ownedBy)}
</td>
<td className="py-3.5 px-5 text-right">
<button
onClick={() => openEditProgramme(pr)}
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
>
<Edit2 className="w-3 h-3" />
Edit
</button>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
)}
{/* Modal: Register / Edit Organisation */}
<Modal
isOpen={isOrgModalOpen}
onClose={() => setIsOrgModalOpen(false)}
title={editingOrg ? 'Edit Organisation Profile' : 'Register New Organisation'}
subtitle="Mandatory master data profile for Recipient or Funder entities (POPIA compliant)"
>
<form onSubmit={orgForm.handleSubmit((data) => orgMutation.mutate(data))} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">
Legal Organisation Name *
</label>
<input
type="text"
{...orgForm.register('name')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-white bg-[#090d16]"
placeholder="e.g. AgriBio Southern Solutions"
/>
{orgForm.formState.errors.name && (
<p className="text-[10px] text-rose-500 mt-1">{orgForm.formState.errors.name.message}</p>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">
Registration / CIPC Number *
</label>
<input
type="text"
{...orgForm.register('registrationNo')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 font-mono text-white bg-[#090d16]"
placeholder="2020/554129/08"
/>
{orgForm.formState.errors.registrationNo && (
<p className="text-[10px] text-rose-500 mt-1">{orgForm.formState.errors.registrationNo.message}</p>
)}
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">
Entity Classification *
</label>
<select
{...orgForm.register('type')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
>
{ORGANISATION_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">
Official Contact Email *
</label>
<input
type="email"
{...orgForm.register('email')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-white bg-[#090d16]"
placeholder="governance@organisation.org.za"
/>
{orgForm.formState.errors.email && (
<p className="text-[10px] text-rose-500 mt-1">{orgForm.formState.errors.email.message}</p>
)}
</div>
{editingOrg && (
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">
Operating Status *
</label>
<select
{...orgForm.register('status')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
>
{ACTIVITY_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
)}
</div>
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
<button
type="button"
onClick={() => setIsOrgModalOpen(false)}
className="px-3.5 py-2 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={orgMutation.isPending}
className="px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 cursor-pointer"
>
{orgMutation.isPending ? 'Saving...' : editingOrg ? 'Update Organisation' : 'Register Organisation'}
</button>
</div>
</form>
</Modal>
{/* Modal: Create / Edit Portfolio */}
<Modal
isOpen={isPortfolioModalOpen}
onClose={() => {
setIsPortfolioModalOpen(false);
setEditingPortfolio(null);
}}
title={editingPortfolio ? 'Edit Funder Portfolio' : 'Create Funder Portfolio'}
subtitle="Strategic allocation bucket grouping multiple programmatic initiatives"
>
<form onSubmit={portfolioForm.handleSubmit((data) => portfolioMutation.mutate(data))} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Portfolio Title *</label>
<input
type="text"
{...portfolioForm.register('name')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-white bg-[#090d16]"
placeholder="e.g. National Green Hydrogen & Climate Fund"
/>
{portfolioForm.formState.errors.name && (
<p className="text-[10px] text-rose-500 mt-1">{portfolioForm.formState.errors.name.message}</p>
)}
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Parent Organisation *</label>
<select
{...portfolioForm.register('organisationId')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
>
{organisations.map((o) => (
<option key={o.id} value={o.id}>
{o.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Owner Email *</label>
<input
type="text"
{...portfolioForm.register('ownerId')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 font-mono text-white bg-[#090d16]"
placeholder="khwezim@gmail.com"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Strategic Objective *</label>
<textarea
rows={3}
{...portfolioForm.register('description')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-white bg-[#090d16]"
placeholder="Define the overarching funding mandate and target impacts..."
/>
{portfolioForm.formState.errors.description && (
<p className="text-[10px] text-rose-500 mt-1">{portfolioForm.formState.errors.description.message}</p>
)}
</div>
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
<button
type="button"
onClick={() => {
setIsPortfolioModalOpen(false);
setEditingPortfolio(null);
}}
className="px-3.5 py-2 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={portfolioMutation.isPending}
className="px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 cursor-pointer"
>
{portfolioMutation.isPending ? 'Saving...' : editingPortfolio ? 'Update Portfolio' : 'Create Portfolio'}
</button>
</div>
</form>
</Modal>
{/* Modal: Create / Edit Programme */}
<Modal
isOpen={isProgrammeModalOpen}
onClose={() => {
setIsProgrammeModalOpen(false);
setEditingProgramme(null);
}}
title={editingProgramme ? 'Edit Strategic Programme' : 'Create Strategic Programme'}
subtitle="Programmatic operational entity funding distinct projects and awards"
>
<form onSubmit={programmeForm.handleSubmit((data) => programmeMutation.mutate(data))} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Programme Title *</label>
<input
type="text"
{...programmeForm.register('name')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-white bg-[#090d16]"
placeholder="e.g. DeepTech Venture Commercialisation"
/>
{programmeForm.formState.errors.name && (
<p className="text-[10px] text-rose-500 mt-1">{programmeForm.formState.errors.name.message}</p>
)}
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Assign to Portfolio *</label>
<select
{...programmeForm.register('portfolioId')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
>
{portfolios.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Governance Status *</label>
<select
{...programmeForm.register('status')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
>
{APPROVAL_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Owner Email *</label>
<input
type="text"
{...programmeForm.register('ownerId')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 font-mono text-white bg-[#090d16]"
placeholder="khwezim@gmail.com"
/>
</div>
</div>
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
<button
type="button"
onClick={() => {
setIsProgrammeModalOpen(false);
setEditingProgramme(null);
}}
className="px-3.5 py-2 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={programmeMutation.isPending}
className="px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 cursor-pointer"
>
{programmeMutation.isPending ? 'Saving...' : editingProgramme ? 'Update Programme' : 'Create Programme'}
</button>
</div>
</form>
</Modal>
</div>
);
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,270 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import {
ShieldCheck,
Lock,
ArrowRight,
Layers,
CheckCircle2,
Coins,
FileSpreadsheet,
BarChart3,
KeyRound,
LogOut,
UserCheck,
ExternalLink,
ChevronRight,
} from 'lucide-react';
import { useAuth } from '../auth/AuthProvider';
export const LandingPage: React.FC = () => {
const { isAuthenticated, user, claims, sub, login, logout, authError, isLoading } = useAuth();
const navigate = useNavigate();
const handleEnterConsole = async () => {
if (isAuthenticated) {
navigate('/dashboard');
return;
}
await login();
};
const handleSignOut = async () => {
await logout();
};
const tokenUserName = user?.name || (claims.name as string) || (claims.preferred_username as string) || 'Authorized User';
const tokenUserEmail = user?.email || (claims.email as string) || (claims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] as string) || '';
return (
<div className="min-h-screen bg-[#090d16] text-slate-100 flex flex-col justify-between selection:bg-blue-600 selection:text-white font-sans relative overflow-hidden">
{/* Background Graphic Grid & Ambient Glows */}
<div
className="absolute inset-0 pointer-events-none opacity-20"
style={{
backgroundImage: `radial-gradient(circle at 1px 1px, rgba(255,255,255,0.15) 1px, transparent 0)`,
backgroundSize: '32px 32px',
}}
/>
<div className="absolute -top-40 -left-40 w-96 h-96 bg-blue-600/20 rounded-full blur-3xl pointer-events-none" />
<div className="absolute top-1/3 -right-40 w-96 h-96 bg-indigo-600/15 rounded-full blur-3xl pointer-events-none" />
<div className="absolute -bottom-40 left-1/3 w-96 h-96 bg-emerald-600/10 rounded-full blur-3xl pointer-events-none" />
{/* Top Header */}
<header className="relative z-10 border-b border-slate-800/80 bg-[#0d121f]/80 backdrop-blur-md px-6 py-4 flex items-center justify-between shadow-lg shadow-black/20">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-xl bg-gradient-to-br from-blue-600 to-indigo-700 flex items-center justify-center font-bold text-white shadow-md shadow-blue-500/20 border border-blue-400/20">
<Layers className="w-5 h-5 text-white" />
</div>
<div>
<h1 className="text-base font-bold tracking-tight text-white flex items-center gap-2">
PFM Console
<span className="text-[10px] font-semibold uppercase bg-blue-500/10 text-blue-400 border border-blue-500/30 px-2 py-0.5 rounded font-mono">
Enterprise STS
</span>
</h1>
<p className="text-xs text-slate-400">Post-Fund Management, Performance & Impact System</p>
</div>
</div>
<div className="flex items-center gap-3">
{isAuthenticated ? (
<button
onClick={() => navigate('/dashboard')}
id="header-nav-console-btn"
className="flex items-center gap-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 px-3.5 py-1.5 rounded-lg shadow-md shadow-blue-600/20 transition cursor-pointer"
>
<span>Go to Console</span>
<ArrowRight className="w-3.5 h-3.5" />
</button>
) : (
<div className="flex items-center gap-2 text-xs text-slate-400 font-medium bg-slate-900/60 border border-slate-800 px-3 py-1.5 rounded-full">
<span className="h-2 w-2 rounded-full bg-emerald-400 animate-pulse"></span>
<span>Duende STS Ready</span>
</div>
)}
</div>
</header>
{/* Main Content */}
<main className="relative z-10 flex-1 max-w-6xl mx-auto w-full px-6 py-12 flex flex-col justify-center">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 items-center">
{/* Left Column: Mission & Highlights */}
<div className="lg:col-span-7 space-y-6">
<div className="inline-flex items-center gap-2 px-3.5 py-1.5 rounded-full bg-blue-500/10 border border-blue-500/30 text-blue-300 text-xs font-semibold">
<ShieldCheck className="w-4 h-4 text-blue-400" />
Controlled Post-Award to Portfolio Governance
</div>
<h2 className="text-3xl sm:text-4xl lg:text-5xl font-extrabold text-white tracking-tight leading-tight">
Post Fund Management <br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-400 via-indigo-300 to-teal-300">
Executive Console
</span>
</h2>
<p className="text-sm sm:text-base text-slate-300 leading-relaxed max-w-2xl font-normal">
Empowers Funders, Science Councils, and Development Agencies to enforce rigorous governance,
milestone-linked disbursements, evidence verification, and real-time portfolio performance from
award intake through final close-out.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-2 text-xs">
<div className="p-4 rounded-xl bg-slate-900/70 border border-slate-800/80 shadow-md flex items-start gap-3 backdrop-blur-sm">
<CheckCircle2 className="w-4 h-4 text-emerald-400 shrink-0 mt-0.5" />
<div>
<strong className="text-white block font-semibold mb-0.5">Award to Contract Enforcement</strong>
<span className="text-slate-400 leading-normal">Legal agreements linked directly to approved awards.</span>
</div>
</div>
<div className="p-4 rounded-xl bg-slate-900/70 border border-slate-800/80 shadow-md flex items-start gap-3 backdrop-blur-sm">
<Coins className="w-4 h-4 text-blue-400 shrink-0 mt-0.5" />
<div>
<strong className="text-white block font-semibold mb-0.5">Controlled Tranche Release</strong>
<span className="text-slate-400 leading-normal">Disbursements released only when milestone conditions are verified.</span>
</div>
</div>
<div className="p-4 rounded-xl bg-slate-900/70 border border-slate-800/80 shadow-md flex items-start gap-3 backdrop-blur-sm">
<FileSpreadsheet className="w-4 h-4 text-indigo-400 shrink-0 mt-0.5" />
<div>
<strong className="text-white block font-semibold mb-0.5">Evidence Verification</strong>
<span className="text-slate-400 leading-normal">Deliverables require tamper-evident verification.</span>
</div>
</div>
<div className="p-4 rounded-xl bg-slate-900/70 border border-slate-800/80 shadow-md flex items-start gap-3 backdrop-blur-sm">
<BarChart3 className="w-4 h-4 text-teal-400 shrink-0 mt-0.5" />
<div>
<strong className="text-white block font-semibold mb-0.5">Automated Portfolio Roll-Up</strong>
<span className="text-slate-400 leading-normal">Project data aggregates automatically to Programmes and Portfolios.</span>
</div>
</div>
</div>
</div>
{/* Right Column: Auth-Aware Box */}
<div className="lg:col-span-5">
{isAuthenticated ? (
/* Auth-Aware Home Guidance Panel */
<div className="bg-[#0f1523]/95 border border-emerald-500/30 rounded-2xl p-8 shadow-2xl relative overflow-hidden space-y-6 backdrop-blur-md">
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-emerald-500 via-teal-400 to-blue-500" />
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="h-3 w-3 rounded-full bg-emerald-400 animate-pulse" />
<span className="text-xs font-semibold uppercase tracking-wider text-emerald-400">
Active Authenticated Session
</span>
</div>
<span className="text-[10px] font-mono bg-emerald-500/10 text-emerald-300 border border-emerald-500/30 px-2 py-0.5 rounded">
STS Token Active
</span>
</div>
{/* Identity Information from Token */}
<div className="p-4 rounded-xl bg-slate-900/90 border border-slate-800 space-y-2.5">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-blue-600/20 border border-blue-500/40 flex items-center justify-center font-bold text-sm text-blue-400 shrink-0">
<UserCheck className="w-5 h-5 text-blue-400" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-bold text-white truncate">{tokenUserName}</p>
<p className="text-xs text-slate-400 font-mono truncate">{tokenUserEmail || 'No email in token claims'}</p>
</div>
</div>
{sub && (
<div className="pt-2 border-t border-slate-800/80 text-[11px] flex items-center justify-between text-slate-400">
<span>Token Subject (sub):</span>
<span className="font-mono text-slate-300 truncate max-w-[200px]">{sub}</span>
</div>
)}
</div>
{/* Guidance Message */}
<div className="p-4 rounded-xl bg-blue-950/30 border border-blue-800/40 text-xs text-blue-200 leading-relaxed">
<p className="font-semibold text-white mb-1">Session Verified</p>
You are authenticated through Duende Identity Server. Your token claims are validated and active. You can proceed to the PFM Console to manage projects, reviews, and disbursements, or sign out through the identity server.
</div>
{/* Navigation and Sign Out Buttons */}
<div className="space-y-3 pt-1">
<button
onClick={() => navigate('/dashboard')}
id="btn-return-to-console"
className="w-full py-3.5 px-5 rounded-xl bg-blue-600 hover:bg-blue-500 font-semibold text-sm text-white shadow-lg shadow-blue-600/25 flex items-center justify-center gap-2 transition cursor-pointer"
>
<span>Return to PFM Console</span>
<ArrowRight className="w-4 h-4" />
</button>
<button
onClick={handleSignOut}
id="btn-logout-identity-server"
className="w-full py-2.5 px-4 rounded-xl bg-slate-900 hover:bg-rose-950/40 hover:text-rose-300 text-slate-300 border border-slate-800 hover:border-rose-800/60 font-medium text-xs flex items-center justify-center gap-2 transition cursor-pointer"
>
<LogOut className="w-3.5 h-3.5" />
<span>Sign Out via Identity Server</span>
</button>
</div>
<div className="pt-2 border-t border-slate-800/80 text-[11px] text-slate-500 flex items-center justify-between">
<span className="flex items-center gap-1">
<KeyRound className="w-3.5 h-3.5 text-slate-400" />
Duende STS
</span>
<span className="font-mono text-slate-400 text-[10px]">Audience: pfm-api-dev</span>
</div>
</div>
) : (
/* Unauthenticated Sign In Box */
<div className="bg-[#0f1523]/90 border border-slate-800 rounded-2xl p-8 shadow-2xl relative overflow-hidden space-y-6 backdrop-blur-md">
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-blue-500 via-indigo-500 to-teal-400" />
<div className="text-center space-y-2 pt-2">
<div className="mx-auto h-12 w-12 rounded-xl bg-blue-500/10 border border-blue-500/30 flex items-center justify-center text-blue-400 shadow-inner">
<Lock className="w-6 h-6" />
</div>
<h3 className="text-xl font-bold text-white tracking-tight">Sign In</h3>
</div>
{authError && (
<div className="p-3.5 rounded-xl bg-rose-950/40 border border-rose-800/60 text-rose-300 text-xs leading-relaxed">
{authError}
</div>
)}
<div className="space-y-4">
<button
onClick={handleEnterConsole}
disabled={isLoading}
id="btn-enter-pfm-console"
className="w-full py-3.5 px-5 rounded-xl bg-blue-600 hover:bg-blue-500 font-semibold text-sm text-white shadow-lg shadow-blue-600/25 flex items-center justify-center gap-2 transition cursor-pointer disabled:opacity-50"
>
{isLoading ? 'Connecting to Identity Server...' : 'Enter PFM Console'}
<ArrowRight className="w-4 h-4" />
</button>
</div>
<div className="pt-2 border-t border-slate-800/80 text-[11px] text-slate-500 flex items-center justify-between">
<span className="flex items-center gap-1">
<KeyRound className="w-3.5 h-3.5 text-slate-400" />
Duende STS OIDC
</span>
<span className="font-mono text-slate-400 text-[10px]">pfm-api-dev</span>
</div>
</div>
)}
</div>
</div>
</main>
{/* Footer */}
<footer className="relative z-10 border-t border-slate-800/80 bg-[#0d121f]/80 backdrop-blur-md px-6 py-4 text-center text-xs text-slate-500">
<p>Post-Fund Management, Performance & Impact System &bull; Enterprise Governance Console</p>
</footer>
</div>
);
};
@@ -0,0 +1,848 @@
import React, { useState, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
ShieldAlert,
FileCheck,
Upload,
Plus,
CheckCircle2,
XCircle,
AlertTriangle,
FileText,
Clock,
TrendingUp,
Download,
Paperclip,
Check,
Filter,
} from 'lucide-react';
import {
getAllIndicators,
createIndicator,
recordIndicatorActual,
getAllEvidence,
uploadEvidenceFile,
updateEvidence,
getAllRisks,
createRisk,
getProjects,
getAllMilestones,
} from '../services/api';
import { Modal } from '../components/ui/Modal';
import { useToast } from '../components/ui/Toast';
import {
Indicator,
Evidence,
Risk,
ApprovalStatus,
UnitOfMeasure,
EvidenceType,
RiskLikelihood,
RiskImpact,
CreateIndicatorRequest,
CreateRiskRequest,
} from '../types';
import {
formatDate,
getApprovalStatusBadge,
getEvidenceTypeLabel,
getUnitOfMeasureLabel,
getRiskScoreColor,
UNIT_OF_MEASURE_OPTIONS,
RISK_LIKELIHOOD_OPTIONS,
RISK_IMPACT_OPTIONS,
EVIDENCE_TYPE_OPTIONS,
getUserEmail,
} from '../lib/formatters';
const indicatorSchema = z.object({
projectId: z.coerce.number().min(1, 'Select project'),
name: z.string().min(5, 'Indicator definition required'),
unitOfMeasure: z.coerce.number().min(1),
baselineAmount: z.coerce.number().min(0),
targetAmount: z.coerce.number().min(1, 'Target must be > 0'),
});
const riskSchema = z.object({
projectId: z.coerce.number().min(1, 'Select project'),
name: z.string().min(5, 'Risk title required'),
description: z.string().min(10, 'Risk description required'),
likelihood: z.coerce.number().min(1).max(5),
impact: z.coerce.number().min(1).max(5),
});
type IndicatorFormValues = z.infer<typeof indicatorSchema>;
type RiskFormValues = z.infer<typeof riskSchema>;
export const MonitoringPage: React.FC = () => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const [activeTab, setActiveTab] = useState<'indicators' | 'evidence' | 'risks'>('evidence');
const fileInputRef = useRef<HTMLInputElement>(null);
// Modals & States
const [isIndicatorModalOpen, setIsIndicatorModalOpen] = useState(false);
const [isRiskModalOpen, setIsRiskModalOpen] = useState(false);
const [selectedIndicatorForActual, setSelectedIndicatorForActual] = useState<Indicator | null>(null);
const [actualInput, setActualInput] = useState<number>(0);
const [uploadEvidenceType, setUploadEvidenceType] = useState<number>(EvidenceType.CompletionCertificate);
// Figure 10 Reviewer commentary
const [reviewerComment, setReviewerComment] = useState('');
// Queries
const { data: indicators = [], isLoading: loadingIndicators } = useQuery({
queryKey: ['indicators'],
queryFn: () => getAllIndicators(),
});
const { data: evidenceList = [], isLoading: loadingEvidence } = useQuery({
queryKey: ['evidence'],
queryFn: () => getAllEvidence(),
});
const { data: risks = [], isLoading: loadingRisks } = useQuery({
queryKey: ['risks'],
queryFn: () => getAllRisks(),
});
const { data: projects = [] } = useQuery({
queryKey: ['projects'],
queryFn: () => getProjects(),
});
const { data: milestones = [] } = useQuery({
queryKey: ['milestones-all'],
queryFn: () => getAllMilestones(),
});
// Forms
const indicatorForm = useForm<IndicatorFormValues>({
resolver: zodResolver(indicatorSchema) as any,
defaultValues: {
projectId: projects[0]?.id ? Number(projects[0].id) : 401,
name: '',
unitOfMeasure: UnitOfMeasure.Count,
baselineAmount: 0,
targetAmount: 100,
},
});
const riskForm = useForm<RiskFormValues>({
resolver: zodResolver(riskSchema) as any,
defaultValues: {
projectId: projects[0]?.id ? Number(projects[0].id) : 401,
name: '',
description: '',
likelihood: RiskLikelihood.Medium,
impact: RiskImpact.Moderate,
},
});
// Mutations
const indicatorMutation = useMutation({
mutationFn: (data: CreateIndicatorRequest) => createIndicator(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['indicators'] });
success('Indicator defined', 'Indicator added to project monitoring plan (M08)');
setIsIndicatorModalOpen(false);
indicatorForm.reset();
},
onError: (err: unknown) => {
toastError('Failed to create indicator', String(err));
},
});
const actualMutation = useMutation({
mutationFn: ({ id, amount }: { id: string | number; amount: number }) =>
recordIndicatorActual(id, amount),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['indicators'] });
success('Actual value recorded', 'M&E variance and indicator status recalculated (BR-009)');
setSelectedIndicatorForActual(null);
},
onError: (err: unknown) => {
toastError('Failed to record actual', String(err));
},
});
const uploadMutation = useMutation({
mutationFn: (formData: FormData) => uploadEvidenceFile(formData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['evidence'] });
success('Evidence uploaded', 'Supporting file registered for reviewer inspection (Figure 10 / M14)');
},
onError: (err: unknown) => {
toastError('Upload failed', String(err));
},
});
const reviewEvidenceMutation = useMutation({
mutationFn: ({ id, status }: { id: string | number; status: ApprovalStatus }) =>
updateEvidence({
evidenceId: id,
version: 2,
evidenceType: EvidenceType.DeliverableProof,
status,
}),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['evidence'] });
if (variables.status === ApprovalStatus.Approved) {
success(
'Evidence Approved & Verified',
'Approval recorded. Auto-triggers payment eligibility check in Disbursements.'
);
} else {
success('Rework Requested', 'Evidence returned to recipient for correction.');
}
setReviewerComment('');
},
onError: (err: unknown) => {
toastError('Review update failed', String(err));
},
});
const riskMutation = useMutation({
mutationFn: (data: CreateRiskRequest) => createRisk(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['risks'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
success('Risk registered', 'Risk item added to project register');
setIsRiskModalOpen(false);
riskForm.reset();
},
onError: (err: unknown) => {
toastError('Failed to log risk', String(err));
},
});
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const formData = new FormData();
formData.append('file', file);
formData.append('projectId', '401');
formData.append('milestoneId', '803');
formData.append('evidenceType', String(uploadEvidenceType));
uploadMutation.mutate(formData);
}
};
// Indicator thresholds from SRS 9.6:
// Green >= 90%, Amber 70-89%, Red < 70%
const getIndicatorStatus = (actual: number, target: number) => {
if (!target) return { label: 'Pending', badge: 'bg-slate-800/60 text-slate-400 border-slate-700/60' };
const pct = (actual / target) * 100;
if (pct >= 90) return { label: 'Green (≥90%)', badge: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' };
if (pct >= 70) return { label: 'Amber (70–89%)', badge: 'bg-amber-500/10 text-amber-400 border-amber-500/20' };
return { label: 'Red (<70%)', badge: 'bg-rose-500/10 text-rose-400 border-rose-500/20' };
};
return (
<div className="space-y-6 max-w-7xl mx-auto">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-[#0f1523]/80 backdrop-blur-md p-6 rounded-2xl border border-slate-800/80 shadow-xl">
<div>
<h2 className="text-2xl font-bold text-white tracking-tight flex items-center gap-2">
M&E, Evidence & Risk Management
</h2>
<p className="text-xs text-slate-400 mt-1">
Verification workflow: Evidence Upload & Review Queue, Indicator Actuals Recording, and Risk Register.
</p>
</div>
<div className="flex items-center gap-2">
{activeTab === 'indicators' && (
<button
onClick={() => setIsIndicatorModalOpen(true)}
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition"
>
<Plus className="w-4 h-4" />
Define Indicator
</button>
)}
{activeTab === 'risks' && (
<button
onClick={() => setIsRiskModalOpen(true)}
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition"
>
<Plus className="w-4 h-4" />
Add Risk to Register
</button>
)}
</div>
</div>
{/* Tabs */}
<div className="flex gap-2 border-b border-slate-800/80 pb-3 overflow-x-auto">
<button
onClick={() => setActiveTab('evidence')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${activeTab === 'evidence'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`}
>
<Paperclip className="w-4 h-4" />
Milestone Evidence & Review Queue
</button>
<button
onClick={() => setActiveTab('indicators')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${activeTab === 'indicators'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`}
>
<TrendingUp className="w-4 h-4" />
Indicators & Actuals ({indicators.length})
</button>
<button
onClick={() => setActiveTab('risks')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${activeTab === 'risks'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`}
>
<ShieldAlert className="w-4 h-4" />
Risk Register & Heatmap ({risks.length})
</button>
</div>
{/* Tab 1: Milestone Evidence Submission & Review (Exact Implementation of Figure 10) */}
{activeTab === 'evidence' && (
<div className="space-y-6">
{/* Wireframe Container from SRS Figure 10 */}
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
{/* Header Bar */}
<div className="bg-slate-900/90 text-white px-6 py-4 flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-slate-800/80">
<span className="text-xs font-mono font-semibold tracking-wider text-blue-400 uppercase">
RECIPIENT PORTAL &bull; Project XYZ &bull; Milestone 3: Prototype Delivery
</span>
<span className="text-[11px] text-slate-400 font-mono">Milestone Evidence Submission</span>
</div>
{/* Workflow Progress Steps */}
<div className="p-6 border-b border-slate-800/80 bg-slate-900/40">
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2 text-center text-xs font-semibold">
<div className="py-2.5 px-3 rounded-xl bg-slate-800/60 text-slate-400 border border-slate-700/50">Planned</div>
<div className="py-2.5 px-3 rounded-xl bg-slate-800/60 text-slate-400 border border-slate-700/50">In Progress</div>
<div className="py-2.5 px-3 rounded-xl bg-blue-600 text-white shadow-lg shadow-blue-600/30 ring-2 ring-blue-500/40">
Submitted
</div>
<div className="py-2.5 px-3 rounded-xl bg-amber-500/15 text-amber-300 border border-amber-500/30">
Under Review
</div>
<div className="py-2.5 px-3 rounded-xl bg-slate-900/40 text-slate-500 border border-dashed border-slate-800">
Verified & Paid
</div>
</div>
<div className="mt-4 flex items-center justify-between text-xs">
<div className="flex items-center gap-2 text-slate-300">
<span className="font-semibold">Current status:</span>
<span className="font-mono text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20 font-bold">
SUBMITTED — awaiting Programme Manager review
</span>
</div>
</div>
</div>
{/* Upload Evidence & Attached Evidence Panels (Matching Figure 10) */}
<div className="grid grid-cols-1 md:grid-cols-2 divide-y md:divide-y-0 md:divide-x divide-slate-800/80">
{/* Upload Box */}
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-bold text-white">Upload Evidence</h4>
<div className="flex items-center gap-1.5">
<label className="text-[11px] font-medium text-slate-400">Type:</label>
<select
value={uploadEvidenceType}
onChange={(e) => setUploadEvidenceType(Number(e.target.value))}
className="text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 max-w-[180px] [&>option]:bg-slate-900 [&>option]:text-white"
>
{EVIDENCE_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
<p className="text-xs text-slate-400">
Drag & drop or browse files. Accepted formats: PDF, JPG, DOCX, XLSX (Max 25MB).
</p>
<div
onClick={() => fileInputRef.current?.click()}
className="border-2 border-dashed border-slate-700/80 hover:border-blue-500/80 bg-slate-900/40 hover:bg-blue-500/5 rounded-2xl p-8 text-center cursor-pointer transition flex flex-col items-center justify-center space-y-3"
>
<Upload className="w-8 h-8 text-slate-400" />
<div>
<span className="text-xs font-bold text-white block">Click to Browse or Drag Files Here</span>
<span className="text-[10px] text-slate-500">Automated SHA-256 hash stamp applied on intake</span>
</div>
<button
type="button"
className="px-4 py-1.5 text-xs font-semibold text-white bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-xl shadow-xs transition"
>
Browse Files
</button>
</div>
<input
type="file"
ref={fileInputRef}
onChange={handleFileUpload}
className="hidden"
accept=".pdf,.jpg,.jpeg,.png,.docx,.xlsx,.zip"
/>
</div>
{/* Attached Evidence List */}
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-bold text-white">Attached Evidence (v2)</h4>
<span className="text-[11px] text-slate-400 font-mono">{evidenceList.length} files loaded</span>
</div>
<div className="space-y-2.5">
{evidenceList.map((ev) => (
<div
key={ev.id}
className="p-3 rounded-xl border border-slate-800/80 bg-slate-900/60 flex items-center justify-between gap-3 text-xs"
>
<div className="flex items-center gap-2.5 min-w-0">
<FileText className="w-4 h-4 text-blue-400 shrink-0" />
<div className="min-w-0">
<span className="font-semibold text-slate-200 block truncate font-mono">
{ev.fileName || `evidence_file_${ev.id}.pdf`}
</span>
<span className="text-[10px] text-slate-400 font-mono">
{ev.fileSize || '3.2 MB'} &bull; v{ev.version} &bull; {getEvidenceTypeLabel(ev.type)}
</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-amber-500/10 text-amber-400 border border-amber-500/20">
Under Review
</span>
{ev.documentUrl && (
<a
href={ev.documentUrl}
target="_blank"
rel="noopener noreferrer"
className="text-slate-400 hover:text-white p-1 transition"
title="Download evidence file"
>
<Download className="w-3.5 h-3.5" />
</a>
)}
</div>
</div>
))}
</div>
</div>
</div>
{/* Programme Manager Review Section (Figure 10) */}
<div className="p-6 border-t border-slate-800/80 bg-slate-900/40 space-y-4">
<h4 className="text-sm font-bold text-white">Programme Manager Review</h4>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
Reviewer comment / rejection reason
</label>
<input
type="text"
value={reviewerComment}
onChange={(e) => setReviewerComment(e.target.value)}
placeholder="Mandatory if requesting rework; optional for approvals..."
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition"
/>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pt-2">
<div className="flex items-center gap-3">
<button
onClick={() =>
reviewEvidenceMutation.mutate({
id: evidenceList[0]?.id || 1201,
status: ApprovalStatus.Rejected,
})
}
disabled={reviewEvidenceMutation.isPending}
className="px-4 py-2 text-xs font-semibold text-white bg-rose-600/90 hover:bg-rose-600 rounded-xl shadow-lg shadow-rose-600/20 transition"
>
Reject / Rework
</button>
<button
onClick={() =>
reviewEvidenceMutation.mutate({
id: evidenceList[0]?.id || 1201,
status: ApprovalStatus.Approved,
})
}
disabled={reviewEvidenceMutation.isPending}
className="px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition"
>
Approve & Verify
</button>
</div>
<p className="text-[11px] text-slate-400 italic">
Approval auto-triggers payment eligibility check.
</p>
</div>
</div>
</div>
</div>
)}
{/* Tab 2: Indicators Table (M08) */}
{activeTab === 'indicators' && (
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
<th className="py-3.5 px-5">Indicator Name</th>
<th className="py-3.5 px-4">Project ID</th>
<th className="py-3.5 px-4">Unit of Measure</th>
<th className="py-3.5 px-4 text-right">Baseline</th>
<th className="py-3.5 px-4 text-right">Target</th>
<th className="py-3.5 px-4 text-right">Actual Recorded</th>
<th className="py-3.5 px-4">Status</th>
<th className="py-3.5 px-5 text-right">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{loadingIndicators ? (
<tr>
<td colSpan={8} className="py-8 text-center text-slate-500">
Loading indicators...
</td>
</tr>
) : (
indicators.map((ind) => {
const statusObj = getIndicatorStatus(ind.actualAmount, ind.targetAmount);
return (
<tr key={ind.id} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-5 font-semibold text-white">{ind.name}</td>
<td className="py-3.5 px-4 text-slate-400 font-mono">Project #{ind.projectId}</td>
<td className="py-3.5 px-4 text-slate-300">{getUnitOfMeasureLabel(ind.unitOfMeasure)}</td>
<td className="py-3.5 px-4 text-right font-mono text-slate-400">{ind.baselineAmount}</td>
<td className="py-3.5 px-4 text-right font-mono font-bold text-white">{ind.targetAmount}</td>
<td className="py-3.5 px-4 text-right font-mono font-bold text-emerald-400 text-xs">
{ind.actualAmount}
</td>
<td className="py-3.5 px-4">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${statusObj.badge}`}>
{statusObj.label}
</span>
</td>
<td className="py-3.5 px-5 text-right">
<button
onClick={() => {
setSelectedIndicatorForActual(ind);
setActualInput(ind.actualAmount);
}}
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-300 hover:text-white bg-slate-800/80 hover:bg-slate-700 px-3 py-1.5 rounded-lg border border-slate-700/60 transition"
>
Record Actual
</button>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
)}
{/* Tab 3: Risk Register (M09) */}
{activeTab === 'risks' && (
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
<th className="py-3.5 px-5">Risk Title & Description</th>
<th className="py-3.5 px-4">Project ID</th>
<th className="py-3.5 px-4 text-center">Likelihood (1–5)</th>
<th className="py-3.5 px-4 text-center">Impact (1–5)</th>
<th className="py-3.5 px-4 text-center">Residual Score</th>
<th className="py-3.5 px-4">Risk Owner</th>
<th className="py-3.5 px-5">Mitigation Control</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{loadingRisks ? (
<tr>
<td colSpan={7} className="py-8 text-center text-slate-500">
Loading risk register...
</td>
</tr>
) : (
risks.map((rk) => {
const score = rk.likelihood * rk.impact;
const color = getRiskScoreColor(rk.likelihood, rk.impact);
return (
<tr key={rk.id} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-5">
<span className="font-semibold text-white block text-xs">{rk.name}</span>
<span className="text-[11px] text-slate-400 line-clamp-1">{rk.description}</span>
</td>
<td className="py-3.5 px-4 text-slate-400 font-mono">Project #{rk.projectId}</td>
<td className="py-3.5 px-4 text-center font-mono font-bold text-slate-200">{rk.likelihood}</td>
<td className="py-3.5 px-4 text-center font-mono font-bold text-slate-200">{rk.impact}</td>
<td className="py-3.5 px-4 text-center">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-bold ${color.badge}`}>
{score} / 25 ({color.level})
</span>
</td>
<td className="py-3.5 px-4 text-slate-300 font-medium">{getUserEmail(rk.owner)}</td>
<td className="py-3.5 px-5 text-slate-400 text-[11px]">
{rk.mitigationAction || 'Standard internal audit controls applied.'}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
)}
{/* Modal: Define Indicator */}
<Modal
isOpen={isIndicatorModalOpen}
onClose={() => setIsIndicatorModalOpen(false)}
title="Define M&E Indicator"
subtitle="Logical Framework & Theory of Change metric"
>
<form onSubmit={indicatorForm.handleSubmit((data) => indicatorMutation.mutate(data as any))} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Project *</label>
<select
{...indicatorForm.register('projectId')}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Indicator Statement *</label>
<input
type="text"
{...indicatorForm.register('name')}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition"
placeholder="e.g. Smallholder farmers adopting precision drone analytics"
/>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Unit of Measure *</label>
<select
{...indicatorForm.register('unitOfMeasure')}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
>
{UNIT_OF_MEASURE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Baseline Value *</label>
<input
type="number"
{...indicatorForm.register('baselineAmount')}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-mono transition"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Target Value *</label>
<input
type="number"
{...indicatorForm.register('targetAmount')}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-mono transition"
/>
</div>
</div>
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
<button
type="button"
onClick={() => setIsIndicatorModalOpen(false)}
className="px-4 py-2 text-xs font-medium text-slate-300 bg-slate-800/80 hover:bg-slate-700 rounded-xl transition"
>
Cancel
</button>
<button
type="submit"
disabled={indicatorMutation.isPending}
className="px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition"
>
{indicatorMutation.isPending ? 'Saving...' : 'Define Indicator'}
</button>
</div>
</form>
</Modal>
{/* Modal: Record Actual Amount */}
<Modal
isOpen={!!selectedIndicatorForActual}
onClose={() => setSelectedIndicatorForActual(null)}
title="Record Indicator Actual Value"
subtitle={selectedIndicatorForActual?.name || ''}
>
<div className="space-y-4">
<div className="p-3.5 bg-slate-900/60 border border-slate-800/80 rounded-xl text-xs flex justify-between text-slate-300">
<span>Baseline: <strong className="text-white">{selectedIndicatorForActual?.baselineAmount}</strong></span>
<span>Target: <strong className="text-white">{selectedIndicatorForActual?.targetAmount}</strong></span>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Actual Value to Record *</label>
<input
type="number"
value={actualInput}
onChange={(e) => setActualInput(Number(e.target.value))}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-mono transition"
/>
</div>
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
<button
type="button"
onClick={() => setSelectedIndicatorForActual(null)}
className="px-4 py-2 text-xs font-medium text-slate-300 bg-slate-800/80 hover:bg-slate-700 rounded-xl transition"
>
Cancel
</button>
<button
type="button"
onClick={() =>
selectedIndicatorForActual &&
actualMutation.mutate({
id: selectedIndicatorForActual.id,
amount: actualInput,
})
}
disabled={actualMutation.isPending}
className="px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition"
>
{actualMutation.isPending ? 'Recording...' : 'Submit Actual Value'}
</button>
</div>
</div>
</Modal>
{/* Modal: Add Risk */}
<Modal
isOpen={isRiskModalOpen}
onClose={() => setIsRiskModalOpen(false)}
title="Add Risk to Risk Register"
subtitle="Identify and score project delivery risks"
>
<form onSubmit={riskForm.handleSubmit((data) => riskMutation.mutate(data as any))} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Associated Project *</label>
<select
{...riskForm.register('projectId')}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Risk Title *</label>
<input
type="text"
{...riskForm.register('name')}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition"
placeholder="e.g. Foreign Exchange Currency Volatility"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Likelihood (1–5) *</label>
<select
{...riskForm.register('likelihood')}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
>
{RISK_LIKELIHOOD_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Impact (1–5) *</label>
<select
{...riskForm.register('impact')}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
>
{RISK_IMPACT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Risk Description *</label>
<textarea
rows={2}
{...riskForm.register('description')}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition"
/>
</div>
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
<button
type="button"
onClick={() => setIsRiskModalOpen(false)}
className="px-4 py-2 text-xs font-medium text-slate-300 bg-slate-800/80 hover:bg-slate-700 rounded-xl transition"
>
Cancel
</button>
<button
type="submit"
disabled={riskMutation.isPending}
className="px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition"
>
{riskMutation.isPending ? 'Logging...' : 'Log Risk'}
</button>
</div>
</form>
</Modal>
</div>
);
};
@@ -0,0 +1,789 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
FolderKanban,
Plus,
CheckCircle2,
Calendar,
Layers,
ChevronRight,
Clock,
Briefcase,
AlertCircle,
Flag,
FileCheck,
TrendingUp,
X,
} from 'lucide-react';
import {
getProjects,
createProject,
updateProjectStatus,
getActivities,
createActivity,
getMilestones,
createMilestone,
getAllProgrammes,
getProjectIndicators,
getProjectRisks,
} from '../services/api';
import { Modal } from '../components/ui/Modal';
import { useToast } from '../components/ui/Toast';
import {
Project,
IndustrySector,
ProjectType,
ApprovalStatus,
Priority,
CreateProjectRequest,
CreateActivityRequest,
CreateMilestoneRequest,
} from '../types';
import {
getIndustrySectorLabel,
getProjectTypeLabel,
getApprovalStatusBadge,
getPriorityBadge,
formatDate,
formatZAR,
INDUSTRY_SECTOR_OPTIONS,
PROJECT_TYPE_OPTIONS,
PRIORITY_OPTIONS,
APPROVAL_STATUS_OPTIONS,
} from '../lib/formatters';
const projectSchema = z.object({
name: z.string().min(4, 'Project name must be at least 4 characters'),
description: z.string().min(10, 'Provide a meaningful implementation description'),
programmeId: z.coerce.number().min(1, 'Select a programme'),
sector: z.coerce.number().min(1),
projectType: z.coerce.number().min(1),
});
const activitySchema = z.object({
title: z.string().min(3, 'Activity title required'),
description: z.string().min(5, 'Description required'),
category: z.string().min(2, 'Category required'),
amount: z.coerce.number().min(1, 'Amount must be positive'),
});
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),
});
type ProjectFormValues = z.infer<typeof projectSchema>;
type ActivityFormValues = z.infer<typeof activitySchema>;
type MilestoneFormValues = z.infer<typeof milestoneSchema>;
export const ProjectsPage: React.FC = () => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
const [detailTab, setDetailTab] = useState<'activities' | 'milestones' | 'indicators' | 'risks'>('activities');
// Modals
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
const [isActivityModalOpen, setIsActivityModalOpen] = useState(false);
const [isMilestoneModalOpen, setIsMilestoneModalOpen] = useState(false);
// Queries
const { data: projects = [], isLoading: loadingProjects } = useQuery({
queryKey: ['projects'],
queryFn: () => getProjects(),
});
const { data: programmes = [] } = useQuery({
queryKey: ['programmes'],
queryFn: () => getAllProgrammes(),
});
// Details Queries (for currently selected project)
const activeProjectId = selectedProject ? selectedProject.id : null;
const { data: activities = [] } = useQuery({
queryKey: ['activities', activeProjectId],
queryFn: () => (activeProjectId ? getActivities(activeProjectId) : Promise.resolve([])),
enabled: !!activeProjectId,
});
const { data: milestones = [] } = useQuery({
queryKey: ['milestones', activeProjectId],
queryFn: () => (activeProjectId ? getMilestones(activeProjectId) : Promise.resolve([])),
enabled: !!activeProjectId,
});
const { data: indicators = [] } = useQuery({
queryKey: ['indicators', activeProjectId],
queryFn: () => (activeProjectId ? getProjectIndicators(activeProjectId) : Promise.resolve([])),
enabled: !!activeProjectId,
});
const { data: risks = [] } = useQuery({
queryKey: ['risks', activeProjectId],
queryFn: () => (activeProjectId ? getProjectRisks(activeProjectId) : Promise.resolve([])),
enabled: !!activeProjectId,
});
// Forms
const projectForm = useForm<ProjectFormValues>({
resolver: zodResolver(projectSchema) as any,
defaultValues: {
name: '',
description: '',
programmeId: programmes[0]?.id ? Number(programmes[0].id) : 0,
sector: IndustrySector.AgriTech,
projectType: ProjectType.Innovation,
},
});
const activityForm = useForm<ActivityFormValues>({
resolver: zodResolver(activitySchema) as any,
defaultValues: {
title: '',
description: '',
category: '',
amount: 0,
},
});
const milestoneForm = useForm<MilestoneFormValues>({
resolver: zodResolver(milestoneSchema) as any,
defaultValues: {
name: '',
dueAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10),
priority: Priority.High,
},
});
// Mutations
const projectMutation = useMutation({
mutationFn: (data: CreateProjectRequest) => createProject(data),
onSuccess: (newProj) => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
success('Project registered', `Project "${newProj.name}" created successfully`);
setIsProjectModalOpen(false);
projectForm.reset();
setSelectedProject(newProj);
},
onError: (err: unknown) => {
toastError('Failed to create project', String(err));
},
});
const statusMutation = useMutation({
mutationFn: ({ projectId, status }: { projectId: number | string; status: ApprovalStatus }) =>
updateProjectStatus({ projectId, status }),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
if (selectedProject) {
setSelectedProject({ ...selectedProject, status: variables.status });
}
success('Status updated', 'Project governance status changed successfully');
},
onError: (err: unknown) => {
toastError('Failed to update status', String(err));
},
});
const activityMutation = useMutation({
mutationFn: (data: CreateActivityRequest) => createActivity(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['activities', activeProjectId] });
success('Activity added', 'Activity registered to project work breakdown');
setIsActivityModalOpen(false);
activityForm.reset();
},
onError: (err: unknown) => {
toastError('Failed to add activity', String(err));
},
});
const milestoneMutation = useMutation({
mutationFn: (data: CreateMilestoneRequest) => createMilestone(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['milestones', activeProjectId] });
success('Milestone created', 'Contractual deliverable added to implementation schedule');
setIsMilestoneModalOpen(false);
milestoneForm.reset();
},
onError: (err: unknown) => {
toastError('Failed to add milestone', String(err));
},
});
// Calculate project progress based on milestones (FR-M04-018)
const completedMilestones = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
const milestoneProgress = milestones.length > 0 ? Math.round((completedMilestones / milestones.length) * 100) : 0;
return (
<div className="space-y-6 max-w-7xl mx-auto">
{/* Page Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-[#0d121f]/90 p-5 rounded-2xl border border-slate-800/80 backdrop-blur-md shadow-xl">
<div>
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold text-white tracking-tight flex items-center gap-2">
Project & Milestone Management
</h2>
<span className="text-[11px] font-semibold text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-full border border-blue-500/30">
Work Breakdown
</span>
</div>
<p className="text-xs text-slate-400 mt-1">
Work breakdown structure, contractual deliverables, milestone evidence tracking, and progress calculation.
</p>
</div>
<button
onClick={() => setIsProjectModalOpen(true)}
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 transition cursor-pointer self-start sm:self-auto"
>
<Plus className="w-4 h-4" />
Create Project
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* Left Column: Project Directory List */}
<div className="lg:col-span-5 space-y-3">
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-wider px-1">
Projects Directory ({projects.length})
</h3>
{loadingProjects ? (
<div className="bg-[#0f1523]/80 p-6 rounded-2xl border border-slate-800/80 text-center text-xs text-slate-400">
Loading projects...
</div>
) : (
<div className="space-y-2.5">
{projects.map((p) => {
const isSelected = selectedProject?.id === p.id;
const statusBadge = getApprovalStatusBadge(p.status);
return (
<div
key={p.id}
onClick={() => setSelectedProject(p)}
className={`p-4 rounded-2xl border transition-all cursor-pointer bg-[#0f1523]/80 backdrop-blur-sm text-left ${isSelected
? 'border-blue-500 ring-2 ring-blue-500/20 shadow-lg shadow-blue-500/10'
: 'border-slate-800/80 hover:border-slate-700 hover:shadow-md'
}`}
>
<div className="flex items-start justify-between gap-2">
<span className="font-bold text-sm text-white leading-snug">{p.name}</span>
<span className={`shrink-0 inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
{statusBadge.label.split('/')[0]}
</span>
</div>
<p className="text-xs text-slate-400 mt-1 line-clamp-2 leading-relaxed">{p.description}</p>
<div className="flex items-center gap-2 mt-3 pt-2.5 border-t border-slate-800/80 text-[11px] text-slate-400">
<span className="bg-slate-900/80 text-slate-300 border border-slate-700/80 px-2 py-0.5 rounded-md font-medium">
{getIndustrySectorLabel(p.sector)}
</span>
<span>&bull;</span>
<span className="text-slate-300">{getProjectTypeLabel(p.projectType)}</span>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Right Column: Detailed Project Workspace */}
<div className="lg:col-span-7">
{selectedProject ? (
<div className="bg-[#0f1523]/80 rounded-2xl border border-slate-800/80 shadow-xl backdrop-blur-md overflow-hidden sticky top-6">
{/* Project Header */}
<div className="p-6 border-b border-slate-800/80 bg-[#0d121f]/90">
<div className="flex items-start justify-between gap-3">
<div>
<span className="text-[10px] font-mono uppercase tracking-wider text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-md border border-blue-500/30">
Project #{selectedProject.id}
</span>
<h3 className="text-lg font-bold text-white mt-1.5 leading-snug">{selectedProject.name}</h3>
<p className="text-xs text-slate-400 mt-1 leading-relaxed">{selectedProject.description}</p>
</div>
{/* Status Change Selector */}
<div className="shrink-0 text-right">
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">Approval Status</label>
<select
value={selectedProject.status}
onChange={(e) =>
statusMutation.mutate({
projectId: selectedProject.id,
status: Number(e.target.value) as ApprovalStatus,
})
}
className="text-xs font-semibold bg-[#090d16] border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 cursor-pointer"
>
{APPROVAL_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
{/* Progress bar (FR-M04-018: calculated from approved milestones) */}
<div className="mt-5 pt-4 border-t border-slate-800/80">
<div className="flex items-center justify-between text-xs mb-1.5">
<span className="font-semibold text-slate-300 flex items-center gap-1.5">
<TrendingUp className="w-3.5 h-3.5 text-blue-400" />
Calculated Progress (Milestone-based)
</span>
<span className="font-bold text-blue-400">{milestoneProgress}%</span>
</div>
<div className="w-full h-2 bg-slate-900 rounded-full overflow-hidden border border-slate-800">
<div
className="h-full bg-gradient-to-r from-blue-600 to-indigo-500 transition-all duration-300 rounded-full"
style={{ width: `${milestoneProgress}%` }}
></div>
</div>
<div className="flex justify-between text-[10px] text-slate-500 mt-1">
<span>{completedMilestones} of {milestones.length} milestones verified</span>
<span>Target: 100% completion</span>
</div>
</div>
</div>
{/* Sub-tabs */}
<div className="flex border-b border-slate-800/80 px-4 bg-[#0d121f]/60">
<button
onClick={() => setDetailTab('activities')}
className={`py-3 px-3 text-xs font-semibold border-b-2 transition flex items-center gap-1.5 cursor-pointer ${detailTab === 'activities'
? 'border-blue-500 text-blue-400'
: 'border-transparent text-slate-400 hover:text-slate-200'
}`}
>
<Briefcase className="w-3.5 h-3.5" />
Work Activities ({activities.length})
</button>
<button
onClick={() => setDetailTab('milestones')}
className={`py-3 px-3 text-xs font-semibold border-b-2 transition flex items-center gap-1.5 cursor-pointer ${detailTab === 'milestones'
? 'border-blue-500 text-blue-400'
: 'border-transparent text-slate-400 hover:text-slate-200'
}`}
>
<Flag className="w-3.5 h-3.5" />
Milestones ({milestones.length})
</button>
<button
onClick={() => setDetailTab('indicators')}
className={`py-3 px-3 text-xs font-semibold border-b-2 transition flex items-center gap-1.5 cursor-pointer ${detailTab === 'indicators'
? 'border-blue-500 text-blue-400'
: 'border-transparent text-slate-400 hover:text-slate-200'
}`}
>
<FileCheck className="w-3.5 h-3.5" />
Indicators ({indicators.length})
</button>
<button
onClick={() => setDetailTab('risks')}
className={`py-3 px-3 text-xs font-semibold border-b-2 transition flex items-center gap-1.5 cursor-pointer ${detailTab === 'risks'
? 'border-blue-500 text-blue-400'
: 'border-transparent text-slate-400 hover:text-slate-200'
}`}
>
<AlertCircle className="w-3.5 h-3.5" />
Risks ({risks.length})
</button>
</div>
{/* Tab Content */}
<div className="p-5 max-h-[500px] overflow-y-auto">
{/* Activities Sub-tab */}
{detailTab === 'activities' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-slate-300">Project Work Breakdown</span>
<button
onClick={() => setIsActivityModalOpen(true)}
className="inline-flex items-center gap-1 text-xs font-semibold text-blue-400 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 px-3 py-1 rounded-xl transition cursor-pointer"
>
<Plus className="w-3.5 h-3.5" />
Add Activity
</button>
</div>
{activities.length === 0 ? (
<p className="text-xs text-slate-500 py-4 text-center">No activities recorded yet.</p>
) : (
<div className="space-y-2.5">
{activities.map((a) => (
<div key={a.id} className="p-3.5 rounded-xl border border-slate-800/80 bg-[#0d121f]/90">
<div className="flex items-start justify-between">
<span className="font-semibold text-xs text-white">{a.title}</span>
<span className="font-mono text-xs font-bold text-blue-400">{formatZAR(a.amount)}</span>
</div>
<p className="text-xs text-slate-400 mt-1">{a.description}</p>
<div className="flex items-center justify-between mt-2 pt-2 border-t border-slate-800/80 text-[10px] text-slate-400">
<span className="bg-slate-900/80 px-2 py-0.5 rounded-md border border-slate-700/80 text-slate-300 font-medium">{a.category}</span>
<span>Date: {formatDate(a.performedAt)}</span>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* Milestones Sub-tab */}
{detailTab === 'milestones' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-slate-300">Contractual Milestones</span>
<button
onClick={() => setIsMilestoneModalOpen(true)}
className="inline-flex items-center gap-1 text-xs font-semibold text-blue-400 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 px-3 py-1 rounded-xl transition cursor-pointer"
>
<Plus className="w-3.5 h-3.5" />
Add Milestone
</button>
</div>
{milestones.length === 0 ? (
<p className="text-xs text-slate-500 py-4 text-center">No milestones scheduled.</p>
) : (
<div className="space-y-2.5">
{milestones.map((m) => {
const statusBadge = getApprovalStatusBadge(m.status);
const priBadge = getPriorityBadge(m.priority);
return (
<div key={m.id} className="p-3.5 rounded-xl border border-slate-800/80 bg-[#0d121f]/90 space-y-2">
<div className="flex items-start justify-between gap-2">
<span className="font-semibold text-xs text-white">{m.name}</span>
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
{statusBadge.label}
</span>
</div>
<div className="flex items-center justify-between text-[11px] text-slate-400">
<span className="flex items-center gap-1">
<Clock className="w-3 h-3 text-slate-500" />
Due: {formatDate(m.dueAt)}
</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded-md font-medium border ${priBadge.className}`}>
{priBadge.label}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
)}
{/* Indicators Sub-tab */}
{detailTab === 'indicators' && (
<div className="space-y-3">
<span className="text-xs font-bold text-slate-300 block">Assigned Project Indicators</span>
{indicators.length === 0 ? (
<p className="text-xs text-slate-500 py-4 text-center">No indicators attached.</p>
) : (
indicators.map((ind) => {
const pct = ind.targetAmount > 0 ? Math.round((ind.actualAmount / ind.targetAmount) * 100) : 0;
return (
<div key={ind.id} className="p-3.5 rounded-xl border border-slate-800/80 bg-[#0d121f]/90 space-y-1.5">
<div className="flex justify-between text-xs font-semibold">
<span className="text-white">{ind.name}</span>
<span className={pct >= 90 ? 'text-emerald-400' : 'text-amber-400'}>{pct}%</span>
</div>
<div className="flex justify-between text-[11px] text-slate-400">
<span>Baseline: {ind.baselineAmount}</span>
<span>Target: {ind.targetAmount}</span>
<span className="font-bold text-slate-200">Actual: {ind.actualAmount}</span>
</div>
</div>
);
})
)}
</div>
)}
{/* Risks Sub-tab */}
{detailTab === 'risks' && (
<div className="space-y-3">
<span className="text-xs font-bold text-slate-300 block">Project Risk Register (M09)</span>
{risks.length === 0 ? (
<p className="text-xs text-slate-500 py-4 text-center">No project risks logged.</p>
) : (
risks.map((rk) => (
<div key={rk.id} className="p-3.5 rounded-xl border border-slate-800/80 bg-[#0d121f]/90 space-y-1">
<div className="flex justify-between text-xs font-semibold text-white">
<span>{rk.name}</span>
<span className="text-[10px] font-bold text-rose-400 bg-rose-500/10 px-2 py-0.5 rounded-md border border-rose-500/30 font-mono">
Score: {rk.likelihood * rk.impact} / 25
</span>
</div>
<p className="text-xs text-slate-400">{rk.description}</p>
{rk.mitigationAction && (
<p className="text-[11px] text-emerald-400 bg-emerald-500/10 p-2 rounded-lg mt-1.5 border border-emerald-500/20">
<strong className="text-emerald-300">Mitigation:</strong> {rk.mitigationAction}
</p>
)}
</div>
))
)}
</div>
)}
</div>
</div>
) : (
<div className="bg-[#0f1523]/80 rounded-2xl border border-dashed border-slate-800 p-12 text-center text-slate-400 flex flex-col items-center justify-center">
<FolderKanban className="w-10 h-10 text-slate-600 mb-3" />
<p className="text-sm font-semibold text-slate-200">Select a project to inspect implementation</p>
<p className="text-xs text-slate-500 mt-1">View activities, milestones, indicators, and risk controls.</p>
</div>
)}
</div>
</div>
{/* Modal: Create Project */}
<Modal
isOpen={isProjectModalOpen}
onClose={() => setIsProjectModalOpen(false)}
title="Create Funded Project"
subtitle="Operational entity under an approved Programme"
>
<form onSubmit={projectForm.handleSubmit((data) => projectMutation.mutate(data as any))} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Project Name *</label>
<input
type="text"
{...projectForm.register('name')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-white bg-[#090d16]"
placeholder="e.g. Drone Precision Seed & Thermal Scouting"
/>
{projectForm.formState.errors.name && (
<p className="text-[10px] text-rose-500 mt-1">{projectForm.formState.errors.name.message}</p>
)}
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Parent Programme *</label>
<select
{...projectForm.register('programmeId')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
>
{programmes.map((pr) => (
<option key={pr.id} value={pr.id}>
{pr.name}
</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Industry Sector *</label>
<select
{...projectForm.register('sector')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
>
{INDUSTRY_SECTOR_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Project Type *</label>
<select
{...projectForm.register('projectType')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
>
{PROJECT_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Project Scope & Objectives *</label>
<textarea
rows={3}
{...projectForm.register('description')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-white bg-[#090d16]"
placeholder="Outline the operational work, methodology, and key milestones..."
/>
</div>
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
<button
type="button"
onClick={() => setIsProjectModalOpen(false)}
className="px-3.5 py-2 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={projectMutation.isPending}
className="px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 cursor-pointer"
>
{projectMutation.isPending ? 'Creating...' : 'Register Project'}
</button>
</div>
</form>
</Modal>
{/* Modal: Add Activity */}
<Modal
isOpen={isActivityModalOpen}
onClose={() => setIsActivityModalOpen(false)}
title="Add Activity to Implementation Plan"
subtitle={`Registering activity under Project #${activeProjectId}`}
>
<form
onSubmit={activityForm.handleSubmit((data) =>
activityMutation.mutate({
projectId: activeProjectId!,
...data,
} as any)
)}
className="space-y-4"
>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Activity Title *</label>
<input
type="text"
{...activityForm.register('title')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-white bg-[#090d16]"
placeholder="e.g. Cleanroom Sensor Calibration"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Budget Allocation (ZAR) *</label>
<input
type="number"
{...activityForm.register('amount')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 font-mono text-white bg-[#090d16]"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Cost Category *</label>
<input
type="text"
{...activityForm.register('category')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-white bg-[#090d16]"
placeholder="Hardware / Field Testing"
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Description *</label>
<textarea
rows={2}
{...activityForm.register('description')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-white bg-[#090d16]"
/>
</div>
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
<button
type="button"
onClick={() => setIsActivityModalOpen(false)}
className="px-3.5 py-2 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={activityMutation.isPending}
className="px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 cursor-pointer"
>
{activityMutation.isPending ? 'Saving...' : 'Add Activity'}
</button>
</div>
</form>
</Modal>
{/* Modal: Add Milestone */}
<Modal
isOpen={isMilestoneModalOpen}
onClose={() => setIsMilestoneModalOpen(false)}
title="Define Contractual Milestone"
subtitle={`Deliverable schedule for Project #${activeProjectId}`}
>
<form
onSubmit={milestoneForm.handleSubmit((data) =>
milestoneMutation.mutate({
projectId: activeProjectId!,
...data,
} as any)
)}
className="space-y-4"
>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Milestone Deliverable Name *</label>
<input
type="text"
{...milestoneForm.register('name')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-white bg-[#090d16]"
placeholder="e.g. Milestone 4: Prototype Pilot Validation Report"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Contractual Due Date *</label>
<input
type="date"
{...milestoneForm.register('dueAt')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-white bg-[#090d16]"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Priority Level *</label>
<select
{...milestoneForm.register('priority')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
>
{PRIORITY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
<button
type="button"
onClick={() => setIsMilestoneModalOpen(false)}
className="px-3.5 py-2 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={milestoneMutation.isPending}
className="px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 cursor-pointer"
>
{milestoneMutation.isPending ? 'Saving...' : 'Define Milestone'}
</button>
</div>
</form>
</Modal>
</div>
);
};
@@ -0,0 +1,501 @@
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
Shield,
History,
CheckCircle,
Bell,
Send,
Radio,
Filter,
UserCheck,
} from 'lucide-react';
import {
getAuditLogs,
} from '../services/api';
import { Modal } from '../components/ui/Modal';
import { useToast } from '../components/ui/Toast';
import { useAuth } from '../auth/AuthProvider';
import {
EmailStatuses,
NotificationStatus,
NotificationPlatform,
} from '../types';
import {
formatDate,
getEmailStatusLabel,
getEmailStatusBadge,
getNotificationStatusLabel,
getNotificationStatusBadge,
getNotificationPlatformLabel,
getUserEmail,
EMAIL_STATUSES_OPTIONS,
NOTIFICATION_STATUS_OPTIONS,
NOTIFICATION_PLATFORM_OPTIONS,
} from '../lib/formatters';
interface NotificationItem {
id: number;
recipient: string;
platform: NotificationPlatform;
status: NotificationStatus;
subject: string;
sentAt: string;
errorMessage?: string;
}
export const SettingsPage: React.FC = () => {
const { user } = useAuth();
const { success, error: toastError } = useToast();
const [activeTab, setActiveTab] = useState<'notifications' | 'audit' | 'profile'>('notifications');
const [isSendNotificationModalOpen, setIsSendNotificationModalOpen] = useState(false);
// Email Gateway & Notification Engine states
const [emailGatewayStatus, setEmailGatewayStatus] = useState<EmailStatuses>(EmailStatuses.Connected);
const [selectedPlatformFilter, setSelectedPlatformFilter] = useState<number | 'all'>('all');
const [selectedStatusFilter, setSelectedStatusFilter] = useState<number | 'all'>('all');
// Dispatch Form State
const [dispatchPlatform, setDispatchPlatform] = useState<NotificationPlatform>(NotificationPlatform.Email);
const [dispatchStatus, setDispatchStatus] = useState<NotificationStatus>(NotificationStatus.Sent);
const [dispatchRecipient, setDispatchRecipient] = useState('');
const [dispatchSubject, setDispatchSubject] = useState('');
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
// Queries
const { data: auditLogs = [], isLoading: loadingAudit } = useQuery({
queryKey: ['auditLogs'],
queryFn: () => getAuditLogs(),
});
return (
<div className="space-y-6 max-w-7xl mx-auto">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-[#0f1523]/80 backdrop-blur-md p-6 rounded-2xl border border-slate-800/80 shadow-xl">
<div>
<h2 className="text-2xl font-bold text-white tracking-tight">
System Settings & Communications
</h2>
<p className="text-xs text-slate-400 mt-1">
Communication dispatch gateways, statutory audit trail, and active session claims.
</p>
</div>
{activeTab === 'notifications' && (
<button
onClick={() => setIsSendNotificationModalOpen(true)}
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition"
>
<Send className="w-4 h-4" />
Dispatch Notification
</button>
)}
</div>
{/* Tabs */}
<div className="flex gap-2 border-b border-slate-800/80 pb-3 overflow-x-auto">
<button
onClick={() => setActiveTab('notifications')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap ${activeTab === 'notifications'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`}
>
<Bell className="w-4 h-4" />
Notifications & Email Gateways ({notifications.length})
</button>
<button
onClick={() => setActiveTab('audit')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap ${activeTab === 'audit'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`}
>
<History className="w-4 h-4" />
Statutory Audit Trail ({auditLogs.length})
</button>
<button
onClick={() => setActiveTab('profile')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap ${activeTab === 'profile'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`}
>
<UserCheck className="w-4 h-4" />
Session & Token Claims
</button>
</div>
{/* Tab 1: Notifications & Email Gateways */}
{activeTab === 'notifications' && (
<div className="space-y-6">
{/* Status KPI Cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-slate-400">Email Gateway Status</span>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${getEmailStatusBadge(emailGatewayStatus).className}`}>
{getEmailStatusBadge(emailGatewayStatus).label}
</span>
</div>
<div className="pt-1">
<label className="block text-[11px] font-medium text-slate-400 mb-1.5">Update Gateway Status:</label>
<select
value={emailGatewayStatus}
onChange={(e) => {
const newStatus = Number(e.target.value) as EmailStatuses;
setEmailGatewayStatus(newStatus);
success('Gateway State Updated', `Email service transitioned to ${getEmailStatusLabel(newStatus)}`);
}}
className="w-full text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-medium transition [&>option]:bg-slate-900 [&>option]:text-white"
>
{EMAIL_STATUSES_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-1">
<span className="text-xs font-semibold text-slate-400">Queued / Active Pipeline</span>
<div className="text-2xl font-bold font-mono text-white">
{notifications.filter((n) => n.status === NotificationStatus.Pending || n.status === NotificationStatus.Queued || n.status === NotificationStatus.Processing).length}
</div>
<span className="text-[11px] text-slate-400">Automated retry scheduler active</span>
</div>
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-1">
<span className="text-xs font-semibold text-slate-400">Delivery Success Ratio</span>
<div className="text-2xl font-bold font-mono text-emerald-400">97.4%</div>
<span className="text-[11px] text-slate-400">Cross-platform SLA target: ≥95.0%</span>
</div>
</div>
{/* Filter Bar */}
<div className="bg-[#0f1523]/80 backdrop-blur-md p-4 rounded-2xl border border-slate-800/80 shadow-xl flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 text-xs font-semibold text-slate-300">
<Filter className="w-4 h-4 text-slate-400" />
Filter Notification Logs:
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-1.5">
<label className="text-xs text-slate-400 font-medium">Platform:</label>
<select
value={selectedPlatformFilter}
onChange={(e) => setSelectedPlatformFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))}
className="text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-medium transition [&>option]:bg-slate-900 [&>option]:text-white"
>
<option value="all">All Channels ({NOTIFICATION_PLATFORM_OPTIONS.length})</option>
{NOTIFICATION_PLATFORM_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="flex items-center gap-1.5">
<label className="text-xs text-slate-400 font-medium">Status:</label>
<select
value={selectedStatusFilter}
onChange={(e) => setSelectedStatusFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))}
className="text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-medium transition [&>option]:bg-slate-900 [&>option]:text-white"
>
<option value="all">All Statuses ({NOTIFICATION_STATUS_OPTIONS.length})</option>
{NOTIFICATION_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
</div>
{/* Notifications Table */}
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
<th className="py-3.5 px-5">Platform Channel</th>
<th className="py-3.5 px-4">Recipient Destination</th>
<th className="py-3.5 px-4">Subject / Notification Message</th>
<th className="py-3.5 px-4">Dispatch Timestamp</th>
<th className="py-3.5 px-4">Status</th>
<th className="py-3.5 px-5 text-right">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{notifications
.filter((n) => selectedPlatformFilter === 'all' || n.platform === selectedPlatformFilter)
.filter((n) => selectedStatusFilter === 'all' || n.status === selectedStatusFilter)
.map((item) => {
const statusBadge = getNotificationStatusBadge(item.status);
return (
<tr key={item.id} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-5">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[11px] font-semibold bg-slate-800/80 text-slate-200 border border-slate-700/80">
<Radio className="w-3 h-3 text-blue-400" />
{getNotificationPlatformLabel(item.platform)}
</span>
</td>
<td className="py-3.5 px-4 font-mono font-semibold text-white">
{item.recipient}
</td>
<td className="py-3.5 px-4 max-w-xs truncate text-slate-300">
<div className="font-semibold text-white truncate">{item.subject}</div>
{item.errorMessage && (
<div className="text-[10px] text-rose-400 font-mono mt-0.5">{item.errorMessage}</div>
)}
</td>
<td className="py-3.5 px-4 text-slate-400 font-mono text-[11px]">
{formatDate(item.sentAt)}
</td>
<td className="py-3.5 px-4">
<select
value={item.status}
onChange={(e) => {
const newStatus = Number(e.target.value) as NotificationStatus;
setNotifications((prev) =>
prev.map((n) => (n.id === item.id ? { ...n, status: newStatus } : n))
);
success('Status Updated', `Notification #${item.id} status updated to ${getNotificationStatusLabel(newStatus)}`);
}}
className={`text-[10px] font-semibold border rounded-full px-2 py-0.5 bg-slate-900 cursor-pointer ${statusBadge.className} [&>option]:bg-slate-900 [&>option]:text-white`}
>
{NOTIFICATION_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</td>
<td className="py-3.5 px-5 text-right">
<button
onClick={() => {
setNotifications((prev) =>
prev.map((n) =>
n.id === item.id
? { ...n, status: NotificationStatus.Delivered, errorMessage: undefined, sentAt: new Date().toISOString() }
: n
)
);
success('Re-dispatched', `Notification #${item.id} re-sent successfully via ${getNotificationPlatformLabel(item.platform)}`);
}}
className="px-2.5 py-1 text-[11px] font-semibold text-slate-300 hover:text-white bg-slate-800/80 hover:bg-slate-700 rounded-lg border border-slate-700/60 transition"
>
Retry / Resend
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* Tab 2: Statutory Audit Trail */}
{activeTab === 'audit' && (
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
<th className="py-3.5 px-5">Timestamp</th>
<th className="py-3.5 px-4">Action Type</th>
<th className="py-3.5 px-4">Target Entity</th>
<th className="py-3.5 px-4">Actor Email</th>
<th className="py-3.5 px-5 text-right">Origin Details</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{loadingAudit ? (
<tr>
<td colSpan={5} className="py-8 text-center text-slate-500">
Loading audit log entries...
</td>
</tr>
) : (
auditLogs.map((log) => (
<tr key={log.id} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3 px-5 text-slate-400 font-mono">{formatDate(log.createdAt)}</td>
<td className="py-3 px-4 font-bold text-white">{log.action}</td>
<td className="py-3 px-4 text-blue-400 font-medium font-mono">
{log.entityType} #{log.entityId}
</td>
<td className="py-3 px-4 font-mono font-medium text-slate-200">
{getUserEmail(log.userEmail)}
</td>
<td className="py-3 px-5 text-right text-slate-400 font-mono text-[11px]">
{log.details || 'Web Session'}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)}
{/* Tab 3: Session & Token Claims */}
{activeTab === 'profile' && (
<div className="max-w-2xl bg-[#0f1523]/80 backdrop-blur-md p-6 rounded-2xl border border-slate-800/80 shadow-xl space-y-5">
<div className="flex items-center gap-3 pb-4 border-b border-slate-800/80">
<div className="h-12 w-12 rounded-full bg-gradient-to-r from-blue-600 to-indigo-600 flex items-center justify-center font-bold text-white text-base shadow-lg shadow-blue-500/25">
{user?.name?.slice(0, 2).toUpperCase() || 'PF'}
</div>
<div>
<h3 className="text-base font-bold text-white">{user?.name}</h3>
<p className="text-xs text-slate-400 font-mono">{user?.email}</p>
</div>
</div>
<div className="space-y-3 text-xs">
<h4 className="text-xs font-bold text-slate-300 uppercase tracking-wider">
Token Claims Accessible by UI
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1">
<div className="p-3.5 bg-slate-900/60 rounded-xl border border-slate-800/80">
<span className="text-slate-400 block text-[11px] font-medium">User Identifier (sub)</span>
<span className="font-mono text-white font-semibold">{user?.id}</span>
</div>
<div className="p-3.5 bg-slate-900/60 rounded-xl border border-slate-800/80">
<span className="text-slate-400 block text-[11px] font-medium">Email Address (email)</span>
<span className="font-mono text-white font-semibold">{user?.email}</span>
</div>
<div className="p-3.5 bg-slate-900/60 rounded-xl border border-slate-800/80">
<span className="text-slate-400 block text-[11px] font-medium">Full Name (name)</span>
<span className="text-white font-semibold">{user?.name}</span>
</div>
<div className="p-3.5 bg-slate-900/60 rounded-xl border border-slate-800/80">
<span className="text-slate-400 block text-[11px] font-medium">Assigned Role (role)</span>
<span className="text-blue-400 font-semibold">{user?.role}</span>
</div>
</div>
</div>
</div>
)}
{/* Modal: Dispatch Multi-Channel Notification */}
<Modal
isOpen={isSendNotificationModalOpen}
onClose={() => setIsSendNotificationModalOpen(false)}
title="Dispatch Outbound Notification"
subtitle="Route message through configured multi-channel notification gateway"
>
<form
onSubmit={(e) => {
e.preventDefault();
if (!dispatchRecipient || !dispatchSubject) {
toastError('Validation Error', 'Recipient and notification message are required');
return;
}
const newItem: NotificationItem = {
id: Date.now(),
recipient: dispatchRecipient,
platform: dispatchPlatform,
status: dispatchStatus,
subject: dispatchSubject,
sentAt: new Date().toISOString(),
};
setNotifications((prev) => [newItem, ...prev]);
success('Notification Queued', `Message queued for delivery via ${getNotificationPlatformLabel(dispatchPlatform)} (Status: ${getNotificationStatusLabel(dispatchStatus)})`);
setIsSendNotificationModalOpen(false);
setDispatchRecipient('');
setDispatchSubject('');
}}
className="space-y-4"
>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
Notification Platform Channel *
</label>
<select
value={dispatchPlatform}
onChange={(e) => setDispatchPlatform(Number(e.target.value) as NotificationPlatform)}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-medium transition [&>option]:bg-slate-900 [&>option]:text-white"
>
{NOTIFICATION_PLATFORM_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
Initial Status Pipeline *
</label>
<select
value={dispatchStatus}
onChange={(e) => setDispatchStatus(Number(e.target.value) as NotificationStatus)}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-medium transition [&>option]:bg-slate-900 [&>option]:text-white"
>
{NOTIFICATION_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
Recipient Destination Address / Handle *
</label>
<input
type="text"
value={dispatchRecipient}
onChange={(e) => setDispatchRecipient(e.target.value)}
placeholder="e.g. user@funder.org.za or +27 82 123 4567 or https://webhook..."
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-mono transition"
required
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
Message Subject / Body *
</label>
<textarea
rows={3}
value={dispatchSubject}
onChange={(e) => setDispatchSubject(e.target.value)}
placeholder="Enter notification content or payload summary..."
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition"
required
/>
</div>
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
<button
type="button"
onClick={() => setIsSendNotificationModalOpen(false)}
className="px-4 py-2 text-xs font-medium text-slate-300 bg-slate-800/80 hover:bg-slate-700 rounded-xl transition"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition"
>
Dispatch Notification
</button>
</div>
</form>
</Modal>
</div>
);
};
@@ -0,0 +1,535 @@
import axios, { AxiosError } from 'axios';
import { apiClient } from './httpClient';
import { toolsService } from './toolsService';
import { getCurrentUserSub } from '../auth/AuthProvider';
import {
Dashboard,
Organisation,
CreateOrganisationRequest,
UpdateOrganisationRequest,
Portfolio,
CreatePortfolioRequest,
UpdatePortfolioRequest,
Programme,
CreateProgrammeRequest,
UpdateProgrammeRequest,
Project,
CreateProjectRequest,
UpdateProjectRequest,
UpdateProjectStatusRequest,
Activity,
CreateActivityRequest,
Milestone,
CreateMilestoneRequest,
UpdateMilestoneRequest,
Award,
CreateAwardRequest,
Contract,
CreateContractRequest,
ExecuteContractRequest,
Budget,
CreateBudgetRequest,
Disbursement,
CreateDisbursementRequest,
UpdateDisbursementRequest,
Indicator,
CreateIndicatorRequest,
Evidence,
UpdateEvidenceRequest,
Risk,
CreateRiskRequest,
UpdateRiskRequest,
User,
UserFilterRequest,
Invite,
InviteRequest,
InviteUserRequest,
AuditLog,
ProblemDetails,
} from '../types';
// Helper to extract ProblemDetails from Axios error
export function parseApiError(error: unknown): ProblemDetails {
if (axios.isAxiosError(error)) {
const axiosErr = error as AxiosError<ProblemDetails>;
if (axiosErr.response?.data) {
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,
};
}
return {
title: 'Unexpected Application Error',
detail: String(error),
status: 500,
};
}
// ==========================================
// 1. Executive Dashboard APIs
// ==========================================
export const getDashboard = async (): Promise<Dashboard> => {
const res = await apiClient.get<Dashboard>('/v1/api/governance/dashboard');
return res.data;
};
// ==========================================
// 2. Governance & Structure APIs
// ==========================================
export const getOrganisations = async (page = 1, pageSize = 100): Promise<Organisation[]> => {
const res = await apiClient.get<Organisation[]>('/v1/api/governance/organisations', {
params: { page, pageSize },
});
return res.data || [];
};
export const getOrganisation = async (organisationId: string | number): Promise<Organisation> => {
const hashedOrgId = await toolsService.resolveOrganisationId(organisationId);
const res = await apiClient.get<Organisation>(`/v1/api/governance/organisations/${hashedOrgId}`);
return res.data;
};
export const createOrganisation = async (payload: CreateOrganisationRequest): Promise<Organisation> => {
const userSub = getCurrentUserSub();
const res = await apiClient.post('/v1/api/governance/organisations', {
...payload,
createdBy: userSub || undefined,
});
return res.data;
};
export const updateOrganisation = async (payload: UpdateOrganisationRequest): Promise<Organisation> => {
const userSub = getCurrentUserSub();
const res = await apiClient.put('/v1/api/governance/organisations', {
...payload,
updatedBy: userSub || undefined,
});
return res.data;
};
export const getPortfolios = async (organisationId: string | number): Promise<Portfolio[]> => {
const hashedOrgId = await toolsService.resolveOrganisationId(organisationId);
const res = await apiClient.get<Portfolio[]>(`/v1/api/governance/organisations/${hashedOrgId}/portfolio`);
return res.data || [];
};
export const getAllPortfolios = async (): Promise<Portfolio[]> => {
try {
const res = await apiClient.get<Portfolio[]>('/v1/api/governance/organisations/portfolio');
return res.data || [];
} catch {
return [];
}
};
export const createPortfolio = async (payload: CreatePortfolioRequest): Promise<Portfolio> => {
const userSub = getCurrentUserSub();
const res = await apiClient.post('/v1/api/governance/organisations/portfolio', {
...payload,
ownerId: payload.ownerId || userSub || undefined,
createdBy: userSub || undefined,
});
return res.data;
};
export const updatePortfolio = async (payload: UpdatePortfolioRequest): Promise<Portfolio> => {
const userSub = getCurrentUserSub();
const res = await apiClient.put('/v1/api/governance/organisations/portfolio', {
...payload,
ownerId: payload.ownerId || userSub || undefined,
updatedBy: userSub || undefined,
});
return res.data;
};
export const getProgrammes = async (portfolioId: string | number): Promise<Programme[]> => {
const hashedPortId = await toolsService.resolvePortfolioId(portfolioId);
const res = await apiClient.get<Programme[]>(`/v1/api/governance/programmes/${hashedPortId}`);
return res.data || [];
};
export const getAllProgrammes = async (): Promise<Programme[]> => {
try {
const res = await apiClient.get<Programme[]>('/v1/api/projects/programmes');
return res.data || [];
} catch {
try {
const resFallback = await apiClient.get<Programme[]>('/v1/api/governance/programmes');
return resFallback.data || [];
} catch {
return [];
}
}
};
export const createProgramme = async (payload: CreateProgrammeRequest): Promise<Programme> => {
const userSub = getCurrentUserSub();
const res = await apiClient.post('/v1/api/projects/programme', {
...payload,
ownerId: payload.ownerId || userSub || undefined,
createdBy: userSub || undefined,
});
return res.data;
};
export const updateProgramme = async (payload: UpdateProgrammeRequest): Promise<Programme> => {
const userSub = getCurrentUserSub();
const res = await apiClient.put('/v1/api/projects/programme', {
...payload,
ownerId: payload.ownerId || userSub || undefined,
updatedBy: userSub || undefined,
});
return res.data;
};
// ==========================================
// 3. Project & Milestone Management APIs
// ==========================================
export const getProjects = async (page = 1, pageSize = 100): Promise<Project[]> => {
const res = await apiClient.get<Project[]>('/v1/api/projects', {
params: { page, pageSize },
});
return res.data || [];
};
export const getProject = async (projectId: string | number): Promise<Project> => {
const hashedProjectId = await toolsService.resolveProjectId(projectId);
const res = await apiClient.get<Project | Project[]>(`/v1/api/projects/${hashedProjectId}`);
return Array.isArray(res.data) ? res.data[0] : res.data;
};
export const createProject = async (payload: CreateProjectRequest): Promise<Project> => {
const userSub = getCurrentUserSub();
const res = await apiClient.post('/v1/api/projects', {
...payload,
createdBy: userSub || undefined,
});
return res.data;
};
export const updateProject = async (payload: UpdateProjectRequest): Promise<Project> => {
const userSub = getCurrentUserSub();
const res = await apiClient.put('/v1/api/projects', {
...payload,
updatedBy: userSub || undefined,
});
return res.data;
};
export const updateProjectStatus = async (payload: UpdateProjectStatusRequest): Promise<void> => {
const userSub = getCurrentUserSub();
await apiClient.put('/v1/api/projects/status', {
...payload,
updatedBy: userSub || undefined,
});
};
export const getActivities = async (projectId: string | number): Promise<Activity[]> => {
const hashedProjectId = await toolsService.resolveProjectId(projectId);
const res = await apiClient.get<Activity[]>(`/v1/api/projects/${hashedProjectId}/activities`);
return res.data || [];
};
export const createActivity = async (payload: CreateActivityRequest): Promise<Activity> => {
const userSub = getCurrentUserSub();
const res = await apiClient.post('/v1/api/projects/activities', {
...payload,
createdBy: userSub || undefined,
});
return res.data;
};
export const getMilestones = async (projectId: string | number): Promise<Milestone[]> => {
const hashedProjectId = await toolsService.resolveProjectId(projectId);
const res = await apiClient.get<Milestone[]>(`/v1/api/projects/${hashedProjectId}/milestones`);
return res.data || [];
};
export const getAllMilestones = async (page = 1, pageSize = 100): Promise<Milestone[]> => {
const res = await apiClient.get<Milestone[]>(`/v1/api/projects/milestones/${page}/${pageSize}`);
return res.data || [];
};
export const createMilestone = async (payload: CreateMilestoneRequest): Promise<Milestone> => {
const userSub = getCurrentUserSub();
const res = await apiClient.post('/v1/api/evidence/milestones', {
...payload,
createdBy: userSub || undefined,
});
return res.data;
};
export const updateMilestone = async (payload: UpdateMilestoneRequest): Promise<void> => {
const userSub = getCurrentUserSub();
await apiClient.put('/v1/api/projects/milestone', {
...payload,
updatedBy: userSub || undefined,
});
};
// ==========================================
// 4. Grant & Financial Management APIs
// ==========================================
export const getAwards = async (page = 1, pageSize = 100): Promise<Award[]> => {
const res = await apiClient.get<Award[]>('/v1/api/grants/awards', {
params: { page, pageSize },
});
return res.data || [];
};
export const getAward = async (awardId: string | number): Promise<Award> => {
const res = await apiClient.get<Award>(`/v1/api/grants/awards/${awardId}`);
return res.data;
};
export const createAward = async (payload: CreateAwardRequest): Promise<Award> => {
const userSub = getCurrentUserSub();
const res = await apiClient.post('/v1/api/grants/awards', {
...payload,
createdBy: userSub || undefined,
});
return res.data;
};
export const getContracts = async (page = 1, pageSize = 100): Promise<Contract[]> => {
const res = await apiClient.get<Contract[]>('/v1/api/grants/contracts', {
params: { page, pageSize },
});
return res.data || [];
};
export const getContract = async (contractId: string | number): Promise<Contract> => {
const res = await apiClient.get<Contract>(`/v1/api/grants/contracts/${contractId}`);
return res.data;
};
export const createContract = async (payload: CreateContractRequest): Promise<Contract> => {
const userSub = getCurrentUserSub();
const res = await apiClient.post('/v1/api/grants/contracts', {
...payload,
createdBy: userSub || undefined,
});
return res.data;
};
export const executeContract = async (payload: ExecuteContractRequest): Promise<void> => {
const userSub = getCurrentUserSub();
await apiClient.post('/v1/api/grants/contracts/execute', {
...payload,
executedBy: userSub || undefined,
});
};
export const getBudgets = async (page = 1, pageSize = 100): Promise<Budget[]> => {
const res = await apiClient.get<Budget[]>('/v1/api/grants/budgets', {
params: { page, pageSize },
});
return res.data || [];
};
export const createBudget = async (payload: CreateBudgetRequest): Promise<Budget> => {
const userSub = getCurrentUserSub();
const res = await apiClient.post('/v1/api/grants/budgets', {
...payload,
createdBy: userSub || undefined,
});
return res.data;
};
export const getDisbursements = async (page = 1, pageSize = 100): Promise<Disbursement[]> => {
const res = await apiClient.get<Disbursement[]>('/v1/api/execution/disbursements', {
params: { page, pageSize },
});
return res.data || [];
};
export const createDisbursement = async (payload: CreateDisbursementRequest): Promise<Disbursement> => {
const userSub = getCurrentUserSub();
const res = await apiClient.post('/v1/api/execution/disbursements', {
...payload,
createdBy: userSub || undefined,
});
return res.data;
};
export const updateDisbursement = async (payload: UpdateDisbursementRequest): Promise<void> => {
const userSub = getCurrentUserSub();
await apiClient.put('/v1/api/execution/disbursements', {
...payload,
updatedBy: userSub || undefined,
});
};
export const approveDisbursement = async (disbursementId: string | number): Promise<void> => {
const userSub = getCurrentUserSub();
await apiClient.post(`/v1/api/execution/disbursements/${disbursementId}/approve`, {
approvedBy: userSub || undefined,
});
};
// ==========================================
// 5. M&E, Evidence & Risk Management APIs
// ==========================================
export const getProjectIndicators = async (projectId: string | number): Promise<Indicator[]> => {
const hashedProjectId = await toolsService.resolveProjectId(projectId);
const res = await apiClient.get<Indicator[]>(`/v1/api/projects/indicators/project/${hashedProjectId}`);
return res.data || [];
};
export const getAllIndicators = async (page = 1, pageSize = 100): Promise<Indicator[]> => {
const res = await apiClient.get<Indicator[]>(`/v1/api/evidence/indicators/${page}/${pageSize}`);
return res.data || [];
};
export const createIndicator = async (payload: CreateIndicatorRequest): Promise<Indicator> => {
const userSub = getCurrentUserSub();
const res = await apiClient.post('/v1/api/evidence/indicators', {
...payload,
createdBy: userSub || undefined,
});
return res.data;
};
export const recordIndicatorActual = async (indicatorId: string | number, actualAmount: number): Promise<void> => {
const userSub = getCurrentUserSub();
await apiClient.post(`/v1/api/evidence/indicators/${indicatorId}/actuals/${actualAmount}`, {
recordedBy: userSub || undefined,
});
};
export const getProjectEvidence = async (projectId: string | number): Promise<Evidence[]> => {
const hashedProjectId = await toolsService.resolveProjectId(projectId);
const res = await apiClient.get<Evidence[]>(`/v1/api/evidence/${hashedProjectId}`);
return res.data || [];
};
export const getAllEvidence = async (): Promise<Evidence[]> => {
try {
const res = await apiClient.get<Evidence[]>('/v1/api/evidence');
return res.data || [];
} catch {
return [];
}
};
export const uploadEvidenceFile = async (formData: FormData): Promise<Evidence> => {
const userSub = getCurrentUserSub();
if (userSub && !formData.get('userId')) {
formData.append('userId', userSub);
}
const res = await apiClient.put<Evidence>('/v1/api/evidence/file', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return res.data;
};
export const updateEvidence = async (payload: UpdateEvidenceRequest): Promise<void> => {
const userSub = getCurrentUserSub();
await apiClient.put('/v1/api/evidence', {
...payload,
updatedBy: userSub || undefined,
});
};
export const getProjectRisks = async (projectId: string | number): Promise<Risk[]> => {
const hashedProjectId = await toolsService.resolveProjectId(projectId);
const res = await apiClient.get<Risk[]>(`/v1/api/projects/${hashedProjectId}/risks`);
return res.data || [];
};
export const getAllRisks = async (page = 1, pageSize = 100): Promise<Risk[]> => {
const res = await apiClient.get<Risk[]>('/v1/api/execution/risks', {
params: { page, pageSize },
});
return res.data || [];
};
export const createRisk = async (payload: CreateRiskRequest): Promise<Risk> => {
const userSub = getCurrentUserSub();
const res = await apiClient.post('/v1/api/execution/risks', {
...payload,
createdBy: userSub || undefined,
});
return res.data;
};
export const updateRisk = async (payload: UpdateRiskRequest): Promise<void> => {
const userSub = getCurrentUserSub();
await apiClient.put('/v1/api/execution/risks', {
...payload,
updatedBy: userSub || undefined,
});
};
// ==========================================
// 6. User Directory & Communications APIs
// ==========================================
export const getUsers = async (
page = 1,
pageSize = 100,
filter: UserFilterRequest = {}
): Promise<User[]> => {
const res = await apiClient.post<User[]>(`/v1/api/users/${page}/${pageSize}`, filter);
return res.data || [];
};
export const getUser = async (userId: string): Promise<User> => {
const res = await apiClient.get<User>(`/v1/api/users/${userId}`);
return res.data;
};
export const getCurrentUser = async (): Promise<User> => {
const userSub = getCurrentUserSub();
if (userSub) {
return getUser(userSub);
}
const res = await apiClient.get<User>('/v1/api/users/current');
return res.data;
};
export const updateUserStatus = async (userId: string, status: number): Promise<void> => {
await apiClient.get(`/v1/api/users/${userId}/status/${status}`);
};
export const sendInvite = async (payload: InviteRequest): Promise<Invite> => {
const userSub = getCurrentUserSub();
const res = await apiClient.post('/v1/api/communications/invite', {
...payload,
createdBy: userSub || undefined,
});
return res.data;
};
export const getInvites = async (): Promise<Invite[]> => {
try {
const res = await apiClient.get<Invite[]>('/v1/api/communications/invite');
return res.data || [];
} catch {
return [];
}
};
export const inviteUser = async (payload: InviteUserRequest): Promise<User> => {
const userSub = getCurrentUserSub();
const res = await apiClient.post('/v1/api/governance/users/invite', {
...payload,
invitedBy: userSub || undefined,
});
return res.data;
};
export const revokeUser = async (userId: string): Promise<void> => {
await apiClient.delete(`/v1/api/governance/users/${userId}`);
};
export const getAuditLogs = async (): Promise<AuditLog[]> => {
try {
const res = await apiClient.get<AuditLog[]>('/v1/api/governance/audit');
return res.data || [];
} catch {
return [];
}
};
@@ -0,0 +1,28 @@
import axios, { InternalAxiosRequestConfig } from 'axios';
import { getApiBaseUrl } from '../config';
export const BASE_API_URL = getApiBaseUrl();
// Create Axios Instance
export const apiClient = axios.create({
baseURL: BASE_API_URL,
headers: {
'Content-Type': 'application/json; version=1',
Accept: 'application/json; version=1',
},
timeout: 15000,
});
// Request Interceptor to dynamically set baseURL and attach Bearer Token
apiClient.interceptors.request.use(
(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}`;
}
return config;
},
(error) => Promise.reject(error)
);
@@ -0,0 +1,196 @@
import { useQuery } from '@tanstack/react-query';
import { apiClient } from './httpClient';
import { queryClient } from '../lib/queryClient';
/**
* Deterministic fallback encoder for offline/preview sessions
* Generates an obfuscated, unique HashID string with salt
*/
function generateDeterministicHashId(id: number | string, context: string): string {
const str = `${context}:${id}:pfm-salt-2026`;
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash) + str.charCodeAt(i);
hash = hash & hash; // Convert to 32bit integer
}
const positiveHash = Math.abs(hash);
const base62Chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
let encoded = '';
let num = positiveHash;
while (num > 0) {
encoded = base62Chars[num % 62] + encoded;
num = Math.floor(num / 62);
}
const prefix = context ? context.slice(0, 3).toLowerCase() : 'id';
return `${prefix}_${encoded.padStart(8, 'x')}`;
}
/**
* Invokes the backend tools encoding endpoint to retrieve the designated HashID string
*/
async function fetchHashIdFromEndpoint(id: number | string, context = 'general'): Promise<string> {
if (id === undefined || id === null || id === '') {
return '';
}
try {
// Attempt standard tools encoding endpoint variations
const response = await apiClient.get<string | { hashId?: string; encoded?: string; result?: string; value?: string }>(
`/v1/api/tools/encode/${id}`,
{
params: { context, type: context },
timeout: 4000,
}
);
if (typeof response.data === 'string' && response.data.trim()) {
return response.data.trim();
}
if (response.data && typeof response.data === 'object') {
const candidate = response.data.hashId || response.data.encoded || response.data.result || response.data.value;
if (candidate) return candidate;
}
} catch {
// Attempt query param variant
try {
const response = await apiClient.get<string | { hashId?: string; encoded?: string; result?: string; value?: string }>(
'/v1/api/tools/encode',
{
params: { id, context, type: context },
timeout: 4000,
}
);
if (typeof response.data === 'string' && response.data.trim()) {
return response.data.trim();
}
if (response.data && typeof response.data === 'object') {
const candidate = response.data.hashId || response.data.encoded || response.data.result || response.data.value;
if (candidate) return candidate;
}
} catch {
// Fallback to deterministic HashID generator
}
}
// Graceful fallback guaranteeing valid HashID output during preview
return generateDeterministicHashId(id, context);
}
/**
* Invokes the backend tools decoding endpoint
*/
async function fetchDecodedIdFromEndpoint(hashId: string, context = 'general'): Promise<number | string> {
if (!hashId) return '';
try {
const response = await apiClient.get<{ id?: number | string; decoded?: number | string } | number | string>(
`/v1/api/tools/decode/${encodeURIComponent(hashId)}`,
{ params: { context }, timeout: 4000 }
);
if (typeof response.data === 'number' || typeof response.data === 'string') {
return response.data;
}
if (response.data && typeof response.data === 'object') {
if (response.data.id !== undefined) return response.data.id;
if (response.data.decoded !== undefined) return response.data.decoded;
}
} catch {
// Passthrough or fallback
}
return hashId;
}
export const toolsService = {
/**
* Encodes a raw ID to a HashID, cached client-side in TanStack Query for the active session
*/
async encodeId(id: number | string, context = 'general'): Promise<string> {
if (id === undefined || id === null || id === '') return '';
const idStr = String(id);
return queryClient.fetchQuery({
queryKey: ['hashId', context, idStr],
queryFn: () => fetchHashIdFromEndpoint(id, context),
staleTime: Infinity, // Caches client-side during the active session to eliminate redundant round trips
gcTime: 1000 * 60 * 60 * 24, // Keep in memory
});
},
/**
* Decodes a HashID string back to original identifier
*/
async decodeId(hashId: string, context = 'general'): Promise<number | string> {
if (!hashId) return '';
return queryClient.fetchQuery({
queryKey: ['decodeHashId', context, hashId],
queryFn: () => fetchDecodedIdFromEndpoint(hashId, context),
staleTime: Infinity,
gcTime: 1000 * 60 * 60 * 24,
});
},
/**
* Specific convenience resolvers for domain entities
*/
async resolveProjectId(projectId: number | string): Promise<string> {
return this.encodeId(projectId, 'project');
},
async resolveOrganisationId(organisationId: number | string): Promise<string> {
return this.encodeId(organisationId, 'organisation');
},
async resolveProgrammeId(programmeId: number | string): Promise<string> {
return this.encodeId(programmeId, 'programme');
},
async resolvePortfolioId(portfolioId: number | string): Promise<string> {
return this.encodeId(portfolioId, 'portfolio');
},
async resolveAwardId(awardId: number | string): Promise<string> {
return this.encodeId(awardId, 'award');
},
async resolveContractId(contractId: number | string): Promise<string> {
return this.encodeId(contractId, 'contract');
},
/**
* Resolves query parameters or path variables encoded as HashIDs
* before performing API calls
*/
async resolveHashedParams<T extends Record<string, any>>(
params: T,
keysToHash: (keyof T)[]
): Promise<T> {
const resolved: any = { ...params };
for (const key of keysToHash) {
const val = params[key];
if (val !== undefined && val !== null) {
const contextStr = String(key).replace(/Id$/i, '').toLowerCase();
resolved[key] = await this.encodeId(val, contextStr);
}
}
return resolved;
},
/**
* Pre-fetches and primes cache for a collection of IDs
*/
async primeCache(items: { id: number | string; context: string }[]): Promise<void> {
await Promise.all(items.map((item) => this.encodeId(item.id, item.context)));
},
};
/**
* React Hook for consuming HashIDs with TanStack Query caching in components
*/
export function useHashId(id?: number | string | null, context = 'general') {
return useQuery({
queryKey: ['hashId', context, id !== undefined && id !== null ? String(id) : ''],
queryFn: () => (id !== undefined && id !== null ? toolsService.encodeId(id, context) : Promise.resolve('')),
enabled: id !== undefined && id !== null && id !== '',
staleTime: Infinity,
});
}
+666
View File
@@ -0,0 +1,666 @@
/**
* Post Fund Management (PFM) Console - Domain & API Types
* Based on OpenAPI 3.1.1 specification and PFM System Requirements Specification v2.0
*/
// ==========================================
// Enumerations
// ==========================================
export enum EmailStatuses {
GeneralError = 0,
AuthenticationError = 1,
ProtocolError = 2,
Connected = 3,
Disconnected = 4,
TooManyConnections = 5,
ConnectionAborted = 6,
Success = 7,
}
export enum NotificationStatus {
Pending = 1,
Queued = 2,
Processing = 3,
Sent = 4,
Delivered = 5,
Read = 6,
Failed = 7,
Cancelled = 8,
Bounced = 9,
}
export enum NotificationPlatform {
Email = 1,
SMS = 2,
WhatsApp = 3,
PushNotification = 4,
InApp = 5,
Teams = 6,
Slack = 7,
Webhook = 8,
Other = 99,
}
export enum EvidenceType {
AttendanceRegister = 1,
BankStatement = 2,
CompletionCertificate = 3,
FinancialInvoiceOrReceipt = 4,
GeotaggedPhoto = 5,
InspectionReport = 6,
MeetingMinutes = 7,
PayrollOrStipendRegister = 8,
ProcurementDocument = 9,
TechnicalSpecificationOrDesign = 10,
VideoRecording = 11,
Other = 99,
// Compatibility aliases
ProgressReport = 1,
FinancialStatement = 2,
Invoice = 4,
DeliverableProof = 10,
Certification = 3,
AuditReport = 6,
}
export enum ProjectType {
CapacityBuildingAndTraining = 1,
CapitalExpenditureAndInfrastructure = 2,
CommercializationAndScaling = 3,
FeasibilityAndTechnicalAssistance = 4,
OperationalGrantAndSubsidy = 5,
ProductDevelopmentAndPrototyping = 6,
ResearchAndDevelopment = 7,
SocialImpactAndCommunityDevelopment = 8,
TechnologyTransferAndAdoption = 9,
Innovation = 10,
Other = 99,
// Compatibility aliases
Grant = 5,
BlendedFinance = 3,
TechnicalAssistance = 4,
}
export enum IndustrySector {
AgricultureAndAgroProcessing = 1,
AutomotiveAndTransportEquipment = 2,
ChemicalsAndPharmaceuticals = 3,
ConstructionAndInfrastructure = 4,
CreativeIndustriesAndMedia = 5,
DefenseAndAerospace = 6,
EducationAndSkillsDevelopment = 7,
EnergyAndUtilities = 8,
FinancialServices = 9,
HealthcareAndLifeSciences = 10,
InformationCommunicationTechnology = 11,
ManufacturingAndIndustrial = 12,
MiningAndMetals = 13,
RealEstateAndHousing = 14,
RetailAndConsumerGoods = 15,
TourismAndHospitality = 16,
WaterAndSanitation = 17,
Other = 99,
// Compatibility aliases
AgriTech = 1,
CleanEnergy = 8,
HealthTech = 10,
Education = 7,
ICTDigital = 11,
Manufacturing = 12,
SocialDevelopment = 15,
}
export enum OrganisationType {
NonGovernmentalOrganisation = 1,
CivilSocietyOrganisation = 2,
Trust = 3,
FaithBasedOrganisation = 4,
PrivateLimitedCompany = 5,
PublicLimitedCompany = 6,
SoleProprietorship = 7,
Partnership = 8,
SocialEnterprise = 9,
NationalGovernmentMinistry = 10,
ProvincialGovernmentEntity = 11,
LocalGovernmentMunicipality = 12,
StateOwnedEnterprise = 13,
PublicAgency = 14,
PublicUniversity = 15,
PrivateUniversity = 16,
ResearchInstitute = 17,
TechnicalVocationalCollege = 18,
Cooperative = 19,
MicrofinanceInstitution = 20,
CommercialBank = 21,
DevelopmentFinanceInstitution = 22,
UnitedNationsAgency = 23,
IntergovernmentalOrganisation = 24,
InternationalNGO = 25,
Consortium = 26,
SpecialPurposeVehicle = 27,
// Compatibility aliases
GovernmentDepartment = 10,
CorporateCSI = 3,
Enterprise = 5,
}
export enum Priority {
Low = 1,
Medium = 2,
High = 3,
Critical = 4,
}
export enum UnitOfMeasure {
Count = 1,
Percentage = 2,
Currency = 3,
Area = 4,
Weight = 5,
Volume = 6,
DurationDays = 7,
DurationMonths = 8,
Ratio = 9,
// Compatibility alias
CurrencyZAR = 3,
}
export enum ContractStatus {
Draft = 1,
PendingApproval = 2,
Active = 3,
Expired = 4,
Terminated = 5,
Amended = 6,
// Compatibility aliases
UnderReview = 2,
Executed = 3,
}
export enum RiskLikelihood {
Rare = 1,
Unlikely = 2,
Possible = 3,
Likely = 4,
AlmostCertain = 5,
// Compatibility aliases
VeryLow = 1,
Low = 2,
Medium = 3,
High = 4,
VeryHigh = 5,
}
export enum RiskImpact {
Minor = 1,
Moderate = 2,
Major = 3,
Critical = 4,
Severe = 5,
// Compatibility aliases
Insignificant = 1,
}
export enum ApprovalStatus {
Pending = 1,
Review = 2,
Approved = 3,
Rejected = 4,
Removed = 5,
// Compatibility aliases
Draft = 1,
PendingReview = 2,
}
export enum ActivityStatus {
Active = 1,
Inactive = 2,
Disabled = 3,
// Compatibility alias
Suspended = 3,
}
// ==========================================
// Entities
// ==========================================
export interface ProblemDetails {
type?: string | null;
title?: string | null;
status?: number | null;
detail?: string | null;
instance?: string | null;
errors?: Record<string, string[]>;
}
export interface Dashboard {
portfolioValue: number;
activeAwards: number;
disbursed: number;
onTrackPercentage: number;
atRiskCount: number;
beneficiariesReached: number;
}
export enum UserRole {
SuperAdmin = 1,
Executive = 2,
ProgrammeManager = 3,
FinanceOfficer = 4,
RecipientViewer = 5,
}
export interface User {
id: string;
email: string | null;
status: ActivityStatus;
lastLoginAt: string | null;
role?: string | UserRole;
name?: string;
organisationId?: number | string;
organisationName?: string;
}
export type SystemUser = User;
export interface InviteUserRequest {
email: string;
role: UserRole;
organisationId: number | string;
}
export interface AuditLog {
id: string;
action: string;
entityType: string;
entityId: string | number;
userEmail: string;
details?: string;
createdAt: string;
}
export interface UserFilterRequest {
status?: ActivityStatus | null;
email?: string | null;
sortBy?: string | null;
sortDescending?: boolean;
}
export interface Organisation {
id: number | string;
name: string | null;
registrationNo: string | null;
email: string | null;
type: OrganisationType;
status: ActivityStatus;
createdAt: string;
createdBy: string;
}
export interface CreateOrganisationRequest {
name: string;
registrationNo: string;
email: string;
type: OrganisationType;
}
export interface UpdateOrganisationRequest {
organisationId: number | string;
name: string;
registrationNo: string;
email: string;
type: OrganisationType;
status?: ActivityStatus;
}
export interface Portfolio {
id: number | string;
organisationId: number | string;
name: string | null;
description: string | null;
ownedBy: string;
createdBy: string;
createdAt: string;
}
export interface CreatePortfolioRequest {
name: string;
description: string;
organisationId: number | string;
ownerId: string;
}
export interface UpdatePortfolioRequest {
portfolioId: number | string;
name: string;
description: string;
organisationId?: number | string;
ownerId: string;
}
export interface Programme {
id: number | string;
portfolioId: number | string;
name: string | null;
status: ApprovalStatus;
ownedBy: string;
createdBy: string;
updatedBy?: string | null;
createdAt: string;
updatedAt?: string | null;
}
export interface CreateProgrammeRequest {
portfolioId: number | string;
name: string;
ownerId: string;
}
export interface UpdateProgrammeRequest {
programmeId: number | string;
name: string;
ownerId: string;
status: ApprovalStatus;
portfolioId?: number | string;
}
export interface Instrument {
id: number | string;
programmeId: number | string;
code: string | null;
name: string | null;
description: string | null;
status: ActivityStatus;
createdBy: string;
createdAt: string;
}
export interface Project {
id: number | string;
programmeId: number | string;
name: string | null;
description: string | null;
sector: IndustrySector;
projectType: ProjectType;
status: ApprovalStatus;
startedAt?: string | null;
endedAt?: string | null;
createdBy: string;
updatedBy?: string | null;
createdAt: string;
updatedAt?: string | null;
programmeName?: string;
}
export interface CreateProjectRequest {
programmeId: number | string;
name: string;
description: string;
sector: IndustrySector;
projectType: ProjectType;
}
export interface UpdateProjectRequest {
projectId: number | string;
name: string;
description: string;
startedAt?: string | null;
endedAt?: string | null;
sector: IndustrySector;
projectType: ProjectType;
}
export interface UpdateProjectStatusRequest {
projectId: number | string;
status: ApprovalStatus;
}
export interface Activity {
id: number | string;
projectId: number | string | null;
milestoneId: number | string | null;
category: string | null;
status: ApprovalStatus;
amount: number;
title: string | null;
description: string | null;
performedAt?: string | null;
createdBy: string;
updatedBy?: string | null;
approvedBy?: string | null;
createdAt: string;
updatedAt?: string | null;
}
export interface CreateActivityRequest {
projectId: number | string;
milestoneId?: number | string | null;
category: string;
amount: number;
title: string;
description: string;
performedAt?: string | null;
}
export interface Milestone {
id: number | string;
projectId: number | string;
name: string | null;
dueAt: string;
status: ApprovalStatus;
priority: Priority;
createdBy: string;
updatedBy?: string | null;
createdAt: string;
updatedAt?: string | null;
}
export interface CreateMilestoneRequest {
projectId: number | string;
name: string;
dueAt: string;
priority: Priority;
}
export interface UpdateMilestoneRequest {
milestoneId: number | string;
name: string;
dueAt: string;
priority: Priority;
status: ApprovalStatus;
}
export interface Award {
id: number | string;
organisationId: number | string;
programmeId: number | string;
projectId?: number | string | null;
amount: number;
status: ApprovalStatus;
createdBy: string;
updatedBy?: string | null;
approvedBy?: string | null;
createdAt: string;
updatedAt?: string | null;
referenceNo?: string;
organisationName?: string;
programmeName?: string;
}
export interface CreateAwardRequest {
organisationId: number | string;
programmeId: number | string;
amount: number;
referenceNo?: string;
fundingPeriod?: string;
}
export interface Contract {
id: number | string;
awardId: number | string;
totalValue: number;
effectiveAt?: string | null;
expiresAt?: string | null;
status: ContractStatus;
externalSignatureId?: string | null;
signedDocumentUrl?: string | null;
createdBy: string;
createdAt: string;
}
export interface CreateContractRequest {
awardId: number | string;
totalValue: number;
effectiveAt: string;
expiresAt: string;
}
export interface ExecuteContractRequest {
contractId: number | string;
externalSignatureId: string;
signedDocumentUrl: string;
}
export interface Budget {
id: number | string;
projectId: number | string;
name: string | null;
amount: number;
createdBy: string;
approvedBy?: string | null;
createdAt: string;
updatedAt?: string | null;
}
export interface CreateBudgetRequest {
projectId: number | string;
name: string;
amount: number;
}
export interface Disbursement {
id: number | string;
projectId: number | string;
milestoneId?: number | string | null;
amount: number;
status: ApprovalStatus;
createdBy: string;
updatedBy?: string | null;
createdAt: string;
updatedAt?: string | null;
paymentReference?: string | null;
conditionsChecked?: boolean;
}
export interface CreateDisbursementRequest {
projectId: number | string;
milestoneId?: number | string | null;
amount: number;
}
export interface UpdateDisbursementRequest {
disbursementId: number | string;
milestoneId?: number | string | null;
amount: number;
}
export interface Indicator {
id: number | string;
projectId: number | string;
name: string | null;
unitOfMeasure?: UnitOfMeasure | null;
baselineAmount: number;
targetAmount: number;
actualAmount: number;
createdBy: string;
createdAt: string;
}
export interface CreateIndicatorRequest {
projectId: number | string;
name: string;
unitOfMeasure: UnitOfMeasure;
baselineAmount: number;
targetAmount: number;
}
export interface Risk {
id: number | string;
projectId: number | string;
name: string | null;
description: string | null;
likelihood: RiskLikelihood;
impact: RiskImpact;
createdBy: string;
createdAt: string;
owner?: string;
mitigationAction?: string;
status?: string;
}
export interface CreateRiskRequest {
projectId: number | string;
name: string;
description: string;
likelihood: RiskLikelihood;
impact: RiskImpact;
}
export interface UpdateRiskRequest {
riskId: number | string;
name: string;
description: string;
likelihood: RiskLikelihood;
impact: RiskImpact;
}
export interface Evidence {
id: number | string;
projectId: number | string;
milestoneId?: number | string | null;
indicatorId?: number | string | null;
activityId?: number | string | null;
version: number;
type: EvidenceType;
documentUrl?: string | null;
status: ApprovalStatus;
createdBy: string;
updatedBy?: string | null;
createdAt: string;
updatedAt?: string | null;
fileName?: string;
fileSize?: string;
}
export interface UpdateEvidenceRequest {
evidenceId: number | string;
version: number;
evidenceType: EvidenceType;
status: ApprovalStatus;
}
export interface Invite {
id: number | string;
organisationId: number | string;
invitedOrganisationId: number | string;
awardId: number | string;
contractId: number | string;
recipient: string;
createdBy: string;
updatedBy?: string | null;
createdAt: string;
updatedAt?: string | null;
message?: string;
platform?: NotificationPlatform;
}
export interface InviteRequest {
organisationId: number | string;
invitedOrganisationId: number | string;
awardId: number | string;
contractId: number | string;
recipient: string;
message: string;
platform: NotificationPlatform;
}
+13
View File
@@ -0,0 +1,13 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_OIDC_AUTHORITY?: string;
readonly VITE_OIDC_AUDIENCE?: string;
readonly VITE_OIDC_CLIENT_ID?: string;
readonly VITE_OIDC_CLIENT_SECRET?: string;
readonly VITE_API_BASE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}
@@ -0,0 +1,22 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import { defineConfig } from 'vite';
export default defineConfig(() => {
return {
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
server: {
// HMR is disabled in AI Studio via DISABLE_HMR env var.
// Do not modify—file watching is disabled to prevent flickering during agent edits.
hmr: process.env.DISABLE_HMR !== 'true',
// Disable file watching when DISABLE_HMR is true to save CPU during agent edits.
watch: process.env.DISABLE_HMR === 'true' ? null : {},
},
};
});