85 Commits
Author SHA1 Message Date
khwezi 3c0fd8844c Merge pull request 'Refactored UI endpoint usage' (#84) from garages3 into main
Reviewed-on: #84
2026-09-20 08:27:14 +02:00
khwezi b7c135f0fc Merge pull request 'Garages3' (#83) from garages3 into main
Reviewed-on: #83
2026-09-18 16:12:06 +02:00
khwezi db053e3e5f Merge pull request 'Garages3' (#82) from garages3 into main
Reviewed-on: #82
2026-09-17 07:39:34 +02:00
khwezi d7177e1a72 Merge pull request 'Removed package.json' (#81) from garages3 into main
Reviewed-on: #81
2026-09-17 07:23:54 +02:00
khwezi c67041faea Merge pull request 'Refactored UI and compose file' (#80) from garages3 into main
Reviewed-on: #80
2026-09-17 07:08:26 +02:00
khwezi 0e8a5e1169 Merge pull request 'Removed docker debug code' (#79) from garages3 into main
Reviewed-on: #79
2026-09-15 12:13:47 +02:00
khwezi 41fdb067fe Merge pull request 'Refactored ApiVersioning service registration' (#78) from garages3 into main
Reviewed-on: #78
2026-09-15 12:09:47 +02:00
khwezi b4b687dfcc Merge pull request 'Added OPTIONS header handling' (#77) from garages3 into main
Reviewed-on: #77
2026-09-15 08:52:23 +02:00
khwezi f200ccbf60 Merge pull request 'Added RequireCors() to RouteGroup' (#76) from garages3 into main
Reviewed-on: #76
2026-09-15 08:45:25 +02:00
khwezi 27f57150e1 Merge pull request 'Moved .RequireAuthentication() to endpoints, removed it from EndpointGroup' (#75) from garages3 into main
Reviewed-on: #75
2026-09-15 08:37:43 +02:00
khwezi 79c63107e9 Merge pull request 'Applied configurable CORS policy to API' (#74) from garages3 into main
Reviewed-on: #74
2026-09-15 07:30:29 +02:00
khwezi d945731252 Merge pull request 'Fixed UI' (#73) from garages3 into main
Reviewed-on: #73
2026-09-14 11:44:00 +02:00
khwezi c979a4632e Merge pull request 'Debug refactor' (#72) from garages3 into main
Reviewed-on: #72
2026-09-14 10:46:59 +02:00
khwezi 9eed3cff6b Merge pull request 'Debugs refactor' (#71) from garages3 into main
Reviewed-on: #71
2026-09-14 10:39:04 +02:00
khwezi 6db1a2f367 Merge pull request 'Garages3' (#70) from garages3 into main
Reviewed-on: #70
2026-09-14 10:34:46 +02:00
khwezi 06d9952cd7 Merge pull request 'Rebuilt pfm-api dockerfile' (#69) from garages3 into main
Reviewed-on: #69
2026-09-14 10:29:13 +02:00
khwezi 92d481f75d Merge pull request 'Light refactor' (#68) from garages3 into main
Reviewed-on: #68
2026-09-14 10:23:19 +02:00
khwezi 1c36602995 Merge pull request 'Simplified fpm-api dockerfile' (#67) from garages3 into main
Reviewed-on: #67
2026-09-14 10:21:10 +02:00
khwezi 5ae0c500ac Merge pull request 'Added debug code' (#66) from garages3 into main
Reviewed-on: #66
2026-09-14 10:13:58 +02:00
khwezi db04f800e9 Merge pull request 'Changed pfm-api dockerfile paths' (#65) from garages3 into main
Reviewed-on: #65
2026-09-14 10:10:49 +02:00
khwezi e7a19611f0 Merge pull request 'Simplified dockerfile (pfm-api)' (#64) from garages3 into main
Reviewed-on: #64
2026-09-14 10:04:10 +02:00
khwezi 5ad24da7cf Merge pull request 'Refactored compose file to assign same context to both services' (#63) from garages3 into main
Reviewed-on: #63
2026-09-14 10:02:01 +02:00
khwezi 156dae40b7 Merge pull request 'Fixed missing file copy' (#62) from garages3 into main
Reviewed-on: #62
2026-09-14 09:50:32 +02:00
khwezi 1b67d9cf8b Merge pull request 'Debug refactor on dockerfile' (#61) from garages3 into main
Reviewed-on: #61
2026-09-14 09:47:00 +02:00
khwezi 1bd05e64e5 Merge pull request 'Added better dotnet build handling' (#60) from garages3 into main
Reviewed-on: #60
2026-09-14 09:42:10 +02:00
khwezi 303dba3c6f Merge pull request 'Added more verbosity to dotnet build' (#59) from garages3 into main
Reviewed-on: #59
2026-09-14 09:35:30 +02:00
khwezi 1dd4f54a1d Merge pull request 'Added .dockerignore' (#58) from garages3 into main
Reviewed-on: #58
2026-09-14 09:32:21 +02:00
khwezi fae1e080c4 Merge pull request 'Added stream redirection to dotnet commands' (#57) from garages3 into main
Reviewed-on: #57
2026-09-14 09:14:17 +02:00
khwezi 4de23d27b3 Merge pull request 'Commented out pfm-console to see if the api image will build' (#56) from garages3 into main
Reviewed-on: #56
2026-09-14 09:06:50 +02:00
khwezi d4bce12dbb Merge pull request 'Removed stream redirection' (#55) from garages3 into main
Reviewed-on: #55
2026-09-13 18:15:33 +02:00
khwezi 299b876e1c Merge pull request 'Fixed Api docker file paths' (#54) from garages3 into main
Reviewed-on: #54
2026-09-13 18:13:13 +02:00
khwezi dde1dbe056 Merge pull request 'Added debug code to dotnet build and publish lines' (#53) from garages3 into main
Reviewed-on: #53
2026-09-13 18:09:51 +02:00
khwezi b81e6feb45 Merge pull request 'Moved api dockerfile to project folder' (#52) from garages3 into main
Reviewed-on: #52
2026-09-13 18:07:52 +02:00
khwezi 4138a1941f Merge pull request 'Corrected compose service contexts' (#51) from garages3 into main
Reviewed-on: #51
2026-09-13 18:01:52 +02:00
khwezi 17af31d810 Merge pull request 'Fixed dockerfile paths' (#50) from garages3 into main
Reviewed-on: #50
2026-09-13 17:56:19 +02:00
khwezi b85cf6a1cb Merge pull request 'Light refactor' (#49) from garages3 into main
Reviewed-on: #49
2026-09-13 17:51:45 +02:00
khwezi 81ddc11746 Merge pull request 'Refactored API dockerfile' (#48) from garages3 into main
Reviewed-on: #48
2026-09-13 17:50:29 +02:00
khwezi 5b94672ff5 Merge pull request 'Stable API run' (#47) from garages3 into main
Reviewed-on: #47
2026-09-13 17:41:42 +02:00
khwezi c535392ef1 Merge pull request 'Added debug term' (#46) from garages3 into main
Reviewed-on: #46
2026-09-12 12:22:43 +02:00
khwezi 0607de1802 Merge pull request 'Second fix' (#45) from garages3 into main
Reviewed-on: #45
2026-09-12 12:19:34 +02:00
khwezi 4c1e52e452 Merge pull request 'Light refactor' (#44) from garages3 into main
Reviewed-on: #44
2026-09-12 12:14:13 +02:00
khwezi 2414d74d1c Merge pull request 'Light refactor' (#43) from garages3 into main
Reviewed-on: #43
2026-09-12 12:10:17 +02:00
khwezi 93d9661a7f Merge pull request 'Rewrite pfm-api docker file' (#42) from garages3 into main
Reviewed-on: #42
2026-09-12 12:04:47 +02:00
khwezi dba401fe94 Merge pull request 'Fixed publish alias' (#41) from garages3 into main
Reviewed-on: #41
2026-09-12 11:58:37 +02:00
khwezi 42d73fe6cb Merge pull request 'Added error output to dotnet build line' (#40) from garages3 into main
Reviewed-on: #40
2026-09-12 11:54:57 +02:00
khwezi 1475887a7f Merge pull request 'Added working directory switch' (#39) from garages3 into main
Reviewed-on: #39
2026-09-12 11:49:52 +02:00
khwezi 730664d9c4 Merge pull request 'PFM Api docker refactor' (#38) from garages3 into main
Reviewed-on: #38
2026-09-12 11:45:32 +02:00
khwezi f38e569a34 Merge pull request 'Implemented missing file handling endpoints' (#37) from garages3 into main
Reviewed-on: #37
2026-09-12 11:26:45 +02:00
khwezi 7e79171f97 Merge pull request 'Added image name to compose file for console' (#36) from endpoints into main
Reviewed-on: #36
2026-09-11 16:24:11 +02:00
khwezi d7b868f2c1 Merge pull request 'Added more cors origins' (#35) from endpoints into main
Reviewed-on: #35
2026-09-11 16:17:07 +02:00
khwezi d43a09b371 Merge pull request 'Refactored console dockerfile' (#34) from endpoints into main
Reviewed-on: #34
2026-09-11 16:05:21 +02:00
khwezi 0dc0de1502 Merge pull request 'Fixed dockerfile paths' (#33) from endpoints into main
Reviewed-on: #33
2026-09-11 15:47:30 +02:00
khwezi de47dd4a4f Merge pull request 'Supplied defaults to compose env variables' (#32) from endpoints into main
Reviewed-on: #32
2026-09-11 15:33:47 +02:00
khwezi 2810cc669b Merge pull request 'Fixed compose syntax error' (#31) from endpoints into main
Reviewed-on: #31
2026-09-11 15:30:40 +02:00
khwezi 734af8c86f Merge pull request 'Refactored compose file to include the ui in deployment' (#30) from endpoints into main
Reviewed-on: #30
2026-09-11 15:29:21 +02:00
khwezi a754c42d14 Merge pull request 'Updated Console compose file to use variables' (#29) from endpoints into main
Reviewed-on: #29
2026-09-11 15:19:31 +02:00
khwezi bd7ca9372f Merge pull request 'Added specific origin' (#28) from endpoints into main
Reviewed-on: #28
2026-09-11 14:59:34 +02:00
khwezi 0d01d91ab4 Merge pull request 'Added cors after routes' (#27) from endpoints into main
Reviewed-on: #27
2026-09-11 14:54:47 +02:00
khwezi 2c817a8818 Merge pull request 'Endpoints' (#26) from endpoints into main
Reviewed-on: #26
2026-09-11 14:35:02 +02:00
khwezi 4c3a96917b Merge pull request 'Improved results handling in service layer' (#25) from endpoints into main
Reviewed-on: #25
2026-09-11 10:33:47 +02:00
khwezi e5c3a75795 Merge pull request 'Stable run with validations' (#24) from endpoints into main
Reviewed-on: #24
2026-09-06 10:49:57 +02:00
khwezi 1bc7e65960 Merge pull request 'Added tooling' (#23) from endpoints into main
Reviewed-on: #23
2026-09-05 18:27:35 +02:00
khwezi 094126d84b Merge pull request 'Fixed endpointname uniqueness' (#22) from endpoints into main
Reviewed-on: #22
2026-09-05 13:07:41 +02:00
khwezi 2506241a25 Merge pull request 'Fixed endpoint uniqueness' (#21) from endpoints into main
Reviewed-on: #21
2026-09-05 12:58:17 +02:00
khwezi cfe3530639 Merge pull request 'Endpoints' (#20) from endpoints into main
Reviewed-on: #20
2026-09-05 12:50:04 +02:00
khwezi 985557bee4 Merge pull request 'Implemented caching on all get endpoints and service layer' (#19) from endpoints into main
Reviewed-on: #19
2026-09-05 08:30:16 +02:00
khwezi f0b8b3051a Merge pull request 'Improved getusers cache key' (#18) from endpoints into main
Reviewed-on: #18
2026-09-04 06:08:39 +02:00
khwezi 6c88b9e533 Merge pull request 'Endpoints' (#17) from endpoints into main
Reviewed-on: #17
2026-09-04 05:54:30 +02:00
khwezi 7d67408d2c Merge pull request 'Additional redis configs' (#16) from endpoints into main
Reviewed-on: #16
2026-09-04 05:39:44 +02:00
khwezi d1d58a6183 Merge pull request 'Added service level caching to IdentityService, Communication and Evidence' (#15) from endpoints into main
Reviewed-on: #15
2026-09-03 16:38:08 +02:00
khwezi 60ad154841 Merge pull request 'Implemented distributed caching' (#14) from endpoints into main
Reviewed-on: #14
2026-09-03 11:09:52 +02:00
khwezi 2c13170859 Merge pull request 'Endpoints' (#13) from endpoints into main
Reviewed-on: #13
2026-09-02 16:14:34 +02:00
khwezi d87c8aa3c9 Merge pull request 'Updated configurations' (#12) from endpoints into main
Reviewed-on: #12
2026-09-02 09:45:38 +02:00
khwezi 6af7815ad1 Merge pull request 'Removed node tooling install' (#11) from endpoints into main
Reviewed-on: #11
2026-09-02 09:30:06 +02:00
khwezi a169f6c3f6 Merge pull request 'Endpoints' (#10) from endpoints into main
Reviewed-on: #10
2026-09-02 09:28:17 +02:00
khwezi 65ea753c19 Merge pull request 'Endpoints' (#9) from endpoints into main
Reviewed-on: #9
2026-09-01 12:29:47 +02:00
khwezi e6d2663265 Merge pull request 'Endpoints' (#8) from endpoints into main
Reviewed-on: #8
2026-08-30 17:47:24 +02:00
khwezi 91b5814b60 Merge pull request 'Endpoints' (#7) from endpoints into main
Reviewed-on: #7
2026-08-30 16:27:37 +02:00
khwezi 0c4c848568 Merge pull request 'Endpoints' (#6) from endpoints into main
Reviewed-on: #6
2026-08-30 16:11:53 +02:00
khwezi fb05150ebf Merge pull request 'Endpoints' (#5) from endpoints into main
Reviewed-on: #5
2026-08-30 15:47:32 +02:00
khwezi 2f9c20020b Merge pull request 'Reogranised API and Security assets' (#4) from endpoints into main
Reviewed-on: #4
2026-08-30 15:09:32 +02:00
khwezi a771957db3 Merge pull request 'Endpoints' (#3) from endpoints into main
Reviewed-on: #3
2026-08-30 14:54:58 +02:00
khwezi 46267a2a34 Merge pull request 'Endpoints' (#2) from endpoints into main
Reviewed-on: #2
2026-08-30 14:26:08 +02:00
khwezi a7f360cc2a Merge pull request 'Security' (#1) from security into main
Reviewed-on: #1
2026-08-21 11:47:36 +02:00
hermes 67c08b40ca Refactored gitignore 2026-08-21 11:45:33 +02:00
19 changed files with 766 additions and 8567 deletions
File diff suppressed because it is too large Load Diff
@@ -14,7 +14,6 @@ import {
} from '../services/userSyncService';
import { AccountActivationModal } from '../components/auth/AccountActivationModal';
import { SessionExpiredModal } from '../components/auth/SessionExpiredModal';
import { encodeHashIdSync } from '../services/toolsService';
import {
onSessionExpired,
triggerSessionExpired,
@@ -31,7 +30,6 @@ export interface AuthUserProfile {
role: string;
organisationName?: string;
tenant?: string;
tenant_id?: number;
avatarUrl?: string;
}
@@ -41,8 +39,6 @@ interface AuthContextType {
user: AuthUserProfile | null;
sub: string;
claims: UserClaims;
tenantId: number | null;
hashedTenantId: string | null;
accessToken: string | null;
isAuthenticated: boolean;
isLoading: boolean;
@@ -64,75 +60,6 @@ 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 extractTenantId(claims?: UserClaims | null, token?: string | null): number | null {
if (claims) {
const raw =
claims.tenant_id ??
(claims as any).tenantId ??
claims['tenant_id'] ??
claims['http://schemas.microsoft.com/ws/2008/06/identity/claims/tenantid'];
if (raw !== undefined && raw !== null && raw !== '') {
const parsed = Number(raw);
if (!isNaN(parsed) && isFinite(parsed)) return parsed;
}
}
if (token) {
try {
const tokenClaims = parseJwtClaims(token);
const raw = tokenClaims.tenant_id ?? tokenClaims.tenantId ?? tokenClaims['tenant_id'];
if (raw !== undefined && raw !== null && raw !== '') {
const parsed = Number(raw);
if (!isNaN(parsed) && isFinite(parsed)) return parsed;
}
} catch {}
}
if (typeof window !== 'undefined') {
try {
const storedClaimsRaw = localStorage.getItem(LOCAL_STORAGE_CLAIMS_KEY);
if (storedClaimsRaw) {
const parsedClaims = JSON.parse(storedClaimsRaw);
const raw = parsedClaims.tenant_id ?? parsedClaims.tenantId ?? parsedClaims['tenant_id'];
if (raw !== undefined && raw !== null && raw !== '') {
const parsed = Number(raw);
if (!isNaN(parsed) && isFinite(parsed)) return parsed;
}
}
const storedToken = localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY);
if (storedToken) {
const tokenClaims = parseJwtClaims(storedToken);
const raw = tokenClaims.tenant_id ?? tokenClaims.tenantId ?? tokenClaims['tenant_id'];
if (raw !== undefined && raw !== null && raw !== '') {
const parsed = Number(raw);
if (!isNaN(parsed) && isFinite(parsed)) return parsed;
}
}
const storedUser = localStorage.getItem(LOCAL_STORAGE_USER_KEY);
if (storedUser) {
const parsedUser = JSON.parse(storedUser);
const raw = parsedUser.tenant_id ?? parsedUser.tenantId;
if (raw !== undefined && raw !== null && raw !== '') {
const parsed = Number(raw);
if (!isNaN(parsed) && isFinite(parsed)) return parsed;
}
}
} catch {}
}
return null;
}
export function getCurrentUserTenantId(): number | null {
return extractTenantId(
getCurrentUserClaims(),
typeof window !== 'undefined' ? localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY) : null
);
}
export function getCurrentUserHashedTenantId(): string | null {
const tid = getCurrentUserTenantId();
if (tid === null || tid === undefined) return null;
return encodeHashIdSync(tid);
}
export function getCurrentUserSub(): string {
if (typeof window !== 'undefined') {
return localStorage.getItem(LOCAL_STORAGE_SUB_KEY) || '';
@@ -192,12 +119,6 @@ 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 [tenantId, setTenantId] = useState<number | null>(() => {
return extractTenantId(
getCurrentUserClaims(),
typeof window !== 'undefined' ? localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY) : null
);
});
const [accessToken, setAccessToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isSynced, setIsSynced] = useState<boolean>(() => isUserSyncedInBrowser());
@@ -207,8 +128,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const [isSessionExpired, setIsSessionExpired] = useState<boolean>(false);
const [sessionExpiredReason, setSessionExpiredReason] = useState<string | null>(null);
const hashedTenantId = tenantId !== null && tenantId !== undefined ? encodeHashIdSync(tenantId) : null;
// Subscribe to global HTTP 401 session expiration events
useEffect(() => {
const unsubscribe = onSessionExpired((reason) => {
@@ -284,8 +203,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(mergedClaims));
const tenantClaim = (mergedClaims.tenant as string) || (oidcUser.profile?.tenant as string) || undefined;
const parsedTenantId = extractTenantId(mergedClaims, token);
setTenantId(parsedTenantId);
const profile: AuthUserProfile = {
id: userSub || 'user-1',
@@ -295,7 +212,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
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,
tenant: tenantClaim,
tenant_id: parsedTenantId ?? undefined,
};
setUser(profile);
@@ -365,9 +281,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
setSub(storedSub);
}
const storedTenantId = extractTenantId(parsedClaims, storedToken);
setTenantId(storedTenantId);
let resolvedUser: AuthUserProfile | null = null;
if (storedUser) {
try {
@@ -375,9 +288,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
if (!parsedUser.tenant && parsedClaims.tenant) {
parsedUser.tenant = parsedClaims.tenant;
}
if (parsedUser.tenant_id === undefined && storedTenantId !== null) {
parsedUser.tenant_id = storedTenantId;
}
setUser(parsedUser);
resolvedUser = parsedUser;
} catch {}
@@ -492,7 +402,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
setUser(null);
setSub('');
setClaims({});
setTenantId(null);
setAccessToken(null);
setIsSynced(false);
setIsPendingActivation(false);
@@ -515,8 +424,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
user,
sub,
claims,
tenantId,
hashedTenantId,
accessToken,
isAuthenticated: !!accessToken,
isLoading,
@@ -63,7 +63,7 @@ const navItems = [
];
export const AppLayout: React.FC = () => {
const { user, claims, sub, tenantId, hashedTenantId, logout } = useAuth();
const { user, claims, sub, logout } = useAuth();
const [sidebarOpen, setSidebarOpen] = useState(true);
const [userMenuOpen, setUserMenuOpen] = useState(false);
const navigate = useNavigate();
@@ -80,12 +80,8 @@ export const AppLayout: React.FC = () => {
});
// Ensure current logged-in user is registered in the email cache
const effectiveEmail = ((user?.email || claims?.email || claims?.['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] || claims?.upn) as string);
if (sub && effectiveEmail) {
registerUserEmail(sub, effectiveEmail);
}
if (user?.id && effectiveEmail) {
registerUserEmail(user.id, effectiveEmail);
if (sub && (user?.email || claims.email)) {
registerUserEmail(sub, ((user?.email || claims.email) as string));
}
// Extract name and email directly from validated token claims
@@ -152,20 +148,31 @@ export const AppLayout: React.FC = () => {
{navItems.map((item) => {
const Icon = item.icon;
return (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
`flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all min-w-0 ${
isActive
? 'bg-gradient-to-r from-blue-600 to-indigo-600 text-white shadow-md shadow-blue-600/25 font-semibold'
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
}`
}
>
<Icon className="w-4 h-4 shrink-0" />
{sidebarOpen && <span className="truncate">{item.name}</span>}
</NavLink>
<div key={item.path} className="flex items-center justify-between group">
<NavLink
to={item.path}
className={({ isActive }) =>
`flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all flex-1 min-w-0 ${
isActive
? 'bg-gradient-to-r from-blue-600 to-indigo-600 text-white shadow-md shadow-blue-600/25 font-semibold'
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
}`
}
>
<Icon className="w-4 h-4 shrink-0" />
{sidebarOpen && <span className="truncate">{item.name}</span>}
</NavLink>
{sidebarOpen && (
<div className="pl-1">
<HelpTooltip
title={item.name}
content={item.help}
size="sm"
placement="right"
/>
</div>
)}
</div>
);
})}
</nav>
@@ -203,6 +210,21 @@ export const AppLayout: React.FC = () => {
{/* Top Header */}
<header className="h-16 bg-[#0d121f]/90 border-b border-slate-800/80 backdrop-blur-md px-4 sm:px-6 md:px-8 flex items-center justify-between shrink-0 z-20">
<div className="flex items-center gap-3">
{/* Brand Icon */}
<Link
to="/"
id="header-brand-logo-link"
className="flex items-center gap-2 group cursor-pointer"
title="PFM Console Home"
>
<BrandLogo size={28} showGlow={false} animateOnHover />
<span className="font-bold text-xs tracking-tight text-white hidden md:inline-block group-hover:text-blue-400 transition">
PFM
</span>
</Link>
<span className="h-4 w-px bg-slate-800 hidden md:block" />
{/* Home Navigation */}
<Link
to="/"
@@ -219,25 +241,15 @@ export const AppLayout: React.FC = () => {
<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 max-w-[320px] truncate"
title={`${tenantName}${hashedTenantId ? ` (Partition: ${hashedTenantId})` : ''}`}
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 max-w-[280px] truncate"
title={tenantName}
>
<Database className="w-3.5 h-3.5 text-blue-400 shrink-0" />
<span className="truncate">{tenantName}</span>
{hashedTenantId && (
<span
className="text-[10px] font-mono text-blue-400 bg-blue-950/60 px-1.5 py-0.5 rounded border border-blue-800/60 shrink-0"
title={`Tenant Partition HashID: ${hashedTenantId}`}
>
{hashedTenantId}
</span>
)}
</span>
<HelpTooltip
title="Tenant Context"
content={`Identifies the multi-tenant organization partition and database boundary within the national PFM environment.${
hashedTenantId ? ` Tenant Partition (HashID): ${hashedTenantId}` : ''
}`}
content="Identifies the multi-tenant organization partition and database boundary within the national PFM environment."
size="sm"
placement="bottom"
/>
@@ -797,8 +797,9 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
{filteredAwardsForDropdown.map((award) => {
const org = getOrganisationForAward(award);
const isApproved = Number(award.status) === ApprovalStatus.Approved;
const hashId = toolsService.encodeIdSync(award.id);
return (
<option key={award.id} value={String(award.id)}>
<option key={award.id} value={hashId || String(award.id)}>
Award #{award.id} {award.referenceNo ? `(${award.referenceNo})` : ''} —{' '}
{org?.name || 'Organisation'} • {formatZAR(award.amount)}
{!isApproved ? ` [Status: ${getApprovalStatusBadge(award.status).label}]` : ' [Approved]'}
@@ -807,7 +808,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
})}
</select>
<p className="text-[11px] text-slate-500 mt-1">
Selecting an approved award links Programme, Project, and Organisation automatically.
The endpoint expects the award identifier hash; selecting an award links Programme, Project, and Organisation.
</p>
</div>
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,4 @@
import React, { useState, useRef, useEffect, useLayoutEffect, useCallback, ReactNode } from 'react';
import { createPortal } from 'react-dom';
import React, { useState, useRef, useEffect, ReactNode } from 'react';
import { Info, X } from 'lucide-react';
export interface HelpTooltipProps {
@@ -26,118 +25,14 @@ export const HelpTooltip: React.FC<HelpTooltipProps> = ({
ariaLabel = 'Information and guidance',
}) => {
const [isOpen, setIsOpen] = useState(false);
const [coords, setCoords] = useState<{ top: number; left: number } | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const updatePosition = useCallback(() => {
if (!buttonRef.current) return;
const triggerRect = buttonRef.current.getBoundingClientRect();
const tooltipEl = tooltipRef.current;
const tooltipWidth = tooltipEl ? tooltipEl.offsetWidth : 288;
const tooltipHeight = tooltipEl ? tooltipEl.offsetHeight : 120;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const PADDING = 12;
const GAP = 8;
const spaceAbove = triggerRect.top - GAP - PADDING;
const spaceBelow = viewportHeight - triggerRect.bottom - GAP - PADDING;
const spaceLeft = triggerRect.left - GAP - PADDING;
const spaceRight = viewportWidth - triggerRect.right - GAP - PADDING;
let chosenPlacement = placement;
if (placement === 'bottom') {
if (spaceBelow < tooltipHeight && spaceAbove > spaceBelow) {
chosenPlacement = 'top';
}
} else if (placement === 'top') {
if (spaceAbove < tooltipHeight && spaceBelow > spaceAbove) {
chosenPlacement = 'bottom';
}
} else if (placement === 'left') {
if (spaceLeft < tooltipWidth && spaceRight > spaceLeft) {
chosenPlacement = 'right';
}
} else if (placement === 'right') {
if (spaceRight < tooltipWidth && spaceLeft > spaceRight) {
chosenPlacement = 'left';
}
}
let top = 0;
let left = 0;
if (chosenPlacement === 'top') {
top = triggerRect.top - tooltipHeight - GAP;
left = triggerRect.left + triggerRect.width / 2 - tooltipWidth / 2;
} else if (chosenPlacement === 'bottom') {
top = triggerRect.bottom + GAP;
left = triggerRect.left + triggerRect.width / 2 - tooltipWidth / 2;
} else if (chosenPlacement === 'left') {
top = triggerRect.top + triggerRect.height / 2 - tooltipHeight / 2;
left = triggerRect.left - tooltipWidth - GAP;
} else {
// right
top = triggerRect.top + triggerRect.height / 2 - tooltipHeight / 2;
left = triggerRect.right + GAP;
}
// Viewport boundary clamping to guarantee tooltip is never cropped by screen edges
const maxLeft = Math.max(PADDING, viewportWidth - tooltipWidth - PADDING);
left = Math.max(PADDING, Math.min(left, maxLeft));
const maxTop = Math.max(PADDING, viewportHeight - tooltipHeight - PADDING);
top = Math.max(PADDING, Math.min(top, maxTop));
setCoords({ top, left });
}, [placement]);
// Position updates on open, resize, or scroll of any container
useLayoutEffect(() => {
if (!isOpen) {
setCoords(null);
return;
}
updatePosition();
let resizeObserver: ResizeObserver | null = null;
if (typeof ResizeObserver !== 'undefined' && tooltipRef.current) {
resizeObserver = new ResizeObserver(() => {
updatePosition();
});
resizeObserver.observe(tooltipRef.current);
}
window.addEventListener('resize', updatePosition, { passive: true });
window.addEventListener('scroll', updatePosition, { capture: true, passive: true });
return () => {
if (resizeObserver) {
resizeObserver.disconnect();
}
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
};
}, [isOpen, updatePosition]);
// Close on Escape or click outside both button and tooltip
// Close on Escape or click outside
useEffect(() => {
if (!isOpen) return;
const handleOutsideClick = (e: MouseEvent | TouchEvent) => {
const target = e.target as Node;
if (
buttonRef.current &&
!buttonRef.current.contains(target) &&
tooltipRef.current &&
!tooltipRef.current.contains(target)
) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
@@ -171,53 +66,16 @@ export const HelpTooltip: React.FC<HelpTooltipProps> = ({
lg: 'w-4 h-4',
}[size];
const tooltipPortal =
isOpen && typeof document !== 'undefined'
? createPortal(
<div
ref={tooltipRef}
role="tooltip"
onClick={(e) => e.stopPropagation()}
style={{
position: 'fixed',
top: coords ? `${coords.top}px` : '-9999px',
left: coords ? `${coords.left}px` : '-9999px',
visibility: coords ? 'visible' : 'hidden',
}}
className="z-[9999] w-72 max-w-[calc(100vw-24px)] p-3.5 rounded-xl bg-[#0c121e] border border-blue-500/30 text-slate-200 shadow-2xl backdrop-blur-md animate-in fade-in zoom-in-95 duration-150"
>
<div className="flex items-start justify-between gap-2 border-b border-slate-800/80 pb-1.5 mb-2">
<div className="flex items-center gap-1.5 text-blue-400">
<Info className="w-3.5 h-3.5 shrink-0" />
<span className="font-semibold text-xs tracking-tight text-white">
{title || 'Guidance & Information'}
</span>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsOpen(false);
}}
className="rounded-md p-0.5 text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
aria-label="Close tooltip"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
<div className="text-[11px] leading-relaxed text-slate-300 font-normal space-y-1">
{content}
</div>
</div>,
document.body
)
: null;
const placementClasses = {
top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
bottom: 'top-full left-1/2 -translate-x-1/2 mt-2',
left: 'right-full top-1/2 -translate-y-1/2 mr-2',
right: 'left-full top-1/2 -translate-y-1/2 ml-2',
}[placement];
return (
<div ref={containerRef} className={`relative inline-flex items-center align-middle ${className}`}>
<button
ref={buttonRef}
type="button"
aria-label={ariaLabel}
aria-expanded={isOpen}
@@ -231,7 +89,37 @@ export const HelpTooltip: React.FC<HelpTooltipProps> = ({
<Info className={`${iconSizes} shrink-0`} />
</button>
{tooltipPortal}
{isOpen && (
<div
role="tooltip"
onClick={(e) => e.stopPropagation()}
className={`absolute ${placementClasses} z-50 w-72 max-w-[calc(100vw-32px)] p-3 rounded-xl bg-[#0c121e] border border-blue-500/30 text-slate-200 shadow-2xl backdrop-blur-md animate-in fade-in zoom-in-95 duration-150`}
>
<div className="flex items-start justify-between gap-2 border-b border-slate-800/80 pb-1.5 mb-2">
<div className="flex items-center gap-1.5 text-blue-400">
<Info className="w-3.5 h-3.5 shrink-0" />
<span className="font-semibold text-xs tracking-tight text-white">
{title || 'Guidance & Information'}
</span>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsOpen(false);
}}
className="rounded-md p-0.5 text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
aria-label="Close tooltip"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
<div className="text-[11px] leading-relaxed text-slate-300 font-normal space-y-1">
{content}
</div>
</div>
)}
</div>
);
};
@@ -43,28 +43,24 @@ export const ProblemDetailsAlert: React.FC<ProblemDetailsAlertProps> = ({
)}
</div>
{/* Direct error message or error list from ProblemDetails */}
{parsed.errorsList && parsed.errorsList.length > 0 ? (
<div className="mt-2 space-y-1.5">
{parsed.message && !parsed.errorsList.includes(parsed.message) && (
<p className="text-xs text-rose-200/90 leading-relaxed whitespace-pre-line">
{parsed.message}
</p>
)}
<ul className="space-y-1 bg-black/30 rounded-lg p-2.5 border border-rose-900/40">
{parsed.errorsList.map((err, idx) => (
<li key={idx} className="text-xs text-rose-200/90 flex items-start gap-2">
<span className="text-rose-400 mt-0.5 font-bold">•</span>
<span className="leading-snug">{err}</span>
</li>
))}
</ul>
</div>
) : parsed.message ? (
{/* Single error or fallback message displayed directly */}
{(!parsed.errorsList || parsed.errorsList.length <= 1) && parsed.message && (
<p className="text-xs mt-1.5 text-rose-200/90 leading-relaxed whitespace-pre-line">
{parsed.message}
</p>
) : null}
)}
{/* Multiple errors from ProblemDetails displayed directly */}
{parsed.errorsList && parsed.errorsList.length > 1 && (
<ul className="mt-2 space-y-1 bg-black/30 rounded-lg p-2.5 border border-rose-900/40">
{parsed.errorsList.map((err, idx) => (
<li key={idx} className="text-xs text-rose-200/90 flex items-start gap-2">
<span className="text-rose-400 mt-0.5">•</span>
<span className="leading-snug">{err}</span>
</li>
))}
</ul>
)}
{parsed.technicalDetails && (
<p className="text-[10px] font-mono text-slate-500 mt-2">
@@ -155,21 +155,16 @@ const ToastCard: React.FC<{
)}
</div>
{/* Display errors from ProblemDetails errors member */}
{toast.errorsList && toast.errorsList.length > 0 ? (
<div className="mt-1.5 space-y-1">
{toast.message && !toast.errorsList.includes(toast.message) && (
<p className="text-xs text-slate-300 leading-relaxed">{toast.message}</p>
)}
<ul className="space-y-1">
{toast.errorsList.map((err, idx) => (
<li key={idx} className="text-xs text-rose-200/90 flex items-start gap-1.5">
<span className="text-rose-400 mt-0.5 font-bold">•</span>
<span className="leading-snug break-words">{err}</span>
</li>
))}
</ul>
</div>
{/* Display errors directly if multiple, or single message directly */}
{toast.errorsList && toast.errorsList.length > 1 ? (
<ul className="mt-1.5 space-y-1">
{toast.errorsList.map((err, idx) => (
<li key={idx} className="text-xs text-rose-200/90 flex items-start gap-1.5">
<span className="text-rose-400 mt-0.5">•</span>
<span className="leading-snug break-words">{err}</span>
</li>
))}
</ul>
) : toast.message ? (
<p className="text-xs mt-1.5 text-slate-300 leading-relaxed whitespace-pre-line break-words">
{toast.message}
@@ -512,7 +512,6 @@ export const getRiskScoreColor = (likelihood: RiskLikelihood | number, impact: R
// ==========================================
const USER_EMAIL_CACHE: Record<string, string> = {};
const EMAIL_TO_USER_ID_CACHE: Record<string, string> = {};
export const registerUserEmail = (userId: string | number, email: string) => {
if (userId && email && typeof email === 'string' && email.trim()) {
@@ -520,7 +519,6 @@ export const registerUserEmail = (userId: string | number, email: string) => {
const cleanEmail = email.trim();
USER_EMAIL_CACHE[cleanId] = cleanEmail;
USER_EMAIL_CACHE[cleanId.toLowerCase()] = cleanEmail;
EMAIL_TO_USER_ID_CACHE[cleanEmail.toLowerCase()] = cleanId;
}
};
@@ -533,11 +531,6 @@ export const registerUserEmails = (users: Array<{ id: string | number; email?: s
}
};
export const getUserIdByEmail = (email?: string | null): string | undefined => {
if (!email || typeof email !== 'string') return undefined;
return EMAIL_TO_USER_ID_CACHE[email.trim().toLowerCase()];
};
export const getUserEmail = (userIdOrEmail?: string | number | null): string => {
if (!userIdOrEmail) return '—';
const str = String(userIdOrEmail).trim();
@@ -545,28 +538,5 @@ export const getUserEmail = (userIdOrEmail?: string | number | null): string =>
if (str.includes('@')) return str;
if (USER_EMAIL_CACHE[str]) return USER_EMAIL_CACHE[str];
if (USER_EMAIL_CACHE[str.toLowerCase()]) return USER_EMAIL_CACHE[str.toLowerCase()];
// Synchronous fallback from stored auth session in browser
try {
const storedUserJson = localStorage.getItem('pfm_auth_user');
const storedSub = localStorage.getItem('pfm_user_sub');
if (storedUserJson) {
const parsed = JSON.parse(storedUserJson);
if (parsed?.email) {
const email = String(parsed.email).trim();
if (
(parsed.id && String(parsed.id).toLowerCase() === str.toLowerCase()) ||
(parsed.sub && String(parsed.sub).toLowerCase() === str.toLowerCase()) ||
(storedSub && String(storedSub).toLowerCase() === str.toLowerCase())
) {
registerUserEmail(str, email);
return email;
}
}
}
} catch {
// Ignore storage parse errors
}
return str;
};
@@ -13,7 +13,6 @@ import {
RefreshCw,
Clock,
ShieldAlert,
RotateCcw,
} from 'lucide-react';
import {
getDashboard,
@@ -37,18 +36,6 @@ export const ExecutiveDashboard: React.FC = () => {
const [selectedSector, setSelectedSector] = useState('All');
const [selectedPeriod, setSelectedPeriod] = useState('FY 2026/27');
const isFiltered = selectedProgramme !== 'All' || selectedSector !== 'All' || selectedPeriod !== 'FY 2026/27';
const activeFilterCount =
(selectedProgramme !== 'All' ? 1 : 0) +
(selectedSector !== 'All' ? 1 : 0) +
(selectedPeriod !== 'FY 2026/27' ? 1 : 0);
const resetFilters = () => {
setSelectedProgramme('All');
setSelectedSector('All');
setSelectedPeriod('FY 2026/27');
};
// Data queries
const { data: dashboard, isLoading: loadingDashboard, refetch: refetchDashboard, isFetching } = useQuery({
queryKey: ['dashboard'],
@@ -331,79 +318,41 @@ export const ExecutiveDashboard: React.FC = () => {
return (
<div className="space-y-6 max-w-7xl mx-auto">
{/* Page Title & Filter Bar */}
<div className="bg-[#0d121f]/90 p-5 sm:p-6 rounded-2xl border border-slate-800/80 backdrop-blur-md shadow-xl space-y-4">
{/* Top Tier: Title, Status Badge, and Primary Actions */}
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2.5 flex-wrap">
<h2 className="text-xl sm:text-2xl font-bold text-white tracking-tight">
Executive Portfolio Dashboard
</h2>
<HelpTooltip
title="Executive Dashboard Overview"
content="Macro-level command center showing aggregated national portfolio commitments, actual tranche disbursements, delivery pace, and critical project escalations."
size="sm"
placement="bottom"
/>
<span className="inline-flex items-center gap-1.5 text-[11px] font-semibold text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-full border border-blue-500/30">
<span className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
Live Portfolio Feed
</span>
</div>
<p className="text-xs sm:text-sm text-slate-400 mt-1">
Real-time portfolio overview, disbursement tracking, risk heatmap, and projects requiring governance action.
</p>
</div>
<div className="flex items-center gap-2.5 shrink-0 self-start lg:self-center">
{isFiltered && (
<button
type="button"
onClick={resetFilters}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-amber-300 bg-amber-500/10 hover:bg-amber-500/20 border border-amber-500/30 rounded-xl transition cursor-pointer shadow-xs"
title="Reset all filters to default"
>
<RotateCcw className="w-3.5 h-3.5" />
<span>Reset Filters</span>
</button>
)}
<button
type="button"
onClick={refetchAll}
disabled={isFetching}
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900 hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
title="Refresh dashboard metrics"
>
<RefreshCw className={`w-3.5 h-3.5 ${isFetching ? 'animate-spin text-blue-400' : 'text-slate-400'}`} />
<span>{isFetching ? 'Refreshing...' : 'Refresh'}</span>
</button>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-[#0d121f]/90 p-5 rounded-2xl border border-slate-800/80 backdrop-blur-md shadow-xl">
<div>
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold text-white tracking-tight">
Executive Portfolio Dashboard
</h2>
<HelpTooltip
title="Executive Dashboard Overview"
content="Macro-level command center showing aggregated national portfolio commitments, actual tranche disbursements, delivery pace, and critical project escalations."
size="sm"
placement="bottom"
/>
<span className="text-[11px] font-semibold text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-full border border-blue-500/30">
Live Portfolio Feed
</span>
</div>
<p className="text-xs text-slate-400 mt-1">
Real-time portfolio overview, disbursement tracking, risk heatmap, and projects requiring governance action.
</p>
</div>
{/* Dedicated Filter Toolbar */}
<div className="pt-4 border-t border-slate-800/80 flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2 text-xs font-semibold text-slate-300 pr-1">
<Filter className="w-3.5 h-3.5 text-blue-400" />
<span>Filters:</span>
{activeFilterCount > 0 && (
<span className="text-[10px] font-bold text-blue-300 bg-blue-500/20 border border-blue-500/40 px-2 py-0.5 rounded-full">
{activeFilterCount} Active
</span>
)}
{/* 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>
{/* Programme Filter */}
<div className="flex items-center bg-[#090d16] border border-slate-700/80 hover:border-slate-600 rounded-xl px-3 py-1.5 text-xs transition shadow-inner">
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider mr-2 shrink-0">
Programme
</span>
<div className="flex items-center gap-1">
<select
value={selectedProgramme}
onChange={(e) => setSelectedProgramme(e.target.value)}
className="bg-transparent text-xs font-medium text-slate-100 focus:outline-none cursor-pointer pr-1 [&>option]:bg-slate-900 [&>option]:text-white max-w-[200px] truncate"
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">All</option>
<option value="All">Programme: All</option>
{programmes.map((pr) => (
<option key={pr.id} value={pr.id}>
{pr.name}
@@ -418,17 +367,13 @@ export const ExecutiveDashboard: React.FC = () => {
/>
</div>
{/* Sector Filter */}
<div className="flex items-center bg-[#090d16] border border-slate-700/80 hover:border-slate-600 rounded-xl px-3 py-1.5 text-xs transition shadow-inner">
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider mr-2 shrink-0">
Sector
</span>
<div className="flex items-center gap-1">
<select
value={selectedSector}
onChange={(e) => setSelectedSector(e.target.value)}
className="bg-transparent text-xs font-medium text-slate-100 focus:outline-none cursor-pointer pr-1 [&>option]:bg-slate-900 [&>option]:text-white max-w-[170px] truncate"
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">All</option>
<option value="All">Sector: All</option>
{sectorData.map((s) => (
<option key={s.name} value={s.name}>
{s.name}
@@ -443,18 +388,14 @@ export const ExecutiveDashboard: React.FC = () => {
/>
</div>
{/* Financial Period Filter */}
<div className="flex items-center bg-[#090d16] border border-slate-700/80 hover:border-slate-600 rounded-xl px-3 py-1.5 text-xs transition shadow-inner">
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider mr-2 shrink-0">
Period
</span>
<div className="flex items-center gap-1">
<select
value={selectedPeriod}
onChange={(e) => setSelectedPeriod(e.target.value)}
className="bg-transparent text-xs font-medium text-slate-100 focus:outline-none cursor-pointer pr-1 [&>option]:bg-slate-900 [&>option]:text-white"
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">FY 2026/27</option>
<option value="FY 2025/26">FY 2025/26</option>
<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>
<HelpTooltip
@@ -464,6 +405,15 @@ export const ExecutiveDashboard: React.FC = () => {
placement="bottom"
/>
</div>
<button
onClick={refetchAll}
disabled={isFetching}
className="p-2 text-slate-400 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs"
title="Refresh dashboard metrics"
>
<RefreshCw className={`w-4 h-4 ${isFetching ? 'animate-spin text-blue-400' : ''}`} />
</button>
</div>
</div>
@@ -662,7 +612,7 @@ export const ExecutiveDashboard: React.FC = () => {
<div className="pt-4 border-t border-slate-800/80 mt-4 text-[11px] text-slate-400 flex justify-between">
<span>Primary Focus: {sectorData[0]?.name || 'All Sectors'}</span>
<Link to="/governance?tab=portfolios" className="text-blue-400 font-medium hover:underline flex items-center gap-1">
<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>
@@ -1,5 +1,4 @@
import React, { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
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';
@@ -17,7 +16,6 @@ import {
Coins,
Eye,
RefreshCw,
ArrowRight,
} from 'lucide-react';
import {
getOrganisations,
@@ -36,8 +34,6 @@ import {
updateProgramme,
getInstruments,
getPortfolioProgrammes,
getUsers,
getUser,
} from '../services/api';
import { Modal } from '../components/ui/Modal';
import { HelpTooltip } from '../components/ui/HelpTooltip';
@@ -62,8 +58,6 @@ import {
getApprovalStatusBadge,
formatDate,
getUserEmail,
registerUserEmail,
getUserIdByEmail,
ORGANISATION_TYPE_OPTIONS,
ACTIVITY_STATUS_OPTIONS,
APPROVAL_STATUS_OPTIONS,
@@ -82,14 +76,14 @@ const portfolioSchema = z.object({
name: z.string().min(3, 'Portfolio name is required'),
description: z.string().min(5, 'Description is required'),
organisationId: z.coerce.number().min(1, 'Select a parent organisation'),
ownerEmail: z.string().min(1, 'Owner email is required'),
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(),
ownerEmail: z.string().min(1, 'Owner email is required'),
ownerId: z.string().min(1, 'Owner ID is required'),
});
type OrganisationFormValues = z.infer<typeof organisationSchema>;
@@ -98,72 +92,11 @@ type ProgrammeFormValues = z.infer<typeof programmeSchema>;
export const GovernancePage: React.FC = () => {
const queryClient = useQueryClient();
const { sub, user, claims } = useAuth();
const { sub, user } = useAuth();
const currentUserId = sub || user?.id || '';
const { success, error: toastError } = useToast();
const effectiveUserEmail =
user?.email ||
(claims?.email as string) ||
(claims?.['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] as string) ||
(claims?.upn as string) ||
(sub ? getUserEmail(sub) : '') ||
'';
useEffect(() => {
if (effectiveUserEmail) {
if (sub) registerUserEmail(sub, effectiveUserEmail);
if (user?.id) registerUserEmail(user.id, effectiveUserEmail);
}
}, [sub, user, effectiveUserEmail]);
const [searchParams, setSearchParams] = useSearchParams();
const tabParam = searchParams.get('tab');
const getValidTab = (val: string | null): 'organisations' | 'portfolios' | 'programmes' => {
if (val === 'portfolios' || val === 'programmes' || val === 'organisations') {
return val;
}
return 'organisations';
};
const [activeTab, setActiveTab] = useState<'organisations' | 'portfolios' | 'programmes'>(() => getValidTab(tabParam));
useEffect(() => {
if (tabParam && (tabParam === 'organisations' || tabParam === 'portfolios' || tabParam === 'programmes')) {
setActiveTab(tabParam);
}
}, [tabParam]);
const handleTabChange = (tab: 'organisations' | 'portfolios' | 'programmes') => {
setActiveTab(tab);
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set('tab', tab);
return next;
});
};
const [activeTab, setActiveTab] = useState<'organisations' | 'portfolios' | 'programmes'>('organisations');
const [searchTerm, setSearchTerm] = useState('');
const [highlightedPortfolioId, setHighlightedPortfolioId] = useState<number | string | null>(null);
const handleNavigateToPortfolioTab = (portfolioId?: number | string) => {
setIsOrgDetailModalOpen(false);
setSelectedOrgForDetail(null);
handleTabChange('portfolios');
if (portfolioId != null) {
setHighlightedPortfolioId(portfolioId);
setTimeout(() => {
const el = document.getElementById(`portfolio-card-${portfolioId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 150);
setTimeout(() => {
setHighlightedPortfolioId(null);
}, 3500);
}
};
// Modals & Editing State
const [isOrgModalOpen, setIsOrgModalOpen] = useState(false);
@@ -192,12 +125,6 @@ export const GovernancePage: React.FC = () => {
const [isProgrammeDetailModalOpen, setIsProgrammeDetailModalOpen] = useState(false);
// Queries
const { data: usersDirectory = [] } = useQuery({
queryKey: ['users-directory'],
queryFn: () => getUsers(1, 200),
staleTime: 5 * 60 * 1000,
});
const { data: organisations = [], isLoading: loadingOrgs } = useQuery({
queryKey: ['organisations'],
queryFn: () => getOrganisations(),
@@ -255,92 +182,6 @@ export const GovernancePage: React.FC = () => {
enabled: !!selectedProgrammeForDetail && isProgrammeDetailModalOpen,
});
// Owner resolution helpers
const resolveOwnerId = (emailOrId: string, fallbackOwnerId?: string | number | null): string => {
const trimmed = (emailOrId || '').trim();
if (!trimmed) {
return fallbackOwnerId ? String(fallbackOwnerId) : currentUserId;
}
// If already a valid UUID
if (/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(trimmed)) {
return trimmed;
}
const cleanEmail = trimmed.toLowerCase();
// 1. Current logged-in user match
if (effectiveUserEmail && cleanEmail === effectiveUserEmail.toLowerCase()) {
return sub || user?.id || (fallbackOwnerId ? String(fallbackOwnerId) : currentUserId);
}
// 2. Reverse cache lookup
const cachedId = getUserIdByEmail(cleanEmail);
if (cachedId) return cachedId;
// 3. Search loaded users directory
const matchedUser = usersDirectory.find(
(u) => u.email && u.email.trim().toLowerCase() === cleanEmail
);
if (matchedUser?.id) return String(matchedUser.id);
// 4. Fallback ID match check
if (fallbackOwnerId) {
const origEmail = getUserEmail(fallbackOwnerId);
if (origEmail && origEmail.toLowerCase() === cleanEmail) {
return String(fallbackOwnerId);
}
}
// 5. Default fallback
return fallbackOwnerId ? String(fallbackOwnerId) : currentUserId;
};
const resolveOwnerEmailForDisplay = async (rawOwnerId?: string | number | null): Promise<string> => {
if (!rawOwnerId) {
return effectiveUserEmail;
}
const raw = String(rawOwnerId).trim();
if (raw.includes('@')) {
return raw;
}
// Check if it's the current user
if (
(sub && raw.toLowerCase() === sub.toLowerCase()) ||
(user?.id && raw.toLowerCase() === user.id.toLowerCase())
) {
return effectiveUserEmail;
}
// Check cached email
const cached = getUserEmail(raw);
if (cached && cached.includes('@')) {
return cached;
}
// Check users directory
const found = usersDirectory.find((u) => String(u.id).toLowerCase() === raw.toLowerCase());
if (found?.email) {
return found.email;
}
// If it's a UUID, fetch user canonical record from API
if (/^[0-9a-fA-F-]{36}$/.test(raw)) {
try {
const u = await getUser(raw);
if (u?.email) {
registerUserEmail(u.id, u.email);
return u.email;
}
} catch {
// Fetch failed
}
}
return effectiveUserEmail;
};
// Forms
const orgForm = useForm<OrganisationFormValues>({
resolver: zodResolver(organisationSchema) as any,
@@ -359,7 +200,7 @@ export const GovernancePage: React.FC = () => {
name: '',
description: '',
organisationId: organisations[0]?.id ? Number(organisations[0].id) : 101,
ownerEmail: effectiveUserEmail,
ownerId: currentUserId,
},
});
@@ -369,7 +210,7 @@ export const GovernancePage: React.FC = () => {
name: '',
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
status: ApprovalStatus.Approved,
ownerEmail: effectiveUserEmail,
ownerId: currentUserId,
},
});
@@ -402,22 +243,16 @@ export const GovernancePage: React.FC = () => {
const portfolioMutation = useMutation({
mutationFn: (data: PortfolioFormValues) => {
const resolvedOwnerId = resolveOwnerId(data.ownerEmail, editingPortfolio?.ownedBy);
if (editingPortfolio) {
return updatePortfolio({
portfolioId: editingPortfolio.id,
name: data.name,
description: data.description,
organisationId: Number(data.organisationId),
ownerId: resolvedOwnerId,
ownerId: data.ownerId,
});
}
return createPortfolio({
name: data.name,
description: data.description,
organisationId: Number(data.organisationId),
ownerId: resolvedOwnerId,
});
return createPortfolio(data as CreatePortfolioRequest);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['portfolios'] });
@@ -433,21 +268,16 @@ export const GovernancePage: React.FC = () => {
const programmeMutation = useMutation({
mutationFn: (data: ProgrammeFormValues) => {
const resolvedOwnerId = resolveOwnerId(data.ownerEmail, editingProgramme?.ownedBy);
if (editingProgramme) {
return updateProgramme({
programmeId: editingProgramme.id,
name: data.name,
portfolioId: Number(data.portfolioId),
status: data.status !== undefined ? Number(data.status) : undefined,
ownerId: resolvedOwnerId,
ownerId: data.ownerId,
});
}
return createProgramme({
name: data.name,
portfolioId: Number(data.portfolioId),
ownerId: resolvedOwnerId,
});
return createProgramme(data as CreateProgrammeRequest);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['programmes'] });
@@ -491,19 +321,18 @@ export const GovernancePage: React.FC = () => {
name: '',
description: '',
organisationId: organisations[0]?.id ? Number(organisations[0].id) : 101,
ownerEmail: effectiveUserEmail,
ownerId: currentUserId,
});
setIsPortfolioModalOpen(true);
};
const openEditPortfolio = async (p: Portfolio) => {
const openEditPortfolio = (p: Portfolio) => {
setEditingPortfolio(p);
const email = await resolveOwnerEmailForDisplay(p.ownedBy);
portfolioForm.reset({
name: p.name || '',
description: p.description || '',
organisationId: Number(p.organisationId) || 101,
ownerEmail: email,
ownerId: p.ownedBy || currentUserId,
});
setIsPortfolioModalOpen(true);
};
@@ -514,19 +343,18 @@ export const GovernancePage: React.FC = () => {
name: '',
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
status: ApprovalStatus.Approved,
ownerEmail: effectiveUserEmail,
ownerId: currentUserId,
});
setIsProgrammeModalOpen(true);
};
const openEditProgramme = async (pr: Programme) => {
const openEditProgramme = (pr: Programme) => {
setEditingProgramme(pr);
const email = await resolveOwnerEmailForDisplay(pr.ownedBy);
programmeForm.reset({
name: pr.name || '',
portfolioId: Number(pr.portfolioId) || 201,
status: pr.status ?? ApprovalStatus.Approved,
ownerEmail: email,
ownerId: pr.ownedBy || currentUserId,
});
setIsProgrammeModalOpen(true);
};
@@ -597,7 +425,7 @@ export const GovernancePage: React.FC = () => {
{/* Tabs */}
<div className="flex border-b border-slate-800/80 bg-[#0d121f]/80 px-4 rounded-2xl backdrop-blur-sm">
<button
onClick={() => handleTabChange('organisations')}
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'
@@ -614,7 +442,7 @@ export const GovernancePage: React.FC = () => {
/>
</button>
<button
onClick={() => handleTabChange('portfolios')}
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'
@@ -631,7 +459,7 @@ export const GovernancePage: React.FC = () => {
/>
</button>
<button
onClick={() => handleTabChange('programmes')}
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'
@@ -740,7 +568,7 @@ export const GovernancePage: React.FC = () => {
setIsOrgDetailModalOpen(true);
}}
className="inline-flex items-center gap-1 text-xs font-semibold text-blue-400 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 px-2.5 py-1.5 rounded-lg transition shadow-xs cursor-pointer"
title="Inspect Organisation"
title="Inspect Organisation (GET /v1/api/governance/organisations/{id})"
>
<Eye className="w-3 h-3" />
Inspect
@@ -773,17 +601,8 @@ export const GovernancePage: React.FC = () => {
portfolios.map((p) => {
const org = organisations.find((o) => String(o.id) === String(p.organisationId));
const progCount = programmes.filter((pr) => String(pr.portfolioId) === String(p.id)).length;
const isHighlighted = String(highlightedPortfolioId) === String(p.id);
return (
<div
key={p.id}
id={`portfolio-card-${p.id}`}
className={`bg-[#0f1523]/80 p-5 rounded-2xl border shadow-lg backdrop-blur-sm flex flex-col justify-between transition-all ${
isHighlighted
? 'border-blue-500 ring-2 ring-blue-500/50 shadow-blue-500/20 scale-[1.01]'
: 'border-slate-800/80 hover:border-slate-700/80'
}`}
>
<div 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">
@@ -795,53 +614,48 @@ export const GovernancePage: React.FC = () => {
<p className="text-xs text-slate-400 leading-relaxed">{p.description}</p>
</div>
<div className="mt-4 pt-3 border-t border-slate-800/80 space-y-2.5">
<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-[200px] font-medium text-slate-300" title={org ? org.name : `Org #${p.organisationId}`}>
{org ? org.name : `Org #${p.organisationId}`}
</span>
<span className="font-mono text-[11px] text-slate-500 shrink-0">{formatDate(p.createdAt)}</span>
<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 gap-1.5 text-xs text-slate-400 min-w-0">
<span className="font-medium shrink-0">Owner:</span>
<span className="text-slate-200 font-mono text-[11px] break-all">
{getUserEmail(p.ownedBy)}
<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>
</div>
<div className="flex items-center justify-end gap-1.5 pt-1.5 border-t border-slate-800/50">
<button
type="button"
onClick={() => {
setSelectedPortfolioForDetail(p);
setIsPortfolioDetailModalOpen(true);
}}
className="inline-flex items-center gap-1 text-xs font-semibold text-cyan-400 hover:text-white bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
title="Inspect Portfolio"
>
<Eye className="w-3 h-3" />
Inspect
</button>
<button
type="button"
onClick={() => {
setSelectedPortfolioForProgrammes(p);
setIsPortfolioProgrammesModalOpen(true);
}}
className="inline-flex items-center gap-1 text-xs font-semibold text-blue-400 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
>
<Layers className="w-3 h-3" />
Programmes
</button>
<button
type="button"
onClick={() => openEditPortfolio(p)}
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
>
<Edit2 className="w-3 h-3" />
Edit
</button>
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => {
setSelectedPortfolioForDetail(p);
setIsPortfolioDetailModalOpen(true);
}}
className="inline-flex items-center gap-1 text-xs font-semibold text-cyan-400 hover:text-white bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
title="Inspect Portfolio (GET /v1/api/governance/portfolios/{id})"
>
<Eye className="w-3 h-3" />
Inspect
</button>
<button
type="button"
onClick={() => {
setSelectedPortfolioForProgrammes(p);
setIsPortfolioProgrammesModalOpen(true);
}}
className="inline-flex items-center gap-1 text-xs font-semibold text-blue-400 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
>
<Layers className="w-3 h-3" />
Programmes
</button>
<button
type="button"
onClick={() => openEditPortfolio(p)}
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
>
<Edit2 className="w-3 h-3" />
Edit
</button>
</div>
</div>
</div>
</div>
@@ -904,7 +718,7 @@ export const GovernancePage: React.FC = () => {
setIsProgrammeDetailModalOpen(true);
}}
className="inline-flex items-center gap-1 text-xs font-semibold text-cyan-400 hover:text-white bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
title="Inspect Programme"
title="Inspect Programme (GET /v1/api/projects/programme/{id})"
>
<Eye className="w-3 h-3" />
Inspect
@@ -1158,22 +972,11 @@ export const GovernancePage: React.FC = () => {
/>
</div>
<input
type="email"
list="portfolio-owner-email-suggestions"
{...portfolioForm.register('ownerEmail')}
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="e.g. khwezim@gmail.com"
placeholder="khwezim@gmail.com"
/>
<datalist id="portfolio-owner-email-suggestions">
{usersDirectory.filter((u) => u.email).map((u) => (
<option key={u.id} value={u.email!}>
{u.name ? `${u.name} (${u.email})` : u.email!}
</option>
))}
</datalist>
{portfolioForm.formState.errors.ownerEmail && (
<p className="text-[10px] text-rose-500 mt-1">{portfolioForm.formState.errors.ownerEmail.message}</p>
)}
</div>
<div>
@@ -1308,22 +1111,11 @@ export const GovernancePage: React.FC = () => {
/>
</div>
<input
type="email"
list="programme-owner-email-suggestions"
{...programmeForm.register('ownerEmail')}
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="e.g. khwezim@gmail.com"
placeholder="khwezim@gmail.com"
/>
<datalist id="programme-owner-email-suggestions">
{usersDirectory.filter((u) => u.email).map((u) => (
<option key={u.id} value={u.email!}>
{u.name ? `${u.name} (${u.email})` : u.email!}
</option>
))}
</datalist>
{programmeForm.formState.errors.ownerEmail && (
<p className="text-[10px] text-rose-500 mt-1">{programmeForm.formState.errors.ownerEmail.message}</p>
)}
</div>
</div>
@@ -1468,7 +1260,7 @@ export const GovernancePage: React.FC = () => {
</div>
</Modal>
{/* Modal: View Organisation Details & Linked Portfolio */}
{/* Modal: View Organisation Details (GET /v1/api/governance/organisations/{id} & /portfolio) */}
<Modal
isOpen={isOrgDetailModalOpen}
onClose={() => {
@@ -1482,89 +1274,51 @@ export const GovernancePage: React.FC = () => {
{loadingOrgFreshDetail ? (
<div className="py-8 text-center text-slate-400">
<RefreshCw className="w-5 h-5 animate-spin mx-auto mb-2 text-blue-400" />
Fetching organisation details...
Fetching canonical record (GET /v1/api/governance/organisations/{selectedOrgForDetail?.id})...
</div>
) : (
<>
<div className="p-4 bg-[#0d121f] rounded-xl border border-slate-800 space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="flex items-center gap-2 min-w-0">
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Legal Name:</span>
<span className="text-white font-medium truncate">{orgFreshDetail?.name || selectedOrgForDetail?.name}</span>
<div className="p-4 bg-[#0d121f] rounded-xl border border-slate-800 space-y-2">
<div className="grid grid-cols-2 gap-3">
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Legal Name:</span>
<span className="text-white font-medium">{orgFreshDetail?.name || selectedOrgForDetail?.name}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Reg Number:</span>
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Reg Number:</span>
<span className="font-mono text-slate-300">{orgFreshDetail?.registrationNo || selectedOrgForDetail?.registrationNo || '—'}</span>
</div>
<div className="flex items-center gap-2 min-w-0">
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Official Email:</span>
<span className="font-mono text-slate-300 truncate">{orgFreshDetail?.email || selectedOrgForDetail?.email}</span>
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Official Email:</span>
<span className="font-mono text-slate-300">{orgFreshDetail?.email || selectedOrgForDetail?.email}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Entity Type:</span>
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Entity Type:</span>
<span className="text-slate-300">{getOrganisationTypeLabel(orgFreshDetail?.type ?? selectedOrgForDetail?.type ?? OrganisationType.Enterprise)}</span>
</div>
</div>
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<h4 className="font-semibold text-slate-300 text-xs">Linked Portfolios</h4>
<span className="text-[10px] font-mono text-slate-400 bg-slate-800/80 border border-slate-700/60 px-2 py-0.5 rounded-full">
{orgPortfolios.length} linked
</span>
</div>
{orgPortfolios.length > 0 && (
<button
type="button"
onClick={() => handleNavigateToPortfolioTab()}
className="inline-flex items-center gap-1 text-[11px] font-semibold text-blue-400 hover:text-blue-300 transition cursor-pointer"
>
<span>View Portfolios tab</span>
<ArrowRight className="w-3 h-3" />
</button>
)}
</div>
<h4 className="font-semibold text-slate-300 text-xs mb-2 flex items-center justify-between">
<span>Linked Portfolios (GET /v1/api/governance/organisations/{selectedOrgForDetail?.id}/portfolio)</span>
<span className="text-[10px] font-mono text-slate-400">{orgPortfolios.length} linked</span>
</h4>
{loadingOrgPortfolios ? (
<p className="text-xs text-slate-500 py-3 text-center">Loading portfolios...</p>
<p className="text-xs text-slate-500">Loading portfolios...</p>
) : orgPortfolios.length === 0 ? (
<div className="p-3.5 bg-slate-900/40 rounded-xl border border-slate-800/80 text-center">
<p className="text-xs text-slate-400">No portfolios directly linked to this organisation.</p>
</div>
<p className="text-xs text-slate-500">No portfolios directly linked to this organisation.</p>
) : (
<div className="space-y-2 max-h-[220px] overflow-y-auto pr-0.5">
<div className="space-y-2 max-h-[220px] overflow-y-auto">
{orgPortfolios.map((port) => (
<div
key={port.id}
onClick={() => handleNavigateToPortfolioTab(port.id)}
className="p-3 bg-slate-900/60 hover:bg-slate-900 rounded-xl border border-slate-800 hover:border-blue-500/40 flex items-center justify-between gap-3 transition cursor-pointer group"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-white font-medium block group-hover:text-blue-300 transition-colors truncate">
{port.name}
</span>
<span className="text-[10px] text-blue-400 bg-blue-500/10 px-1.5 py-0.5 rounded border border-blue-500/20 font-mono shrink-0">
Portfolio #{port.id}
</span>
</div>
{port.description && (
<p className="text-[11px] text-slate-400 truncate mt-0.5">{port.description}</p>
)}
<div key={port.id} className="p-3 bg-slate-900/60 rounded-xl border border-slate-800 flex items-center justify-between">
<div>
<span className="text-white font-medium block">{port.name}</span>
<span className="text-[10px] text-slate-500 font-mono">ID: {port.id}</span>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleNavigateToPortfolioTab(port.id);
}}
className="inline-flex items-center gap-1 text-[11px] font-semibold text-blue-400 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 px-2.5 py-1 rounded-lg transition shadow-xs shrink-0 cursor-pointer"
>
<span>Open</span>
<ArrowRight className="w-3 h-3 group-hover:translate-x-0.5 transition-transform" />
</button>
<span className="text-[10px] text-blue-400 bg-blue-500/10 px-2 py-0.5 rounded border border-blue-500/20 font-mono">
Portfolio
</span>
</div>
))}
</div>
@@ -1588,7 +1342,7 @@ export const GovernancePage: React.FC = () => {
</div>
</Modal>
{/* Modal: View Portfolio Details */}
{/* Modal: View Portfolio Details (GET /v1/api/governance/portfolios/{id}) */}
<Modal
isOpen={isPortfolioDetailModalOpen}
onClose={() => {
@@ -1602,7 +1356,7 @@ export const GovernancePage: React.FC = () => {
{loadingPortfolioFreshDetail ? (
<div className="py-8 text-center text-slate-400">
<RefreshCw className="w-5 h-5 animate-spin mx-auto mb-2 text-blue-400" />
Fetching portfolio details...
Fetching canonical record (GET /v1/api/governance/portfolios/{selectedPortfolioForDetail?.id})...
</div>
) : (
<div className="p-4 bg-[#0d121f] rounded-xl border border-slate-800 space-y-3">
@@ -1614,16 +1368,14 @@ export const GovernancePage: React.FC = () => {
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Description:</span>
<p className="text-slate-300 mt-0.5 leading-relaxed">{portfolioFreshDetail?.description || selectedPortfolioForDetail?.description || 'None provided'}</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-3 border-t border-slate-800/80 items-center">
<div className="flex items-center gap-2 min-w-0">
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Owner:</span>
<span className="font-mono text-slate-300 text-xs truncate" title={getUserEmail(portfolioFreshDetail?.ownedBy || selectedPortfolioForDetail?.ownedBy)}>
{getUserEmail(portfolioFreshDetail?.ownedBy || selectedPortfolioForDetail?.ownedBy)}
</span>
<div className="grid grid-cols-2 gap-2 pt-2 border-t border-slate-800/80">
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Owner:</span>
<span className="font-mono text-slate-300">{getUserEmail(portfolioFreshDetail?.ownedBy || selectedPortfolioForDetail?.ownedBy)}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Created:</span>
<span className="font-mono text-slate-300 text-xs">
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Created:</span>
<span className="font-mono text-slate-300">
{formatDate(portfolioFreshDetail?.createdAt || selectedPortfolioForDetail?.createdAt || '')}
</span>
</div>
@@ -1646,7 +1398,7 @@ export const GovernancePage: React.FC = () => {
</div>
</Modal>
{/* Modal: View Programme Details */}
{/* Modal: View Programme Details (GET /v1/api/projects/programme/{id}) */}
<Modal
isOpen={isProgrammeDetailModalOpen}
onClose={() => {
@@ -1660,26 +1412,24 @@ export const GovernancePage: React.FC = () => {
{loadingProgrammeFreshDetail ? (
<div className="py-8 text-center text-slate-400">
<RefreshCw className="w-5 h-5 animate-spin mx-auto mb-2 text-blue-400" />
Fetching programme details...
Fetching canonical record (GET /v1/api/projects/programme/{selectedProgrammeForDetail?.id})...
</div>
) : (
<div className="p-4 bg-[#0d121f] rounded-xl border border-slate-800/80 space-y-3.5">
<div className="pb-3 border-b border-slate-800/80">
<span className="text-slate-400 font-semibold block text-[11px] uppercase tracking-wider mb-1">Programme Name</span>
<span className="text-white font-semibold text-sm block">{programmeFreshDetail?.name || selectedProgrammeForDetail?.name}</span>
<div className="p-4 bg-[#0d121f] rounded-xl border border-slate-800 space-y-3">
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Programme Name:</span>
<span className="text-white font-medium text-sm">{programmeFreshDetail?.name || selectedProgrammeForDetail?.name}</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 items-center">
<div className="flex items-center gap-2">
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Approval Status:</span>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border whitespace-nowrap ${getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).className}`}>
<div className="grid grid-cols-2 gap-2 pt-2">
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Approval Status:</span>
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).className}`}>
{getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).label}
</span>
</div>
<div className="flex items-center gap-2 min-w-0">
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Owner:</span>
<span className="font-mono text-slate-300 text-xs truncate" title={getUserEmail(programmeFreshDetail?.ownedBy || selectedProgrammeForDetail?.ownedBy)}>
{getUserEmail(programmeFreshDetail?.ownedBy || selectedProgrammeForDetail?.ownedBy)}
</span>
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Owner:</span>
<span className="font-mono text-slate-300">{getUserEmail(programmeFreshDetail?.ownedBy || selectedProgrammeForDetail?.ownedBy)}</span>
</div>
</div>
</div>
@@ -1,5 +1,4 @@
import React, { useState, useRef, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
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';
@@ -21,12 +20,16 @@ import {
Filter,
Link as LinkIcon,
Edit2,
ChevronRight,
} from 'lucide-react';
import {
getAllIndicators,
createIndicator,
recordIndicatorActual,
getAllEvidence,
getMilestoneEvidence,
getEvidenceSecureLink,
uploadEvidenceFile,
updateEvidence,
getAllRisks,
createRisk,
updateRisk,
@@ -37,10 +40,10 @@ import { Modal } from '../components/ui/Modal';
import { HelpTooltip } from '../components/ui/HelpTooltip';
import { useToast } from '../components/ui/Toast';
import { ContractsManagement } from '../components/monitoring/ContractsManagement';
import { EvidenceReviewQueue } from '../components/monitoring/EvidenceReviewQueue';
import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix';
import {
Indicator,
Evidence,
Risk,
ApprovalStatus,
UnitOfMeasure,
@@ -82,40 +85,13 @@ const riskSchema = z.object({
type IndicatorFormValues = z.infer<typeof indicatorSchema>;
type RiskFormValues = z.infer<typeof riskSchema>;
export const WORKFLOW_STAGES = [
{ id: 'planned', label: 'Planned', step: 1, description: 'Milestone scope agreed and deliverables planned' },
{ id: 'in_progress', label: 'In Progress', step: 2, description: 'Activity underway by recipient organisation' },
{ id: 'submitted', label: 'Submitted', step: 3, description: 'Evidence and deliverables submitted by recipient' },
{ id: 'under_review', label: 'Under Review', step: 4, description: 'Programme Manager verifying evidence against criteria' },
{ id: 'verified_paid', label: 'Verified & Paid', step: 5, description: 'Milestone verified and disbursement authorized' },
] as const;
export const MonitoringPage: React.FC = () => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const [searchParams, setSearchParams] = useSearchParams();
const tabParam = searchParams.get('tab');
const [activeTab, setActiveTab] = useState<'contracts' | 'evidence' | 'indicators' | 'risks'>(
tabParam === 'evidence' || tabParam === 'indicators' || tabParam === 'risks' ? tabParam : 'contracts'
);
const [activeTab, setActiveTab] = useState<'contracts' | 'evidence' | 'indicators' | 'risks'>('contracts');
const [isCreateContractModalOpen, setIsCreateContractModalOpen] = useState(false);
useEffect(() => {
const t = searchParams.get('tab');
if (t === 'evidence' || t === 'indicators' || t === 'risks' || t === 'contracts') {
setActiveTab(t);
}
}, [searchParams]);
const handleTabChange = (tab: 'contracts' | 'evidence' | 'indicators' | 'risks') => {
setActiveTab(tab);
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set('tab', tab);
return next;
});
};
const fileInputRef = useRef<HTMLInputElement>(null);
// Modals & States
const [isIndicatorModalOpen, setIsIndicatorModalOpen] = useState(false);
@@ -123,6 +99,11 @@ export const MonitoringPage: React.FC = () => {
const [editingRisk, setEditingRisk] = useState<Risk | null>(null);
const [selectedIndicatorForActual, setSelectedIndicatorForActual] = useState<Indicator | null>(null);
const [actualInput, setActualInput] = useState<number>(0);
const [uploadEvidenceType, setUploadEvidenceType] = useState<number>(EvidenceType.CompletionCertificate);
// Reviewer commentary
const [reviewerComment, setReviewerComment] = useState('');
const [selectedMilestoneEvidenceFilter, setSelectedMilestoneEvidenceFilter] = useState<string | number | 'all'>('all');
// Queries
const { data: indicators = [], isLoading: loadingIndicators } = useQuery({
@@ -130,6 +111,14 @@ export const MonitoringPage: React.FC = () => {
queryFn: () => getAllIndicators(),
});
const { data: evidenceList = [], isLoading: loadingEvidence } = useQuery({
queryKey: ['evidence', selectedMilestoneEvidenceFilter],
queryFn: () =>
selectedMilestoneEvidenceFilter !== 'all'
? getMilestoneEvidence(selectedMilestoneEvidenceFilter)
: getAllEvidence(),
});
const { data: risks = [], isLoading: loadingRisks } = useQuery({
queryKey: ['risks'],
queryFn: () => getAllRisks(),
@@ -195,6 +184,42 @@ export const MonitoringPage: React.FC = () => {
},
});
const uploadMutation = useMutation({
mutationFn: (formData: FormData) => uploadEvidenceFile(formData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['evidence'] });
success('Evidence uploaded', 'Supporting file registered for reviewer inspection.');
},
onError: (err: unknown) => {
toastError('Upload failed', err);
},
});
const reviewEvidenceMutation = useMutation({
mutationFn: ({ id, status }: { id: string | number; status: ApprovalStatus }) =>
updateEvidence({
evidenceId: id,
version: 2,
evidenceType: EvidenceType.DeliverableProof,
status,
}),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['evidence'] });
if (variables.status === ApprovalStatus.Approved) {
success(
'Evidence Approved & Verified',
'Approval recorded. Auto-triggers payment eligibility check in Disbursements.'
);
} else {
success('Rework Requested', 'Evidence returned to recipient for correction.');
}
setReviewerComment('');
},
onError: (err: unknown) => {
toastError('Review update failed', err);
},
});
const openCreateRisk = () => {
setEditingRisk(null);
riskForm.reset({
@@ -254,14 +279,40 @@ export const MonitoringPage: React.FC = () => {
},
});
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const formData = new FormData();
formData.append('file', file);
formData.append('projectId', '401');
formData.append('milestoneId', '803');
formData.append('evidenceType', String(uploadEvidenceType));
uploadMutation.mutate(formData);
}
};
const handleGetEvidenceSecureLink = async (evidenceId: string | number) => {
try {
const url = await getEvidenceSecureLink(evidenceId);
if (url) {
window.open(url, '_blank', 'noopener,noreferrer');
success('Secure URL Retrieved', 'Signed evidence download link opened.');
} else {
toastError('Download Error', 'No active secure download link generated.');
}
} catch (err: any) {
toastError('Link Error', err);
}
};
// Performance indicator thresholds:
// Green >= 90%, Amber 70-89%, Red < 70%
const getIndicatorStatus = (actual: number, target: number) => {
if (!target) return { label: 'Pending', badge: 'bg-slate-800/60 text-slate-400 border-slate-700/60' };
const pct = (actual / target) * 100;
if (pct >= 90) return { label: '≥90%', badge: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' };
if (pct >= 70) return { label: '70–89%', badge: 'bg-amber-500/10 text-amber-400 border-amber-500/20' };
return { label: '<70%', badge: 'bg-rose-500/10 text-rose-400 border-rose-500/20' };
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 (
@@ -316,8 +367,8 @@ export const MonitoringPage: React.FC = () => {
{/* Tabs */}
<div className="flex gap-2 border-b border-slate-800/80 pb-3 overflow-x-auto">
<button
onClick={() => handleTabChange('contracts')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 cursor-pointer ${
onClick={() => setActiveTab('contracts')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${
activeTab === 'contracts'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
@@ -333,25 +384,25 @@ export const MonitoringPage: React.FC = () => {
/>
</button>
<button
onClick={() => handleTabChange('evidence')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 cursor-pointer ${
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'
}`}
>
<FileCheck className="w-4 h-4" />
<span>Evidence Review & Verification</span>
<Paperclip className="w-4 h-4" />
<span>Milestone Evidence & Review Queue</span>
<HelpTooltip
title="Evidence Review & Verification"
content="Peer review, document inspection, and verification sign-offs for deliverables submitted against projects and milestones."
title="Verification Workflow"
content="Evidence submission, file validation, and peer review sign-off gating milestone payments."
size="sm"
placement="top"
/>
</button>
<button
onClick={() => handleTabChange('indicators')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 cursor-pointer ${
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'
@@ -367,8 +418,8 @@ export const MonitoringPage: React.FC = () => {
/>
</button>
<button
onClick={() => handleTabChange('risks')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 cursor-pointer ${
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'
@@ -393,14 +444,220 @@ export const MonitoringPage: React.FC = () => {
/>
)}
{/* Tab 1: Project & Milestone Evidence Review Hub */}
{/* Tab 1: Milestone Evidence Submission & Review */}
{activeTab === 'evidence' && (
<EvidenceReviewQueue
initialProjectId={searchParams.get('projectId') || 'all'}
initialMilestoneId={searchParams.get('milestoneId') || 'all'}
initialActivityId={searchParams.get('activityId') || 'all'}
initialUserId={searchParams.get('userId') || 'all'}
/>
<div className="space-y-6">
{/* Milestone Evidence Review Container */}
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
{/* Header Bar */}
<div className="bg-slate-900/90 text-white px-6 py-4 flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-slate-800/80">
<span className="text-xs font-mono font-semibold tracking-wider text-blue-400 uppercase">
RECIPIENT PORTAL &bull; Project XYZ &bull; Milestone 3: Prototype Delivery
</span>
<span className="text-[11px] text-slate-400 font-mono">Milestone Evidence Submission</span>
</div>
{/* Workflow Progress Steps */}
<div className="p-6 border-b border-slate-800/80 bg-slate-900/40">
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2 text-center text-xs font-semibold">
<div className="py-2.5 px-3 rounded-xl bg-slate-800/60 text-slate-400 border border-slate-700/50">Planned</div>
<div className="py-2.5 px-3 rounded-xl bg-slate-800/60 text-slate-400 border border-slate-700/50">In Progress</div>
<div className="py-2.5 px-3 rounded-xl bg-blue-600 text-white shadow-lg shadow-blue-600/30 ring-2 ring-blue-500/40">
Submitted
</div>
<div className="py-2.5 px-3 rounded-xl bg-amber-500/15 text-amber-300 border border-amber-500/30">
Under Review
</div>
<div className="py-2.5 px-3 rounded-xl bg-slate-900/40 text-slate-500 border border-dashed border-slate-800">
Verified & Paid
</div>
</div>
<div className="mt-4 flex items-center justify-between text-xs">
<div className="flex items-center gap-2 text-slate-300">
<span className="font-semibold">Current status:</span>
<span className="font-mono text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20 font-bold">
SUBMITTED — awaiting Programme Manager review
</span>
</div>
</div>
</div>
{/* Upload Evidence & Attached Evidence Panels */}
<div className="grid grid-cols-1 md:grid-cols-2 divide-y md:divide-y-0 md:divide-x divide-slate-800/80">
{/* Upload Box */}
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-bold text-white">Upload Evidence</h4>
<div className="flex items-center gap-1.5">
<label className="text-[11px] font-medium text-slate-400">Type:</label>
<select
value={uploadEvidenceType}
onChange={(e) => setUploadEvidenceType(Number(e.target.value))}
className="text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 max-w-[180px] [&>option]:bg-slate-900 [&>option]:text-white"
>
{EVIDENCE_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
<p className="text-xs text-slate-400">
Drag & drop or browse files. Accepted formats: PDF, JPG, DOCX, XLSX (Max 25MB).
</p>
<div
onClick={() => fileInputRef.current?.click()}
className="border-2 border-dashed border-slate-700/80 hover:border-blue-500/80 bg-slate-900/40 hover:bg-blue-500/5 rounded-2xl p-8 text-center cursor-pointer transition flex flex-col items-center justify-center space-y-3"
>
<Upload className="w-8 h-8 text-slate-400" />
<div>
<span className="text-xs font-bold text-white block">Click to Browse or Drag Files Here</span>
<span className="text-[10px] text-slate-500">Automated SHA-256 hash stamp applied on intake</span>
</div>
<button
type="button"
className="px-4 py-1.5 text-xs font-semibold text-white bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-xl shadow-xs transition"
>
Browse Files
</button>
</div>
<input
type="file"
ref={fileInputRef}
onChange={handleFileUpload}
className="hidden"
accept=".pdf,.jpg,.jpeg,.png,.docx,.xlsx,.zip"
/>
</div>
{/* Attached Evidence List */}
<div className="p-6 space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div>
<h4 className="text-sm font-bold text-white">Attached Evidence (v2)</h4>
<span className="text-[11px] text-slate-400 font-mono">{evidenceList.length} files loaded</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-400">Milestone:</span>
<select
value={selectedMilestoneEvidenceFilter}
onChange={(e) => setSelectedMilestoneEvidenceFilter(e.target.value === 'all' ? 'all' : e.target.value)}
className="text-xs bg-slate-900 border border-slate-700/80 rounded-xl px-2.5 py-1 text-white focus:outline-none focus:ring-1 focus:ring-blue-500 max-w-[200px] [&>option]:bg-slate-900 [&>option]:text-white"
>
<option value="all">All Milestones</option>
{milestones.map((m) => (
<option key={m.id} value={m.id}>
{m.name || `Milestone #${m.id}`}
</option>
))}
</select>
</div>
</div>
<div className="space-y-2.5">
{evidenceList.map((ev) => (
<div
key={ev.id}
className="p-3 rounded-xl border border-slate-800/80 bg-slate-900/60 flex items-center justify-between gap-3 text-xs"
>
<div className="flex items-center gap-2.5 min-w-0">
<FileText className="w-4 h-4 text-blue-400 shrink-0" />
<div className="min-w-0">
<span className="font-semibold text-slate-200 block truncate font-mono">
{ev.fileName || `evidence_file_${ev.id}.pdf`}
</span>
<span className="text-[10px] text-slate-400 font-mono">
{ev.fileSize || '3.2 MB'} &bull; v{ev.version} &bull; {getEvidenceTypeLabel(ev.type)}
</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-amber-500/10 text-amber-400 border border-amber-500/20">
Under Review
</span>
<button
type="button"
onClick={() => handleGetEvidenceSecureLink(ev.id)}
className="p-1 rounded text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Fetch Secure Signed Link"
>
<LinkIcon className="w-3.5 h-3.5" />
</button>
{ev.documentUrl && (
<a
href={ev.documentUrl}
target="_blank"
rel="noopener noreferrer"
className="text-slate-400 hover:text-white p-1 transition"
title="Download evidence file"
>
<Download className="w-3.5 h-3.5" />
</a>
)}
</div>
</div>
))}
</div>
</div>
</div>
{/* Programme Manager Review Section */}
<div className="p-6 border-t border-slate-800/80 bg-slate-900/40 space-y-4">
<h4 className="text-sm font-bold text-white">Programme Manager Review</h4>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
Reviewer comment / rejection reason
</label>
<input
type="text"
value={reviewerComment}
onChange={(e) => setReviewerComment(e.target.value)}
placeholder="Mandatory if requesting rework; optional for approvals..."
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition"
/>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pt-2">
<div className="flex items-center gap-3">
<button
onClick={() =>
reviewEvidenceMutation.mutate({
id: evidenceList[0]?.id || 1201,
status: ApprovalStatus.Rejected,
})
}
disabled={reviewEvidenceMutation.isPending}
className="px-4 py-2 text-xs font-semibold text-white bg-rose-600/90 hover:bg-rose-600 rounded-xl shadow-lg shadow-rose-600/20 transition"
>
Reject / Rework
</button>
<button
onClick={() =>
reviewEvidenceMutation.mutate({
id: evidenceList[0]?.id || 1201,
status: ApprovalStatus.Approved,
})
}
disabled={reviewEvidenceMutation.isPending}
className="px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition"
>
Approve & Verify
</button>
</div>
<p className="text-[11px] text-slate-400 italic">
Approval auto-triggers payment eligibility check.
</p>
</div>
</div>
</div>
</div>
)}
{/* Tab 2: Indicators Table (M08) */}
@@ -707,7 +964,7 @@ export const MonitoringPage: React.FC = () => {
<label className="block text-xs font-semibold text-slate-300">Actual Value to Record *</label>
<HelpTooltip
title="Monitored Measurement"
content="Current realized value for this reporting period. Automatically updates to ≥90%, 70–89%, or <70% performance threshold status."
content="Current realized value for this reporting period. Automatically updates Green (>=90%), Amber (70-89%), or Red (<70%) status."
size="sm"
placement="right"
/>
@@ -1,5 +1,4 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -521,25 +520,15 @@ export const ProjectsPage: React.FC = () => {
{/* Actions & Status Change Selector */}
<div className="shrink-0 text-right space-y-2">
<div className="flex items-center justify-end gap-2">
<Link
to={`/monitoring?tab=evidence&projectId=${selectedProject.id}`}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-blue-300 hover:text-white bg-blue-500/15 hover:bg-blue-500/25 border border-blue-500/30 rounded-xl transition cursor-pointer"
title="Review submitted evidence for this project"
>
<FileCheck className="w-3.5 h-3.5 text-blue-400" />
Review Evidence
</Link>
<button
type="button"
onClick={() => openEditProject(selectedProject)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-slate-200 bg-slate-800 hover:bg-slate-700 border border-slate-700/80 rounded-xl transition cursor-pointer"
title="Edit project details"
>
<Edit2 className="w-3.5 h-3.5 text-blue-400" />
Edit Project
</button>
</div>
<button
type="button"
onClick={() => openEditProject(selectedProject)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-slate-200 bg-slate-800 hover:bg-slate-700 border border-slate-700/80 rounded-xl transition cursor-pointer"
title="Edit project details"
>
<Edit2 className="w-3.5 h-3.5 text-blue-400" />
Edit Project
</button>
<div>
<div className="flex items-center gap-1 mb-1">
<label className="text-[10px] uppercase font-bold text-slate-400 block">Approval Status</label>
@@ -695,14 +684,6 @@ export const ProjectsPage: React.FC = () => {
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="font-mono text-xs font-bold text-blue-400">{formatZAR(a.amount)}</span>
<Link
to={`/monitoring?tab=evidence&projectId=${selectedProject.id}&activityId=${a.id}`}
className="inline-flex items-center gap-1 text-[10px] font-semibold text-cyan-300 hover:text-white bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/25 px-2 py-1 rounded-lg transition"
title="View & review evidence for this activity"
>
<FileCheck className="w-3 h-3 text-cyan-400" />
Evidence
</Link>
<button
onClick={() => openEditActivity(a)}
className="p-1.5 rounded-lg text-slate-400 hover:text-blue-400 hover:bg-slate-800 transition cursor-pointer"
@@ -755,29 +736,16 @@ export const ProjectsPage: React.FC = () => {
const statusBadge = getApprovalStatusBadge(m.status);
const priBadge = getPriorityBadge(m.priority);
return (
<div key={m.id} className="p-3.5 rounded-xl border border-slate-800/80 bg-[#0d121f]/90 space-y-2.5">
<div className="flex items-start justify-between gap-3">
<div key={m.id} className="p-3.5 rounded-xl border border-slate-800/80 bg-[#0d121f]/90 space-y-2">
<div className="flex items-start justify-between gap-2">
<div>
<span className="font-semibold text-xs text-white block">{m.name}</span>
<span className="text-[10px] text-slate-500 font-mono">Milestone #{m.id}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="flex items-center gap-1.5">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
{statusBadge.label}
</span>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${priBadge.className}`}>
{priBadge.label}
</span>
</div>
<Link
to={`/monitoring?tab=evidence&projectId=${selectedProject.id}&milestoneId=${m.id}`}
className="inline-flex items-center gap-1 text-[10px] font-semibold text-blue-300 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/25 px-2 py-1 rounded-lg transition"
title="View & review evidence for this milestone"
>
<FileCheck className="w-3 h-3 text-blue-400" />
Evidence
</Link>
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
{statusBadge.label}
</span>
<button
onClick={() => openEditMilestone(m)}
className="p-1.5 rounded-lg text-slate-400 hover:text-blue-400 hover:bg-slate-800 transition cursor-pointer"
@@ -787,11 +755,14 @@ export const ProjectsPage: React.FC = () => {
</button>
</div>
</div>
<div className="flex items-center text-[11px] text-slate-400 pt-1.5 border-t border-slate-800/60">
<span className="flex items-center gap-1.5 text-slate-400">
<Clock className="w-3.5 h-3.5 text-slate-500" />
<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>
);
@@ -121,7 +121,7 @@ export const SettingsPage: React.FC = () => {
const syncIntegrationMutation = useMutation({
mutationFn: () => syncIntegration(true),
onSuccess: () => {
success('Backend Integration Synced', 'System records and telemetry successfully synchronized.');
success('Backend Integration Synced', 'Direct POST /v1/api/integration/sync executed successfully.');
queryClient.invalidateQueries({ queryKey: ['users'] });
},
onError: (err: any) => {
@@ -260,7 +260,7 @@ export const SettingsPage: React.FC = () => {
<div className="flex items-center gap-2">
<span
className="hidden sm:inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-[11px] font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/20"
title="User directory synchronization active in browser"
title={getUserSyncRecord() ? `Endpoint: ${getUserSyncRecord()?.syncedEndpoint || '/v1/api/integration/sync'} (Synced in browser)` : 'Sync active in browser'}
>
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
Browser Sync Saved
@@ -553,7 +553,7 @@ export const SettingsPage: React.FC = () => {
type="submit"
disabled={searchingApplicant || !applicantSearchId.trim()}
className="px-3.5 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl transition cursor-pointer disabled:opacity-50"
title="Lookup all invites"
title="Lookup all invites (GET /v1/api/communications/invites/{applicantUserId})"
>
{searchingApplicant ? 'Searching...' : 'All Invites'}
</button>
@@ -562,7 +562,7 @@ export const SettingsPage: React.FC = () => {
onClick={handleLookupSingleApplicantInvite}
disabled={searchingApplicant || !applicantSearchId.trim()}
className="px-3.5 py-2 text-xs font-semibold text-blue-300 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 rounded-xl transition cursor-pointer disabled:opacity-50"
title="Lookup primary invite"
title="Lookup primary invite (GET /v1/api/communications/invites/applicant/{applicantUserId})"
>
Primary Invite
</button>
@@ -721,10 +721,10 @@ export const SettingsPage: React.FC = () => {
<div>
<h3 className="text-sm font-bold text-white flex items-center gap-2">
<RefreshCw className="w-4 h-4 text-blue-400" />
Backend Integration Sync Engine
Backend Integration Sync Engine (POST /v1/api/integration/sync)
</h3>
<p className="text-xs text-slate-400 mt-1 max-w-2xl leading-relaxed">
The integration sync service provisions and synchronizes authenticated accounts with the backend security directory. To avoid hotspotting the backend during frequent navigation, synchronizations are locally persisted in the browser and refreshed on demand or when roles update.
The integration sync endpoint provisions and synchronizes authenticated accounts with the backend security directory. To avoid hotspotting the backend during frequent navigation, synchronizations are locally persisted in the browser and refreshed on demand or when roles update.
</p>
</div>
@@ -734,7 +734,7 @@ export const SettingsPage: React.FC = () => {
onClick={() => syncIntegrationMutation.mutate()}
disabled={syncIntegrationMutation.isPending}
className="inline-flex items-center gap-2 px-4 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl shadow-lg shadow-blue-500/20 transition cursor-pointer disabled:opacity-50"
title="Trigger synchronization with directory"
title="Direct POST /v1/api/integration/sync call"
>
<RefreshCw className={`w-3.5 h-3.5 ${syncIntegrationMutation.isPending ? 'animate-spin' : ''}`} />
{syncIntegrationMutation.isPending ? 'Syncing...' : 'Trigger Integration Sync'}
@@ -769,19 +769,19 @@ export const SettingsPage: React.FC = () => {
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-2">
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider block">
Synchronization Service Profile
OpenAPI Target Route
</span>
<div className="text-xs space-y-1.5 font-mono">
<div>
<span className="text-slate-500">Service Mode:</span> <span className="text-emerald-400 font-bold">Secure Directory Handshake</span>
<span className="text-slate-500">HTTP Method:</span> <span className="text-emerald-400 font-bold">POST</span>
</div>
<div>
<span className="text-slate-500">Sync Scope:</span>{' '}
<span className="text-cyan-400">Authoritative User Directory & Security Claims</span>
<span className="text-slate-500">Endpoint Path:</span>{' '}
<span className="text-cyan-400">/v1/api/integration/sync</span>
</div>
<div>
<span className="text-slate-500">Service Status:</span>{' '}
<span className="text-white">Operational & Connected</span>
<span className="text-slate-500">Execution Target:</span>{' '}
<span className="text-white">Authoritative User Directory Synchronization</span>
</div>
</div>
</div>
@@ -1129,7 +1129,7 @@ export const SettingsPage: React.FC = () => {
{loadingUserFreshDetail ? (
<div className="p-6 text-center text-slate-400">
<RefreshCw className="w-5 h-5 animate-spin mx-auto mb-2 text-blue-400" />
Fetching user profile details...
Querying backend endpoint (GET /v1/api/users/{selectedUserForDetail.id})...
</div>
) : (
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-2">
+123 -267
View File
@@ -1,13 +1,7 @@
import axios, { AxiosError } from 'axios';
import { apiClient } from './httpClient';
import { toolsService } from './toolsService';
import {
getCurrentUserSub,
getCurrentUserTenantId,
getCurrentUserHashedTenantId,
} from '../auth/AuthProvider';
export { getCurrentUserTenantId, getCurrentUserHashedTenantId };
import { getCurrentUserSub } from '../auth/AuthProvider';
import {
Dashboard,
Organisation,
@@ -60,7 +54,7 @@ import {
} from '../types';
import { parseFriendlyError, FriendlyError } from '../utils/apiErrorParser';
import { registerUserEmail, registerUserEmails, getUserIdByEmail } from '../lib/formatters';
import { registerUserEmail, registerUserEmails } from '../lib/formatters';
// Helper to extract ProblemDetails from Axios error or unknown error
export function parseApiError(error: unknown): ProblemDetails {
@@ -94,20 +88,6 @@ export const getDashboard = async (): Promise<Dashboard> => {
// 2. Governance & Structure APIs
// ==========================================
/**
* Ensures an ID is converted to a canonical int64 integer number.
* Decodes HashIDs if a HashID string is passed, ensuring request body schemas
* (pattern: ^-?(?:0|[1-9]\d*)$) receive numeric integers, never raw HashIDs.
*/
export const toIntegerId = (id: number | string | null | undefined): number => {
return toolsService.decodeToNumberSync(id);
};
export const toNullableIntegerId = (id: number | string | null | undefined): number | null => {
if (id === null || id === undefined || id === '') return null;
return toolsService.decodeToNumberSync(id);
};
/**
* Normalizes an Organisation object from backend responses.
* Robustly maps status across variations (status, Status, activityStatus, approvalStatus, statusId, isActive).
@@ -150,51 +130,32 @@ export const getOrganisations = async (page = 1, pageSize = 100): Promise<Organi
return list.map(normalizeOrganisation);
};
export const getOrganisation = async (organisationId?: string | number): Promise<Organisation> => {
const targetOrgId =
organisationId !== undefined && organisationId !== '' && organisationId !== null
? organisationId
: getCurrentUserTenantId();
if (targetOrgId === undefined || targetOrgId === null || targetOrgId === '') {
throw new Error('Organisation ID is required');
}
const hashedOrgId = await toolsService.resolveOrganisationId(targetOrgId);
export const getOrganisation = async (organisationId: string | number): Promise<Organisation> => {
const hashedOrgId = await toolsService.resolveOrganisationId(organisationId);
const res = await apiClient.get<any>(`/v1/api/governance/organisations/${hashedOrgId}`);
return normalizeOrganisation(res.data);
};
export const createOrganisation = async (payload: CreateOrganisationRequest): Promise<Organisation> => {
const userSub = getCurrentUserSub();
const res = await apiClient.post('/v1/api/governance/organisations', {
name: payload.name,
registrationNo: payload.registrationNo,
email: payload.email,
type: payload.type !== undefined ? Number(payload.type) : 5,
...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', {
organisationId: toIntegerId(payload.organisationId),
name: payload.name,
registrationNo: payload.registrationNo,
email: payload.email,
type: Number(payload.type),
...payload,
updatedBy: userSub || undefined,
});
return res.data;
};
export const getPortfolios = async (organisationId?: string | number): Promise<Portfolio[]> => {
const targetOrgId =
organisationId !== undefined && organisationId !== '' && organisationId !== null
? organisationId
: getCurrentUserTenantId();
if (targetOrgId === undefined || targetOrgId === null || targetOrgId === '') {
return [];
}
const hashedOrgId = await toolsService.resolveOrganisationId(targetOrgId);
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 || [];
};
@@ -232,36 +193,20 @@ export const getPortfolioProgrammes = async (portfolioId: string | number): Prom
export const createPortfolio = async (payload: CreatePortfolioRequest): Promise<Portfolio> => {
const userSub = getCurrentUserSub();
let ownerId = payload.ownerId;
if (ownerId && typeof ownerId === 'string' && ownerId.includes('@')) {
ownerId = getUserIdByEmail(ownerId) || userSub || ownerId;
}
const tenantId = getCurrentUserTenantId();
const orgId =
payload.organisationId !== undefined && payload.organisationId !== '' && payload.organisationId !== null
? toIntegerId(payload.organisationId)
: (tenantId !== null ? tenantId : 0);
const res = await apiClient.post('/v1/api/governance/organisations/portfolio', {
name: payload.name,
description: payload.description,
organisationId: orgId,
ownerId: ownerId || userSub || '00000000-0000-0000-0000-000000000000',
...payload,
ownerId: payload.ownerId || userSub || undefined,
createdBy: userSub || undefined,
});
return res.data;
};
export const updatePortfolio = async (payload: UpdatePortfolioRequest): Promise<Portfolio> => {
const userSub = getCurrentUserSub();
let ownerId = payload.ownerId;
if (ownerId && typeof ownerId === 'string' && ownerId.includes('@')) {
ownerId = getUserIdByEmail(ownerId) || userSub || ownerId;
}
const res = await apiClient.put('/v1/api/governance/organisations/portfolio', {
portfolioId: toIntegerId(payload.portfolioId),
name: payload.name,
description: payload.description,
ownerId: ownerId || userSub || '00000000-0000-0000-0000-000000000000',
...payload,
ownerId: payload.ownerId || userSub || undefined,
updatedBy: userSub || undefined,
});
return res.data;
};
@@ -300,29 +245,20 @@ export const getProgrammeProjects = async (programmeId: string | number): Promis
export const createProgramme = async (payload: CreateProgrammeRequest): Promise<Programme> => {
const userSub = getCurrentUserSub();
let ownerId = payload.ownerId;
if (ownerId && typeof ownerId === 'string' && ownerId.includes('@')) {
ownerId = getUserIdByEmail(ownerId) || userSub || ownerId;
}
const res = await apiClient.post('/v1/api/projects/programme', {
portfolioId: toIntegerId(payload.portfolioId),
name: payload.name,
ownerId: ownerId || userSub || '00000000-0000-0000-0000-000000000000',
...payload,
ownerId: payload.ownerId || userSub || undefined,
createdBy: userSub || undefined,
});
return res.data;
};
export const updateProgramme = async (payload: UpdateProgrammeRequest): Promise<Programme> => {
const userSub = getCurrentUserSub();
let ownerId = payload.ownerId;
if (ownerId && typeof ownerId === 'string' && ownerId.includes('@')) {
ownerId = getUserIdByEmail(ownerId) || userSub || ownerId;
}
const res = await apiClient.put('/v1/api/projects/programme', {
programmeId: toIntegerId(payload.programmeId),
name: payload.name,
ownerId: ownerId || userSub || '00000000-0000-0000-0000-000000000000',
status: Number(payload.status) || 1,
...payload,
ownerId: payload.ownerId || userSub || undefined,
updatedBy: userSub || undefined,
});
return res.data;
};
@@ -341,40 +277,28 @@ export const getProject = async (projectId: string | number): Promise<Project> =
};
export const createProject = async (payload: CreateProjectRequest): Promise<Project> => {
const userSub = getCurrentUserSub();
const res = await apiClient.post('/v1/api/projects', {
programmeId: toIntegerId(payload.programmeId),
name: payload.name,
description: payload.description,
sector: Number(payload.sector),
projectType: Number(payload.projectType),
...payload,
createdBy: userSub || undefined,
});
return res.data;
};
export const updateProject = async (payload: UpdateProjectRequest): Promise<Project> => {
const startedAt = payload.startedAt
? (payload.startedAt.includes('T') ? payload.startedAt : new Date(payload.startedAt).toISOString())
: null;
const endedAt = payload.endedAt
? (payload.endedAt.includes('T') ? payload.endedAt : new Date(payload.endedAt).toISOString())
: null;
const userSub = getCurrentUserSub();
const res = await apiClient.put('/v1/api/projects', {
projectId: toIntegerId(payload.projectId),
name: payload.name,
description: payload.description,
startedAt,
endedAt,
sector: Number(payload.sector),
projectType: Number(payload.projectType),
...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', {
projectId: toIntegerId(payload.projectId),
status: Number(payload.status),
...payload,
updatedBy: userSub || undefined,
});
};
@@ -391,36 +315,36 @@ export const getActivity = async (activityId: string | number): Promise<Activity
};
export const createActivity = async (payload: CreateActivityRequest): Promise<Activity> => {
const performedAt = payload.performedAt
? (payload.performedAt.includes('T') ? payload.performedAt : new Date(payload.performedAt).toISOString())
: null;
const body = {
projectId: toIntegerId(payload.projectId),
milestoneId: toNullableIntegerId(payload.milestoneId),
category: payload.category || 'Operations',
amount: Number(payload.amount) || 0,
title: payload.title,
description: payload.description || '',
performedAt,
const userSub = getCurrentUserSub();
const body: any = {
...payload,
milestoneId:
payload.milestoneId !== undefined && payload.milestoneId !== null && payload.milestoneId !== ''
? Number(payload.milestoneId)
: null,
createdBy: userSub || undefined,
};
const res = await apiClient.post('/v1/api/projects/activities', body);
return res.data;
try {
const res = await apiClient.post('/v1/api/projects/activities', body);
return res.data;
} catch (err: any) {
if (err?.response?.status === 405) {
const res = await apiClient.put('/v1/api/projects/activities', body);
return res.data;
}
throw err;
}
};
export const updateActivity = async (payload: UpdateActivityRequest): Promise<Activity> => {
const performedAt = payload.performedAt
? (payload.performedAt.includes('T') ? payload.performedAt : new Date(payload.performedAt).toISOString())
: null;
const body = {
projectId: toIntegerId(payload.projectId),
milestoneId: toNullableIntegerId(payload.milestoneId),
category: payload.category || 'Operations',
amount: Number(payload.amount) || 0,
title: payload.title,
description: payload.description || '',
performedAt,
const userSub = getCurrentUserSub();
const body: any = {
...payload,
milestoneId:
payload.milestoneId !== undefined && payload.milestoneId !== null && payload.milestoneId !== ''
? Number(payload.milestoneId)
: null,
updatedBy: userSub || undefined,
};
try {
const res = await apiClient.put('/v1/api/projects/activities', body);
@@ -452,30 +376,19 @@ export const getMilestone = async (milestoneId: string | number): Promise<Milest
};
export const createMilestone = async (payload: CreateMilestoneRequest): Promise<Milestone> => {
const dueAtIso = payload.dueAt.includes('T')
? payload.dueAt
: new Date(payload.dueAt).toISOString();
const userSub = getCurrentUserSub();
const res = await apiClient.post('/v1/api/evidence/milestones', {
projectId: toIntegerId(payload.projectId),
dueAt: dueAtIso,
name: payload.name,
priority: Number(payload.priority) || 1,
...payload,
createdBy: userSub || undefined,
});
return res.data;
};
export const updateMilestone = async (payload: UpdateMilestoneRequest): Promise<void> => {
const dueAtIso = payload.dueAt.includes('T')
? payload.dueAt
: new Date(payload.dueAt).toISOString();
const userSub = getCurrentUserSub();
await apiClient.put('/v1/api/projects/milestone', {
milestoneId: toIntegerId(payload.milestoneId),
dueAt: dueAtIso,
name: payload.name,
priority: Number(payload.priority) || 1,
status: Number(payload.status) || 1,
...payload,
updatedBy: userSub || undefined,
});
};
@@ -509,16 +422,10 @@ export const getAward = async (awardId: string | number): Promise<Award> => {
};
export const createAward = async (payload: CreateAwardRequest): Promise<Award> => {
const tenantId = getCurrentUserTenantId();
const orgId =
payload.organisationId !== undefined && payload.organisationId !== '' && payload.organisationId !== null
? toIntegerId(payload.organisationId)
: (tenantId !== null ? tenantId : 0);
const userSub = getCurrentUserSub();
const res = await apiClient.post('/v1/api/grants/awards', {
organisationId: orgId,
programmeId: toIntegerId(payload.programmeId),
amount: Number(payload.amount),
...payload,
createdBy: userSub || undefined,
});
return res.data;
};
@@ -549,27 +456,12 @@ export const getContract = async (contractId: string | number): Promise<Contract
};
export const createContract = async (payload: CreateContractRequest): Promise<Contract> => {
// Ensure awardId is numeric int64 as required by OpenAPI schema (pattern: ^-?(?:0|[1-9]\d*)$)
let numericAwardId: number = Number(payload.awardId);
if (isNaN(numericAwardId) || !/^-?\d+$/.test(String(payload.awardId))) {
const decoded = toolsService.decodeIdSync(String(payload.awardId));
if (decoded && /^-?\d+$/.test(String(decoded))) {
numericAwardId = Number(decoded);
}
}
const effectiveAtIso = payload.effectiveAt.includes('T')
? payload.effectiveAt
: new Date(payload.effectiveAt).toISOString();
const expiresAtIso = payload.expiresAt.includes('T')
? payload.expiresAt
: new Date(payload.expiresAt).toISOString();
const userSub = getCurrentUserSub();
const hashedAwardId = await toolsService.resolveAwardId(payload.awardId);
const res = await apiClient.post('/v1/api/grants/contracts', {
awardId: !isNaN(numericAwardId) ? numericAwardId : payload.awardId,
totalValue: Number(payload.totalValue),
effectiveAt: effectiveAtIso,
expiresAt: expiresAtIso,
...payload,
awardId: hashedAwardId,
createdBy: userSub || undefined,
});
// Extract ID from headers (e.g. Location or Content-Location) if present
@@ -580,8 +472,6 @@ export const createContract = async (payload: CreateContractRequest): Promise<Co
headerId = parts[parts.length - 1];
}
const hashedAwardId = toolsService.encodeIdSync(numericAwardId || payload.awardId);
// Case 1: Backend returns string contractId/hash directly
if (typeof res.data === 'string' && res.data.trim().length > 0) {
const rawStr = res.data.trim();
@@ -589,9 +479,9 @@ export const createContract = async (payload: CreateContractRequest): Promise<Co
id: rawStr,
contractId: rawStr,
awardId: hashedAwardId,
totalValue: Number(payload.totalValue),
effectiveAt: effectiveAtIso,
expiresAt: expiresAtIso,
totalValue: payload.totalValue,
effectiveAt: payload.effectiveAt,
expiresAt: payload.expiresAt,
status: ContractStatus.Draft,
} as unknown as Contract;
}
@@ -620,9 +510,9 @@ export const createContract = async (payload: CreateContractRequest): Promise<Co
id: headerId,
contractId: headerId,
awardId: hashedAwardId,
totalValue: Number(payload.totalValue),
effectiveAt: effectiveAtIso,
expiresAt: expiresAtIso,
totalValue: payload.totalValue,
effectiveAt: payload.effectiveAt,
expiresAt: payload.expiresAt,
status: ContractStatus.Draft,
} as unknown as Contract;
}
@@ -633,7 +523,6 @@ export const createContract = async (payload: CreateContractRequest): Promise<Co
const matched = existing.find(
(c) =>
String(c.awardId) === String(hashedAwardId) ||
String(c.awardId) === String(numericAwardId) ||
String(c.awardId) === String(payload.awardId)
);
if (matched) return matched;
@@ -643,17 +532,18 @@ export const createContract = async (payload: CreateContractRequest): Promise<Co
id: hashedAwardId,
contractId: hashedAwardId,
awardId: hashedAwardId,
totalValue: Number(payload.totalValue),
effectiveAt: effectiveAtIso,
expiresAt: expiresAtIso,
totalValue: payload.totalValue,
effectiveAt: payload.effectiveAt,
expiresAt: payload.expiresAt,
status: ContractStatus.Draft,
}) as unknown as Contract;
};
export const updateContract = async (payload: UpdateContractRequest): Promise<void> => {
const hashedContractId = await toolsService.resolveContractId(payload.contractId);
await apiClient.put('/v1/api/grants/contracts', {
contractId: toIntegerId(payload.contractId),
status: Number(payload.status),
...payload,
contractId: hashedContractId,
});
};
@@ -681,10 +571,12 @@ export const getContractSecureLink = async (contractId: string | number): Promis
};
export const executeContract = async (payload: ExecuteContractRequest): Promise<void> => {
const userSub = getCurrentUserSub();
const hashedContractId = await toolsService.resolveContractId(payload.contractId);
await apiClient.post('/v1/api/grants/contracts/execute', {
contractId: toIntegerId(payload.contractId),
externalSignatureId: payload.externalSignatureId,
signedDocumentUrl: payload.signedDocumentUrl,
...payload,
contractId: hashedContractId,
executedBy: userSub || undefined,
});
};
@@ -702,10 +594,10 @@ export const getBudget = async (budgetId: string | number): Promise<Budget> => {
};
export const createBudget = async (payload: CreateBudgetRequest): Promise<Budget> => {
const userSub = getCurrentUserSub();
const res = await apiClient.post('/v1/api/grants/budgets', {
projectId: toIntegerId(payload.projectId),
name: payload.name,
amount: Number(payload.amount),
...payload,
createdBy: userSub || undefined,
});
return res.data;
};
@@ -727,25 +619,28 @@ export const getDisbursement = async (disbursementId: string | number): Promise<
};
export const createDisbursement = async (payload: CreateDisbursementRequest): Promise<Disbursement> => {
const userSub = getCurrentUserSub();
const res = await apiClient.post('/v1/api/execution/disbursements', {
projectId: toIntegerId(payload.projectId),
milestoneId: toNullableIntegerId(payload.milestoneId),
amount: Number(payload.amount),
...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', {
disbursementId: toIntegerId(payload.disbursementId),
milestoneId: toNullableIntegerId(payload.milestoneId),
amount: Number(payload.amount),
...payload,
updatedBy: userSub || undefined,
});
};
export const approveDisbursement = async (disbursementId: string | number): Promise<void> => {
const hashedDisbId = await toolsService.encodeId(disbursementId);
await apiClient.post(`/v1/api/execution/disbursements/${hashedDisbId}/approve`, {});
const userSub = getCurrentUserSub();
await apiClient.post(`/v1/api/execution/disbursements/${hashedDisbId}/approve`, {
approvedBy: userSub || undefined,
});
};
/**
@@ -811,23 +706,23 @@ export const getRisk = async (riskId: string | number): Promise<Risk> => {
};
export const createRisk = async (payload: CreateRiskRequest): Promise<Risk> => {
const userSub = getCurrentUserSub();
const res = await apiClient.post('/v1/api/execution/risks', {
projectId: toIntegerId(payload.projectId),
...payload,
likelihood: Number(payload.likelihood),
impact: Number(payload.impact),
name: payload.name,
description: payload.description || '',
createdBy: userSub || undefined,
});
return normalizeRisk(res.data);
};
export const updateRisk = async (payload: UpdateRiskRequest): Promise<void> => {
const userSub = getCurrentUserSub();
await apiClient.put('/v1/api/execution/risks', {
riskId: toIntegerId(payload.riskId),
...payload,
likelihood: Number(payload.likelihood),
impact: Number(payload.impact),
name: payload.name,
description: payload.description || '',
updatedBy: userSub || undefined,
});
};
@@ -846,19 +741,20 @@ export const getIndicator = async (indicatorId: string | number): Promise<Indica
};
export const createIndicator = async (payload: CreateIndicatorRequest): Promise<Indicator> => {
const userSub = getCurrentUserSub();
const res = await apiClient.post('/v1/api/evidence/indicators', {
projectId: toIntegerId(payload.projectId),
name: payload.name,
unitOfMeasure: Number(payload.unitOfMeasure) || 1,
baselineAmount: Number(payload.baselineAmount) || 0,
targetAmount: Number(payload.targetAmount) || 0,
...payload,
createdBy: userSub || undefined,
});
return res.data;
};
export const recordIndicatorActual = async (indicatorId: string | number, actualAmount: number): Promise<void> => {
const hashedIndId = await toolsService.encodeId(indicatorId);
await apiClient.post(`/v1/api/evidence/indicators/${hashedIndId}/actuals/${actualAmount}`, {});
const userSub = getCurrentUserSub();
await apiClient.post(`/v1/api/evidence/indicators/${hashedIndId}/actuals/${actualAmount}`, {
recordedBy: userSub || undefined,
});
};
export const getEvidenceFiles = async (page = 1, pageSize = 100): Promise<Evidence[]> => {
@@ -906,11 +802,10 @@ export const uploadEvidenceFile = async (formData: FormData): Promise<Evidence>
};
export const updateEvidence = async (payload: UpdateEvidenceRequest): Promise<void> => {
const userSub = getCurrentUserSub();
await apiClient.put('/v1/api/evidence', {
evidenceId: toIntegerId(payload.evidenceId),
version: Number(payload.version) || 1,
evidenceType: Number(payload.evidenceType),
status: Number(payload.status),
...payload,
updatedBy: userSub || undefined,
});
};
@@ -922,36 +817,7 @@ export const getUsers = async (
pageSize = 100,
filter: UserFilterRequest = {}
): Promise<User[]> => {
const tenantId = getCurrentUserTenantId();
// Unless otherwise specified by the user, ensure tenant_id is passed into endpoints where organisationId is required for system activities
const orgId =
filter.organisationId !== undefined
? (filter.organisationId !== null && filter.organisationId !== ''
? toIntegerId(filter.organisationId)
: null)
: tenantId;
const requestBody: Record<string, any> = {
...filter,
};
if (orgId !== null && orgId !== undefined) {
requestBody.organisationId = orgId;
}
// HashID query parameter for query string protection
const queryParams: Record<string, any> = {};
if (orgId !== null && orgId !== undefined) {
const hashedOrgId = await toolsService.encodeId(orgId);
if (hashedOrgId) {
queryParams.organisationId = hashedOrgId;
}
}
const res = await apiClient.post<User[]>(
`/v1/api/users/${page}/${pageSize}`,
requestBody,
{ params: Object.keys(queryParams).length > 0 ? queryParams : undefined }
);
const res = await apiClient.post<User[]>(`/v1/api/users/${page}/${pageSize}`, filter);
const list = res.data || [];
registerUserEmails(list);
return list;
@@ -1044,20 +910,10 @@ export const syncIntegration = async (force = true): Promise<void> => {
// 8. Communications & Invites APIs
// ==========================================
export const sendInvite = async (payload: InviteRequest): Promise<Invite> => {
const tenantId = getCurrentUserTenantId();
const hostOrgId =
payload.organisationId !== undefined && payload.organisationId !== '' && payload.organisationId !== null
? toIntegerId(payload.organisationId)
: (tenantId !== null ? tenantId : 0);
const userSub = getCurrentUserSub();
const res = await apiClient.post<Invite>('/v1/api/communications/invite', {
organisationId: hostOrgId,
invitedOrganisationId: toIntegerId(payload.invitedOrganisationId),
awardId: toIntegerId(payload.awardId),
contractId: toIntegerId(payload.contractId),
recipient: payload.recipient,
message: payload.message,
platform: payload.platform !== undefined ? Number(payload.platform) : 1,
...payload,
createdBy: userSub || undefined,
});
return res.data;
};
@@ -99,19 +99,11 @@ export function encodeHashIdSync(id: number | string | null | undefined): string
* Deterministically decodes a HashID string back to original numeric ID or string.
* Synchronous and does not perform HTTP network round trips.
*/
export function decodeHashIdSync(hashId: string | number | null | undefined): number | string {
if (hashId === undefined || hashId === null || hashId === '') return '';
if (typeof hashId === 'number') return hashId;
export function decodeHashIdSync(hashId: string | null | undefined): number | string {
if (!hashId) return '';
const str = String(hashId).trim();
if (!str) return '';
// If already pure integer string
if (/^-?\d+$/.test(str)) {
const parsed = parseInt(str, 10);
return isNaN(parsed) ? str : parsed;
}
try {
const decoded = hasher.decode(str);
if (Array.isArray(decoded) && decoded.length > 0) {
@@ -124,30 +116,6 @@ export function decodeHashIdSync(hashId: string | number | null | undefined): nu
return str;
}
/**
* Decodes a HashID or numeric value strictly to a canonical integer.
* Useful for request payloads expecting int64 integers.
*/
export function decodeToNumberSync(hashId: number | string | null | undefined): number {
if (hashId === null || hashId === undefined || hashId === '') return 0;
if (typeof hashId === 'number') {
return isNaN(hashId) ? 0 : Math.trunc(hashId);
}
const str = String(hashId).trim();
if (/^-?\d+$/.test(str)) {
const parsed = parseInt(str, 10);
return isNaN(parsed) ? 0 : parsed;
}
try {
const decoded = hasher.decode(str);
if (Array.isArray(decoded) && decoded.length > 0) {
return Number(decoded[0]);
}
} catch {}
const fallback = Number(str);
return isNaN(fallback) ? 0 : Math.trunc(fallback);
}
export const toolsService = {
/**
* Synchronous encoding
@@ -159,17 +127,10 @@ export const toolsService = {
/**
* Synchronous decoding
*/
decodeIdSync(hashId: string | number | null | undefined): number | string {
decodeIdSync(hashId: string | null | undefined): number | string {
return decodeHashIdSync(hashId);
},
/**
* Synchronous decoding strictly to canonical integer
*/
decodeToNumberSync(hashId: number | string | null | undefined): number {
return decodeToNumberSync(hashId);
},
/**
* Encodes a raw ID to a HashID (returns Promise for backward compatibility with existing callers)
*/
@@ -20,8 +20,6 @@ export interface UserSyncProfile {
name?: string | null;
role?: string | null;
tenant?: string | null;
tenant_id?: number | string | null;
organisationId?: number | string | null;
organisationName?: string | null;
}
@@ -201,19 +199,6 @@ async function executeBackendSync(
userSub?: string,
profile?: UserSyncProfile
): Promise<{ endpoint: string; data?: any }> {
const rawTenantId = profile?.tenant_id ?? (profile as any)?.tenantId;
const parsedTenantId =
rawTenantId !== undefined && rawTenantId !== null && rawTenantId !== ''
? Number(rawTenantId)
: undefined;
const resolvedOrgId =
parsedTenantId !== undefined
? parsedTenantId
: profile?.organisationId !== undefined && profile?.organisationId !== null && profile?.organisationId !== ''
? Number(profile.organisationId)
: undefined;
const payload = {
id: userSub || profile?.id || profile?.sub,
sub: userSub || profile?.sub,
@@ -221,8 +206,6 @@ async function executeBackendSync(
name: profile?.name || undefined,
role: profile?.role || undefined,
tenant: profile?.tenant || undefined,
tenant_id: parsedTenantId,
organisationId: resolvedOrgId,
};
const candidateEndpoints = USER_SYNC_CANDIDATE_ENDPOINTS;
@@ -261,8 +261,6 @@ export interface User {
name?: string;
organisationId?: number | string;
organisationName?: string;
tenant?: string;
tenant_id?: number;
}
export type SystemUser = User;
@@ -288,7 +286,6 @@ export interface UserFilterRequest {
email?: string | null;
sortBy?: string | null;
sortDescending?: boolean;
organisationId?: number | string | null;
}
export interface Organisation {
@@ -75,53 +75,12 @@ function humanizeFieldName(field: string): string {
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
/**
* Checks if a string contains raw HTTP request/response headers or dump.
*/
export function isRawHttpOutput(text: string): boolean {
if (!text || typeof text !== 'string') return false;
// If it has multiple header-like lines or standard HTTP request/response signatures
const headerPatterns = [
/^(?:accept|authorization|content-type|content-length|origin|referer|user-agent|sec-ch-ua|sec-fetch|x-forwarded|x-real-ip|host):/im,
/(?:authorization:\s*bearer|content-type:\s*application\/)/i,
/HTTP\/\d(?:\.\d)?\s+\d{3}/i,
];
let matches = 0;
const lines = text.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (/^[A-Za-z0-9-]+:\s*.+$/.test(trimmed)) {
if (headerPatterns.some((p) => p.test(trimmed))) {
matches++;
}
}
}
return matches >= 2 || (matches >= 1 && /(?:authorization:\s*bearer|content-type:\s*application)/i.test(text));
}
/**
* Redacts sensitive tokens and credentials from technical traces.
*/
export function sanitizeTechnicalDetails(text: string): string {
if (!text) return '';
return text
.replace(/(authorization:\s*bearer\s+)[a-zA-Z0-9._-]+/gi, '$1[REDACTED_TOKEN]')
.replace(/(bearer\s+)[a-zA-Z0-9._-]+/gi, '$1[REDACTED_TOKEN]')
.replace(/("?(?:token|access_token|password|secret|apiKey)"?\s*[:=]\s*)"[^"]+"/gi, '$1"[REDACTED]"');
}
/**
* Cleans technical jargon, stack traces, and framework namespaces from text strings.
*/
function cleanTechnicalJargon(text: string): string {
if (!text) return '';
// If text is a raw HTTP dump, do not return it as clean user text
if (isRawHttpOutput(text)) {
return '';
}
const cleaned = text
.replace(/^AxiosError:\s*/i, '')
.replace(/^Error:\s*/i, '')
@@ -246,28 +205,23 @@ export function parseFriendlyError(
rawErrors = dataObj;
}
} else if (typeof rawResponseData === 'string') {
if (isRawHttpOutput(rawResponseData)) {
technicalSummary = sanitizeTechnicalDetails(rawResponseData);
rawDetail = undefined;
} else {
// Check if response data is stringified JSON containing ProblemDetails
try {
const parsed = JSON.parse(rawResponseData) as any;
if (isProblemDetailsObject(parsed)) {
isProblemDetails = true;
rawTitle = parsed.title || parsed.Title || undefined;
rawDetail = parsed.detail || parsed.Detail || undefined;
statusCode = parsed.status || parsed.Status || statusCode;
rawErrors = parsed.errors ?? parsed.Errors ?? parsed.errorList ?? parsed.ErrorList;
} else if (Array.isArray(parsed)) {
rawErrors = parsed;
} else {
rawDetail = cleanTechnicalJargon(rawResponseData);
}
} catch {
if (!rawResponseData.includes('<html')) {
rawDetail = cleanTechnicalJargon(rawResponseData);
}
// Check if response data is stringified JSON containing ProblemDetails
try {
const parsed = JSON.parse(rawResponseData) as any;
if (isProblemDetailsObject(parsed)) {
isProblemDetails = true;
rawTitle = parsed.title || parsed.Title || undefined;
rawDetail = parsed.detail || parsed.Detail || undefined;
statusCode = parsed.status || parsed.Status || statusCode;
rawErrors = parsed.errors ?? parsed.Errors ?? parsed.errorList ?? parsed.ErrorList;
} else if (Array.isArray(parsed)) {
rawErrors = parsed;
} else {
rawDetail = cleanTechnicalJargon(rawResponseData);
}
} catch {
if (!rawResponseData.includes('<html')) {
rawDetail = cleanTechnicalJargon(rawResponseData);
}
}
}
@@ -527,6 +481,6 @@ export function parseFriendlyError(
categoryLabel,
fieldErrors: fieldErrors.length > 0 ? fieldErrors : undefined,
errorsList: errorsList.length > 0 ? errorsList : undefined,
technicalDetails: technicalSummary ? sanitizeTechnicalDetails(technicalSummary) : (statusCode ? `HTTP Status ${statusCode}` : undefined),
technicalDetails: technicalSummary || (statusCode ? `HTTP Status ${statusCode}` : undefined),
};
}