Refactored UI endpoint usage
This commit is contained in:
@@ -17,12 +17,7 @@ const sizeMap = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Modern, stylized SVG brand emblem representing Post Fund Management (PFM).
|
||||
* Features an isometric kinetic nexus with three interlocking governance facets:
|
||||
* 1. Top Canopy: Governance, Strategy & Portfolios (Radiant Indigo / Violet)
|
||||
* 2. Left Vault: Capital, Grants & Disbursement Assurance (Electric Sapphire)
|
||||
* 3. Right Wing: Milestones, Evidence & Impact Delivery (Cyan to Emerald)
|
||||
* 4. Center Core: Luminous Floating Diamond Milestone Star
|
||||
* SVG brand logo component for Post Fund Management (PFM).
|
||||
*/
|
||||
export const BrandLogo: React.FC<BrandLogoProps> = ({
|
||||
size = 'md',
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import React, { useState, useRef, useEffect, ReactNode } from 'react';
|
||||
import { Info, X } from 'lucide-react';
|
||||
|
||||
export interface HelpTooltipProps {
|
||||
/** Concise context-sensitive explanation of the section, control, metric, or dialog */
|
||||
content: ReactNode;
|
||||
/** Optional title for the tooltip popover */
|
||||
title?: string;
|
||||
/** Custom alignment: top, bottom, left, right (defaults to top) */
|
||||
placement?: 'top' | 'bottom' | 'left' | 'right';
|
||||
/** Size of the (i) icon: sm (13px), md (15px), lg (18px) */
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
/** Additional styling classes for the container or trigger button */
|
||||
className?: string;
|
||||
/** Accessible label or ARIA label */
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export const HelpTooltip: React.FC<HelpTooltipProps> = ({
|
||||
content,
|
||||
title,
|
||||
placement = 'top',
|
||||
size = 'md',
|
||||
className = '',
|
||||
ariaLabel = 'Information and guidance',
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close on Escape or click outside
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleOutsideClick = (e: MouseEvent | TouchEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleOutsideClick);
|
||||
document.addEventListener('touchstart', handleOutsideClick);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleOutsideClick);
|
||||
document.removeEventListener('touchstart', handleOutsideClick);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'w-3.5 h-3.5 text-[11px]',
|
||||
md: 'w-4 h-4 text-xs',
|
||||
lg: 'w-5 h-5 text-sm',
|
||||
}[size];
|
||||
|
||||
const iconSizes = {
|
||||
sm: 'w-3 h-3',
|
||||
md: 'w-3.5 h-3.5',
|
||||
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];
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`relative inline-flex items-center align-middle ${className}`}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
aria-expanded={isOpen}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsOpen((prev) => !prev);
|
||||
}}
|
||||
className={`inline-flex items-center justify-center rounded-full text-slate-400 hover:text-blue-400 bg-slate-800/80 hover:bg-slate-700/80 border border-slate-700/60 hover:border-blue-500/50 transition-all cursor-pointer shadow-xs focus:outline-none focus:ring-1 focus:ring-blue-500/60 ${sizeClasses} p-0.5`}
|
||||
title="Click for context guidance"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,13 @@
|
||||
import React, { ReactNode, useEffect } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { HelpTooltip } from './HelpTooltip';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
helpText?: ReactNode;
|
||||
children: ReactNode;
|
||||
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||
}
|
||||
@@ -15,6 +17,7 @@ export const Modal: React.FC<ModalProps> = ({
|
||||
onClose,
|
||||
title,
|
||||
subtitle,
|
||||
helpText,
|
||||
children,
|
||||
maxWidth = 'lg',
|
||||
}) => {
|
||||
@@ -56,7 +59,17 @@ export const Modal: React.FC<ModalProps> = ({
|
||||
>
|
||||
<div className="flex items-start justify-between p-5 border-b border-slate-800/80 bg-[#0d121f]/90">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white tracking-tight">{title}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold text-white tracking-tight">{title}</h3>
|
||||
{helpText && (
|
||||
<HelpTooltip
|
||||
title={`${title} Guidance`}
|
||||
content={helpText}
|
||||
size="sm"
|
||||
placement="bottom"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{subtitle && <p className="text-xs text-slate-400 mt-0.5">{subtitle}</p>}
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import { AlertCircle, AlertTriangle, X } from 'lucide-react';
|
||||
import { parseFriendlyError } from '../../utils/apiErrorParser';
|
||||
|
||||
interface ProblemDetailsAlertProps {
|
||||
error: unknown;
|
||||
onDismiss?: () => void;
|
||||
title?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ProblemDetailsAlert: React.FC<ProblemDetailsAlertProps> = ({
|
||||
error,
|
||||
onDismiss,
|
||||
title,
|
||||
className = '',
|
||||
}) => {
|
||||
if (!error) return null;
|
||||
|
||||
const parsed = parseFriendlyError(error, title || 'Action Could Not Be Completed');
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className={`rounded-xl border border-rose-500/40 bg-[#160d14] p-4 text-gray-200 shadow-lg shadow-rose-950/20 ${className}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-rose-400 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h4 className="text-sm font-semibold text-white tracking-tight">
|
||||
{parsed.title}
|
||||
</h4>
|
||||
{parsed.categoryLabel && (
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded border bg-rose-950/60 text-rose-300 border-rose-800/60">
|
||||
{parsed.categoryLabel}
|
||||
</span>
|
||||
)}
|
||||
{parsed.statusCode && (
|
||||
<span className="text-[10px] font-mono font-medium px-1.5 py-0.5 rounded bg-slate-800 text-slate-300 border border-slate-700">
|
||||
HTTP {parsed.statusCode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Single error or fallback message displayed directly */}
|
||||
{(!parsed.errorsList || parsed.errorsList.length <= 1) && parsed.message && (
|
||||
<p className="text-xs mt-1.5 text-rose-200/90 leading-relaxed whitespace-pre-line">
|
||||
{parsed.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Multiple errors from ProblemDetails displayed directly */}
|
||||
{parsed.errorsList && parsed.errorsList.length > 1 && (
|
||||
<ul className="mt-2 space-y-1 bg-black/30 rounded-lg p-2.5 border border-rose-900/40">
|
||||
{parsed.errorsList.map((err, idx) => (
|
||||
<li key={idx} className="text-xs text-rose-200/90 flex items-start gap-2">
|
||||
<span className="text-rose-400 mt-0.5">•</span>
|
||||
<span className="leading-snug">{err}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{parsed.technicalDetails && (
|
||||
<p className="text-[10px] font-mono text-slate-500 mt-2">
|
||||
Ref: {parsed.technicalDetails}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{onDismiss && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
className="text-slate-400 hover:text-white p-1 rounded-lg hover:bg-slate-800/60 transition-colors shrink-0 cursor-pointer"
|
||||
aria-label="Dismiss error"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useMemo } from 'react';
|
||||
import { Risk, Project } from '../../types';
|
||||
import { getUserEmail } from '../../lib/formatters';
|
||||
import { AlertTriangle, ShieldAlert, CheckCircle2, ChevronRight, X, Info } from 'lucide-react';
|
||||
import { HelpTooltip } from './HelpTooltip';
|
||||
|
||||
interface RiskHeatmapMatrixProps {
|
||||
risks: Risk[];
|
||||
@@ -168,7 +169,15 @@ export const RiskHeatmapMatrix: React.FC<RiskHeatmapMatrixProps> = ({
|
||||
{/* Header & Controls */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
{title && <h3 className="text-sm font-semibold text-white">{title}</h3>}
|
||||
<div className="flex items-center gap-2">
|
||||
{title && <h3 className="text-sm font-semibold text-white">{title}</h3>}
|
||||
<HelpTooltip
|
||||
title="Risk Heatmap Guidance"
|
||||
content="Visualizes project risk exposure based on ISO 31000 standards. Severity score = Likelihood (1–5) × Impact (1–5). Click any populated cell to drill into registered risks."
|
||||
size="sm"
|
||||
placement="bottom"
|
||||
/>
|
||||
</div>
|
||||
{subtitle && <p className="text-xs text-slate-400 mt-0.5">{subtitle}</p>}
|
||||
</div>
|
||||
|
||||
@@ -200,6 +209,12 @@ export const RiskHeatmapMatrix: React.FC<RiskHeatmapMatrixProps> = ({
|
||||
3×3 Summary
|
||||
</button>
|
||||
</div>
|
||||
<HelpTooltip
|
||||
title="Matrix Resolution Switcher"
|
||||
content="Toggle between 5×5 detailed granular risk distribution and 3×3 executive aggregated categories (Low, Medium, High)."
|
||||
size="sm"
|
||||
placement="left"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface ToastItem {
|
||||
categoryLabel?: string;
|
||||
technicalDetails?: string;
|
||||
fieldErrors?: Array<{ field: string; message: string }>;
|
||||
errorsList?: string[];
|
||||
}
|
||||
|
||||
export interface ToastOptions {
|
||||
@@ -34,6 +35,7 @@ export interface ToastOptions {
|
||||
categoryLabel?: string;
|
||||
technicalDetails?: string;
|
||||
fieldErrors?: Array<{ field: string; message: string }>;
|
||||
errorsList?: string[];
|
||||
}
|
||||
|
||||
interface ToastContextType {
|
||||
@@ -153,11 +155,21 @@ const ToastCard: React.FC<{
|
||||
)}
|
||||
</div>
|
||||
|
||||
{toast.message && (
|
||||
{/* Display errors directly if multiple, or single message directly */}
|
||||
{toast.errorsList && toast.errorsList.length > 1 ? (
|
||||
<ul className="mt-1.5 space-y-1">
|
||||
{toast.errorsList.map((err, idx) => (
|
||||
<li key={idx} className="text-xs text-rose-200/90 flex items-start gap-1.5">
|
||||
<span className="text-rose-400 mt-0.5">•</span>
|
||||
<span className="leading-snug break-words">{err}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : toast.message ? (
|
||||
<p className="text-xs mt-1.5 text-slate-300 leading-relaxed whitespace-pre-line break-words">
|
||||
{toast.message}
|
||||
</p>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Optional collapsible technical reference */}
|
||||
{toast.technicalDetails && (
|
||||
@@ -255,6 +267,7 @@ export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) =
|
||||
categoryLabel: options?.categoryLabel,
|
||||
technicalDetails: options?.technicalDetails,
|
||||
fieldErrors: options?.fieldErrors,
|
||||
errorsList: options?.errorsList,
|
||||
},
|
||||
]);
|
||||
};
|
||||
@@ -302,6 +315,7 @@ export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) =
|
||||
categoryLabel: options?.categoryLabel || parsed.categoryLabel,
|
||||
technicalDetails: parsed.technicalDetails || options?.technicalDetails,
|
||||
fieldErrors: parsed.fieldErrors || options?.fieldErrors,
|
||||
errorsList: parsed.errorsList || options?.errorsList,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user