Compare commits
85
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c0fd8844c | ||
|
|
b7c135f0fc | ||
|
|
db053e3e5f | ||
|
|
d7177e1a72 | ||
|
|
c67041faea | ||
|
|
0e8a5e1169 | ||
|
|
41fdb067fe | ||
|
|
b4b687dfcc | ||
|
|
f200ccbf60 | ||
|
|
27f57150e1 | ||
|
|
79c63107e9 | ||
|
|
d945731252 | ||
|
|
c979a4632e | ||
|
|
9eed3cff6b | ||
|
|
6db1a2f367 | ||
|
|
06d9952cd7 | ||
|
|
92d481f75d | ||
|
|
1c36602995 | ||
|
|
5ae0c500ac | ||
|
|
db04f800e9 | ||
|
|
e7a19611f0 | ||
|
|
5ad24da7cf | ||
|
|
156dae40b7 | ||
|
|
1b67d9cf8b | ||
|
|
1bd05e64e5 | ||
|
|
303dba3c6f | ||
|
|
1dd4f54a1d | ||
|
|
fae1e080c4 | ||
|
|
4de23d27b3 | ||
|
|
d4bce12dbb | ||
|
|
299b876e1c | ||
|
|
dde1dbe056 | ||
|
|
b81e6feb45 | ||
|
|
4138a1941f | ||
|
|
17af31d810 | ||
|
|
b85cf6a1cb | ||
|
|
81ddc11746 | ||
|
|
5b94672ff5 | ||
|
|
c535392ef1 | ||
|
|
0607de1802 | ||
|
|
4c1e52e452 | ||
|
|
2414d74d1c | ||
|
|
93d9661a7f | ||
|
|
dba401fe94 | ||
|
|
42d73fe6cb | ||
|
|
1475887a7f | ||
|
|
730664d9c4 | ||
|
|
f38e569a34 | ||
|
|
7e79171f97 | ||
|
|
d7b868f2c1 | ||
|
|
d43a09b371 | ||
|
|
0dc0de1502 | ||
|
|
de47dd4a4f | ||
|
|
2810cc669b | ||
|
|
734af8c86f | ||
|
|
a754c42d14 | ||
|
|
bd7ca9372f | ||
|
|
0d01d91ab4 | ||
|
|
2c817a8818 | ||
|
|
4c3a96917b | ||
|
|
e5c3a75795 | ||
|
|
1bc7e65960 | ||
|
|
094126d84b | ||
|
|
2506241a25 | ||
|
|
cfe3530639 | ||
|
|
985557bee4 | ||
|
|
f0b8b3051a | ||
|
|
6c88b9e533 | ||
|
|
7d67408d2c | ||
|
|
d1d58a6183 | ||
|
|
60ad154841 | ||
|
|
2c13170859 | ||
|
|
d87c8aa3c9 | ||
|
|
6af7815ad1 | ||
|
|
a169f6c3f6 | ||
|
|
65ea753c19 | ||
|
|
e6d2663265 | ||
|
|
91b5814b60 | ||
|
|
0c4c848568 | ||
|
|
fb05150ebf | ||
|
|
2f9c20020b | ||
|
|
a771957db3 | ||
|
|
46267a2a34 | ||
|
|
a7f360cc2a | ||
|
|
67c08b40ca |
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,6 @@ import {
|
|||||||
} from '../services/userSyncService';
|
} from '../services/userSyncService';
|
||||||
import { AccountActivationModal } from '../components/auth/AccountActivationModal';
|
import { AccountActivationModal } from '../components/auth/AccountActivationModal';
|
||||||
import { SessionExpiredModal } from '../components/auth/SessionExpiredModal';
|
import { SessionExpiredModal } from '../components/auth/SessionExpiredModal';
|
||||||
import { encodeHashIdSync } from '../services/toolsService';
|
|
||||||
import {
|
import {
|
||||||
onSessionExpired,
|
onSessionExpired,
|
||||||
triggerSessionExpired,
|
triggerSessionExpired,
|
||||||
@@ -31,7 +30,6 @@ export interface AuthUserProfile {
|
|||||||
role: string;
|
role: string;
|
||||||
organisationName?: string;
|
organisationName?: string;
|
||||||
tenant?: string;
|
tenant?: string;
|
||||||
tenant_id?: number;
|
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,8 +39,6 @@ interface AuthContextType {
|
|||||||
user: AuthUserProfile | null;
|
user: AuthUserProfile | null;
|
||||||
sub: string;
|
sub: string;
|
||||||
claims: UserClaims;
|
claims: UserClaims;
|
||||||
tenantId: number | null;
|
|
||||||
hashedTenantId: string | null;
|
|
||||||
accessToken: string | null;
|
accessToken: string | null;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
isLoading: 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_SUB_KEY = 'pfm_user_sub';
|
||||||
export const LOCAL_STORAGE_CLAIMS_KEY = 'pfm_user_claims';
|
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 {
|
export function getCurrentUserSub(): string {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
return localStorage.getItem(LOCAL_STORAGE_SUB_KEY) || '';
|
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 [user, setUser] = useState<AuthUserProfile | null>(null);
|
||||||
const [sub, setSub] = useState<string>('');
|
const [sub, setSub] = useState<string>('');
|
||||||
const [claims, setClaims] = useState<UserClaims>({});
|
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 [accessToken, setAccessToken] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
const [isSynced, setIsSynced] = useState<boolean>(() => isUserSyncedInBrowser());
|
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 [isSessionExpired, setIsSessionExpired] = useState<boolean>(false);
|
||||||
const [sessionExpiredReason, setSessionExpiredReason] = useState<string | null>(null);
|
const [sessionExpiredReason, setSessionExpiredReason] = useState<string | null>(null);
|
||||||
|
|
||||||
const hashedTenantId = tenantId !== null && tenantId !== undefined ? encodeHashIdSync(tenantId) : null;
|
|
||||||
|
|
||||||
// Subscribe to global HTTP 401 session expiration events
|
// Subscribe to global HTTP 401 session expiration events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = onSessionExpired((reason) => {
|
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));
|
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(mergedClaims));
|
||||||
|
|
||||||
const tenantClaim = (mergedClaims.tenant as string) || (oidcUser.profile?.tenant as string) || undefined;
|
const tenantClaim = (mergedClaims.tenant as string) || (oidcUser.profile?.tenant as string) || undefined;
|
||||||
const parsedTenantId = extractTenantId(mergedClaims, token);
|
|
||||||
setTenantId(parsedTenantId);
|
|
||||||
|
|
||||||
const profile: AuthUserProfile = {
|
const profile: AuthUserProfile = {
|
||||||
id: userSub || 'user-1',
|
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'),
|
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,
|
organisationName: (mergedClaims.organisation as string) || (mergedClaims.org as string) || undefined,
|
||||||
tenant: tenantClaim,
|
tenant: tenantClaim,
|
||||||
tenant_id: parsedTenantId ?? undefined,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
setUser(profile);
|
setUser(profile);
|
||||||
@@ -365,9 +281,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
setSub(storedSub);
|
setSub(storedSub);
|
||||||
}
|
}
|
||||||
|
|
||||||
const storedTenantId = extractTenantId(parsedClaims, storedToken);
|
|
||||||
setTenantId(storedTenantId);
|
|
||||||
|
|
||||||
let resolvedUser: AuthUserProfile | null = null;
|
let resolvedUser: AuthUserProfile | null = null;
|
||||||
if (storedUser) {
|
if (storedUser) {
|
||||||
try {
|
try {
|
||||||
@@ -375,9 +288,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
if (!parsedUser.tenant && parsedClaims.tenant) {
|
if (!parsedUser.tenant && parsedClaims.tenant) {
|
||||||
parsedUser.tenant = parsedClaims.tenant;
|
parsedUser.tenant = parsedClaims.tenant;
|
||||||
}
|
}
|
||||||
if (parsedUser.tenant_id === undefined && storedTenantId !== null) {
|
|
||||||
parsedUser.tenant_id = storedTenantId;
|
|
||||||
}
|
|
||||||
setUser(parsedUser);
|
setUser(parsedUser);
|
||||||
resolvedUser = parsedUser;
|
resolvedUser = parsedUser;
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -492,7 +402,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
setUser(null);
|
setUser(null);
|
||||||
setSub('');
|
setSub('');
|
||||||
setClaims({});
|
setClaims({});
|
||||||
setTenantId(null);
|
|
||||||
setAccessToken(null);
|
setAccessToken(null);
|
||||||
setIsSynced(false);
|
setIsSynced(false);
|
||||||
setIsPendingActivation(false);
|
setIsPendingActivation(false);
|
||||||
@@ -515,8 +424,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
user,
|
user,
|
||||||
sub,
|
sub,
|
||||||
claims,
|
claims,
|
||||||
tenantId,
|
|
||||||
hashedTenantId,
|
|
||||||
accessToken,
|
accessToken,
|
||||||
isAuthenticated: !!accessToken,
|
isAuthenticated: !!accessToken,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const navItems = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const AppLayout: React.FC = () => {
|
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 [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -80,12 +80,8 @@ export const AppLayout: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Ensure current logged-in user is registered in the email cache
|
// 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 && (user?.email || claims.email)) {
|
||||||
if (sub && effectiveEmail) {
|
registerUserEmail(sub, ((user?.email || claims.email) as string));
|
||||||
registerUserEmail(sub, effectiveEmail);
|
|
||||||
}
|
|
||||||
if (user?.id && effectiveEmail) {
|
|
||||||
registerUserEmail(user.id, effectiveEmail);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract name and email directly from validated token claims
|
// Extract name and email directly from validated token claims
|
||||||
@@ -152,20 +148,31 @@ export const AppLayout: React.FC = () => {
|
|||||||
{navItems.map((item) => {
|
{navItems.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<div key={item.path} className="flex items-center justify-between group">
|
||||||
key={item.path}
|
<NavLink
|
||||||
to={item.path}
|
to={item.path}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all min-w-0 ${
|
`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
|
isActive
|
||||||
? 'bg-gradient-to-r from-blue-600 to-indigo-600 text-white shadow-md shadow-blue-600/25 font-semibold'
|
? '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'
|
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Icon className="w-4 h-4 shrink-0" />
|
<Icon className="w-4 h-4 shrink-0" />
|
||||||
{sidebarOpen && <span className="truncate">{item.name}</span>}
|
{sidebarOpen && <span className="truncate">{item.name}</span>}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div className="pl-1">
|
||||||
|
<HelpTooltip
|
||||||
|
title={item.name}
|
||||||
|
content={item.help}
|
||||||
|
size="sm"
|
||||||
|
placement="right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -203,6 +210,21 @@ export const AppLayout: React.FC = () => {
|
|||||||
{/* Top Header */}
|
{/* 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">
|
<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">
|
<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 */}
|
{/* Home Navigation */}
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
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">
|
<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="text-slate-400">Tenant:</span>
|
||||||
<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"
|
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}${hashedTenantId ? ` (Partition: ${hashedTenantId})` : ''}`}
|
title={tenantName}
|
||||||
>
|
>
|
||||||
<Database className="w-3.5 h-3.5 text-blue-400 shrink-0" />
|
<Database className="w-3.5 h-3.5 text-blue-400 shrink-0" />
|
||||||
<span className="truncate">{tenantName}</span>
|
<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>
|
</span>
|
||||||
<HelpTooltip
|
<HelpTooltip
|
||||||
title="Tenant Context"
|
title="Tenant Context"
|
||||||
content={`Identifies the multi-tenant organization partition and database boundary within the national PFM environment.${
|
content="Identifies the multi-tenant organization partition and database boundary within the national PFM environment."
|
||||||
hashedTenantId ? ` Tenant Partition (HashID): ${hashedTenantId}` : ''
|
|
||||||
}`}
|
|
||||||
size="sm"
|
size="sm"
|
||||||
placement="bottom"
|
placement="bottom"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -797,8 +797,9 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
|||||||
{filteredAwardsForDropdown.map((award) => {
|
{filteredAwardsForDropdown.map((award) => {
|
||||||
const org = getOrganisationForAward(award);
|
const org = getOrganisationForAward(award);
|
||||||
const isApproved = Number(award.status) === ApprovalStatus.Approved;
|
const isApproved = Number(award.status) === ApprovalStatus.Approved;
|
||||||
|
const hashId = toolsService.encodeIdSync(award.id);
|
||||||
return (
|
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})` : ''} —{' '}
|
Award #{award.id} {award.referenceNo ? `(${award.referenceNo})` : ''} —{' '}
|
||||||
{org?.name || 'Organisation'} • {formatZAR(award.amount)}
|
{org?.name || 'Organisation'} • {formatZAR(award.amount)}
|
||||||
{!isApproved ? ` [Status: ${getApprovalStatusBadge(award.status).label}]` : ' [Approved]'}
|
{!isApproved ? ` [Status: ${getApprovalStatusBadge(award.status).label}]` : ' [Approved]'}
|
||||||
@@ -807,7 +808,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
|||||||
})}
|
})}
|
||||||
</select>
|
</select>
|
||||||
<p className="text-[11px] text-slate-500 mt-1">
|
<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>
|
</p>
|
||||||
</div>
|
</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 React, { useState, useRef, useEffect, ReactNode } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
import { Info, X } from 'lucide-react';
|
import { Info, X } from 'lucide-react';
|
||||||
|
|
||||||
export interface HelpTooltipProps {
|
export interface HelpTooltipProps {
|
||||||
@@ -26,118 +25,14 @@ export const HelpTooltip: React.FC<HelpTooltipProps> = ({
|
|||||||
ariaLabel = 'Information and guidance',
|
ariaLabel = 'Information and guidance',
|
||||||
}) => {
|
}) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [coords, setCoords] = useState<{ top: number; left: number } | null>(null);
|
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
||||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const updatePosition = useCallback(() => {
|
// Close on Escape or click outside
|
||||||
if (!buttonRef.current) return;
|
|
||||||
const triggerRect = buttonRef.current.getBoundingClientRect();
|
|
||||||
const tooltipEl = tooltipRef.current;
|
|
||||||
const tooltipWidth = tooltipEl ? tooltipEl.offsetWidth : 288;
|
|
||||||
const tooltipHeight = tooltipEl ? tooltipEl.offsetHeight : 120;
|
|
||||||
|
|
||||||
const viewportWidth = window.innerWidth;
|
|
||||||
const viewportHeight = window.innerHeight;
|
|
||||||
const PADDING = 12;
|
|
||||||
const GAP = 8;
|
|
||||||
|
|
||||||
const spaceAbove = triggerRect.top - GAP - PADDING;
|
|
||||||
const spaceBelow = viewportHeight - triggerRect.bottom - GAP - PADDING;
|
|
||||||
const spaceLeft = triggerRect.left - GAP - PADDING;
|
|
||||||
const spaceRight = viewportWidth - triggerRect.right - GAP - PADDING;
|
|
||||||
|
|
||||||
let chosenPlacement = placement;
|
|
||||||
|
|
||||||
if (placement === 'bottom') {
|
|
||||||
if (spaceBelow < tooltipHeight && spaceAbove > spaceBelow) {
|
|
||||||
chosenPlacement = 'top';
|
|
||||||
}
|
|
||||||
} else if (placement === 'top') {
|
|
||||||
if (spaceAbove < tooltipHeight && spaceBelow > spaceAbove) {
|
|
||||||
chosenPlacement = 'bottom';
|
|
||||||
}
|
|
||||||
} else if (placement === 'left') {
|
|
||||||
if (spaceLeft < tooltipWidth && spaceRight > spaceLeft) {
|
|
||||||
chosenPlacement = 'right';
|
|
||||||
}
|
|
||||||
} else if (placement === 'right') {
|
|
||||||
if (spaceRight < tooltipWidth && spaceLeft > spaceRight) {
|
|
||||||
chosenPlacement = 'left';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let top = 0;
|
|
||||||
let left = 0;
|
|
||||||
|
|
||||||
if (chosenPlacement === 'top') {
|
|
||||||
top = triggerRect.top - tooltipHeight - GAP;
|
|
||||||
left = triggerRect.left + triggerRect.width / 2 - tooltipWidth / 2;
|
|
||||||
} else if (chosenPlacement === 'bottom') {
|
|
||||||
top = triggerRect.bottom + GAP;
|
|
||||||
left = triggerRect.left + triggerRect.width / 2 - tooltipWidth / 2;
|
|
||||||
} else if (chosenPlacement === 'left') {
|
|
||||||
top = triggerRect.top + triggerRect.height / 2 - tooltipHeight / 2;
|
|
||||||
left = triggerRect.left - tooltipWidth - GAP;
|
|
||||||
} else {
|
|
||||||
// right
|
|
||||||
top = triggerRect.top + triggerRect.height / 2 - tooltipHeight / 2;
|
|
||||||
left = triggerRect.right + GAP;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Viewport boundary clamping to guarantee tooltip is never cropped by screen edges
|
|
||||||
const maxLeft = Math.max(PADDING, viewportWidth - tooltipWidth - PADDING);
|
|
||||||
left = Math.max(PADDING, Math.min(left, maxLeft));
|
|
||||||
|
|
||||||
const maxTop = Math.max(PADDING, viewportHeight - tooltipHeight - PADDING);
|
|
||||||
top = Math.max(PADDING, Math.min(top, maxTop));
|
|
||||||
|
|
||||||
setCoords({ top, left });
|
|
||||||
}, [placement]);
|
|
||||||
|
|
||||||
// Position updates on open, resize, or scroll of any container
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (!isOpen) {
|
|
||||||
setCoords(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
updatePosition();
|
|
||||||
|
|
||||||
let resizeObserver: ResizeObserver | null = null;
|
|
||||||
if (typeof ResizeObserver !== 'undefined' && tooltipRef.current) {
|
|
||||||
resizeObserver = new ResizeObserver(() => {
|
|
||||||
updatePosition();
|
|
||||||
});
|
|
||||||
resizeObserver.observe(tooltipRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('resize', updatePosition, { passive: true });
|
|
||||||
window.addEventListener('scroll', updatePosition, { capture: true, passive: true });
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (resizeObserver) {
|
|
||||||
resizeObserver.disconnect();
|
|
||||||
}
|
|
||||||
window.removeEventListener('resize', updatePosition);
|
|
||||||
window.removeEventListener('scroll', updatePosition, true);
|
|
||||||
};
|
|
||||||
}, [isOpen, updatePosition]);
|
|
||||||
|
|
||||||
// Close on Escape or click outside both button and tooltip
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
|
|
||||||
const handleOutsideClick = (e: MouseEvent | TouchEvent) => {
|
const handleOutsideClick = (e: MouseEvent | TouchEvent) => {
|
||||||
const target = e.target as Node;
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
if (
|
|
||||||
buttonRef.current &&
|
|
||||||
!buttonRef.current.contains(target) &&
|
|
||||||
tooltipRef.current &&
|
|
||||||
!tooltipRef.current.contains(target)
|
|
||||||
) {
|
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -171,53 +66,16 @@ export const HelpTooltip: React.FC<HelpTooltipProps> = ({
|
|||||||
lg: 'w-4 h-4',
|
lg: 'w-4 h-4',
|
||||||
}[size];
|
}[size];
|
||||||
|
|
||||||
const tooltipPortal =
|
const placementClasses = {
|
||||||
isOpen && typeof document !== 'undefined'
|
top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
|
||||||
? createPortal(
|
bottom: 'top-full left-1/2 -translate-x-1/2 mt-2',
|
||||||
<div
|
left: 'right-full top-1/2 -translate-y-1/2 mr-2',
|
||||||
ref={tooltipRef}
|
right: 'left-full top-1/2 -translate-y-1/2 ml-2',
|
||||||
role="tooltip"
|
}[placement];
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
top: coords ? `${coords.top}px` : '-9999px',
|
|
||||||
left: coords ? `${coords.left}px` : '-9999px',
|
|
||||||
visibility: coords ? 'visible' : 'hidden',
|
|
||||||
}}
|
|
||||||
className="z-[9999] w-72 max-w-[calc(100vw-24px)] p-3.5 rounded-xl bg-[#0c121e] border border-blue-500/30 text-slate-200 shadow-2xl backdrop-blur-md animate-in fade-in zoom-in-95 duration-150"
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-2 border-b border-slate-800/80 pb-1.5 mb-2">
|
|
||||||
<div className="flex items-center gap-1.5 text-blue-400">
|
|
||||||
<Info className="w-3.5 h-3.5 shrink-0" />
|
|
||||||
<span className="font-semibold text-xs tracking-tight text-white">
|
|
||||||
{title || 'Guidance & Information'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsOpen(false);
|
|
||||||
}}
|
|
||||||
className="rounded-md p-0.5 text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
|
||||||
aria-label="Close tooltip"
|
|
||||||
>
|
|
||||||
<X className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-[11px] leading-relaxed text-slate-300 font-normal space-y-1">
|
|
||||||
{content}
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className={`relative inline-flex items-center align-middle ${className}`}>
|
<div ref={containerRef} className={`relative inline-flex items-center align-middle ${className}`}>
|
||||||
<button
|
<button
|
||||||
ref={buttonRef}
|
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
@@ -231,7 +89,37 @@ export const HelpTooltip: React.FC<HelpTooltipProps> = ({
|
|||||||
<Info className={`${iconSizes} shrink-0`} />
|
<Info className={`${iconSizes} shrink-0`} />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,28 +43,24 @@ export const ProblemDetailsAlert: React.FC<ProblemDetailsAlertProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Direct error message or error list from ProblemDetails */}
|
{/* Single error or fallback message displayed directly */}
|
||||||
{parsed.errorsList && parsed.errorsList.length > 0 ? (
|
{(!parsed.errorsList || parsed.errorsList.length <= 1) && parsed.message && (
|
||||||
<div className="mt-2 space-y-1.5">
|
|
||||||
{parsed.message && !parsed.errorsList.includes(parsed.message) && (
|
|
||||||
<p className="text-xs text-rose-200/90 leading-relaxed whitespace-pre-line">
|
|
||||||
{parsed.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<ul className="space-y-1 bg-black/30 rounded-lg p-2.5 border border-rose-900/40">
|
|
||||||
{parsed.errorsList.map((err, idx) => (
|
|
||||||
<li key={idx} className="text-xs text-rose-200/90 flex items-start gap-2">
|
|
||||||
<span className="text-rose-400 mt-0.5 font-bold">•</span>
|
|
||||||
<span className="leading-snug">{err}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
) : parsed.message ? (
|
|
||||||
<p className="text-xs mt-1.5 text-rose-200/90 leading-relaxed whitespace-pre-line">
|
<p className="text-xs mt-1.5 text-rose-200/90 leading-relaxed whitespace-pre-line">
|
||||||
{parsed.message}
|
{parsed.message}
|
||||||
</p>
|
</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 && (
|
{parsed.technicalDetails && (
|
||||||
<p className="text-[10px] font-mono text-slate-500 mt-2">
|
<p className="text-[10px] font-mono text-slate-500 mt-2">
|
||||||
|
|||||||
@@ -155,21 +155,16 @@ const ToastCard: React.FC<{
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Display errors from ProblemDetails errors member */}
|
{/* Display errors directly if multiple, or single message directly */}
|
||||||
{toast.errorsList && toast.errorsList.length > 0 ? (
|
{toast.errorsList && toast.errorsList.length > 1 ? (
|
||||||
<div className="mt-1.5 space-y-1">
|
<ul className="mt-1.5 space-y-1">
|
||||||
{toast.message && !toast.errorsList.includes(toast.message) && (
|
{toast.errorsList.map((err, idx) => (
|
||||||
<p className="text-xs text-slate-300 leading-relaxed">{toast.message}</p>
|
<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>
|
||||||
<ul className="space-y-1">
|
<span className="leading-snug break-words">{err}</span>
|
||||||
{toast.errorsList.map((err, idx) => (
|
</li>
|
||||||
<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>
|
</ul>
|
||||||
<span className="leading-snug break-words">{err}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
) : toast.message ? (
|
) : toast.message ? (
|
||||||
<p className="text-xs mt-1.5 text-slate-300 leading-relaxed whitespace-pre-line break-words">
|
<p className="text-xs mt-1.5 text-slate-300 leading-relaxed whitespace-pre-line break-words">
|
||||||
{toast.message}
|
{toast.message}
|
||||||
|
|||||||
@@ -512,7 +512,6 @@ export const getRiskScoreColor = (likelihood: RiskLikelihood | number, impact: R
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
const USER_EMAIL_CACHE: Record<string, string> = {};
|
const USER_EMAIL_CACHE: Record<string, string> = {};
|
||||||
const EMAIL_TO_USER_ID_CACHE: Record<string, string> = {};
|
|
||||||
|
|
||||||
export const registerUserEmail = (userId: string | number, email: string) => {
|
export const registerUserEmail = (userId: string | number, email: string) => {
|
||||||
if (userId && email && typeof email === 'string' && email.trim()) {
|
if (userId && email && typeof email === 'string' && email.trim()) {
|
||||||
@@ -520,7 +519,6 @@ export const registerUserEmail = (userId: string | number, email: string) => {
|
|||||||
const cleanEmail = email.trim();
|
const cleanEmail = email.trim();
|
||||||
USER_EMAIL_CACHE[cleanId] = cleanEmail;
|
USER_EMAIL_CACHE[cleanId] = cleanEmail;
|
||||||
USER_EMAIL_CACHE[cleanId.toLowerCase()] = 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 => {
|
export const getUserEmail = (userIdOrEmail?: string | number | null): string => {
|
||||||
if (!userIdOrEmail) return '—';
|
if (!userIdOrEmail) return '—';
|
||||||
const str = String(userIdOrEmail).trim();
|
const str = String(userIdOrEmail).trim();
|
||||||
@@ -545,28 +538,5 @@ export const getUserEmail = (userIdOrEmail?: string | number | null): string =>
|
|||||||
if (str.includes('@')) return str;
|
if (str.includes('@')) return str;
|
||||||
if (USER_EMAIL_CACHE[str]) return USER_EMAIL_CACHE[str];
|
if (USER_EMAIL_CACHE[str]) return USER_EMAIL_CACHE[str];
|
||||||
if (USER_EMAIL_CACHE[str.toLowerCase()]) return USER_EMAIL_CACHE[str.toLowerCase()];
|
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;
|
return str;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
Clock,
|
Clock,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
RotateCcw,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
getDashboard,
|
getDashboard,
|
||||||
@@ -37,18 +36,6 @@ export const ExecutiveDashboard: React.FC = () => {
|
|||||||
const [selectedSector, setSelectedSector] = useState('All');
|
const [selectedSector, setSelectedSector] = useState('All');
|
||||||
const [selectedPeriod, setSelectedPeriod] = useState('FY 2026/27');
|
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
|
// Data queries
|
||||||
const { data: dashboard, isLoading: loadingDashboard, refetch: refetchDashboard, isFetching } = useQuery({
|
const { data: dashboard, isLoading: loadingDashboard, refetch: refetchDashboard, isFetching } = useQuery({
|
||||||
queryKey: ['dashboard'],
|
queryKey: ['dashboard'],
|
||||||
@@ -331,79 +318,41 @@ export const ExecutiveDashboard: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-7xl mx-auto">
|
<div className="space-y-6 max-w-7xl mx-auto">
|
||||||
{/* Page Title & Filter Bar */}
|
{/* 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">
|
<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">
|
||||||
{/* Top Tier: Title, Status Badge, and Primary Actions */}
|
<div>
|
||||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
|
<div className="flex items-center gap-2">
|
||||||
<div>
|
<h2 className="text-xl font-bold text-white tracking-tight">
|
||||||
<div className="flex items-center gap-2.5 flex-wrap">
|
Executive Portfolio Dashboard
|
||||||
<h2 className="text-xl sm:text-2xl font-bold text-white tracking-tight">
|
</h2>
|
||||||
Executive Portfolio Dashboard
|
<HelpTooltip
|
||||||
</h2>
|
title="Executive Dashboard Overview"
|
||||||
<HelpTooltip
|
content="Macro-level command center showing aggregated national portfolio commitments, actual tranche disbursements, delivery pace, and critical project escalations."
|
||||||
title="Executive Dashboard Overview"
|
size="sm"
|
||||||
content="Macro-level command center showing aggregated national portfolio commitments, actual tranche disbursements, delivery pace, and critical project escalations."
|
placement="bottom"
|
||||||
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 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>
|
||||||
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Dedicated Filter Toolbar */}
|
{/* Filters */}
|
||||||
<div className="pt-4 border-t border-slate-800/80 flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div className="flex items-center gap-2 text-xs font-semibold text-slate-300 pr-1">
|
<div className="flex items-center gap-1.5 text-xs text-slate-400">
|
||||||
<Filter className="w-3.5 h-3.5 text-blue-400" />
|
<Filter className="w-3.5 h-3.5 text-slate-500" />
|
||||||
<span>Filters:</span>
|
<span>Filter:</span>
|
||||||
{activeFilterCount > 0 && (
|
|
||||||
<span className="text-[10px] font-bold text-blue-300 bg-blue-500/20 border border-blue-500/40 px-2 py-0.5 rounded-full">
|
|
||||||
{activeFilterCount} Active
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Programme Filter */}
|
<div className="flex items-center gap-1">
|
||||||
<div className="flex items-center bg-[#090d16] border border-slate-700/80 hover:border-slate-600 rounded-xl px-3 py-1.5 text-xs transition shadow-inner">
|
|
||||||
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider mr-2 shrink-0">
|
|
||||||
Programme
|
|
||||||
</span>
|
|
||||||
<select
|
<select
|
||||||
value={selectedProgramme}
|
value={selectedProgramme}
|
||||||
onChange={(e) => setSelectedProgramme(e.target.value)}
|
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) => (
|
{programmes.map((pr) => (
|
||||||
<option key={pr.id} value={pr.id}>
|
<option key={pr.id} value={pr.id}>
|
||||||
{pr.name}
|
{pr.name}
|
||||||
@@ -418,17 +367,13 @@ export const ExecutiveDashboard: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sector Filter */}
|
<div className="flex items-center gap-1">
|
||||||
<div className="flex items-center bg-[#090d16] border border-slate-700/80 hover:border-slate-600 rounded-xl px-3 py-1.5 text-xs transition shadow-inner">
|
|
||||||
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider mr-2 shrink-0">
|
|
||||||
Sector
|
|
||||||
</span>
|
|
||||||
<select
|
<select
|
||||||
value={selectedSector}
|
value={selectedSector}
|
||||||
onChange={(e) => setSelectedSector(e.target.value)}
|
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) => (
|
{sectorData.map((s) => (
|
||||||
<option key={s.name} value={s.name}>
|
<option key={s.name} value={s.name}>
|
||||||
{s.name}
|
{s.name}
|
||||||
@@ -443,18 +388,14 @@ export const ExecutiveDashboard: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Financial Period Filter */}
|
<div className="flex items-center gap-1">
|
||||||
<div className="flex items-center bg-[#090d16] border border-slate-700/80 hover:border-slate-600 rounded-xl px-3 py-1.5 text-xs transition shadow-inner">
|
|
||||||
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider mr-2 shrink-0">
|
|
||||||
Period
|
|
||||||
</span>
|
|
||||||
<select
|
<select
|
||||||
value={selectedPeriod}
|
value={selectedPeriod}
|
||||||
onChange={(e) => setSelectedPeriod(e.target.value)}
|
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 2026/27">Period: FY 2026/27</option>
|
||||||
<option value="FY 2025/26">FY 2025/26</option>
|
<option value="FY 2025/26">Period: FY 2025/26</option>
|
||||||
<option value="All-Time">All-Time Cumulative</option>
|
<option value="All-Time">All-Time Cumulative</option>
|
||||||
</select>
|
</select>
|
||||||
<HelpTooltip
|
<HelpTooltip
|
||||||
@@ -464,6 +405,15 @@ export const ExecutiveDashboard: React.FC = () => {
|
|||||||
placement="bottom"
|
placement="bottom"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
</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">
|
<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>
|
<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" />
|
View Portfolios <ArrowUpRight className="w-3 h-3" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
@@ -17,7 +16,6 @@ import {
|
|||||||
Coins,
|
Coins,
|
||||||
Eye,
|
Eye,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
ArrowRight,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
getOrganisations,
|
getOrganisations,
|
||||||
@@ -36,8 +34,6 @@ import {
|
|||||||
updateProgramme,
|
updateProgramme,
|
||||||
getInstruments,
|
getInstruments,
|
||||||
getPortfolioProgrammes,
|
getPortfolioProgrammes,
|
||||||
getUsers,
|
|
||||||
getUser,
|
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
import { Modal } from '../components/ui/Modal';
|
import { Modal } from '../components/ui/Modal';
|
||||||
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
||||||
@@ -62,8 +58,6 @@ import {
|
|||||||
getApprovalStatusBadge,
|
getApprovalStatusBadge,
|
||||||
formatDate,
|
formatDate,
|
||||||
getUserEmail,
|
getUserEmail,
|
||||||
registerUserEmail,
|
|
||||||
getUserIdByEmail,
|
|
||||||
ORGANISATION_TYPE_OPTIONS,
|
ORGANISATION_TYPE_OPTIONS,
|
||||||
ACTIVITY_STATUS_OPTIONS,
|
ACTIVITY_STATUS_OPTIONS,
|
||||||
APPROVAL_STATUS_OPTIONS,
|
APPROVAL_STATUS_OPTIONS,
|
||||||
@@ -82,14 +76,14 @@ const portfolioSchema = z.object({
|
|||||||
name: z.string().min(3, 'Portfolio name is required'),
|
name: z.string().min(3, 'Portfolio name is required'),
|
||||||
description: z.string().min(5, 'Description is required'),
|
description: z.string().min(5, 'Description is required'),
|
||||||
organisationId: z.coerce.number().min(1, 'Select a parent organisation'),
|
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({
|
const programmeSchema = z.object({
|
||||||
name: z.string().min(3, 'Programme name is required'),
|
name: z.string().min(3, 'Programme name is required'),
|
||||||
portfolioId: z.coerce.number().min(1, 'Select a parent portfolio'),
|
portfolioId: z.coerce.number().min(1, 'Select a parent portfolio'),
|
||||||
status: z.coerce.number().optional(),
|
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>;
|
type OrganisationFormValues = z.infer<typeof organisationSchema>;
|
||||||
@@ -98,72 +92,11 @@ type ProgrammeFormValues = z.infer<typeof programmeSchema>;
|
|||||||
|
|
||||||
export const GovernancePage: React.FC = () => {
|
export const GovernancePage: React.FC = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { sub, user, claims } = useAuth();
|
const { sub, user } = useAuth();
|
||||||
const currentUserId = sub || user?.id || '';
|
const currentUserId = sub || user?.id || '';
|
||||||
const { success, error: toastError } = useToast();
|
const { success, error: toastError } = useToast();
|
||||||
|
const [activeTab, setActiveTab] = useState<'organisations' | 'portfolios' | 'programmes'>('organisations');
|
||||||
const effectiveUserEmail =
|
|
||||||
user?.email ||
|
|
||||||
(claims?.email as string) ||
|
|
||||||
(claims?.['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] as string) ||
|
|
||||||
(claims?.upn as string) ||
|
|
||||||
(sub ? getUserEmail(sub) : '') ||
|
|
||||||
'';
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (effectiveUserEmail) {
|
|
||||||
if (sub) registerUserEmail(sub, effectiveUserEmail);
|
|
||||||
if (user?.id) registerUserEmail(user.id, effectiveUserEmail);
|
|
||||||
}
|
|
||||||
}, [sub, user, effectiveUserEmail]);
|
|
||||||
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
const tabParam = searchParams.get('tab');
|
|
||||||
|
|
||||||
const getValidTab = (val: string | null): 'organisations' | 'portfolios' | 'programmes' => {
|
|
||||||
if (val === 'portfolios' || val === 'programmes' || val === 'organisations') {
|
|
||||||
return val;
|
|
||||||
}
|
|
||||||
return 'organisations';
|
|
||||||
};
|
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<'organisations' | 'portfolios' | 'programmes'>(() => getValidTab(tabParam));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (tabParam && (tabParam === 'organisations' || tabParam === 'portfolios' || tabParam === 'programmes')) {
|
|
||||||
setActiveTab(tabParam);
|
|
||||||
}
|
|
||||||
}, [tabParam]);
|
|
||||||
|
|
||||||
const handleTabChange = (tab: 'organisations' | 'portfolios' | 'programmes') => {
|
|
||||||
setActiveTab(tab);
|
|
||||||
setSearchParams((prev) => {
|
|
||||||
const next = new URLSearchParams(prev);
|
|
||||||
next.set('tab', tab);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [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
|
// Modals & Editing State
|
||||||
const [isOrgModalOpen, setIsOrgModalOpen] = useState(false);
|
const [isOrgModalOpen, setIsOrgModalOpen] = useState(false);
|
||||||
@@ -192,12 +125,6 @@ export const GovernancePage: React.FC = () => {
|
|||||||
const [isProgrammeDetailModalOpen, setIsProgrammeDetailModalOpen] = useState(false);
|
const [isProgrammeDetailModalOpen, setIsProgrammeDetailModalOpen] = useState(false);
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
const { data: usersDirectory = [] } = useQuery({
|
|
||||||
queryKey: ['users-directory'],
|
|
||||||
queryFn: () => getUsers(1, 200),
|
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: organisations = [], isLoading: loadingOrgs } = useQuery({
|
const { data: organisations = [], isLoading: loadingOrgs } = useQuery({
|
||||||
queryKey: ['organisations'],
|
queryKey: ['organisations'],
|
||||||
queryFn: () => getOrganisations(),
|
queryFn: () => getOrganisations(),
|
||||||
@@ -255,92 +182,6 @@ export const GovernancePage: React.FC = () => {
|
|||||||
enabled: !!selectedProgrammeForDetail && isProgrammeDetailModalOpen,
|
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
|
// Forms
|
||||||
const orgForm = useForm<OrganisationFormValues>({
|
const orgForm = useForm<OrganisationFormValues>({
|
||||||
resolver: zodResolver(organisationSchema) as any,
|
resolver: zodResolver(organisationSchema) as any,
|
||||||
@@ -359,7 +200,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
organisationId: organisations[0]?.id ? Number(organisations[0].id) : 101,
|
organisationId: organisations[0]?.id ? Number(organisations[0].id) : 101,
|
||||||
ownerEmail: effectiveUserEmail,
|
ownerId: currentUserId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -369,7 +210,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
name: '',
|
name: '',
|
||||||
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
|
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
|
||||||
status: ApprovalStatus.Approved,
|
status: ApprovalStatus.Approved,
|
||||||
ownerEmail: effectiveUserEmail,
|
ownerId: currentUserId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -402,22 +243,16 @@ export const GovernancePage: React.FC = () => {
|
|||||||
|
|
||||||
const portfolioMutation = useMutation({
|
const portfolioMutation = useMutation({
|
||||||
mutationFn: (data: PortfolioFormValues) => {
|
mutationFn: (data: PortfolioFormValues) => {
|
||||||
const resolvedOwnerId = resolveOwnerId(data.ownerEmail, editingPortfolio?.ownedBy);
|
|
||||||
if (editingPortfolio) {
|
if (editingPortfolio) {
|
||||||
return updatePortfolio({
|
return updatePortfolio({
|
||||||
portfolioId: editingPortfolio.id,
|
portfolioId: editingPortfolio.id,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
organisationId: Number(data.organisationId),
|
organisationId: Number(data.organisationId),
|
||||||
ownerId: resolvedOwnerId,
|
ownerId: data.ownerId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return createPortfolio({
|
return createPortfolio(data as CreatePortfolioRequest);
|
||||||
name: data.name,
|
|
||||||
description: data.description,
|
|
||||||
organisationId: Number(data.organisationId),
|
|
||||||
ownerId: resolvedOwnerId,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['portfolios'] });
|
queryClient.invalidateQueries({ queryKey: ['portfolios'] });
|
||||||
@@ -433,21 +268,16 @@ export const GovernancePage: React.FC = () => {
|
|||||||
|
|
||||||
const programmeMutation = useMutation({
|
const programmeMutation = useMutation({
|
||||||
mutationFn: (data: ProgrammeFormValues) => {
|
mutationFn: (data: ProgrammeFormValues) => {
|
||||||
const resolvedOwnerId = resolveOwnerId(data.ownerEmail, editingProgramme?.ownedBy);
|
|
||||||
if (editingProgramme) {
|
if (editingProgramme) {
|
||||||
return updateProgramme({
|
return updateProgramme({
|
||||||
programmeId: editingProgramme.id,
|
programmeId: editingProgramme.id,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
portfolioId: Number(data.portfolioId),
|
portfolioId: Number(data.portfolioId),
|
||||||
status: data.status !== undefined ? Number(data.status) : undefined,
|
status: data.status !== undefined ? Number(data.status) : undefined,
|
||||||
ownerId: resolvedOwnerId,
|
ownerId: data.ownerId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return createProgramme({
|
return createProgramme(data as CreateProgrammeRequest);
|
||||||
name: data.name,
|
|
||||||
portfolioId: Number(data.portfolioId),
|
|
||||||
ownerId: resolvedOwnerId,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['programmes'] });
|
queryClient.invalidateQueries({ queryKey: ['programmes'] });
|
||||||
@@ -491,19 +321,18 @@ export const GovernancePage: React.FC = () => {
|
|||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
organisationId: organisations[0]?.id ? Number(organisations[0].id) : 101,
|
organisationId: organisations[0]?.id ? Number(organisations[0].id) : 101,
|
||||||
ownerEmail: effectiveUserEmail,
|
ownerId: currentUserId,
|
||||||
});
|
});
|
||||||
setIsPortfolioModalOpen(true);
|
setIsPortfolioModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEditPortfolio = async (p: Portfolio) => {
|
const openEditPortfolio = (p: Portfolio) => {
|
||||||
setEditingPortfolio(p);
|
setEditingPortfolio(p);
|
||||||
const email = await resolveOwnerEmailForDisplay(p.ownedBy);
|
|
||||||
portfolioForm.reset({
|
portfolioForm.reset({
|
||||||
name: p.name || '',
|
name: p.name || '',
|
||||||
description: p.description || '',
|
description: p.description || '',
|
||||||
organisationId: Number(p.organisationId) || 101,
|
organisationId: Number(p.organisationId) || 101,
|
||||||
ownerEmail: email,
|
ownerId: p.ownedBy || currentUserId,
|
||||||
});
|
});
|
||||||
setIsPortfolioModalOpen(true);
|
setIsPortfolioModalOpen(true);
|
||||||
};
|
};
|
||||||
@@ -514,19 +343,18 @@ export const GovernancePage: React.FC = () => {
|
|||||||
name: '',
|
name: '',
|
||||||
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
|
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
|
||||||
status: ApprovalStatus.Approved,
|
status: ApprovalStatus.Approved,
|
||||||
ownerEmail: effectiveUserEmail,
|
ownerId: currentUserId,
|
||||||
});
|
});
|
||||||
setIsProgrammeModalOpen(true);
|
setIsProgrammeModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEditProgramme = async (pr: Programme) => {
|
const openEditProgramme = (pr: Programme) => {
|
||||||
setEditingProgramme(pr);
|
setEditingProgramme(pr);
|
||||||
const email = await resolveOwnerEmailForDisplay(pr.ownedBy);
|
|
||||||
programmeForm.reset({
|
programmeForm.reset({
|
||||||
name: pr.name || '',
|
name: pr.name || '',
|
||||||
portfolioId: Number(pr.portfolioId) || 201,
|
portfolioId: Number(pr.portfolioId) || 201,
|
||||||
status: pr.status ?? ApprovalStatus.Approved,
|
status: pr.status ?? ApprovalStatus.Approved,
|
||||||
ownerEmail: email,
|
ownerId: pr.ownedBy || currentUserId,
|
||||||
});
|
});
|
||||||
setIsProgrammeModalOpen(true);
|
setIsProgrammeModalOpen(true);
|
||||||
};
|
};
|
||||||
@@ -597,7 +425,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex border-b border-slate-800/80 bg-[#0d121f]/80 px-4 rounded-2xl backdrop-blur-sm">
|
<div className="flex border-b border-slate-800/80 bg-[#0d121f]/80 px-4 rounded-2xl backdrop-blur-sm">
|
||||||
<button
|
<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 ${
|
className={`py-3.5 px-4 text-xs font-semibold border-b-2 transition-colors flex items-center gap-2 cursor-pointer ${
|
||||||
activeTab === 'organisations'
|
activeTab === 'organisations'
|
||||||
? 'border-blue-500 text-blue-400'
|
? 'border-blue-500 text-blue-400'
|
||||||
@@ -614,7 +442,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<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 ${
|
className={`py-3.5 px-4 text-xs font-semibold border-b-2 transition-colors flex items-center gap-2 cursor-pointer ${
|
||||||
activeTab === 'portfolios'
|
activeTab === 'portfolios'
|
||||||
? 'border-blue-500 text-blue-400'
|
? 'border-blue-500 text-blue-400'
|
||||||
@@ -631,7 +459,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<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 ${
|
className={`py-3.5 px-4 text-xs font-semibold border-b-2 transition-colors flex items-center gap-2 cursor-pointer ${
|
||||||
activeTab === 'programmes'
|
activeTab === 'programmes'
|
||||||
? 'border-blue-500 text-blue-400'
|
? 'border-blue-500 text-blue-400'
|
||||||
@@ -740,7 +568,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
setIsOrgDetailModalOpen(true);
|
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"
|
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" />
|
<Eye className="w-3 h-3" />
|
||||||
Inspect
|
Inspect
|
||||||
@@ -773,17 +601,8 @@ export const GovernancePage: React.FC = () => {
|
|||||||
portfolios.map((p) => {
|
portfolios.map((p) => {
|
||||||
const org = organisations.find((o) => String(o.id) === String(p.organisationId));
|
const org = organisations.find((o) => String(o.id) === String(p.organisationId));
|
||||||
const progCount = programmes.filter((pr) => String(pr.portfolioId) === String(p.id)).length;
|
const progCount = programmes.filter((pr) => String(pr.portfolioId) === String(p.id)).length;
|
||||||
const isHighlighted = String(highlightedPortfolioId) === String(p.id);
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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">
|
||||||
key={p.id}
|
|
||||||
id={`portfolio-card-${p.id}`}
|
|
||||||
className={`bg-[#0f1523]/80 p-5 rounded-2xl border shadow-lg backdrop-blur-sm flex flex-col justify-between transition-all ${
|
|
||||||
isHighlighted
|
|
||||||
? 'border-blue-500 ring-2 ring-blue-500/50 shadow-blue-500/20 scale-[1.01]'
|
|
||||||
: 'border-slate-800/80 hover:border-slate-700/80'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<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">
|
<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>
|
<p className="text-xs text-slate-400 leading-relaxed">{p.description}</p>
|
||||||
</div>
|
</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">
|
<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}`}>
|
<span className="truncate max-w-[170px] font-medium text-slate-300">{org ? org.name : `Org #${p.organisationId}`}</span>
|
||||||
{org ? org.name : `Org #${p.organisationId}`}
|
<span className="font-mono text-[11px] text-slate-500">{formatDate(p.createdAt)}</span>
|
||||||
</span>
|
|
||||||
<span className="font-mono text-[11px] text-slate-500 shrink-0">{formatDate(p.createdAt)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center justify-between pt-1 text-xs">
|
||||||
<div className="flex items-center gap-1.5 text-xs text-slate-400 min-w-0">
|
<span className="text-slate-400 font-medium truncate max-w-[190px]">
|
||||||
<span className="font-medium shrink-0">Owner:</span>
|
Owner: <span className="text-slate-200 font-mono text-[11px]">{getUserEmail(p.ownedBy)}</span>
|
||||||
<span className="text-slate-200 font-mono text-[11px] break-all">
|
|
||||||
{getUserEmail(p.ownedBy)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<div className="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
<div className="flex items-center justify-end gap-1.5 pt-1.5 border-t border-slate-800/50">
|
type="button"
|
||||||
<button
|
onClick={() => {
|
||||||
type="button"
|
setSelectedPortfolioForDetail(p);
|
||||||
onClick={() => {
|
setIsPortfolioDetailModalOpen(true);
|
||||||
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})"
|
||||||
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
|
||||||
<Eye className="w-3 h-3" />
|
</button>
|
||||||
Inspect
|
<button
|
||||||
</button>
|
type="button"
|
||||||
<button
|
onClick={() => {
|
||||||
type="button"
|
setSelectedPortfolioForProgrammes(p);
|
||||||
onClick={() => {
|
setIsPortfolioProgrammesModalOpen(true);
|
||||||
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"
|
||||||
}}
|
>
|
||||||
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
|
||||||
<Layers className="w-3 h-3" />
|
</button>
|
||||||
Programmes
|
<button
|
||||||
</button>
|
type="button"
|
||||||
<button
|
onClick={() => openEditPortfolio(p)}
|
||||||
type="button"
|
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"
|
||||||
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
|
||||||
<Edit2 className="w-3 h-3" />
|
</button>
|
||||||
Edit
|
</div>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -904,7 +718,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
setIsProgrammeDetailModalOpen(true);
|
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"
|
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" />
|
<Eye className="w-3 h-3" />
|
||||||
Inspect
|
Inspect
|
||||||
@@ -1158,22 +972,11 @@ export const GovernancePage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="text"
|
||||||
list="portfolio-owner-email-suggestions"
|
{...portfolioForm.register('ownerId')}
|
||||||
{...portfolioForm.register('ownerEmail')}
|
|
||||||
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 font-mono text-white bg-[#090d16]"
|
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -1308,22 +1111,11 @@ export const GovernancePage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="text"
|
||||||
list="programme-owner-email-suggestions"
|
{...programmeForm.register('ownerId')}
|
||||||
{...programmeForm.register('ownerEmail')}
|
|
||||||
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 font-mono text-white bg-[#090d16]"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1468,7 +1260,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Modal: View Organisation Details & Linked Portfolio */}
|
{/* Modal: View Organisation Details (GET /v1/api/governance/organisations/{id} & /portfolio) */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={isOrgDetailModalOpen}
|
isOpen={isOrgDetailModalOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
@@ -1482,89 +1274,51 @@ export const GovernancePage: React.FC = () => {
|
|||||||
{loadingOrgFreshDetail ? (
|
{loadingOrgFreshDetail ? (
|
||||||
<div className="py-8 text-center text-slate-400">
|
<div className="py-8 text-center text-slate-400">
|
||||||
<RefreshCw className="w-5 h-5 animate-spin mx-auto mb-2 text-blue-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>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="p-4 bg-[#0d121f] rounded-xl border border-slate-800 space-y-3">
|
<div className="p-4 bg-[#0d121f] rounded-xl border border-slate-800 space-y-2">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div>
|
||||||
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Legal Name:</span>
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Legal Name:</span>
|
||||||
<span className="text-white font-medium truncate">{orgFreshDetail?.name || selectedOrgForDetail?.name}</span>
|
<span className="text-white font-medium">{orgFreshDetail?.name || selectedOrgForDetail?.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div>
|
||||||
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Reg Number:</span>
|
<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>
|
<span className="font-mono text-slate-300">{orgFreshDetail?.registrationNo || selectedOrgForDetail?.registrationNo || '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div>
|
||||||
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Official Email:</span>
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Official Email:</span>
|
||||||
<span className="font-mono text-slate-300 truncate">{orgFreshDetail?.email || selectedOrgForDetail?.email}</span>
|
<span className="font-mono text-slate-300">{orgFreshDetail?.email || selectedOrgForDetail?.email}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div>
|
||||||
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Entity Type:</span>
|
<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>
|
<span className="text-slate-300">{getOrganisationTypeLabel(orgFreshDetail?.type ?? selectedOrgForDetail?.type ?? OrganisationType.Enterprise)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<h4 className="font-semibold text-slate-300 text-xs mb-2 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<span>Linked Portfolios (GET /v1/api/governance/organisations/{selectedOrgForDetail?.id}/portfolio)</span>
|
||||||
<h4 className="font-semibold text-slate-300 text-xs">Linked Portfolios</h4>
|
<span className="text-[10px] font-mono text-slate-400">{orgPortfolios.length} linked</span>
|
||||||
<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">
|
</h4>
|
||||||
{orgPortfolios.length} linked
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{orgPortfolios.length > 0 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleNavigateToPortfolioTab()}
|
|
||||||
className="inline-flex items-center gap-1 text-[11px] font-semibold text-blue-400 hover:text-blue-300 transition cursor-pointer"
|
|
||||||
>
|
|
||||||
<span>View Portfolios tab</span>
|
|
||||||
<ArrowRight className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loadingOrgPortfolios ? (
|
{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 ? (
|
) : 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-500">No portfolios directly linked to this organisation.</p>
|
||||||
<p className="text-xs text-slate-400">No portfolios directly linked to this organisation.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2 max-h-[220px] overflow-y-auto pr-0.5">
|
<div className="space-y-2 max-h-[220px] overflow-y-auto">
|
||||||
{orgPortfolios.map((port) => (
|
{orgPortfolios.map((port) => (
|
||||||
<div
|
<div key={port.id} className="p-3 bg-slate-900/60 rounded-xl border border-slate-800 flex items-center justify-between">
|
||||||
key={port.id}
|
<div>
|
||||||
onClick={() => handleNavigateToPortfolioTab(port.id)}
|
<span className="text-white font-medium block">{port.name}</span>
|
||||||
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"
|
<span className="text-[10px] text-slate-500 font-mono">ID: {port.id}</span>
|
||||||
>
|
|
||||||
<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>
|
</div>
|
||||||
<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">
|
||||||
type="button"
|
Portfolio
|
||||||
onClick={(e) => {
|
</span>
|
||||||
e.stopPropagation();
|
|
||||||
handleNavigateToPortfolioTab(port.id);
|
|
||||||
}}
|
|
||||||
className="inline-flex items-center gap-1 text-[11px] font-semibold text-blue-400 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 px-2.5 py-1 rounded-lg transition shadow-xs shrink-0 cursor-pointer"
|
|
||||||
>
|
|
||||||
<span>Open</span>
|
|
||||||
<ArrowRight className="w-3 h-3 group-hover:translate-x-0.5 transition-transform" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1588,7 +1342,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Modal: View Portfolio Details */}
|
{/* Modal: View Portfolio Details (GET /v1/api/governance/portfolios/{id}) */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={isPortfolioDetailModalOpen}
|
isOpen={isPortfolioDetailModalOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
@@ -1602,7 +1356,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
{loadingPortfolioFreshDetail ? (
|
{loadingPortfolioFreshDetail ? (
|
||||||
<div className="py-8 text-center text-slate-400">
|
<div className="py-8 text-center text-slate-400">
|
||||||
<RefreshCw className="w-5 h-5 animate-spin mx-auto mb-2 text-blue-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>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-4 bg-[#0d121f] rounded-xl border border-slate-800 space-y-3">
|
<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>
|
<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>
|
<p className="text-slate-300 mt-0.5 leading-relaxed">{portfolioFreshDetail?.description || selectedPortfolioForDetail?.description || 'None provided'}</p>
|
||||||
</div>
|
</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="grid grid-cols-2 gap-2 pt-2 border-t border-slate-800/80">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div>
|
||||||
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Owner:</span>
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Owner:</span>
|
||||||
<span className="font-mono text-slate-300 text-xs truncate" title={getUserEmail(portfolioFreshDetail?.ownedBy || selectedPortfolioForDetail?.ownedBy)}>
|
<span className="font-mono text-slate-300">{getUserEmail(portfolioFreshDetail?.ownedBy || selectedPortfolioForDetail?.ownedBy)}</span>
|
||||||
{getUserEmail(portfolioFreshDetail?.ownedBy || selectedPortfolioForDetail?.ownedBy)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div>
|
||||||
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Created:</span>
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Created:</span>
|
||||||
<span className="font-mono text-slate-300 text-xs">
|
<span className="font-mono text-slate-300">
|
||||||
{formatDate(portfolioFreshDetail?.createdAt || selectedPortfolioForDetail?.createdAt || '')}
|
{formatDate(portfolioFreshDetail?.createdAt || selectedPortfolioForDetail?.createdAt || '')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1646,7 +1398,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Modal: View Programme Details */}
|
{/* Modal: View Programme Details (GET /v1/api/projects/programme/{id}) */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={isProgrammeDetailModalOpen}
|
isOpen={isProgrammeDetailModalOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
@@ -1660,26 +1412,24 @@ export const GovernancePage: React.FC = () => {
|
|||||||
{loadingProgrammeFreshDetail ? (
|
{loadingProgrammeFreshDetail ? (
|
||||||
<div className="py-8 text-center text-slate-400">
|
<div className="py-8 text-center text-slate-400">
|
||||||
<RefreshCw className="w-5 h-5 animate-spin mx-auto mb-2 text-blue-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>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-4 bg-[#0d121f] rounded-xl border border-slate-800/80 space-y-3.5">
|
<div className="p-4 bg-[#0d121f] rounded-xl border border-slate-800 space-y-3">
|
||||||
<div className="pb-3 border-b border-slate-800/80">
|
<div>
|
||||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase tracking-wider mb-1">Programme Name</span>
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Programme Name:</span>
|
||||||
<span className="text-white font-semibold text-sm block">{programmeFreshDetail?.name || selectedProgrammeForDetail?.name}</span>
|
<span className="text-white font-medium text-sm">{programmeFreshDetail?.name || selectedProgrammeForDetail?.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 items-center">
|
<div className="grid grid-cols-2 gap-2 pt-2">
|
||||||
<div className="flex items-center gap-2">
|
<div>
|
||||||
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Approval Status:</span>
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase">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}`}>
|
<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}
|
{getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div>
|
||||||
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Owner:</span>
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Owner:</span>
|
||||||
<span className="font-mono text-slate-300 text-xs truncate" title={getUserEmail(programmeFreshDetail?.ownedBy || selectedProgrammeForDetail?.ownedBy)}>
|
<span className="font-mono text-slate-300">{getUserEmail(programmeFreshDetail?.ownedBy || selectedProgrammeForDetail?.ownedBy)}</span>
|
||||||
{getUserEmail(programmeFreshDetail?.ownedBy || selectedProgrammeForDetail?.ownedBy)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
@@ -21,12 +20,16 @@ import {
|
|||||||
Filter,
|
Filter,
|
||||||
Link as LinkIcon,
|
Link as LinkIcon,
|
||||||
Edit2,
|
Edit2,
|
||||||
ChevronRight,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
getAllIndicators,
|
getAllIndicators,
|
||||||
createIndicator,
|
createIndicator,
|
||||||
recordIndicatorActual,
|
recordIndicatorActual,
|
||||||
|
getAllEvidence,
|
||||||
|
getMilestoneEvidence,
|
||||||
|
getEvidenceSecureLink,
|
||||||
|
uploadEvidenceFile,
|
||||||
|
updateEvidence,
|
||||||
getAllRisks,
|
getAllRisks,
|
||||||
createRisk,
|
createRisk,
|
||||||
updateRisk,
|
updateRisk,
|
||||||
@@ -37,10 +40,10 @@ import { Modal } from '../components/ui/Modal';
|
|||||||
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
||||||
import { useToast } from '../components/ui/Toast';
|
import { useToast } from '../components/ui/Toast';
|
||||||
import { ContractsManagement } from '../components/monitoring/ContractsManagement';
|
import { ContractsManagement } from '../components/monitoring/ContractsManagement';
|
||||||
import { EvidenceReviewQueue } from '../components/monitoring/EvidenceReviewQueue';
|
|
||||||
import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix';
|
import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix';
|
||||||
import {
|
import {
|
||||||
Indicator,
|
Indicator,
|
||||||
|
Evidence,
|
||||||
Risk,
|
Risk,
|
||||||
ApprovalStatus,
|
ApprovalStatus,
|
||||||
UnitOfMeasure,
|
UnitOfMeasure,
|
||||||
@@ -82,40 +85,13 @@ const riskSchema = z.object({
|
|||||||
type IndicatorFormValues = z.infer<typeof indicatorSchema>;
|
type IndicatorFormValues = z.infer<typeof indicatorSchema>;
|
||||||
type RiskFormValues = z.infer<typeof riskSchema>;
|
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 = () => {
|
export const MonitoringPage: React.FC = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { success, error: toastError } = useToast();
|
const { success, error: toastError } = useToast();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
|
|
||||||
const tabParam = searchParams.get('tab');
|
const [activeTab, setActiveTab] = useState<'contracts' | 'evidence' | 'indicators' | 'risks'>('contracts');
|
||||||
const [activeTab, setActiveTab] = useState<'contracts' | 'evidence' | 'indicators' | 'risks'>(
|
|
||||||
tabParam === 'evidence' || tabParam === 'indicators' || tabParam === 'risks' ? tabParam : 'contracts'
|
|
||||||
);
|
|
||||||
const [isCreateContractModalOpen, setIsCreateContractModalOpen] = useState(false);
|
const [isCreateContractModalOpen, setIsCreateContractModalOpen] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
useEffect(() => {
|
|
||||||
const t = searchParams.get('tab');
|
|
||||||
if (t === 'evidence' || t === 'indicators' || t === 'risks' || t === 'contracts') {
|
|
||||||
setActiveTab(t);
|
|
||||||
}
|
|
||||||
}, [searchParams]);
|
|
||||||
|
|
||||||
const handleTabChange = (tab: 'contracts' | 'evidence' | 'indicators' | 'risks') => {
|
|
||||||
setActiveTab(tab);
|
|
||||||
setSearchParams((prev) => {
|
|
||||||
const next = new URLSearchParams(prev);
|
|
||||||
next.set('tab', tab);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Modals & States
|
// Modals & States
|
||||||
const [isIndicatorModalOpen, setIsIndicatorModalOpen] = useState(false);
|
const [isIndicatorModalOpen, setIsIndicatorModalOpen] = useState(false);
|
||||||
@@ -123,6 +99,11 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
const [editingRisk, setEditingRisk] = useState<Risk | null>(null);
|
const [editingRisk, setEditingRisk] = useState<Risk | null>(null);
|
||||||
const [selectedIndicatorForActual, setSelectedIndicatorForActual] = useState<Indicator | null>(null);
|
const [selectedIndicatorForActual, setSelectedIndicatorForActual] = useState<Indicator | null>(null);
|
||||||
const [actualInput, setActualInput] = useState<number>(0);
|
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
|
// Queries
|
||||||
const { data: indicators = [], isLoading: loadingIndicators } = useQuery({
|
const { data: indicators = [], isLoading: loadingIndicators } = useQuery({
|
||||||
@@ -130,6 +111,14 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
queryFn: () => getAllIndicators(),
|
queryFn: () => getAllIndicators(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: evidenceList = [], isLoading: loadingEvidence } = useQuery({
|
||||||
|
queryKey: ['evidence', selectedMilestoneEvidenceFilter],
|
||||||
|
queryFn: () =>
|
||||||
|
selectedMilestoneEvidenceFilter !== 'all'
|
||||||
|
? getMilestoneEvidence(selectedMilestoneEvidenceFilter)
|
||||||
|
: getAllEvidence(),
|
||||||
|
});
|
||||||
|
|
||||||
const { data: risks = [], isLoading: loadingRisks } = useQuery({
|
const { data: risks = [], isLoading: loadingRisks } = useQuery({
|
||||||
queryKey: ['risks'],
|
queryKey: ['risks'],
|
||||||
queryFn: () => getAllRisks(),
|
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 = () => {
|
const openCreateRisk = () => {
|
||||||
setEditingRisk(null);
|
setEditingRisk(null);
|
||||||
riskForm.reset({
|
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:
|
// Performance indicator thresholds:
|
||||||
// Green >= 90%, Amber 70-89%, Red < 70%
|
// Green >= 90%, Amber 70-89%, Red < 70%
|
||||||
const getIndicatorStatus = (actual: number, target: number) => {
|
const getIndicatorStatus = (actual: number, target: number) => {
|
||||||
if (!target) return { label: 'Pending', badge: 'bg-slate-800/60 text-slate-400 border-slate-700/60' };
|
if (!target) return { label: 'Pending', badge: 'bg-slate-800/60 text-slate-400 border-slate-700/60' };
|
||||||
const pct = (actual / target) * 100;
|
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 >= 90) return { label: 'Green (≥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' };
|
if (pct >= 70) return { label: 'Amber (70–89%)', badge: 'bg-amber-500/10 text-amber-400 border-amber-500/20' };
|
||||||
return { label: '<70%', badge: 'bg-rose-500/10 text-rose-400 border-rose-500/20' };
|
return { label: 'Red (<70%)', badge: 'bg-rose-500/10 text-rose-400 border-rose-500/20' };
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -316,8 +367,8 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex gap-2 border-b border-slate-800/80 pb-3 overflow-x-auto">
|
<div className="flex gap-2 border-b border-slate-800/80 pb-3 overflow-x-auto">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleTabChange('contracts')}
|
onClick={() => setActiveTab('contracts')}
|
||||||
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 cursor-pointer ${
|
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${
|
||||||
activeTab === 'contracts'
|
activeTab === 'contracts'
|
||||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
||||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
||||||
@@ -333,25 +384,25 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleTabChange('evidence')}
|
onClick={() => setActiveTab('evidence')}
|
||||||
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 cursor-pointer ${
|
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${
|
||||||
activeTab === 'evidence'
|
activeTab === 'evidence'
|
||||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
||||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<FileCheck className="w-4 h-4" />
|
<Paperclip className="w-4 h-4" />
|
||||||
<span>Evidence Review & Verification</span>
|
<span>Milestone Evidence & Review Queue</span>
|
||||||
<HelpTooltip
|
<HelpTooltip
|
||||||
title="Evidence Review & Verification"
|
title="Verification Workflow"
|
||||||
content="Peer review, document inspection, and verification sign-offs for deliverables submitted against projects and milestones."
|
content="Evidence submission, file validation, and peer review sign-off gating milestone payments."
|
||||||
size="sm"
|
size="sm"
|
||||||
placement="top"
|
placement="top"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleTabChange('indicators')}
|
onClick={() => setActiveTab('indicators')}
|
||||||
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 cursor-pointer ${
|
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${
|
||||||
activeTab === 'indicators'
|
activeTab === 'indicators'
|
||||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
||||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
||||||
@@ -367,8 +418,8 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleTabChange('risks')}
|
onClick={() => setActiveTab('risks')}
|
||||||
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 cursor-pointer ${
|
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${
|
||||||
activeTab === 'risks'
|
activeTab === 'risks'
|
||||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
||||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
: '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' && (
|
{activeTab === 'evidence' && (
|
||||||
<EvidenceReviewQueue
|
<div className="space-y-6">
|
||||||
initialProjectId={searchParams.get('projectId') || 'all'}
|
{/* Milestone Evidence Review Container */}
|
||||||
initialMilestoneId={searchParams.get('milestoneId') || 'all'}
|
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
|
||||||
initialActivityId={searchParams.get('activityId') || 'all'}
|
{/* Header Bar */}
|
||||||
initialUserId={searchParams.get('userId') || 'all'}
|
<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 • Project XYZ • 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'} • v{ev.version} • {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) */}
|
{/* 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>
|
<label className="block text-xs font-semibold text-slate-300">Actual Value to Record *</label>
|
||||||
<HelpTooltip
|
<HelpTooltip
|
||||||
title="Monitored Measurement"
|
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"
|
size="sm"
|
||||||
placement="right"
|
placement="right"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
@@ -521,25 +520,15 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Actions & Status Change Selector */}
|
{/* Actions & Status Change Selector */}
|
||||||
<div className="shrink-0 text-right space-y-2">
|
<div className="shrink-0 text-right space-y-2">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<button
|
||||||
<Link
|
type="button"
|
||||||
to={`/monitoring?tab=evidence&projectId=${selectedProject.id}`}
|
onClick={() => openEditProject(selectedProject)}
|
||||||
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"
|
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="Review submitted evidence for this project"
|
title="Edit project details"
|
||||||
>
|
>
|
||||||
<FileCheck className="w-3.5 h-3.5 text-blue-400" />
|
<Edit2 className="w-3.5 h-3.5 text-blue-400" />
|
||||||
Review Evidence
|
Edit Project
|
||||||
</Link>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => openEditProject(selectedProject)}
|
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-slate-200 bg-slate-800 hover:bg-slate-700 border border-slate-700/80 rounded-xl transition cursor-pointer"
|
|
||||||
title="Edit project details"
|
|
||||||
>
|
|
||||||
<Edit2 className="w-3.5 h-3.5 text-blue-400" />
|
|
||||||
Edit Project
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-1 mb-1">
|
<div className="flex items-center gap-1 mb-1">
|
||||||
<label className="text-[10px] uppercase font-bold text-slate-400 block">Approval Status</label>
|
<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>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<span className="font-mono text-xs font-bold text-blue-400">{formatZAR(a.amount)}</span>
|
<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
|
<button
|
||||||
onClick={() => openEditActivity(a)}
|
onClick={() => openEditActivity(a)}
|
||||||
className="p-1.5 rounded-lg text-slate-400 hover:text-blue-400 hover:bg-slate-800 transition cursor-pointer"
|
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 statusBadge = getApprovalStatusBadge(m.status);
|
||||||
const priBadge = getPriorityBadge(m.priority);
|
const priBadge = getPriorityBadge(m.priority);
|
||||||
return (
|
return (
|
||||||
<div key={m.id} className="p-3.5 rounded-xl border border-slate-800/80 bg-[#0d121f]/90 space-y-2.5">
|
<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-3">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-semibold text-xs text-white block">{m.name}</span>
|
<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>
|
<span className="text-[10px] text-slate-500 font-mono">Milestone #{m.id}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<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 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
|
{statusBadge.label}
|
||||||
{statusBadge.label}
|
</span>
|
||||||
</span>
|
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${priBadge.className}`}>
|
|
||||||
{priBadge.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
to={`/monitoring?tab=evidence&projectId=${selectedProject.id}&milestoneId=${m.id}`}
|
|
||||||
className="inline-flex items-center gap-1 text-[10px] font-semibold text-blue-300 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/25 px-2 py-1 rounded-lg transition"
|
|
||||||
title="View & review evidence for this milestone"
|
|
||||||
>
|
|
||||||
<FileCheck className="w-3 h-3 text-blue-400" />
|
|
||||||
Evidence
|
|
||||||
</Link>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => openEditMilestone(m)}
|
onClick={() => openEditMilestone(m)}
|
||||||
className="p-1.5 rounded-lg text-slate-400 hover:text-blue-400 hover:bg-slate-800 transition cursor-pointer"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center text-[11px] text-slate-400 pt-1.5 border-t border-slate-800/60">
|
<div className="flex items-center justify-between text-[11px] text-slate-400">
|
||||||
<span className="flex items-center gap-1.5 text-slate-400">
|
<span className="flex items-center gap-1">
|
||||||
<Clock className="w-3.5 h-3.5 text-slate-500" />
|
<Clock className="w-3 h-3 text-slate-500" />
|
||||||
Due: {formatDate(m.dueAt)}
|
Due: {formatDate(m.dueAt)}
|
||||||
</span>
|
</span>
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded-md font-medium border ${priBadge.className}`}>
|
||||||
|
{priBadge.label}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
const syncIntegrationMutation = useMutation({
|
const syncIntegrationMutation = useMutation({
|
||||||
mutationFn: () => syncIntegration(true),
|
mutationFn: () => syncIntegration(true),
|
||||||
onSuccess: () => {
|
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'] });
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
@@ -260,7 +260,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<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"
|
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" />
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||||
Browser Sync Saved
|
Browser Sync Saved
|
||||||
@@ -553,7 +553,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
type="submit"
|
type="submit"
|
||||||
disabled={searchingApplicant || !applicantSearchId.trim()}
|
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"
|
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'}
|
{searchingApplicant ? 'Searching...' : 'All Invites'}
|
||||||
</button>
|
</button>
|
||||||
@@ -562,7 +562,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
onClick={handleLookupSingleApplicantInvite}
|
onClick={handleLookupSingleApplicantInvite}
|
||||||
disabled={searchingApplicant || !applicantSearchId.trim()}
|
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"
|
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
|
Primary Invite
|
||||||
</button>
|
</button>
|
||||||
@@ -721,10 +721,10 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-bold text-white flex items-center gap-2">
|
<h3 className="text-sm font-bold text-white flex items-center gap-2">
|
||||||
<RefreshCw className="w-4 h-4 text-blue-400" />
|
<RefreshCw className="w-4 h-4 text-blue-400" />
|
||||||
Backend Integration Sync Engine
|
Backend Integration Sync Engine (POST /v1/api/integration/sync)
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-slate-400 mt-1 max-w-2xl leading-relaxed">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -734,7 +734,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
onClick={() => syncIntegrationMutation.mutate()}
|
onClick={() => syncIntegrationMutation.mutate()}
|
||||||
disabled={syncIntegrationMutation.isPending}
|
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"
|
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' : ''}`} />
|
<RefreshCw className={`w-3.5 h-3.5 ${syncIntegrationMutation.isPending ? 'animate-spin' : ''}`} />
|
||||||
{syncIntegrationMutation.isPending ? 'Syncing...' : 'Trigger Integration Sync'}
|
{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">
|
<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">
|
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider block">
|
||||||
Synchronization Service Profile
|
OpenAPI Target Route
|
||||||
</span>
|
</span>
|
||||||
<div className="text-xs space-y-1.5 font-mono">
|
<div className="text-xs space-y-1.5 font-mono">
|
||||||
<div>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-slate-500">Sync Scope:</span>{' '}
|
<span className="text-slate-500">Endpoint Path:</span>{' '}
|
||||||
<span className="text-cyan-400">Authoritative User Directory & Security Claims</span>
|
<span className="text-cyan-400">/v1/api/integration/sync</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-slate-500">Service Status:</span>{' '}
|
<span className="text-slate-500">Execution Target:</span>{' '}
|
||||||
<span className="text-white">Operational & Connected</span>
|
<span className="text-white">Authoritative User Directory Synchronization</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1129,7 +1129,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
{loadingUserFreshDetail ? (
|
{loadingUserFreshDetail ? (
|
||||||
<div className="p-6 text-center text-slate-400">
|
<div className="p-6 text-center text-slate-400">
|
||||||
<RefreshCw className="w-5 h-5 animate-spin mx-auto mb-2 text-blue-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>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-2">
|
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-2">
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
import axios, { AxiosError } from 'axios';
|
import axios, { AxiosError } from 'axios';
|
||||||
import { apiClient } from './httpClient';
|
import { apiClient } from './httpClient';
|
||||||
import { toolsService } from './toolsService';
|
import { toolsService } from './toolsService';
|
||||||
import {
|
import { getCurrentUserSub } from '../auth/AuthProvider';
|
||||||
getCurrentUserSub,
|
|
||||||
getCurrentUserTenantId,
|
|
||||||
getCurrentUserHashedTenantId,
|
|
||||||
} from '../auth/AuthProvider';
|
|
||||||
|
|
||||||
export { getCurrentUserTenantId, getCurrentUserHashedTenantId };
|
|
||||||
import {
|
import {
|
||||||
Dashboard,
|
Dashboard,
|
||||||
Organisation,
|
Organisation,
|
||||||
@@ -60,7 +54,7 @@ import {
|
|||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
import { parseFriendlyError, FriendlyError } from '../utils/apiErrorParser';
|
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
|
// Helper to extract ProblemDetails from Axios error or unknown error
|
||||||
export function parseApiError(error: unknown): ProblemDetails {
|
export function parseApiError(error: unknown): ProblemDetails {
|
||||||
@@ -94,20 +88,6 @@ export const getDashboard = async (): Promise<Dashboard> => {
|
|||||||
// 2. Governance & Structure APIs
|
// 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.
|
* Normalizes an Organisation object from backend responses.
|
||||||
* Robustly maps status across variations (status, Status, activityStatus, approvalStatus, statusId, isActive).
|
* 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);
|
return list.map(normalizeOrganisation);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getOrganisation = async (organisationId?: string | number): Promise<Organisation> => {
|
export const getOrganisation = async (organisationId: string | number): Promise<Organisation> => {
|
||||||
const targetOrgId =
|
const hashedOrgId = await toolsService.resolveOrganisationId(organisationId);
|
||||||
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);
|
|
||||||
const res = await apiClient.get<any>(`/v1/api/governance/organisations/${hashedOrgId}`);
|
const res = await apiClient.get<any>(`/v1/api/governance/organisations/${hashedOrgId}`);
|
||||||
return normalizeOrganisation(res.data);
|
return normalizeOrganisation(res.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createOrganisation = async (payload: CreateOrganisationRequest): Promise<Organisation> => {
|
export const createOrganisation = async (payload: CreateOrganisationRequest): Promise<Organisation> => {
|
||||||
|
const userSub = getCurrentUserSub();
|
||||||
const res = await apiClient.post('/v1/api/governance/organisations', {
|
const res = await apiClient.post('/v1/api/governance/organisations', {
|
||||||
name: payload.name,
|
...payload,
|
||||||
registrationNo: payload.registrationNo,
|
createdBy: userSub || undefined,
|
||||||
email: payload.email,
|
|
||||||
type: payload.type !== undefined ? Number(payload.type) : 5,
|
|
||||||
});
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateOrganisation = async (payload: UpdateOrganisationRequest): Promise<Organisation> => {
|
export const updateOrganisation = async (payload: UpdateOrganisationRequest): Promise<Organisation> => {
|
||||||
|
const userSub = getCurrentUserSub();
|
||||||
const res = await apiClient.put('/v1/api/governance/organisations', {
|
const res = await apiClient.put('/v1/api/governance/organisations', {
|
||||||
organisationId: toIntegerId(payload.organisationId),
|
...payload,
|
||||||
name: payload.name,
|
updatedBy: userSub || undefined,
|
||||||
registrationNo: payload.registrationNo,
|
|
||||||
email: payload.email,
|
|
||||||
type: Number(payload.type),
|
|
||||||
});
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getPortfolios = async (organisationId?: string | number): Promise<Portfolio[]> => {
|
export const getPortfolios = async (organisationId: string | number): Promise<Portfolio[]> => {
|
||||||
const targetOrgId =
|
const hashedOrgId = await toolsService.resolveOrganisationId(organisationId);
|
||||||
organisationId !== undefined && organisationId !== '' && organisationId !== null
|
|
||||||
? organisationId
|
|
||||||
: getCurrentUserTenantId();
|
|
||||||
|
|
||||||
if (targetOrgId === undefined || targetOrgId === null || targetOrgId === '') {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const hashedOrgId = await toolsService.resolveOrganisationId(targetOrgId);
|
|
||||||
const res = await apiClient.get<Portfolio[]>(`/v1/api/governance/organisations/${hashedOrgId}/portfolio`);
|
const res = await apiClient.get<Portfolio[]>(`/v1/api/governance/organisations/${hashedOrgId}/portfolio`);
|
||||||
return res.data || [];
|
return res.data || [];
|
||||||
};
|
};
|
||||||
@@ -232,36 +193,20 @@ export const getPortfolioProgrammes = async (portfolioId: string | number): Prom
|
|||||||
|
|
||||||
export const createPortfolio = async (payload: CreatePortfolioRequest): Promise<Portfolio> => {
|
export const createPortfolio = async (payload: CreatePortfolioRequest): Promise<Portfolio> => {
|
||||||
const userSub = getCurrentUserSub();
|
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', {
|
const res = await apiClient.post('/v1/api/governance/organisations/portfolio', {
|
||||||
name: payload.name,
|
...payload,
|
||||||
description: payload.description,
|
ownerId: payload.ownerId || userSub || undefined,
|
||||||
organisationId: orgId,
|
createdBy: userSub || undefined,
|
||||||
ownerId: ownerId || userSub || '00000000-0000-0000-0000-000000000000',
|
|
||||||
});
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updatePortfolio = async (payload: UpdatePortfolioRequest): Promise<Portfolio> => {
|
export const updatePortfolio = async (payload: UpdatePortfolioRequest): Promise<Portfolio> => {
|
||||||
const userSub = getCurrentUserSub();
|
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', {
|
const res = await apiClient.put('/v1/api/governance/organisations/portfolio', {
|
||||||
portfolioId: toIntegerId(payload.portfolioId),
|
...payload,
|
||||||
name: payload.name,
|
ownerId: payload.ownerId || userSub || undefined,
|
||||||
description: payload.description,
|
updatedBy: userSub || undefined,
|
||||||
ownerId: ownerId || userSub || '00000000-0000-0000-0000-000000000000',
|
|
||||||
});
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
@@ -300,29 +245,20 @@ export const getProgrammeProjects = async (programmeId: string | number): Promis
|
|||||||
|
|
||||||
export const createProgramme = async (payload: CreateProgrammeRequest): Promise<Programme> => {
|
export const createProgramme = async (payload: CreateProgrammeRequest): Promise<Programme> => {
|
||||||
const userSub = getCurrentUserSub();
|
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', {
|
const res = await apiClient.post('/v1/api/projects/programme', {
|
||||||
portfolioId: toIntegerId(payload.portfolioId),
|
...payload,
|
||||||
name: payload.name,
|
ownerId: payload.ownerId || userSub || undefined,
|
||||||
ownerId: ownerId || userSub || '00000000-0000-0000-0000-000000000000',
|
createdBy: userSub || undefined,
|
||||||
});
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateProgramme = async (payload: UpdateProgrammeRequest): Promise<Programme> => {
|
export const updateProgramme = async (payload: UpdateProgrammeRequest): Promise<Programme> => {
|
||||||
const userSub = getCurrentUserSub();
|
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', {
|
const res = await apiClient.put('/v1/api/projects/programme', {
|
||||||
programmeId: toIntegerId(payload.programmeId),
|
...payload,
|
||||||
name: payload.name,
|
ownerId: payload.ownerId || userSub || undefined,
|
||||||
ownerId: ownerId || userSub || '00000000-0000-0000-0000-000000000000',
|
updatedBy: userSub || undefined,
|
||||||
status: Number(payload.status) || 1,
|
|
||||||
});
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
@@ -341,40 +277,28 @@ export const getProject = async (projectId: string | number): Promise<Project> =
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createProject = async (payload: CreateProjectRequest): Promise<Project> => {
|
export const createProject = async (payload: CreateProjectRequest): Promise<Project> => {
|
||||||
|
const userSub = getCurrentUserSub();
|
||||||
const res = await apiClient.post('/v1/api/projects', {
|
const res = await apiClient.post('/v1/api/projects', {
|
||||||
programmeId: toIntegerId(payload.programmeId),
|
...payload,
|
||||||
name: payload.name,
|
createdBy: userSub || undefined,
|
||||||
description: payload.description,
|
|
||||||
sector: Number(payload.sector),
|
|
||||||
projectType: Number(payload.projectType),
|
|
||||||
});
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateProject = async (payload: UpdateProjectRequest): Promise<Project> => {
|
export const updateProject = async (payload: UpdateProjectRequest): Promise<Project> => {
|
||||||
const startedAt = payload.startedAt
|
const userSub = getCurrentUserSub();
|
||||||
? (payload.startedAt.includes('T') ? payload.startedAt : new Date(payload.startedAt).toISOString())
|
|
||||||
: null;
|
|
||||||
const endedAt = payload.endedAt
|
|
||||||
? (payload.endedAt.includes('T') ? payload.endedAt : new Date(payload.endedAt).toISOString())
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const res = await apiClient.put('/v1/api/projects', {
|
const res = await apiClient.put('/v1/api/projects', {
|
||||||
projectId: toIntegerId(payload.projectId),
|
...payload,
|
||||||
name: payload.name,
|
updatedBy: userSub || undefined,
|
||||||
description: payload.description,
|
|
||||||
startedAt,
|
|
||||||
endedAt,
|
|
||||||
sector: Number(payload.sector),
|
|
||||||
projectType: Number(payload.projectType),
|
|
||||||
});
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateProjectStatus = async (payload: UpdateProjectStatusRequest): Promise<void> => {
|
export const updateProjectStatus = async (payload: UpdateProjectStatusRequest): Promise<void> => {
|
||||||
|
const userSub = getCurrentUserSub();
|
||||||
await apiClient.put('/v1/api/projects/status', {
|
await apiClient.put('/v1/api/projects/status', {
|
||||||
projectId: toIntegerId(payload.projectId),
|
...payload,
|
||||||
status: Number(payload.status),
|
updatedBy: userSub || undefined,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -391,36 +315,36 @@ export const getActivity = async (activityId: string | number): Promise<Activity
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createActivity = async (payload: CreateActivityRequest): Promise<Activity> => {
|
export const createActivity = async (payload: CreateActivityRequest): Promise<Activity> => {
|
||||||
const performedAt = payload.performedAt
|
const userSub = getCurrentUserSub();
|
||||||
? (payload.performedAt.includes('T') ? payload.performedAt : new Date(payload.performedAt).toISOString())
|
const body: any = {
|
||||||
: null;
|
...payload,
|
||||||
|
milestoneId:
|
||||||
const body = {
|
payload.milestoneId !== undefined && payload.milestoneId !== null && payload.milestoneId !== ''
|
||||||
projectId: toIntegerId(payload.projectId),
|
? Number(payload.milestoneId)
|
||||||
milestoneId: toNullableIntegerId(payload.milestoneId),
|
: null,
|
||||||
category: payload.category || 'Operations',
|
createdBy: userSub || undefined,
|
||||||
amount: Number(payload.amount) || 0,
|
|
||||||
title: payload.title,
|
|
||||||
description: payload.description || '',
|
|
||||||
performedAt,
|
|
||||||
};
|
};
|
||||||
const res = await apiClient.post('/v1/api/projects/activities', body);
|
try {
|
||||||
return res.data;
|
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> => {
|
export const updateActivity = async (payload: UpdateActivityRequest): Promise<Activity> => {
|
||||||
const performedAt = payload.performedAt
|
const userSub = getCurrentUserSub();
|
||||||
? (payload.performedAt.includes('T') ? payload.performedAt : new Date(payload.performedAt).toISOString())
|
const body: any = {
|
||||||
: null;
|
...payload,
|
||||||
|
milestoneId:
|
||||||
const body = {
|
payload.milestoneId !== undefined && payload.milestoneId !== null && payload.milestoneId !== ''
|
||||||
projectId: toIntegerId(payload.projectId),
|
? Number(payload.milestoneId)
|
||||||
milestoneId: toNullableIntegerId(payload.milestoneId),
|
: null,
|
||||||
category: payload.category || 'Operations',
|
updatedBy: userSub || undefined,
|
||||||
amount: Number(payload.amount) || 0,
|
|
||||||
title: payload.title,
|
|
||||||
description: payload.description || '',
|
|
||||||
performedAt,
|
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.put('/v1/api/projects/activities', body);
|
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> => {
|
export const createMilestone = async (payload: CreateMilestoneRequest): Promise<Milestone> => {
|
||||||
const dueAtIso = payload.dueAt.includes('T')
|
const userSub = getCurrentUserSub();
|
||||||
? payload.dueAt
|
|
||||||
: new Date(payload.dueAt).toISOString();
|
|
||||||
|
|
||||||
const res = await apiClient.post('/v1/api/evidence/milestones', {
|
const res = await apiClient.post('/v1/api/evidence/milestones', {
|
||||||
projectId: toIntegerId(payload.projectId),
|
...payload,
|
||||||
dueAt: dueAtIso,
|
createdBy: userSub || undefined,
|
||||||
name: payload.name,
|
|
||||||
priority: Number(payload.priority) || 1,
|
|
||||||
});
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateMilestone = async (payload: UpdateMilestoneRequest): Promise<void> => {
|
export const updateMilestone = async (payload: UpdateMilestoneRequest): Promise<void> => {
|
||||||
const dueAtIso = payload.dueAt.includes('T')
|
const userSub = getCurrentUserSub();
|
||||||
? payload.dueAt
|
|
||||||
: new Date(payload.dueAt).toISOString();
|
|
||||||
|
|
||||||
await apiClient.put('/v1/api/projects/milestone', {
|
await apiClient.put('/v1/api/projects/milestone', {
|
||||||
milestoneId: toIntegerId(payload.milestoneId),
|
...payload,
|
||||||
dueAt: dueAtIso,
|
updatedBy: userSub || undefined,
|
||||||
name: payload.name,
|
|
||||||
priority: Number(payload.priority) || 1,
|
|
||||||
status: Number(payload.status) || 1,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -509,16 +422,10 @@ export const getAward = async (awardId: string | number): Promise<Award> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createAward = async (payload: CreateAwardRequest): Promise<Award> => {
|
export const createAward = async (payload: CreateAwardRequest): Promise<Award> => {
|
||||||
const tenantId = getCurrentUserTenantId();
|
const userSub = getCurrentUserSub();
|
||||||
const orgId =
|
|
||||||
payload.organisationId !== undefined && payload.organisationId !== '' && payload.organisationId !== null
|
|
||||||
? toIntegerId(payload.organisationId)
|
|
||||||
: (tenantId !== null ? tenantId : 0);
|
|
||||||
|
|
||||||
const res = await apiClient.post('/v1/api/grants/awards', {
|
const res = await apiClient.post('/v1/api/grants/awards', {
|
||||||
organisationId: orgId,
|
...payload,
|
||||||
programmeId: toIntegerId(payload.programmeId),
|
createdBy: userSub || undefined,
|
||||||
amount: Number(payload.amount),
|
|
||||||
});
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
@@ -549,27 +456,12 @@ export const getContract = async (contractId: string | number): Promise<Contract
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createContract = async (payload: CreateContractRequest): 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*)$)
|
const userSub = getCurrentUserSub();
|
||||||
let numericAwardId: number = Number(payload.awardId);
|
const hashedAwardId = await toolsService.resolveAwardId(payload.awardId);
|
||||||
if (isNaN(numericAwardId) || !/^-?\d+$/.test(String(payload.awardId))) {
|
|
||||||
const decoded = toolsService.decodeIdSync(String(payload.awardId));
|
|
||||||
if (decoded && /^-?\d+$/.test(String(decoded))) {
|
|
||||||
numericAwardId = Number(decoded);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const effectiveAtIso = payload.effectiveAt.includes('T')
|
|
||||||
? payload.effectiveAt
|
|
||||||
: new Date(payload.effectiveAt).toISOString();
|
|
||||||
const expiresAtIso = payload.expiresAt.includes('T')
|
|
||||||
? payload.expiresAt
|
|
||||||
: new Date(payload.expiresAt).toISOString();
|
|
||||||
|
|
||||||
const res = await apiClient.post('/v1/api/grants/contracts', {
|
const res = await apiClient.post('/v1/api/grants/contracts', {
|
||||||
awardId: !isNaN(numericAwardId) ? numericAwardId : payload.awardId,
|
...payload,
|
||||||
totalValue: Number(payload.totalValue),
|
awardId: hashedAwardId,
|
||||||
effectiveAt: effectiveAtIso,
|
createdBy: userSub || undefined,
|
||||||
expiresAt: expiresAtIso,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Extract ID from headers (e.g. Location or Content-Location) if present
|
// 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];
|
headerId = parts[parts.length - 1];
|
||||||
}
|
}
|
||||||
|
|
||||||
const hashedAwardId = toolsService.encodeIdSync(numericAwardId || payload.awardId);
|
|
||||||
|
|
||||||
// Case 1: Backend returns string contractId/hash directly
|
// Case 1: Backend returns string contractId/hash directly
|
||||||
if (typeof res.data === 'string' && res.data.trim().length > 0) {
|
if (typeof res.data === 'string' && res.data.trim().length > 0) {
|
||||||
const rawStr = res.data.trim();
|
const rawStr = res.data.trim();
|
||||||
@@ -589,9 +479,9 @@ export const createContract = async (payload: CreateContractRequest): Promise<Co
|
|||||||
id: rawStr,
|
id: rawStr,
|
||||||
contractId: rawStr,
|
contractId: rawStr,
|
||||||
awardId: hashedAwardId,
|
awardId: hashedAwardId,
|
||||||
totalValue: Number(payload.totalValue),
|
totalValue: payload.totalValue,
|
||||||
effectiveAt: effectiveAtIso,
|
effectiveAt: payload.effectiveAt,
|
||||||
expiresAt: expiresAtIso,
|
expiresAt: payload.expiresAt,
|
||||||
status: ContractStatus.Draft,
|
status: ContractStatus.Draft,
|
||||||
} as unknown as Contract;
|
} as unknown as Contract;
|
||||||
}
|
}
|
||||||
@@ -620,9 +510,9 @@ export const createContract = async (payload: CreateContractRequest): Promise<Co
|
|||||||
id: headerId,
|
id: headerId,
|
||||||
contractId: headerId,
|
contractId: headerId,
|
||||||
awardId: hashedAwardId,
|
awardId: hashedAwardId,
|
||||||
totalValue: Number(payload.totalValue),
|
totalValue: payload.totalValue,
|
||||||
effectiveAt: effectiveAtIso,
|
effectiveAt: payload.effectiveAt,
|
||||||
expiresAt: expiresAtIso,
|
expiresAt: payload.expiresAt,
|
||||||
status: ContractStatus.Draft,
|
status: ContractStatus.Draft,
|
||||||
} as unknown as Contract;
|
} as unknown as Contract;
|
||||||
}
|
}
|
||||||
@@ -633,7 +523,6 @@ export const createContract = async (payload: CreateContractRequest): Promise<Co
|
|||||||
const matched = existing.find(
|
const matched = existing.find(
|
||||||
(c) =>
|
(c) =>
|
||||||
String(c.awardId) === String(hashedAwardId) ||
|
String(c.awardId) === String(hashedAwardId) ||
|
||||||
String(c.awardId) === String(numericAwardId) ||
|
|
||||||
String(c.awardId) === String(payload.awardId)
|
String(c.awardId) === String(payload.awardId)
|
||||||
);
|
);
|
||||||
if (matched) return matched;
|
if (matched) return matched;
|
||||||
@@ -643,17 +532,18 @@ export const createContract = async (payload: CreateContractRequest): Promise<Co
|
|||||||
id: hashedAwardId,
|
id: hashedAwardId,
|
||||||
contractId: hashedAwardId,
|
contractId: hashedAwardId,
|
||||||
awardId: hashedAwardId,
|
awardId: hashedAwardId,
|
||||||
totalValue: Number(payload.totalValue),
|
totalValue: payload.totalValue,
|
||||||
effectiveAt: effectiveAtIso,
|
effectiveAt: payload.effectiveAt,
|
||||||
expiresAt: expiresAtIso,
|
expiresAt: payload.expiresAt,
|
||||||
status: ContractStatus.Draft,
|
status: ContractStatus.Draft,
|
||||||
}) as unknown as Contract;
|
}) as unknown as Contract;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateContract = async (payload: UpdateContractRequest): Promise<void> => {
|
export const updateContract = async (payload: UpdateContractRequest): Promise<void> => {
|
||||||
|
const hashedContractId = await toolsService.resolveContractId(payload.contractId);
|
||||||
await apiClient.put('/v1/api/grants/contracts', {
|
await apiClient.put('/v1/api/grants/contracts', {
|
||||||
contractId: toIntegerId(payload.contractId),
|
...payload,
|
||||||
status: Number(payload.status),
|
contractId: hashedContractId,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -681,10 +571,12 @@ export const getContractSecureLink = async (contractId: string | number): Promis
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const executeContract = async (payload: ExecuteContractRequest): Promise<void> => {
|
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', {
|
await apiClient.post('/v1/api/grants/contracts/execute', {
|
||||||
contractId: toIntegerId(payload.contractId),
|
...payload,
|
||||||
externalSignatureId: payload.externalSignatureId,
|
contractId: hashedContractId,
|
||||||
signedDocumentUrl: payload.signedDocumentUrl,
|
executedBy: userSub || undefined,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -702,10 +594,10 @@ export const getBudget = async (budgetId: string | number): Promise<Budget> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createBudget = async (payload: CreateBudgetRequest): Promise<Budget> => {
|
export const createBudget = async (payload: CreateBudgetRequest): Promise<Budget> => {
|
||||||
|
const userSub = getCurrentUserSub();
|
||||||
const res = await apiClient.post('/v1/api/grants/budgets', {
|
const res = await apiClient.post('/v1/api/grants/budgets', {
|
||||||
projectId: toIntegerId(payload.projectId),
|
...payload,
|
||||||
name: payload.name,
|
createdBy: userSub || undefined,
|
||||||
amount: Number(payload.amount),
|
|
||||||
});
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
@@ -727,25 +619,28 @@ export const getDisbursement = async (disbursementId: string | number): Promise<
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createDisbursement = async (payload: CreateDisbursementRequest): Promise<Disbursement> => {
|
export const createDisbursement = async (payload: CreateDisbursementRequest): Promise<Disbursement> => {
|
||||||
|
const userSub = getCurrentUserSub();
|
||||||
const res = await apiClient.post('/v1/api/execution/disbursements', {
|
const res = await apiClient.post('/v1/api/execution/disbursements', {
|
||||||
projectId: toIntegerId(payload.projectId),
|
...payload,
|
||||||
milestoneId: toNullableIntegerId(payload.milestoneId),
|
createdBy: userSub || undefined,
|
||||||
amount: Number(payload.amount),
|
|
||||||
});
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateDisbursement = async (payload: UpdateDisbursementRequest): Promise<void> => {
|
export const updateDisbursement = async (payload: UpdateDisbursementRequest): Promise<void> => {
|
||||||
|
const userSub = getCurrentUserSub();
|
||||||
await apiClient.put('/v1/api/execution/disbursements', {
|
await apiClient.put('/v1/api/execution/disbursements', {
|
||||||
disbursementId: toIntegerId(payload.disbursementId),
|
...payload,
|
||||||
milestoneId: toNullableIntegerId(payload.milestoneId),
|
updatedBy: userSub || undefined,
|
||||||
amount: Number(payload.amount),
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const approveDisbursement = async (disbursementId: string | number): Promise<void> => {
|
export const approveDisbursement = async (disbursementId: string | number): Promise<void> => {
|
||||||
const hashedDisbId = await toolsService.encodeId(disbursementId);
|
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> => {
|
export const createRisk = async (payload: CreateRiskRequest): Promise<Risk> => {
|
||||||
|
const userSub = getCurrentUserSub();
|
||||||
const res = await apiClient.post('/v1/api/execution/risks', {
|
const res = await apiClient.post('/v1/api/execution/risks', {
|
||||||
projectId: toIntegerId(payload.projectId),
|
...payload,
|
||||||
likelihood: Number(payload.likelihood),
|
likelihood: Number(payload.likelihood),
|
||||||
impact: Number(payload.impact),
|
impact: Number(payload.impact),
|
||||||
name: payload.name,
|
createdBy: userSub || undefined,
|
||||||
description: payload.description || '',
|
|
||||||
});
|
});
|
||||||
return normalizeRisk(res.data);
|
return normalizeRisk(res.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateRisk = async (payload: UpdateRiskRequest): Promise<void> => {
|
export const updateRisk = async (payload: UpdateRiskRequest): Promise<void> => {
|
||||||
|
const userSub = getCurrentUserSub();
|
||||||
await apiClient.put('/v1/api/execution/risks', {
|
await apiClient.put('/v1/api/execution/risks', {
|
||||||
riskId: toIntegerId(payload.riskId),
|
...payload,
|
||||||
likelihood: Number(payload.likelihood),
|
likelihood: Number(payload.likelihood),
|
||||||
impact: Number(payload.impact),
|
impact: Number(payload.impact),
|
||||||
name: payload.name,
|
updatedBy: userSub || undefined,
|
||||||
description: payload.description || '',
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -846,19 +741,20 @@ export const getIndicator = async (indicatorId: string | number): Promise<Indica
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createIndicator = async (payload: CreateIndicatorRequest): Promise<Indicator> => {
|
export const createIndicator = async (payload: CreateIndicatorRequest): Promise<Indicator> => {
|
||||||
|
const userSub = getCurrentUserSub();
|
||||||
const res = await apiClient.post('/v1/api/evidence/indicators', {
|
const res = await apiClient.post('/v1/api/evidence/indicators', {
|
||||||
projectId: toIntegerId(payload.projectId),
|
...payload,
|
||||||
name: payload.name,
|
createdBy: userSub || undefined,
|
||||||
unitOfMeasure: Number(payload.unitOfMeasure) || 1,
|
|
||||||
baselineAmount: Number(payload.baselineAmount) || 0,
|
|
||||||
targetAmount: Number(payload.targetAmount) || 0,
|
|
||||||
});
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const recordIndicatorActual = async (indicatorId: string | number, actualAmount: number): Promise<void> => {
|
export const recordIndicatorActual = async (indicatorId: string | number, actualAmount: number): Promise<void> => {
|
||||||
const hashedIndId = await toolsService.encodeId(indicatorId);
|
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[]> => {
|
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> => {
|
export const updateEvidence = async (payload: UpdateEvidenceRequest): Promise<void> => {
|
||||||
|
const userSub = getCurrentUserSub();
|
||||||
await apiClient.put('/v1/api/evidence', {
|
await apiClient.put('/v1/api/evidence', {
|
||||||
evidenceId: toIntegerId(payload.evidenceId),
|
...payload,
|
||||||
version: Number(payload.version) || 1,
|
updatedBy: userSub || undefined,
|
||||||
evidenceType: Number(payload.evidenceType),
|
|
||||||
status: Number(payload.status),
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -922,36 +817,7 @@ export const getUsers = async (
|
|||||||
pageSize = 100,
|
pageSize = 100,
|
||||||
filter: UserFilterRequest = {}
|
filter: UserFilterRequest = {}
|
||||||
): Promise<User[]> => {
|
): Promise<User[]> => {
|
||||||
const tenantId = getCurrentUserTenantId();
|
const res = await apiClient.post<User[]>(`/v1/api/users/${page}/${pageSize}`, filter);
|
||||||
// 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 list = res.data || [];
|
const list = res.data || [];
|
||||||
registerUserEmails(list);
|
registerUserEmails(list);
|
||||||
return list;
|
return list;
|
||||||
@@ -1044,20 +910,10 @@ export const syncIntegration = async (force = true): Promise<void> => {
|
|||||||
// 8. Communications & Invites APIs
|
// 8. Communications & Invites APIs
|
||||||
// ==========================================
|
// ==========================================
|
||||||
export const sendInvite = async (payload: InviteRequest): Promise<Invite> => {
|
export const sendInvite = async (payload: InviteRequest): Promise<Invite> => {
|
||||||
const tenantId = getCurrentUserTenantId();
|
const userSub = getCurrentUserSub();
|
||||||
const hostOrgId =
|
|
||||||
payload.organisationId !== undefined && payload.organisationId !== '' && payload.organisationId !== null
|
|
||||||
? toIntegerId(payload.organisationId)
|
|
||||||
: (tenantId !== null ? tenantId : 0);
|
|
||||||
|
|
||||||
const res = await apiClient.post<Invite>('/v1/api/communications/invite', {
|
const res = await apiClient.post<Invite>('/v1/api/communications/invite', {
|
||||||
organisationId: hostOrgId,
|
...payload,
|
||||||
invitedOrganisationId: toIntegerId(payload.invitedOrganisationId),
|
createdBy: userSub || undefined,
|
||||||
awardId: toIntegerId(payload.awardId),
|
|
||||||
contractId: toIntegerId(payload.contractId),
|
|
||||||
recipient: payload.recipient,
|
|
||||||
message: payload.message,
|
|
||||||
platform: payload.platform !== undefined ? Number(payload.platform) : 1,
|
|
||||||
});
|
});
|
||||||
return res.data;
|
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.
|
* Deterministically decodes a HashID string back to original numeric ID or string.
|
||||||
* Synchronous and does not perform HTTP network round trips.
|
* Synchronous and does not perform HTTP network round trips.
|
||||||
*/
|
*/
|
||||||
export function decodeHashIdSync(hashId: string | number | null | undefined): number | string {
|
export function decodeHashIdSync(hashId: string | null | undefined): number | string {
|
||||||
if (hashId === undefined || hashId === null || hashId === '') return '';
|
if (!hashId) return '';
|
||||||
if (typeof hashId === 'number') return hashId;
|
|
||||||
|
|
||||||
const str = String(hashId).trim();
|
const str = String(hashId).trim();
|
||||||
if (!str) return '';
|
if (!str) return '';
|
||||||
|
|
||||||
// If already pure integer string
|
|
||||||
if (/^-?\d+$/.test(str)) {
|
|
||||||
const parsed = parseInt(str, 10);
|
|
||||||
return isNaN(parsed) ? str : parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const decoded = hasher.decode(str);
|
const decoded = hasher.decode(str);
|
||||||
if (Array.isArray(decoded) && decoded.length > 0) {
|
if (Array.isArray(decoded) && decoded.length > 0) {
|
||||||
@@ -124,30 +116,6 @@ export function decodeHashIdSync(hashId: string | number | null | undefined): nu
|
|||||||
return str;
|
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 = {
|
export const toolsService = {
|
||||||
/**
|
/**
|
||||||
* Synchronous encoding
|
* Synchronous encoding
|
||||||
@@ -159,17 +127,10 @@ export const toolsService = {
|
|||||||
/**
|
/**
|
||||||
* Synchronous decoding
|
* Synchronous decoding
|
||||||
*/
|
*/
|
||||||
decodeIdSync(hashId: string | number | null | undefined): number | string {
|
decodeIdSync(hashId: string | null | undefined): number | string {
|
||||||
return decodeHashIdSync(hashId);
|
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)
|
* 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;
|
name?: string | null;
|
||||||
role?: string | null;
|
role?: string | null;
|
||||||
tenant?: string | null;
|
tenant?: string | null;
|
||||||
tenant_id?: number | string | null;
|
|
||||||
organisationId?: number | string | null;
|
|
||||||
organisationName?: string | null;
|
organisationName?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,19 +199,6 @@ async function executeBackendSync(
|
|||||||
userSub?: string,
|
userSub?: string,
|
||||||
profile?: UserSyncProfile
|
profile?: UserSyncProfile
|
||||||
): Promise<{ endpoint: string; data?: any }> {
|
): 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 = {
|
const payload = {
|
||||||
id: userSub || profile?.id || profile?.sub,
|
id: userSub || profile?.id || profile?.sub,
|
||||||
sub: userSub || profile?.sub,
|
sub: userSub || profile?.sub,
|
||||||
@@ -221,8 +206,6 @@ async function executeBackendSync(
|
|||||||
name: profile?.name || undefined,
|
name: profile?.name || undefined,
|
||||||
role: profile?.role || undefined,
|
role: profile?.role || undefined,
|
||||||
tenant: profile?.tenant || undefined,
|
tenant: profile?.tenant || undefined,
|
||||||
tenant_id: parsedTenantId,
|
|
||||||
organisationId: resolvedOrgId,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const candidateEndpoints = USER_SYNC_CANDIDATE_ENDPOINTS;
|
const candidateEndpoints = USER_SYNC_CANDIDATE_ENDPOINTS;
|
||||||
|
|||||||
@@ -261,8 +261,6 @@ export interface User {
|
|||||||
name?: string;
|
name?: string;
|
||||||
organisationId?: number | string;
|
organisationId?: number | string;
|
||||||
organisationName?: string;
|
organisationName?: string;
|
||||||
tenant?: string;
|
|
||||||
tenant_id?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SystemUser = User;
|
export type SystemUser = User;
|
||||||
@@ -288,7 +286,6 @@ export interface UserFilterRequest {
|
|||||||
email?: string | null;
|
email?: string | null;
|
||||||
sortBy?: string | null;
|
sortBy?: string | null;
|
||||||
sortDescending?: boolean;
|
sortDescending?: boolean;
|
||||||
organisationId?: number | string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Organisation {
|
export interface Organisation {
|
||||||
|
|||||||
@@ -75,53 +75,12 @@ function humanizeFieldName(field: string): string {
|
|||||||
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
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.
|
* Cleans technical jargon, stack traces, and framework namespaces from text strings.
|
||||||
*/
|
*/
|
||||||
function cleanTechnicalJargon(text: string): string {
|
function cleanTechnicalJargon(text: string): string {
|
||||||
if (!text) return '';
|
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
|
const cleaned = text
|
||||||
.replace(/^AxiosError:\s*/i, '')
|
.replace(/^AxiosError:\s*/i, '')
|
||||||
.replace(/^Error:\s*/i, '')
|
.replace(/^Error:\s*/i, '')
|
||||||
@@ -246,28 +205,23 @@ export function parseFriendlyError(
|
|||||||
rawErrors = dataObj;
|
rawErrors = dataObj;
|
||||||
}
|
}
|
||||||
} else if (typeof rawResponseData === 'string') {
|
} else if (typeof rawResponseData === 'string') {
|
||||||
if (isRawHttpOutput(rawResponseData)) {
|
// Check if response data is stringified JSON containing ProblemDetails
|
||||||
technicalSummary = sanitizeTechnicalDetails(rawResponseData);
|
try {
|
||||||
rawDetail = undefined;
|
const parsed = JSON.parse(rawResponseData) as any;
|
||||||
} else {
|
if (isProblemDetailsObject(parsed)) {
|
||||||
// Check if response data is stringified JSON containing ProblemDetails
|
isProblemDetails = true;
|
||||||
try {
|
rawTitle = parsed.title || parsed.Title || undefined;
|
||||||
const parsed = JSON.parse(rawResponseData) as any;
|
rawDetail = parsed.detail || parsed.Detail || undefined;
|
||||||
if (isProblemDetailsObject(parsed)) {
|
statusCode = parsed.status || parsed.Status || statusCode;
|
||||||
isProblemDetails = true;
|
rawErrors = parsed.errors ?? parsed.Errors ?? parsed.errorList ?? parsed.ErrorList;
|
||||||
rawTitle = parsed.title || parsed.Title || undefined;
|
} else if (Array.isArray(parsed)) {
|
||||||
rawDetail = parsed.detail || parsed.Detail || undefined;
|
rawErrors = parsed;
|
||||||
statusCode = parsed.status || parsed.Status || statusCode;
|
} else {
|
||||||
rawErrors = parsed.errors ?? parsed.Errors ?? parsed.errorList ?? parsed.ErrorList;
|
rawDetail = cleanTechnicalJargon(rawResponseData);
|
||||||
} else if (Array.isArray(parsed)) {
|
}
|
||||||
rawErrors = parsed;
|
} catch {
|
||||||
} else {
|
if (!rawResponseData.includes('<html')) {
|
||||||
rawDetail = cleanTechnicalJargon(rawResponseData);
|
rawDetail = cleanTechnicalJargon(rawResponseData);
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if (!rawResponseData.includes('<html')) {
|
|
||||||
rawDetail = cleanTechnicalJargon(rawResponseData);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -527,6 +481,6 @@ export function parseFriendlyError(
|
|||||||
categoryLabel,
|
categoryLabel,
|
||||||
fieldErrors: fieldErrors.length > 0 ? fieldErrors : undefined,
|
fieldErrors: fieldErrors.length > 0 ? fieldErrors : undefined,
|
||||||
errorsList: errorsList.length > 0 ? errorsList : undefined,
|
errorsList: errorsList.length > 0 ? errorsList : undefined,
|
||||||
technicalDetails: technicalSummary ? sanitizeTechnicalDetails(technicalSummary) : (statusCode ? `HTTP Status ${statusCode}` : undefined),
|
technicalDetails: technicalSummary || (statusCode ? `HTTP Status ${statusCode}` : undefined),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user