Remediated test #1 results
This commit is contained in:
@@ -80,8 +80,12 @@ export const AppLayout: React.FC = () => {
|
||||
});
|
||||
|
||||
// Ensure current logged-in user is registered in the email cache
|
||||
if (sub && (user?.email || claims.email)) {
|
||||
registerUserEmail(sub, ((user?.email || claims.email) as string));
|
||||
const effectiveEmail = ((user?.email || claims?.email || claims?.['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] || claims?.upn) as string);
|
||||
if (sub && effectiveEmail) {
|
||||
registerUserEmail(sub, effectiveEmail);
|
||||
}
|
||||
if (user?.id && effectiveEmail) {
|
||||
registerUserEmail(user.id, effectiveEmail);
|
||||
}
|
||||
|
||||
// Extract name and email directly from validated token claims
|
||||
@@ -148,31 +152,20 @@ export const AppLayout: React.FC = () => {
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div key={item.path} className="flex items-center justify-between group">
|
||||
<NavLink
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all flex-1 min-w-0 ${
|
||||
isActive
|
||||
? 'bg-gradient-to-r from-blue-600 to-indigo-600 text-white shadow-md shadow-blue-600/25 font-semibold'
|
||||
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon className="w-4 h-4 shrink-0" />
|
||||
{sidebarOpen && <span className="truncate">{item.name}</span>}
|
||||
</NavLink>
|
||||
{sidebarOpen && (
|
||||
<div className="pl-1">
|
||||
<HelpTooltip
|
||||
title={item.name}
|
||||
content={item.help}
|
||||
size="sm"
|
||||
placement="right"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all min-w-0 ${
|
||||
isActive
|
||||
? 'bg-gradient-to-r from-blue-600 to-indigo-600 text-white shadow-md shadow-blue-600/25 font-semibold'
|
||||
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon className="w-4 h-4 shrink-0" />
|
||||
{sidebarOpen && <span className="truncate">{item.name}</span>}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
@@ -210,21 +203,6 @@ export const AppLayout: React.FC = () => {
|
||||
{/* Top Header */}
|
||||
<header className="h-16 bg-[#0d121f]/90 border-b border-slate-800/80 backdrop-blur-md px-4 sm:px-6 md:px-8 flex items-center justify-between shrink-0 z-20">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Brand Icon */}
|
||||
<Link
|
||||
to="/"
|
||||
id="header-brand-logo-link"
|
||||
className="flex items-center gap-2 group cursor-pointer"
|
||||
title="PFM Console Home"
|
||||
>
|
||||
<BrandLogo size={28} showGlow={false} animateOnHover />
|
||||
<span className="font-bold text-xs tracking-tight text-white hidden md:inline-block group-hover:text-blue-400 transition">
|
||||
PFM
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<span className="h-4 w-px bg-slate-800 hidden md:block" />
|
||||
|
||||
{/* Home Navigation */}
|
||||
<Link
|
||||
to="/"
|
||||
|
||||
@@ -797,9 +797,8 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
||||
{filteredAwardsForDropdown.map((award) => {
|
||||
const org = getOrganisationForAward(award);
|
||||
const isApproved = Number(award.status) === ApprovalStatus.Approved;
|
||||
const hashId = toolsService.encodeIdSync(award.id);
|
||||
return (
|
||||
<option key={award.id} value={hashId || String(award.id)}>
|
||||
<option key={award.id} value={String(award.id)}>
|
||||
Award #{award.id} {award.referenceNo ? `(${award.referenceNo})` : ''} —{' '}
|
||||
{org?.name || 'Organisation'} • {formatZAR(award.amount)}
|
||||
{!isApproved ? ` [Status: ${getApprovalStatusBadge(award.status).label}]` : ' [Approved]'}
|
||||
@@ -808,7 +807,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
||||
})}
|
||||
</select>
|
||||
<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>
|
||||
</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';
|
||||
|
||||
export interface HelpTooltipProps {
|
||||
@@ -25,14 +26,118 @@ export const HelpTooltip: React.FC<HelpTooltipProps> = ({
|
||||
ariaLabel = 'Information and guidance',
|
||||
}) => {
|
||||
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(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -66,16 +171,53 @@ export const HelpTooltip: React.FC<HelpTooltipProps> = ({
|
||||
lg: 'w-4 h-4',
|
||||
}[size];
|
||||
|
||||
const placementClasses = {
|
||||
top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
|
||||
bottom: 'top-full left-1/2 -translate-x-1/2 mt-2',
|
||||
left: 'right-full top-1/2 -translate-y-1/2 mr-2',
|
||||
right: 'left-full top-1/2 -translate-y-1/2 ml-2',
|
||||
}[placement];
|
||||
const tooltipPortal =
|
||||
isOpen && typeof document !== 'undefined'
|
||||
? createPortal(
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
role="tooltip"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: coords ? `${coords.top}px` : '-9999px',
|
||||
left: coords ? `${coords.left}px` : '-9999px',
|
||||
visibility: coords ? 'visible' : 'hidden',
|
||||
}}
|
||||
className="z-[9999] w-72 max-w-[calc(100vw-24px)] p-3.5 rounded-xl bg-[#0c121e] border border-blue-500/30 text-slate-200 shadow-2xl backdrop-blur-md animate-in fade-in zoom-in-95 duration-150"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 border-b border-slate-800/80 pb-1.5 mb-2">
|
||||
<div className="flex items-center gap-1.5 text-blue-400">
|
||||
<Info className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="font-semibold text-xs tracking-tight text-white">
|
||||
{title || 'Guidance & Information'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="rounded-md p-0.5 text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
aria-label="Close tooltip"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-[11px] leading-relaxed text-slate-300 font-normal space-y-1">
|
||||
{content}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`relative inline-flex items-center align-middle ${className}`}>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
aria-expanded={isOpen}
|
||||
@@ -89,37 +231,7 @@ export const HelpTooltip: React.FC<HelpTooltipProps> = ({
|
||||
<Info className={`${iconSizes} shrink-0`} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
role="tooltip"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={`absolute ${placementClasses} z-50 w-72 max-w-[calc(100vw-32px)] p-3 rounded-xl bg-[#0c121e] border border-blue-500/30 text-slate-200 shadow-2xl backdrop-blur-md animate-in fade-in zoom-in-95 duration-150`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 border-b border-slate-800/80 pb-1.5 mb-2">
|
||||
<div className="flex items-center gap-1.5 text-blue-400">
|
||||
<Info className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="font-semibold text-xs tracking-tight text-white">
|
||||
{title || 'Guidance & Information'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="rounded-md p-0.5 text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
aria-label="Close tooltip"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-[11px] leading-relaxed text-slate-300 font-normal space-y-1">
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{tooltipPortal}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -43,24 +43,28 @@ export const ProblemDetailsAlert: React.FC<ProblemDetailsAlertProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Single error or fallback message displayed directly */}
|
||||
{(!parsed.errorsList || parsed.errorsList.length <= 1) && parsed.message && (
|
||||
{/* Direct error message or error list from ProblemDetails */}
|
||||
{parsed.errorsList && parsed.errorsList.length > 0 ? (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{parsed.message && !parsed.errorsList.includes(parsed.message) && (
|
||||
<p className="text-xs text-rose-200/90 leading-relaxed whitespace-pre-line">
|
||||
{parsed.message}
|
||||
</p>
|
||||
)}
|
||||
<ul className="space-y-1 bg-black/30 rounded-lg p-2.5 border border-rose-900/40">
|
||||
{parsed.errorsList.map((err, idx) => (
|
||||
<li key={idx} className="text-xs text-rose-200/90 flex items-start gap-2">
|
||||
<span className="text-rose-400 mt-0.5 font-bold">•</span>
|
||||
<span className="leading-snug">{err}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : parsed.message ? (
|
||||
<p className="text-xs mt-1.5 text-rose-200/90 leading-relaxed whitespace-pre-line">
|
||||
{parsed.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{parsed.technicalDetails && (
|
||||
<p className="text-[10px] font-mono text-slate-500 mt-2">
|
||||
|
||||
@@ -155,16 +155,21 @@ const ToastCard: React.FC<{
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Display errors directly if multiple, or single message directly */}
|
||||
{toast.errorsList && toast.errorsList.length > 1 ? (
|
||||
<ul className="mt-1.5 space-y-1">
|
||||
{toast.errorsList.map((err, idx) => (
|
||||
<li key={idx} className="text-xs text-rose-200/90 flex items-start gap-1.5">
|
||||
<span className="text-rose-400 mt-0.5">•</span>
|
||||
<span className="leading-snug break-words">{err}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{/* Display errors from ProblemDetails errors member */}
|
||||
{toast.errorsList && toast.errorsList.length > 0 ? (
|
||||
<div className="mt-1.5 space-y-1">
|
||||
{toast.message && !toast.errorsList.includes(toast.message) && (
|
||||
<p className="text-xs text-slate-300 leading-relaxed">{toast.message}</p>
|
||||
)}
|
||||
<ul className="space-y-1">
|
||||
{toast.errorsList.map((err, idx) => (
|
||||
<li key={idx} className="text-xs text-rose-200/90 flex items-start gap-1.5">
|
||||
<span className="text-rose-400 mt-0.5 font-bold">•</span>
|
||||
<span className="leading-snug break-words">{err}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : toast.message ? (
|
||||
<p className="text-xs mt-1.5 text-slate-300 leading-relaxed whitespace-pre-line break-words">
|
||||
{toast.message}
|
||||
|
||||
Reference in New Issue
Block a user