File diff suppressed because it is too large
Load Diff
@@ -80,8 +80,12 @@ 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
|
||||||
if (sub && (user?.email || claims.email)) {
|
const effectiveEmail = ((user?.email || claims?.email || claims?.['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] || claims?.upn) as string);
|
||||||
registerUserEmail(sub, ((user?.email || claims.email) as string));
|
if (sub && effectiveEmail) {
|
||||||
|
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
|
||||||
@@ -148,31 +152,20 @@ export const AppLayout: React.FC = () => {
|
|||||||
{navItems.map((item) => {
|
{navItems.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
return (
|
return (
|
||||||
<div key={item.path} className="flex items-center justify-between group">
|
<NavLink
|
||||||
<NavLink
|
key={item.path}
|
||||||
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 flex-1 min-w-0 ${
|
`flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all 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>
|
||||||
@@ -210,21 +203,6 @@ 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="/"
|
||||||
|
|||||||
@@ -797,9 +797,8 @@ 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={hashId || String(award.id)}>
|
<option key={award.id} value={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]'}
|
||||||
@@ -808,7 +807,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">
|
||||||
The endpoint expects the award identifier hash; selecting an award links Programme, Project, and Organisation.
|
Selecting an approved award links Programme, Project, and Organisation automatically.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useRef, useEffect, ReactNode } from 'react';
|
import React, { useState, useRef, useEffect, useLayoutEffect, useCallback, 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 {
|
||||||
@@ -25,14 +26,118 @@ 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 containerRef = useRef<HTMLDivElement>(null);
|
const [coords, setCoords] = useState<{ top: number; left: number } | null>(null);
|
||||||
|
|
||||||
// Close on Escape or click outside
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const updatePosition = useCallback(() => {
|
||||||
|
if (!buttonRef.current) return;
|
||||||
|
const triggerRect = buttonRef.current.getBoundingClientRect();
|
||||||
|
const tooltipEl = tooltipRef.current;
|
||||||
|
const tooltipWidth = tooltipEl ? tooltipEl.offsetWidth : 288;
|
||||||
|
const tooltipHeight = tooltipEl ? tooltipEl.offsetHeight : 120;
|
||||||
|
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
const PADDING = 12;
|
||||||
|
const GAP = 8;
|
||||||
|
|
||||||
|
const spaceAbove = triggerRect.top - GAP - PADDING;
|
||||||
|
const spaceBelow = viewportHeight - triggerRect.bottom - GAP - PADDING;
|
||||||
|
const spaceLeft = triggerRect.left - GAP - PADDING;
|
||||||
|
const spaceRight = viewportWidth - triggerRect.right - GAP - PADDING;
|
||||||
|
|
||||||
|
let chosenPlacement = placement;
|
||||||
|
|
||||||
|
if (placement === 'bottom') {
|
||||||
|
if (spaceBelow < tooltipHeight && spaceAbove > spaceBelow) {
|
||||||
|
chosenPlacement = 'top';
|
||||||
|
}
|
||||||
|
} else if (placement === 'top') {
|
||||||
|
if (spaceAbove < tooltipHeight && spaceBelow > spaceAbove) {
|
||||||
|
chosenPlacement = 'bottom';
|
||||||
|
}
|
||||||
|
} else if (placement === 'left') {
|
||||||
|
if (spaceLeft < tooltipWidth && spaceRight > spaceLeft) {
|
||||||
|
chosenPlacement = 'right';
|
||||||
|
}
|
||||||
|
} else if (placement === 'right') {
|
||||||
|
if (spaceRight < tooltipWidth && spaceLeft > spaceRight) {
|
||||||
|
chosenPlacement = 'left';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let top = 0;
|
||||||
|
let left = 0;
|
||||||
|
|
||||||
|
if (chosenPlacement === 'top') {
|
||||||
|
top = triggerRect.top - tooltipHeight - GAP;
|
||||||
|
left = triggerRect.left + triggerRect.width / 2 - tooltipWidth / 2;
|
||||||
|
} else if (chosenPlacement === 'bottom') {
|
||||||
|
top = triggerRect.bottom + GAP;
|
||||||
|
left = triggerRect.left + triggerRect.width / 2 - tooltipWidth / 2;
|
||||||
|
} else if (chosenPlacement === 'left') {
|
||||||
|
top = triggerRect.top + triggerRect.height / 2 - tooltipHeight / 2;
|
||||||
|
left = triggerRect.left - tooltipWidth - GAP;
|
||||||
|
} else {
|
||||||
|
// right
|
||||||
|
top = triggerRect.top + triggerRect.height / 2 - tooltipHeight / 2;
|
||||||
|
left = triggerRect.right + GAP;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Viewport boundary clamping to guarantee tooltip is never cropped by screen edges
|
||||||
|
const maxLeft = Math.max(PADDING, viewportWidth - tooltipWidth - PADDING);
|
||||||
|
left = Math.max(PADDING, Math.min(left, maxLeft));
|
||||||
|
|
||||||
|
const maxTop = Math.max(PADDING, viewportHeight - tooltipHeight - PADDING);
|
||||||
|
top = Math.max(PADDING, Math.min(top, maxTop));
|
||||||
|
|
||||||
|
setCoords({ top, left });
|
||||||
|
}, [placement]);
|
||||||
|
|
||||||
|
// Position updates on open, resize, or scroll of any container
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setCoords(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePosition();
|
||||||
|
|
||||||
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
if (typeof ResizeObserver !== 'undefined' && tooltipRef.current) {
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
updatePosition();
|
||||||
|
});
|
||||||
|
resizeObserver.observe(tooltipRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', updatePosition, { passive: true });
|
||||||
|
window.addEventListener('scroll', updatePosition, { capture: true, passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (resizeObserver) {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
}
|
||||||
|
window.removeEventListener('resize', updatePosition);
|
||||||
|
window.removeEventListener('scroll', updatePosition, true);
|
||||||
|
};
|
||||||
|
}, [isOpen, updatePosition]);
|
||||||
|
|
||||||
|
// Close on Escape or click outside both button and tooltip
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
|
|
||||||
const handleOutsideClick = (e: MouseEvent | TouchEvent) => {
|
const handleOutsideClick = (e: MouseEvent | TouchEvent) => {
|
||||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
const target = e.target as Node;
|
||||||
|
if (
|
||||||
|
buttonRef.current &&
|
||||||
|
!buttonRef.current.contains(target) &&
|
||||||
|
tooltipRef.current &&
|
||||||
|
!tooltipRef.current.contains(target)
|
||||||
|
) {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -66,16 +171,53 @@ export const HelpTooltip: React.FC<HelpTooltipProps> = ({
|
|||||||
lg: 'w-4 h-4',
|
lg: 'w-4 h-4',
|
||||||
}[size];
|
}[size];
|
||||||
|
|
||||||
const placementClasses = {
|
const tooltipPortal =
|
||||||
top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
|
isOpen && typeof document !== 'undefined'
|
||||||
bottom: 'top-full left-1/2 -translate-x-1/2 mt-2',
|
? createPortal(
|
||||||
left: 'right-full top-1/2 -translate-y-1/2 mr-2',
|
<div
|
||||||
right: 'left-full top-1/2 -translate-y-1/2 ml-2',
|
ref={tooltipRef}
|
||||||
}[placement];
|
role="tooltip"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: coords ? `${coords.top}px` : '-9999px',
|
||||||
|
left: coords ? `${coords.left}px` : '-9999px',
|
||||||
|
visibility: coords ? 'visible' : 'hidden',
|
||||||
|
}}
|
||||||
|
className="z-[9999] w-72 max-w-[calc(100vw-24px)] p-3.5 rounded-xl bg-[#0c121e] border border-blue-500/30 text-slate-200 shadow-2xl backdrop-blur-md animate-in fade-in zoom-in-95 duration-150"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2 border-b border-slate-800/80 pb-1.5 mb-2">
|
||||||
|
<div className="flex items-center gap-1.5 text-blue-400">
|
||||||
|
<Info className="w-3.5 h-3.5 shrink-0" />
|
||||||
|
<span className="font-semibold text-xs tracking-tight text-white">
|
||||||
|
{title || 'Guidance & Information'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
className="rounded-md p-0.5 text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||||
|
aria-label="Close tooltip"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-[11px] leading-relaxed text-slate-300 font-normal space-y-1">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
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}
|
||||||
@@ -89,37 +231,7 @@ export const HelpTooltip: React.FC<HelpTooltipProps> = ({
|
|||||||
<Info className={`${iconSizes} shrink-0`} />
|
<Info className={`${iconSizes} shrink-0`} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{tooltipPortal}
|
||||||
<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,24 +43,28 @@ export const ProblemDetailsAlert: React.FC<ProblemDetailsAlertProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Single error or fallback message displayed directly */}
|
{/* Direct error message or error list from ProblemDetails */}
|
||||||
{(!parsed.errorsList || parsed.errorsList.length <= 1) && parsed.message && (
|
{parsed.errorsList && parsed.errorsList.length > 0 ? (
|
||||||
|
<div className="mt-2 space-y-1.5">
|
||||||
|
{parsed.message && !parsed.errorsList.includes(parsed.message) && (
|
||||||
|
<p className="text-xs text-rose-200/90 leading-relaxed whitespace-pre-line">
|
||||||
|
{parsed.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<ul className="space-y-1 bg-black/30 rounded-lg p-2.5 border border-rose-900/40">
|
||||||
|
{parsed.errorsList.map((err, idx) => (
|
||||||
|
<li key={idx} className="text-xs text-rose-200/90 flex items-start gap-2">
|
||||||
|
<span className="text-rose-400 mt-0.5 font-bold">•</span>
|
||||||
|
<span className="leading-snug">{err}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : parsed.message ? (
|
||||||
<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,16 +155,21 @@ const ToastCard: React.FC<{
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Display errors directly if multiple, or single message directly */}
|
{/* Display errors from ProblemDetails errors member */}
|
||||||
{toast.errorsList && toast.errorsList.length > 1 ? (
|
{toast.errorsList && toast.errorsList.length > 0 ? (
|
||||||
<ul className="mt-1.5 space-y-1">
|
<div className="mt-1.5 space-y-1">
|
||||||
{toast.errorsList.map((err, idx) => (
|
{toast.message && !toast.errorsList.includes(toast.message) && (
|
||||||
<li key={idx} className="text-xs text-rose-200/90 flex items-start gap-1.5">
|
<p className="text-xs text-slate-300 leading-relaxed">{toast.message}</p>
|
||||||
<span className="text-rose-400 mt-0.5">•</span>
|
)}
|
||||||
<span className="leading-snug break-words">{err}</span>
|
<ul className="space-y-1">
|
||||||
</li>
|
{toast.errorsList.map((err, idx) => (
|
||||||
))}
|
<li key={idx} className="text-xs text-rose-200/90 flex items-start gap-1.5">
|
||||||
</ul>
|
<span className="text-rose-400 mt-0.5 font-bold">•</span>
|
||||||
|
<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,6 +512,7 @@ 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()) {
|
||||||
@@ -519,6 +520,7 @@ 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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -531,6 +533,11 @@ 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();
|
||||||
@@ -538,5 +545,28 @@ 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,6 +13,7 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
Clock,
|
Clock,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
|
RotateCcw,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
getDashboard,
|
getDashboard,
|
||||||
@@ -36,6 +37,18 @@ 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'],
|
||||||
@@ -318,41 +331,79 @@ 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="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-[#0d121f]/90 p-5 rounded-2xl border border-slate-800/80 backdrop-blur-md shadow-xl">
|
<div 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>
|
{/* Top Tier: Title, Status Badge, and Primary Actions */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
|
||||||
<h2 className="text-xl font-bold text-white tracking-tight">
|
<div>
|
||||||
Executive Portfolio Dashboard
|
<div className="flex items-center gap-2.5 flex-wrap">
|
||||||
</h2>
|
<h2 className="text-xl sm:text-2xl font-bold text-white tracking-tight">
|
||||||
<HelpTooltip
|
Executive Portfolio Dashboard
|
||||||
title="Executive Dashboard Overview"
|
</h2>
|
||||||
content="Macro-level command center showing aggregated national portfolio commitments, actual tranche disbursements, delivery pace, and critical project escalations."
|
<HelpTooltip
|
||||||
size="sm"
|
title="Executive Dashboard Overview"
|
||||||
placement="bottom"
|
content="Macro-level command center showing aggregated national portfolio commitments, actual tranche disbursements, delivery pace, and critical project escalations."
|
||||||
/>
|
size="sm"
|
||||||
<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">
|
placement="bottom"
|
||||||
Live Portfolio Feed
|
/>
|
||||||
</span>
|
<span className="inline-flex items-center gap-1.5 text-[11px] font-semibold text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-full border border-blue-500/30">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
|
||||||
|
Live Portfolio Feed
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs sm:text-sm text-slate-400 mt-1">
|
||||||
|
Real-time portfolio overview, disbursement tracking, risk heatmap, and projects requiring governance action.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2.5 shrink-0 self-start lg:self-center">
|
||||||
|
{isFiltered && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={resetFilters}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-amber-300 bg-amber-500/10 hover:bg-amber-500/20 border border-amber-500/30 rounded-xl transition cursor-pointer shadow-xs"
|
||||||
|
title="Reset all filters to default"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
<span>Reset Filters</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={refetchAll}
|
||||||
|
disabled={isFetching}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900 hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
|
||||||
|
title="Refresh dashboard metrics"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3.5 h-3.5 ${isFetching ? 'animate-spin text-blue-400' : 'text-slate-400'}`} />
|
||||||
|
<span>{isFetching ? 'Refreshing...' : 'Refresh'}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Dedicated Filter Toolbar */}
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="pt-4 border-t border-slate-800/80 flex flex-wrap items-center gap-3">
|
||||||
<div className="flex items-center gap-1.5 text-xs text-slate-400">
|
<div className="flex items-center gap-2 text-xs font-semibold text-slate-300 pr-1">
|
||||||
<Filter className="w-3.5 h-3.5 text-slate-500" />
|
<Filter className="w-3.5 h-3.5 text-blue-400" />
|
||||||
<span>Filter:</span>
|
<span>Filters:</span>
|
||||||
|
{activeFilterCount > 0 && (
|
||||||
|
<span className="text-[10px] font-bold text-blue-300 bg-blue-500/20 border border-blue-500/40 px-2 py-0.5 rounded-full">
|
||||||
|
{activeFilterCount} Active
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
{/* Programme Filter */}
|
||||||
|
<div className="flex items-center bg-[#090d16] border border-slate-700/80 hover:border-slate-600 rounded-xl px-3 py-1.5 text-xs transition shadow-inner">
|
||||||
|
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider mr-2 shrink-0">
|
||||||
|
Programme
|
||||||
|
</span>
|
||||||
<select
|
<select
|
||||||
value={selectedProgramme}
|
value={selectedProgramme}
|
||||||
onChange={(e) => setSelectedProgramme(e.target.value)}
|
onChange={(e) => setSelectedProgramme(e.target.value)}
|
||||||
className="text-xs font-medium bg-[#090d16] border border-slate-700/80 rounded-xl px-3 py-1.5 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 transition cursor-pointer"
|
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"
|
||||||
>
|
>
|
||||||
<option value="All">Programme: All</option>
|
<option value="All">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}
|
||||||
@@ -367,13 +418,17 @@ export const ExecutiveDashboard: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
{/* Sector Filter */}
|
||||||
|
<div className="flex items-center bg-[#090d16] border border-slate-700/80 hover:border-slate-600 rounded-xl px-3 py-1.5 text-xs transition shadow-inner">
|
||||||
|
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider mr-2 shrink-0">
|
||||||
|
Sector
|
||||||
|
</span>
|
||||||
<select
|
<select
|
||||||
value={selectedSector}
|
value={selectedSector}
|
||||||
onChange={(e) => setSelectedSector(e.target.value)}
|
onChange={(e) => setSelectedSector(e.target.value)}
|
||||||
className="text-xs font-medium bg-[#090d16] border border-slate-700/80 rounded-xl px-3 py-1.5 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 transition cursor-pointer"
|
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"
|
||||||
>
|
>
|
||||||
<option value="All">Sector: All</option>
|
<option value="All">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}
|
||||||
@@ -388,14 +443,18 @@ export const ExecutiveDashboard: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
{/* Financial Period Filter */}
|
||||||
|
<div className="flex items-center bg-[#090d16] border border-slate-700/80 hover:border-slate-600 rounded-xl px-3 py-1.5 text-xs transition shadow-inner">
|
||||||
|
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider mr-2 shrink-0">
|
||||||
|
Period
|
||||||
|
</span>
|
||||||
<select
|
<select
|
||||||
value={selectedPeriod}
|
value={selectedPeriod}
|
||||||
onChange={(e) => setSelectedPeriod(e.target.value)}
|
onChange={(e) => setSelectedPeriod(e.target.value)}
|
||||||
className="text-xs font-medium bg-[#090d16] border border-slate-700/80 rounded-xl px-3 py-1.5 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 transition cursor-pointer"
|
className="bg-transparent text-xs font-medium text-slate-100 focus:outline-none cursor-pointer pr-1 [&>option]:bg-slate-900 [&>option]:text-white"
|
||||||
>
|
>
|
||||||
<option value="FY 2026/27">Period: FY 2026/27</option>
|
<option value="FY 2026/27">FY 2026/27</option>
|
||||||
<option value="FY 2025/26">Period: FY 2025/26</option>
|
<option value="FY 2025/26">FY 2025/26</option>
|
||||||
<option value="All-Time">All-Time Cumulative</option>
|
<option value="All-Time">All-Time Cumulative</option>
|
||||||
</select>
|
</select>
|
||||||
<HelpTooltip
|
<HelpTooltip
|
||||||
@@ -405,15 +464,6 @@ 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>
|
||||||
|
|
||||||
@@ -612,7 +662,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" className="text-blue-400 font-medium hover:underline flex items-center gap-1">
|
<Link to="/governance?tab=portfolios" 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,4 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } 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';
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
Coins,
|
Coins,
|
||||||
Eye,
|
Eye,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
ArrowRight,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
getOrganisations,
|
getOrganisations,
|
||||||
@@ -34,6 +36,8 @@ 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';
|
||||||
@@ -58,6 +62,8 @@ 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,
|
||||||
@@ -76,14 +82,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'),
|
||||||
ownerId: z.string().min(1, 'Owner ID is required'),
|
ownerEmail: z.string().min(1, 'Owner email 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(),
|
||||||
ownerId: z.string().min(1, 'Owner ID is required'),
|
ownerEmail: z.string().min(1, 'Owner email is required'),
|
||||||
});
|
});
|
||||||
|
|
||||||
type OrganisationFormValues = z.infer<typeof organisationSchema>;
|
type OrganisationFormValues = z.infer<typeof organisationSchema>;
|
||||||
@@ -92,11 +98,72 @@ 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 } = useAuth();
|
const { sub, user, claims } = 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);
|
||||||
@@ -125,6 +192,12 @@ 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(),
|
||||||
@@ -182,6 +255,92 @@ 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,
|
||||||
@@ -200,7 +359,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,
|
||||||
ownerId: currentUserId,
|
ownerEmail: effectiveUserEmail,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -210,7 +369,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,
|
||||||
ownerId: currentUserId,
|
ownerEmail: effectiveUserEmail,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -243,16 +402,22 @@ 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: data.ownerId,
|
ownerId: resolvedOwnerId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return createPortfolio(data as CreatePortfolioRequest);
|
return createPortfolio({
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
organisationId: Number(data.organisationId),
|
||||||
|
ownerId: resolvedOwnerId,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['portfolios'] });
|
queryClient.invalidateQueries({ queryKey: ['portfolios'] });
|
||||||
@@ -268,16 +433,21 @@ 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: data.ownerId,
|
ownerId: resolvedOwnerId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return createProgramme(data as CreateProgrammeRequest);
|
return createProgramme({
|
||||||
|
name: data.name,
|
||||||
|
portfolioId: Number(data.portfolioId),
|
||||||
|
ownerId: resolvedOwnerId,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['programmes'] });
|
queryClient.invalidateQueries({ queryKey: ['programmes'] });
|
||||||
@@ -321,18 +491,19 @@ 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,
|
||||||
ownerId: currentUserId,
|
ownerEmail: effectiveUserEmail,
|
||||||
});
|
});
|
||||||
setIsPortfolioModalOpen(true);
|
setIsPortfolioModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEditPortfolio = (p: Portfolio) => {
|
const openEditPortfolio = async (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,
|
||||||
ownerId: p.ownedBy || currentUserId,
|
ownerEmail: email,
|
||||||
});
|
});
|
||||||
setIsPortfolioModalOpen(true);
|
setIsPortfolioModalOpen(true);
|
||||||
};
|
};
|
||||||
@@ -343,18 +514,19 @@ 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,
|
||||||
ownerId: currentUserId,
|
ownerEmail: effectiveUserEmail,
|
||||||
});
|
});
|
||||||
setIsProgrammeModalOpen(true);
|
setIsProgrammeModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEditProgramme = (pr: Programme) => {
|
const openEditProgramme = async (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,
|
||||||
ownerId: pr.ownedBy || currentUserId,
|
ownerEmail: email,
|
||||||
});
|
});
|
||||||
setIsProgrammeModalOpen(true);
|
setIsProgrammeModalOpen(true);
|
||||||
};
|
};
|
||||||
@@ -425,7 +597,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={() => setActiveTab('organisations')}
|
onClick={() => handleTabChange('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'
|
||||||
@@ -442,7 +614,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('portfolios')}
|
onClick={() => handleTabChange('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'
|
||||||
@@ -459,7 +631,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('programmes')}
|
onClick={() => handleTabChange('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'
|
||||||
@@ -568,7 +740,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 (GET /v1/api/governance/organisations/{id})"
|
title="Inspect Organisation"
|
||||||
>
|
>
|
||||||
<Eye className="w-3 h-3" />
|
<Eye className="w-3 h-3" />
|
||||||
Inspect
|
Inspect
|
||||||
@@ -601,8 +773,17 @@ 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 key={p.id} className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm flex flex-col justify-between hover:border-slate-700/80 transition-all">
|
<div
|
||||||
|
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">
|
||||||
@@ -614,48 +795,53 @@ 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">
|
<div className="mt-4 pt-3 border-t border-slate-800/80 space-y-2.5">
|
||||||
<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-[170px] font-medium text-slate-300">{org ? org.name : `Org #${p.organisationId}`}</span>
|
<span className="truncate max-w-[200px] font-medium text-slate-300" title={org ? org.name : `Org #${p.organisationId}`}>
|
||||||
<span className="font-mono text-[11px] text-slate-500">{formatDate(p.createdAt)}</span>
|
{org ? org.name : `Org #${p.organisationId}`}
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between pt-1 text-xs">
|
|
||||||
<span className="text-slate-400 font-medium truncate max-w-[190px]">
|
|
||||||
Owner: <span className="text-slate-200 font-mono text-[11px]">{getUserEmail(p.ownedBy)}</span>
|
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1.5">
|
<span className="font-mono text-[11px] text-slate-500 shrink-0">{formatDate(p.createdAt)}</span>
|
||||||
<button
|
</div>
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
<div className="flex items-center gap-1.5 text-xs text-slate-400 min-w-0">
|
||||||
setSelectedPortfolioForDetail(p);
|
<span className="font-medium shrink-0">Owner:</span>
|
||||||
setIsPortfolioDetailModalOpen(true);
|
<span className="text-slate-200 font-mono text-[11px] break-all">
|
||||||
}}
|
{getUserEmail(p.ownedBy)}
|
||||||
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"
|
</span>
|
||||||
title="Inspect Portfolio (GET /v1/api/governance/portfolios/{id})"
|
</div>
|
||||||
>
|
|
||||||
<Eye className="w-3 h-3" />
|
<div className="flex items-center justify-end gap-1.5 pt-1.5 border-t border-slate-800/50">
|
||||||
Inspect
|
<button
|
||||||
</button>
|
type="button"
|
||||||
<button
|
onClick={() => {
|
||||||
type="button"
|
setSelectedPortfolioForDetail(p);
|
||||||
onClick={() => {
|
setIsPortfolioDetailModalOpen(true);
|
||||||
setSelectedPortfolioForProgrammes(p);
|
}}
|
||||||
setIsPortfolioProgrammesModalOpen(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"
|
||||||
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"
|
>
|
||||||
>
|
<Eye className="w-3 h-3" />
|
||||||
<Layers className="w-3 h-3" />
|
Inspect
|
||||||
Programmes
|
</button>
|
||||||
</button>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={() => {
|
||||||
onClick={() => openEditPortfolio(p)}
|
setSelectedPortfolioForProgrammes(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"
|
setIsPortfolioProgrammesModalOpen(true);
|
||||||
>
|
}}
|
||||||
<Edit2 className="w-3 h-3" />
|
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"
|
||||||
Edit
|
>
|
||||||
</button>
|
<Layers className="w-3 h-3" />
|
||||||
</div>
|
Programmes
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openEditPortfolio(p)}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3 h-3" />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -718,7 +904,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 (GET /v1/api/projects/programme/{id})"
|
title="Inspect Programme"
|
||||||
>
|
>
|
||||||
<Eye className="w-3 h-3" />
|
<Eye className="w-3 h-3" />
|
||||||
Inspect
|
Inspect
|
||||||
@@ -972,11 +1158,22 @@ export const GovernancePage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="email"
|
||||||
{...portfolioForm.register('ownerId')}
|
list="portfolio-owner-email-suggestions"
|
||||||
|
{...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="khwezim@gmail.com"
|
placeholder="e.g. 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>
|
||||||
@@ -1111,11 +1308,22 @@ export const GovernancePage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="email"
|
||||||
{...programmeForm.register('ownerId')}
|
list="programme-owner-email-suggestions"
|
||||||
|
{...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="khwezim@gmail.com"
|
placeholder="e.g. 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>
|
||||||
|
|
||||||
@@ -1260,7 +1468,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Modal: View Organisation Details (GET /v1/api/governance/organisations/{id} & /portfolio) */}
|
{/* Modal: View Organisation Details & Linked Portfolio */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={isOrgDetailModalOpen}
|
isOpen={isOrgDetailModalOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
@@ -1274,51 +1482,89 @@ 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 canonical record (GET /v1/api/governance/organisations/{selectedOrgForDetail?.id})...
|
Fetching organisation details...
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="p-4 bg-[#0d121f] rounded-xl border border-slate-800 space-y-2">
|
<div className="p-4 bg-[#0d121f] rounded-xl border border-slate-800 space-y-3">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<div>
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Legal Name:</span>
|
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Legal Name:</span>
|
||||||
<span className="text-white font-medium">{orgFreshDetail?.name || selectedOrgForDetail?.name}</span>
|
<span className="text-white font-medium truncate">{orgFreshDetail?.name || selectedOrgForDetail?.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Reg Number:</span>
|
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">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>
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Official Email:</span>
|
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Official Email:</span>
|
||||||
<span className="font-mono text-slate-300">{orgFreshDetail?.email || selectedOrgForDetail?.email}</span>
|
<span className="font-mono text-slate-300 truncate">{orgFreshDetail?.email || selectedOrgForDetail?.email}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Entity Type:</span>
|
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">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>
|
||||||
<h4 className="font-semibold text-slate-300 text-xs mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<span>Linked Portfolios (GET /v1/api/governance/organisations/{selectedOrgForDetail?.id}/portfolio)</span>
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-[10px] font-mono text-slate-400">{orgPortfolios.length} linked</span>
|
<h4 className="font-semibold text-slate-300 text-xs">Linked Portfolios</h4>
|
||||||
</h4>
|
<span className="text-[10px] font-mono text-slate-400 bg-slate-800/80 border border-slate-700/60 px-2 py-0.5 rounded-full">
|
||||||
|
{orgPortfolios.length} linked
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{orgPortfolios.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleNavigateToPortfolioTab()}
|
||||||
|
className="inline-flex items-center gap-1 text-[11px] font-semibold text-blue-400 hover:text-blue-300 transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<span>View Portfolios tab</span>
|
||||||
|
<ArrowRight className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{loadingOrgPortfolios ? (
|
{loadingOrgPortfolios ? (
|
||||||
<p className="text-xs text-slate-500">Loading portfolios...</p>
|
<p className="text-xs text-slate-500 py-3 text-center">Loading portfolios...</p>
|
||||||
) : orgPortfolios.length === 0 ? (
|
) : orgPortfolios.length === 0 ? (
|
||||||
<p className="text-xs text-slate-500">No portfolios directly linked to this organisation.</p>
|
<div className="p-3.5 bg-slate-900/40 rounded-xl border border-slate-800/80 text-center">
|
||||||
|
<p className="text-xs text-slate-400">No portfolios directly linked to this organisation.</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2 max-h-[220px] overflow-y-auto">
|
<div className="space-y-2 max-h-[220px] overflow-y-auto pr-0.5">
|
||||||
{orgPortfolios.map((port) => (
|
{orgPortfolios.map((port) => (
|
||||||
<div key={port.id} className="p-3 bg-slate-900/60 rounded-xl border border-slate-800 flex items-center justify-between">
|
<div
|
||||||
<div>
|
key={port.id}
|
||||||
<span className="text-white font-medium block">{port.name}</span>
|
onClick={() => handleNavigateToPortfolioTab(port.id)}
|
||||||
<span className="text-[10px] text-slate-500 font-mono">ID: {port.id}</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"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
<span className="text-[10px] text-blue-400 bg-blue-500/10 px-2 py-0.5 rounded border border-blue-500/20 font-mono">
|
<button
|
||||||
Portfolio
|
type="button"
|
||||||
</span>
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleNavigateToPortfolioTab(port.id);
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1 text-[11px] font-semibold text-blue-400 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 px-2.5 py-1 rounded-lg transition shadow-xs shrink-0 cursor-pointer"
|
||||||
|
>
|
||||||
|
<span>Open</span>
|
||||||
|
<ArrowRight className="w-3 h-3 group-hover:translate-x-0.5 transition-transform" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1342,7 +1588,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Modal: View Portfolio Details (GET /v1/api/governance/portfolios/{id}) */}
|
{/* Modal: View Portfolio Details */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={isPortfolioDetailModalOpen}
|
isOpen={isPortfolioDetailModalOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
@@ -1356,7 +1602,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 canonical record (GET /v1/api/governance/portfolios/{selectedPortfolioForDetail?.id})...
|
Fetching portfolio details...
|
||||||
</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">
|
||||||
@@ -1368,14 +1614,16 @@ 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-2 gap-2 pt-2 border-t border-slate-800/80">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-3 border-t border-slate-800/80 items-center">
|
||||||
<div>
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Owner:</span>
|
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Owner:</span>
|
||||||
<span className="font-mono text-slate-300">{getUserEmail(portfolioFreshDetail?.ownedBy || selectedPortfolioForDetail?.ownedBy)}</span>
|
<span className="font-mono text-slate-300 text-xs truncate" title={getUserEmail(portfolioFreshDetail?.ownedBy || selectedPortfolioForDetail?.ownedBy)}>
|
||||||
|
{getUserEmail(portfolioFreshDetail?.ownedBy || selectedPortfolioForDetail?.ownedBy)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Created:</span>
|
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Created:</span>
|
||||||
<span className="font-mono text-slate-300">
|
<span className="font-mono text-slate-300 text-xs">
|
||||||
{formatDate(portfolioFreshDetail?.createdAt || selectedPortfolioForDetail?.createdAt || '')}
|
{formatDate(portfolioFreshDetail?.createdAt || selectedPortfolioForDetail?.createdAt || '')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1398,7 +1646,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Modal: View Programme Details (GET /v1/api/projects/programme/{id}) */}
|
{/* Modal: View Programme Details */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={isProgrammeDetailModalOpen}
|
isOpen={isProgrammeDetailModalOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
@@ -1412,24 +1660,26 @@ 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 canonical record (GET /v1/api/projects/programme/{selectedProgrammeForDetail?.id})...
|
Fetching programme details...
|
||||||
</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/80 space-y-3.5">
|
||||||
<div>
|
<div className="pb-3 border-b border-slate-800/80">
|
||||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Programme Name:</span>
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase tracking-wider mb-1">Programme Name</span>
|
||||||
<span className="text-white font-medium text-sm">{programmeFreshDetail?.name || selectedProgrammeForDetail?.name}</span>
|
<span className="text-white font-semibold text-sm block">{programmeFreshDetail?.name || selectedProgrammeForDetail?.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2 pt-2">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 items-center">
|
||||||
<div>
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Approval Status:</span>
|
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Approval Status:</span>
|
||||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).className}`}>
|
<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}`}>
|
||||||
{getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).label}
|
{getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Owner:</span>
|
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Owner:</span>
|
||||||
<span className="font-mono text-slate-300">{getUserEmail(programmeFreshDetail?.ownedBy || selectedProgrammeForDetail?.ownedBy)}</span>
|
<span className="font-mono text-slate-300 text-xs truncate" title={getUserEmail(programmeFreshDetail?.ownedBy || selectedProgrammeForDetail?.ownedBy)}>
|
||||||
|
{getUserEmail(programmeFreshDetail?.ownedBy || selectedProgrammeForDetail?.ownedBy)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useRef } from 'react';
|
import React, { useState, useRef, useEffect } 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';
|
||||||
@@ -20,16 +21,12 @@ 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,
|
||||||
@@ -40,10 +37,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,
|
||||||
@@ -85,13 +82,40 @@ 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 [activeTab, setActiveTab] = useState<'contracts' | 'evidence' | 'indicators' | 'risks'>('contracts');
|
const tabParam = searchParams.get('tab');
|
||||||
|
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);
|
||||||
@@ -99,11 +123,6 @@ 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({
|
||||||
@@ -111,14 +130,6 @@ 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(),
|
||||||
@@ -184,42 +195,6 @@ 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({
|
||||||
@@ -279,40 +254,14 @@ 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: 'Green (≥90%)', badge: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' };
|
if (pct >= 90) return { label: '≥90%', badge: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' };
|
||||||
if (pct >= 70) return { label: 'Amber (70–89%)', badge: 'bg-amber-500/10 text-amber-400 border-amber-500/20' };
|
if (pct >= 70) return { label: '70–89%', badge: 'bg-amber-500/10 text-amber-400 border-amber-500/20' };
|
||||||
return { label: 'Red (<70%)', badge: 'bg-rose-500/10 text-rose-400 border-rose-500/20' };
|
return { label: '<70%', badge: 'bg-rose-500/10 text-rose-400 border-rose-500/20' };
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -367,8 +316,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={() => setActiveTab('contracts')}
|
onClick={() => handleTabChange('contracts')}
|
||||||
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${
|
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 cursor-pointer ${
|
||||||
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'
|
||||||
@@ -384,25 +333,25 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('evidence')}
|
onClick={() => handleTabChange('evidence')}
|
||||||
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${
|
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 cursor-pointer ${
|
||||||
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'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Paperclip className="w-4 h-4" />
|
<FileCheck className="w-4 h-4" />
|
||||||
<span>Milestone Evidence & Review Queue</span>
|
<span>Evidence Review & Verification</span>
|
||||||
<HelpTooltip
|
<HelpTooltip
|
||||||
title="Verification Workflow"
|
title="Evidence Review & Verification"
|
||||||
content="Evidence submission, file validation, and peer review sign-off gating milestone payments."
|
content="Peer review, document inspection, and verification sign-offs for deliverables submitted against projects and milestones."
|
||||||
size="sm"
|
size="sm"
|
||||||
placement="top"
|
placement="top"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('indicators')}
|
onClick={() => handleTabChange('indicators')}
|
||||||
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${
|
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 cursor-pointer ${
|
||||||
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'
|
||||||
@@ -418,8 +367,8 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('risks')}
|
onClick={() => handleTabChange('risks')}
|
||||||
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${
|
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 cursor-pointer ${
|
||||||
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'
|
||||||
@@ -444,220 +393,14 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tab 1: Milestone Evidence Submission & Review */}
|
{/* Tab 1: Project & Milestone Evidence Review Hub */}
|
||||||
{activeTab === 'evidence' && (
|
{activeTab === 'evidence' && (
|
||||||
<div className="space-y-6">
|
<EvidenceReviewQueue
|
||||||
{/* Milestone Evidence Review Container */}
|
initialProjectId={searchParams.get('projectId') || 'all'}
|
||||||
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
|
initialMilestoneId={searchParams.get('milestoneId') || 'all'}
|
||||||
{/* Header Bar */}
|
initialActivityId={searchParams.get('activityId') || '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">
|
initialUserId={searchParams.get('userId') || 'all'}
|
||||||
<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) */}
|
||||||
@@ -964,7 +707,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 Green (>=90%), Amber (70-89%), or Red (<70%) status."
|
content="Current realized value for this reporting period. Automatically updates to ≥90%, 70–89%, or <70% performance threshold status."
|
||||||
size="sm"
|
size="sm"
|
||||||
placement="right"
|
placement="right"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
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';
|
||||||
@@ -520,15 +521,25 @@ 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">
|
||||||
<button
|
<div className="flex items-center justify-end gap-2">
|
||||||
type="button"
|
<Link
|
||||||
onClick={() => openEditProject(selectedProject)}
|
to={`/monitoring?tab=evidence&projectId=${selectedProject.id}`}
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-slate-200 bg-slate-800 hover:bg-slate-700 border border-slate-700/80 rounded-xl transition cursor-pointer"
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-blue-300 hover:text-white bg-blue-500/15 hover:bg-blue-500/25 border border-blue-500/30 rounded-xl transition cursor-pointer"
|
||||||
title="Edit project details"
|
title="Review submitted evidence for this project"
|
||||||
>
|
>
|
||||||
<Edit2 className="w-3.5 h-3.5 text-blue-400" />
|
<FileCheck className="w-3.5 h-3.5 text-blue-400" />
|
||||||
Edit Project
|
Review Evidence
|
||||||
</button>
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openEditProject(selectedProject)}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-slate-200 bg-slate-800 hover:bg-slate-700 border border-slate-700/80 rounded-xl transition cursor-pointer"
|
||||||
|
title="Edit project details"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3.5 h-3.5 text-blue-400" />
|
||||||
|
Edit Project
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<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>
|
||||||
@@ -684,6 +695,14 @@ 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"
|
||||||
@@ -736,16 +755,29 @@ 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">
|
<div key={m.id} className="p-3.5 rounded-xl border border-slate-800/80 bg-[#0d121f]/90 space-y-2.5">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<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">
|
||||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
|
<div className="flex items-center gap-1.5">
|
||||||
{statusBadge.label}
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
|
||||||
</span>
|
{statusBadge.label}
|
||||||
|
</span>
|
||||||
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${priBadge.className}`}>
|
||||||
|
{priBadge.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to={`/monitoring?tab=evidence&projectId=${selectedProject.id}&milestoneId=${m.id}`}
|
||||||
|
className="inline-flex items-center gap-1 text-[10px] font-semibold text-blue-300 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/25 px-2 py-1 rounded-lg transition"
|
||||||
|
title="View & review evidence for this milestone"
|
||||||
|
>
|
||||||
|
<FileCheck className="w-3 h-3 text-blue-400" />
|
||||||
|
Evidence
|
||||||
|
</Link>
|
||||||
<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"
|
||||||
@@ -755,14 +787,11 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between text-[11px] text-slate-400">
|
<div className="flex items-center text-[11px] text-slate-400 pt-1.5 border-t border-slate-800/60">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1.5 text-slate-400">
|
||||||
<Clock className="w-3 h-3 text-slate-500" />
|
<Clock className="w-3.5 h-3.5 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', 'Direct POST /v1/api/integration/sync executed successfully.');
|
success('Backend Integration Synced', 'System records and telemetry successfully synchronized.');
|
||||||
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={getUserSyncRecord() ? `Endpoint: ${getUserSyncRecord()?.syncedEndpoint || '/v1/api/integration/sync'} (Synced in browser)` : 'Sync active in browser'}
|
title="User directory synchronization 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 (GET /v1/api/communications/invites/{applicantUserId})"
|
title="Lookup all invites"
|
||||||
>
|
>
|
||||||
{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 (GET /v1/api/communications/invites/applicant/{applicantUserId})"
|
title="Lookup primary invite"
|
||||||
>
|
>
|
||||||
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 (POST /v1/api/integration/sync)
|
Backend Integration Sync Engine
|
||||||
</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 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.
|
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.
|
||||||
</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="Direct POST /v1/api/integration/sync call"
|
title="Trigger synchronization with directory"
|
||||||
>
|
>
|
||||||
<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">
|
||||||
OpenAPI Target Route
|
Synchronization Service Profile
|
||||||
</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">HTTP Method:</span> <span className="text-emerald-400 font-bold">POST</span>
|
<span className="text-slate-500">Service Mode:</span> <span className="text-emerald-400 font-bold">Secure Directory Handshake</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-slate-500">Endpoint Path:</span>{' '}
|
<span className="text-slate-500">Sync Scope:</span>{' '}
|
||||||
<span className="text-cyan-400">/v1/api/integration/sync</span>
|
<span className="text-cyan-400">Authoritative User Directory & Security Claims</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-slate-500">Execution Target:</span>{' '}
|
<span className="text-slate-500">Service Status:</span>{' '}
|
||||||
<span className="text-white">Authoritative User Directory Synchronization</span>
|
<span className="text-white">Operational & Connected</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" />
|
||||||
Querying backend endpoint (GET /v1/api/users/{selectedUserForDetail.id})...
|
Fetching user profile details...
|
||||||
</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">
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ import {
|
|||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
import { parseFriendlyError, FriendlyError } from '../utils/apiErrorParser';
|
import { parseFriendlyError, FriendlyError } from '../utils/apiErrorParser';
|
||||||
import { registerUserEmail, registerUserEmails } from '../lib/formatters';
|
import { registerUserEmail, registerUserEmails, getUserIdByEmail } 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 {
|
||||||
@@ -88,6 +88,20 @@ 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).
|
||||||
@@ -137,19 +151,22 @@ export const getOrganisation = async (organisationId: string | number): Promise<
|
|||||||
};
|
};
|
||||||
|
|
||||||
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', {
|
||||||
...payload,
|
name: payload.name,
|
||||||
createdBy: userSub || undefined,
|
registrationNo: payload.registrationNo,
|
||||||
|
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', {
|
||||||
...payload,
|
organisationId: toIntegerId(payload.organisationId),
|
||||||
updatedBy: userSub || undefined,
|
name: payload.name,
|
||||||
|
registrationNo: payload.registrationNo,
|
||||||
|
email: payload.email,
|
||||||
|
type: Number(payload.type),
|
||||||
});
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
@@ -193,20 +210,30 @@ 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 res = await apiClient.post('/v1/api/governance/organisations/portfolio', {
|
const res = await apiClient.post('/v1/api/governance/organisations/portfolio', {
|
||||||
...payload,
|
name: payload.name,
|
||||||
ownerId: payload.ownerId || userSub || undefined,
|
description: payload.description,
|
||||||
createdBy: userSub || undefined,
|
organisationId: toIntegerId(payload.organisationId),
|
||||||
|
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', {
|
||||||
...payload,
|
portfolioId: toIntegerId(payload.portfolioId),
|
||||||
ownerId: payload.ownerId || userSub || undefined,
|
name: payload.name,
|
||||||
updatedBy: userSub || undefined,
|
description: payload.description,
|
||||||
|
ownerId: ownerId || userSub || '00000000-0000-0000-0000-000000000000',
|
||||||
});
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
@@ -245,20 +272,29 @@ 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', {
|
||||||
...payload,
|
portfolioId: toIntegerId(payload.portfolioId),
|
||||||
ownerId: payload.ownerId || userSub || undefined,
|
name: payload.name,
|
||||||
createdBy: userSub || undefined,
|
ownerId: ownerId || userSub || '00000000-0000-0000-0000-000000000000',
|
||||||
});
|
});
|
||||||
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', {
|
||||||
...payload,
|
programmeId: toIntegerId(payload.programmeId),
|
||||||
ownerId: payload.ownerId || userSub || undefined,
|
name: payload.name,
|
||||||
updatedBy: userSub || undefined,
|
ownerId: ownerId || userSub || '00000000-0000-0000-0000-000000000000',
|
||||||
|
status: Number(payload.status) || 1,
|
||||||
});
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
@@ -277,28 +313,40 @@ 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', {
|
||||||
...payload,
|
programmeId: toIntegerId(payload.programmeId),
|
||||||
createdBy: userSub || undefined,
|
name: payload.name,
|
||||||
|
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 userSub = getCurrentUserSub();
|
const startedAt = payload.startedAt
|
||||||
|
? (payload.startedAt.includes('T') ? payload.startedAt : new Date(payload.startedAt).toISOString())
|
||||||
|
: null;
|
||||||
|
const endedAt = payload.endedAt
|
||||||
|
? (payload.endedAt.includes('T') ? payload.endedAt : new Date(payload.endedAt).toISOString())
|
||||||
|
: null;
|
||||||
|
|
||||||
const res = await apiClient.put('/v1/api/projects', {
|
const res = await apiClient.put('/v1/api/projects', {
|
||||||
...payload,
|
projectId: toIntegerId(payload.projectId),
|
||||||
updatedBy: userSub || undefined,
|
name: payload.name,
|
||||||
|
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', {
|
||||||
...payload,
|
projectId: toIntegerId(payload.projectId),
|
||||||
updatedBy: userSub || undefined,
|
status: Number(payload.status),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -315,36 +363,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 userSub = getCurrentUserSub();
|
const performedAt = payload.performedAt
|
||||||
const body: any = {
|
? (payload.performedAt.includes('T') ? payload.performedAt : new Date(payload.performedAt).toISOString())
|
||||||
...payload,
|
: null;
|
||||||
milestoneId:
|
|
||||||
payload.milestoneId !== undefined && payload.milestoneId !== null && payload.milestoneId !== ''
|
const body = {
|
||||||
? Number(payload.milestoneId)
|
projectId: toIntegerId(payload.projectId),
|
||||||
: null,
|
milestoneId: toNullableIntegerId(payload.milestoneId),
|
||||||
createdBy: userSub || undefined,
|
category: payload.category || 'Operations',
|
||||||
|
amount: Number(payload.amount) || 0,
|
||||||
|
title: payload.title,
|
||||||
|
description: payload.description || '',
|
||||||
|
performedAt,
|
||||||
};
|
};
|
||||||
try {
|
const res = await apiClient.post('/v1/api/projects/activities', body);
|
||||||
const res = await apiClient.post('/v1/api/projects/activities', body);
|
return res.data;
|
||||||
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 userSub = getCurrentUserSub();
|
const performedAt = payload.performedAt
|
||||||
const body: any = {
|
? (payload.performedAt.includes('T') ? payload.performedAt : new Date(payload.performedAt).toISOString())
|
||||||
...payload,
|
: null;
|
||||||
milestoneId:
|
|
||||||
payload.milestoneId !== undefined && payload.milestoneId !== null && payload.milestoneId !== ''
|
const body = {
|
||||||
? Number(payload.milestoneId)
|
projectId: toIntegerId(payload.projectId),
|
||||||
: null,
|
milestoneId: toNullableIntegerId(payload.milestoneId),
|
||||||
updatedBy: userSub || undefined,
|
category: payload.category || 'Operations',
|
||||||
|
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);
|
||||||
@@ -376,19 +424,30 @@ 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 userSub = getCurrentUserSub();
|
const dueAtIso = payload.dueAt.includes('T')
|
||||||
|
? 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', {
|
||||||
...payload,
|
projectId: toIntegerId(payload.projectId),
|
||||||
createdBy: userSub || undefined,
|
dueAt: dueAtIso,
|
||||||
|
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 userSub = getCurrentUserSub();
|
const dueAtIso = payload.dueAt.includes('T')
|
||||||
|
? payload.dueAt
|
||||||
|
: new Date(payload.dueAt).toISOString();
|
||||||
|
|
||||||
await apiClient.put('/v1/api/projects/milestone', {
|
await apiClient.put('/v1/api/projects/milestone', {
|
||||||
...payload,
|
milestoneId: toIntegerId(payload.milestoneId),
|
||||||
updatedBy: userSub || undefined,
|
dueAt: dueAtIso,
|
||||||
|
name: payload.name,
|
||||||
|
priority: Number(payload.priority) || 1,
|
||||||
|
status: Number(payload.status) || 1,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -422,10 +481,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 userSub = getCurrentUserSub();
|
|
||||||
const res = await apiClient.post('/v1/api/grants/awards', {
|
const res = await apiClient.post('/v1/api/grants/awards', {
|
||||||
...payload,
|
organisationId: toIntegerId(payload.organisationId),
|
||||||
createdBy: userSub || undefined,
|
programmeId: toIntegerId(payload.programmeId),
|
||||||
|
amount: Number(payload.amount),
|
||||||
});
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
@@ -456,12 +515,27 @@ 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> => {
|
||||||
const userSub = getCurrentUserSub();
|
// Ensure awardId is numeric int64 as required by OpenAPI schema (pattern: ^-?(?:0|[1-9]\d*)$)
|
||||||
const hashedAwardId = await toolsService.resolveAwardId(payload.awardId);
|
let numericAwardId: number = Number(payload.awardId);
|
||||||
|
if (isNaN(numericAwardId) || !/^-?\d+$/.test(String(payload.awardId))) {
|
||||||
|
const decoded = toolsService.decodeIdSync(String(payload.awardId));
|
||||||
|
if (decoded && /^-?\d+$/.test(String(decoded))) {
|
||||||
|
numericAwardId = Number(decoded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveAtIso = payload.effectiveAt.includes('T')
|
||||||
|
? payload.effectiveAt
|
||||||
|
: new Date(payload.effectiveAt).toISOString();
|
||||||
|
const expiresAtIso = payload.expiresAt.includes('T')
|
||||||
|
? payload.expiresAt
|
||||||
|
: new Date(payload.expiresAt).toISOString();
|
||||||
|
|
||||||
const res = await apiClient.post('/v1/api/grants/contracts', {
|
const res = await apiClient.post('/v1/api/grants/contracts', {
|
||||||
...payload,
|
awardId: !isNaN(numericAwardId) ? numericAwardId : payload.awardId,
|
||||||
awardId: hashedAwardId,
|
totalValue: Number(payload.totalValue),
|
||||||
createdBy: userSub || undefined,
|
effectiveAt: effectiveAtIso,
|
||||||
|
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
|
||||||
@@ -472,6 +546,8 @@ 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();
|
||||||
@@ -479,9 +555,9 @@ export const createContract = async (payload: CreateContractRequest): Promise<Co
|
|||||||
id: rawStr,
|
id: rawStr,
|
||||||
contractId: rawStr,
|
contractId: rawStr,
|
||||||
awardId: hashedAwardId,
|
awardId: hashedAwardId,
|
||||||
totalValue: payload.totalValue,
|
totalValue: Number(payload.totalValue),
|
||||||
effectiveAt: payload.effectiveAt,
|
effectiveAt: effectiveAtIso,
|
||||||
expiresAt: payload.expiresAt,
|
expiresAt: expiresAtIso,
|
||||||
status: ContractStatus.Draft,
|
status: ContractStatus.Draft,
|
||||||
} as unknown as Contract;
|
} as unknown as Contract;
|
||||||
}
|
}
|
||||||
@@ -510,9 +586,9 @@ export const createContract = async (payload: CreateContractRequest): Promise<Co
|
|||||||
id: headerId,
|
id: headerId,
|
||||||
contractId: headerId,
|
contractId: headerId,
|
||||||
awardId: hashedAwardId,
|
awardId: hashedAwardId,
|
||||||
totalValue: payload.totalValue,
|
totalValue: Number(payload.totalValue),
|
||||||
effectiveAt: payload.effectiveAt,
|
effectiveAt: effectiveAtIso,
|
||||||
expiresAt: payload.expiresAt,
|
expiresAt: expiresAtIso,
|
||||||
status: ContractStatus.Draft,
|
status: ContractStatus.Draft,
|
||||||
} as unknown as Contract;
|
} as unknown as Contract;
|
||||||
}
|
}
|
||||||
@@ -523,6 +599,7 @@ 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;
|
||||||
@@ -532,18 +609,17 @@ export const createContract = async (payload: CreateContractRequest): Promise<Co
|
|||||||
id: hashedAwardId,
|
id: hashedAwardId,
|
||||||
contractId: hashedAwardId,
|
contractId: hashedAwardId,
|
||||||
awardId: hashedAwardId,
|
awardId: hashedAwardId,
|
||||||
totalValue: payload.totalValue,
|
totalValue: Number(payload.totalValue),
|
||||||
effectiveAt: payload.effectiveAt,
|
effectiveAt: effectiveAtIso,
|
||||||
expiresAt: payload.expiresAt,
|
expiresAt: expiresAtIso,
|
||||||
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', {
|
||||||
...payload,
|
contractId: toIntegerId(payload.contractId),
|
||||||
contractId: hashedContractId,
|
status: Number(payload.status),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -571,12 +647,10 @@ 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', {
|
||||||
...payload,
|
contractId: toIntegerId(payload.contractId),
|
||||||
contractId: hashedContractId,
|
externalSignatureId: payload.externalSignatureId,
|
||||||
executedBy: userSub || undefined,
|
signedDocumentUrl: payload.signedDocumentUrl,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -594,10 +668,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', {
|
||||||
...payload,
|
projectId: toIntegerId(payload.projectId),
|
||||||
createdBy: userSub || undefined,
|
name: payload.name,
|
||||||
|
amount: Number(payload.amount),
|
||||||
});
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
@@ -619,28 +693,25 @@ 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', {
|
||||||
...payload,
|
projectId: toIntegerId(payload.projectId),
|
||||||
createdBy: userSub || undefined,
|
milestoneId: toNullableIntegerId(payload.milestoneId),
|
||||||
|
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', {
|
||||||
...payload,
|
disbursementId: toIntegerId(payload.disbursementId),
|
||||||
updatedBy: userSub || undefined,
|
milestoneId: toNullableIntegerId(payload.milestoneId),
|
||||||
|
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);
|
||||||
const userSub = getCurrentUserSub();
|
await apiClient.post(`/v1/api/execution/disbursements/${hashedDisbId}/approve`, {});
|
||||||
await apiClient.post(`/v1/api/execution/disbursements/${hashedDisbId}/approve`, {
|
|
||||||
approvedBy: userSub || undefined,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -706,23 +777,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', {
|
||||||
...payload,
|
projectId: toIntegerId(payload.projectId),
|
||||||
likelihood: Number(payload.likelihood),
|
likelihood: Number(payload.likelihood),
|
||||||
impact: Number(payload.impact),
|
impact: Number(payload.impact),
|
||||||
createdBy: userSub || undefined,
|
name: payload.name,
|
||||||
|
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', {
|
||||||
...payload,
|
riskId: toIntegerId(payload.riskId),
|
||||||
likelihood: Number(payload.likelihood),
|
likelihood: Number(payload.likelihood),
|
||||||
impact: Number(payload.impact),
|
impact: Number(payload.impact),
|
||||||
updatedBy: userSub || undefined,
|
name: payload.name,
|
||||||
|
description: payload.description || '',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -741,20 +812,19 @@ 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', {
|
||||||
...payload,
|
projectId: toIntegerId(payload.projectId),
|
||||||
createdBy: userSub || undefined,
|
name: payload.name,
|
||||||
|
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);
|
||||||
const userSub = getCurrentUserSub();
|
await apiClient.post(`/v1/api/evidence/indicators/${hashedIndId}/actuals/${actualAmount}`, {});
|
||||||
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[]> => {
|
||||||
@@ -802,10 +872,11 @@ 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', {
|
||||||
...payload,
|
evidenceId: toIntegerId(payload.evidenceId),
|
||||||
updatedBy: userSub || undefined,
|
version: Number(payload.version) || 1,
|
||||||
|
evidenceType: Number(payload.evidenceType),
|
||||||
|
status: Number(payload.status),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -910,10 +981,14 @@ 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 userSub = getCurrentUserSub();
|
|
||||||
const res = await apiClient.post<Invite>('/v1/api/communications/invite', {
|
const res = await apiClient.post<Invite>('/v1/api/communications/invite', {
|
||||||
...payload,
|
organisationId: toIntegerId(payload.organisationId),
|
||||||
createdBy: userSub || undefined,
|
invitedOrganisationId: toIntegerId(payload.invitedOrganisationId),
|
||||||
|
awardId: toIntegerId(payload.awardId),
|
||||||
|
contractId: toIntegerId(payload.contractId),
|
||||||
|
recipient: payload.recipient,
|
||||||
|
message: payload.message,
|
||||||
|
platform: payload.platform !== undefined ? Number(payload.platform) : 1,
|
||||||
});
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -99,11 +99,19 @@ 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 | null | undefined): number | string {
|
export function decodeHashIdSync(hashId: string | number | null | undefined): number | string {
|
||||||
if (!hashId) return '';
|
if (hashId === undefined || hashId === null || 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) {
|
||||||
@@ -116,6 +124,30 @@ export function decodeHashIdSync(hashId: string | null | undefined): number | st
|
|||||||
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
|
||||||
@@ -127,10 +159,17 @@ export const toolsService = {
|
|||||||
/**
|
/**
|
||||||
* Synchronous decoding
|
* Synchronous decoding
|
||||||
*/
|
*/
|
||||||
decodeIdSync(hashId: string | null | undefined): number | string {
|
decodeIdSync(hashId: string | number | 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)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -75,12 +75,53 @@ 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, '')
|
||||||
@@ -205,23 +246,28 @@ export function parseFriendlyError(
|
|||||||
rawErrors = dataObj;
|
rawErrors = dataObj;
|
||||||
}
|
}
|
||||||
} else if (typeof rawResponseData === 'string') {
|
} else if (typeof rawResponseData === 'string') {
|
||||||
// Check if response data is stringified JSON containing ProblemDetails
|
if (isRawHttpOutput(rawResponseData)) {
|
||||||
try {
|
technicalSummary = sanitizeTechnicalDetails(rawResponseData);
|
||||||
const parsed = JSON.parse(rawResponseData) as any;
|
rawDetail = undefined;
|
||||||
if (isProblemDetailsObject(parsed)) {
|
} else {
|
||||||
isProblemDetails = true;
|
// Check if response data is stringified JSON containing ProblemDetails
|
||||||
rawTitle = parsed.title || parsed.Title || undefined;
|
try {
|
||||||
rawDetail = parsed.detail || parsed.Detail || undefined;
|
const parsed = JSON.parse(rawResponseData) as any;
|
||||||
statusCode = parsed.status || parsed.Status || statusCode;
|
if (isProblemDetailsObject(parsed)) {
|
||||||
rawErrors = parsed.errors ?? parsed.Errors ?? parsed.errorList ?? parsed.ErrorList;
|
isProblemDetails = true;
|
||||||
} else if (Array.isArray(parsed)) {
|
rawTitle = parsed.title || parsed.Title || undefined;
|
||||||
rawErrors = parsed;
|
rawDetail = parsed.detail || parsed.Detail || undefined;
|
||||||
} else {
|
statusCode = parsed.status || parsed.Status || statusCode;
|
||||||
rawDetail = cleanTechnicalJargon(rawResponseData);
|
rawErrors = parsed.errors ?? parsed.Errors ?? parsed.errorList ?? parsed.ErrorList;
|
||||||
}
|
} else if (Array.isArray(parsed)) {
|
||||||
} catch {
|
rawErrors = parsed;
|
||||||
if (!rawResponseData.includes('<html')) {
|
} else {
|
||||||
rawDetail = cleanTechnicalJargon(rawResponseData);
|
rawDetail = cleanTechnicalJargon(rawResponseData);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!rawResponseData.includes('<html')) {
|
||||||
|
rawDetail = cleanTechnicalJargon(rawResponseData);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -481,6 +527,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 || (statusCode ? `HTTP Status ${statusCode}` : undefined),
|
technicalDetails: technicalSummary ? sanitizeTechnicalDetails(technicalSummary) : (statusCode ? `HTTP Status ${statusCode}` : undefined),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user