metrics adn insihigts
This commit is contained in:
@@ -9,6 +9,7 @@ import { useSectorStore } from '../../store/sectorStore';
|
||||
import SiteAndSectorSelector from './SiteAndSectorSelector';
|
||||
import { trackLoading } from './LoadingStateMonitor';
|
||||
import { useErrorHandler } from '../../hooks/useErrorHandler';
|
||||
import { WorkflowInsights, WorkflowInsight } from './WorkflowInsights';
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
@@ -22,6 +23,7 @@ interface PageHeaderProps {
|
||||
};
|
||||
hideSiteSector?: boolean; // Hide site/sector selector and info for global pages
|
||||
navigation?: ReactNode; // Module navigation tabs
|
||||
workflowInsights?: WorkflowInsight[]; // Actionable insights for current page
|
||||
}
|
||||
|
||||
export default function PageHeader({
|
||||
@@ -33,6 +35,7 @@ export default function PageHeader({
|
||||
badge,
|
||||
hideSiteSector = false,
|
||||
navigation,
|
||||
workflowInsights,
|
||||
}: PageHeaderProps) {
|
||||
const { activeSite } = useSiteStore();
|
||||
const { activeSector, loadSectorsForSite } = useSectorStore();
|
||||
@@ -98,77 +101,87 @@ export default function PageHeader({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 ${className}`}>
|
||||
{/* Left side: Title, badge, and site/sector info */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
{badge && (
|
||||
<div className={`flex items-center justify-center w-10 h-10 rounded-xl ${badgeColors[badge.color]} flex-shrink-0`}>
|
||||
{badge.icon && typeof badge.icon === 'object' && 'type' in badge.icon
|
||||
? React.cloneElement(badge.icon as React.ReactElement, { className: 'text-white size-5' })
|
||||
: badge.icon}
|
||||
<div className={`flex flex-col gap-3 ${className}`}>
|
||||
{/* Main header row - single row with 3 sections */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
{/* Left side: Title, badge, and site/sector info */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
{badge && (
|
||||
<div className={`flex items-center justify-center w-10 h-10 rounded-xl ${badgeColors[badge.color]} flex-shrink-0`}>
|
||||
{badge.icon && typeof badge.icon === 'object' && 'type' in badge.icon
|
||||
? React.cloneElement(badge.icon as React.ReactElement, { className: 'text-white size-5' })
|
||||
: badge.icon}
|
||||
</div>
|
||||
)}
|
||||
<h2 className="text-2xl font-bold text-gray-800 dark:text-white/90">{title}</h2>
|
||||
</div>
|
||||
{!hideSiteSector && (
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
{lastUpdated && (
|
||||
<>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Last updated: {lastUpdated.toLocaleTimeString()}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{activeSite && (
|
||||
<>
|
||||
{lastUpdated && <span className="text-sm text-gray-400 dark:text-gray-600">•</span>}
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Site: <span className="text-brand-600 dark:text-brand-400">{activeSite.name}</span>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{activeSector && (
|
||||
<>
|
||||
<span className="text-sm text-gray-400 dark:text-gray-600">•</span>
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Sector: <span className="text-brand-600 dark:text-brand-400">{activeSector.name}</span>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{!activeSector && activeSite && (
|
||||
<>
|
||||
<span className="text-sm text-gray-400 dark:text-gray-600">•</span>
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Sector: <span className="text-brand-600 dark:text-brand-400">All Sectors</span>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<h2 className="text-2xl font-bold text-gray-800 dark:text-white/90">{title}</h2>
|
||||
</div>
|
||||
{!hideSiteSector && (
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
{lastUpdated && (
|
||||
<>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Last updated: {lastUpdated.toLocaleTimeString()}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{activeSite && (
|
||||
<>
|
||||
{lastUpdated && <span className="text-sm text-gray-400 dark:text-gray-600">•</span>}
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Site: <span className="text-brand-600 dark:text-brand-400">{activeSite.name}</span>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{activeSector && (
|
||||
<>
|
||||
<span className="text-sm text-gray-400 dark:text-gray-600">•</span>
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Sector: <span className="text-brand-600 dark:text-brand-400">{activeSector.name}</span>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{!activeSector && activeSite && (
|
||||
<>
|
||||
<span className="text-sm text-gray-400 dark:text-gray-600">•</span>
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Sector: <span className="text-brand-600 dark:text-brand-400">All Sectors</span>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{hideSiteSector && lastUpdated && (
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Last updated: {lastUpdated.toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right side: Navigation bar stacked above site/sector selector */}
|
||||
<div className="flex flex-col items-end gap-3">
|
||||
{navigation && <div>{navigation}</div>}
|
||||
<div className="flex items-center gap-3">
|
||||
{!hideSiteSector && <SiteAndSectorSelector />}
|
||||
{showRefresh && onRefresh && (
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="px-4 py-2 text-sm font-medium text-brand-500 hover:text-brand-600 border border-brand-200 rounded-lg hover:bg-brand-50 dark:border-brand-800 dark:hover:bg-brand-500/10 transition-colors"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
{hideSiteSector && lastUpdated && (
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Last updated: {lastUpdated.toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Middle: Workflow insights - takes available space */}
|
||||
{workflowInsights && workflowInsights.length > 0 && (
|
||||
<div className="flex-1 flex items-center justify-center px-6">
|
||||
<WorkflowInsights insights={workflowInsights} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right side: Navigation bar stacked above site/sector selector */}
|
||||
<div className="flex flex-col items-end gap-3 flex-shrink-0">
|
||||
{navigation && <div>{navigation}</div>}
|
||||
<div className="flex items-center gap-3">
|
||||
{!hideSiteSector && <SiteAndSectorSelector />}
|
||||
{showRefresh && onRefresh && (
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="px-4 py-2 text-sm font-medium text-brand-500 hover:text-brand-600 border border-brand-200 rounded-lg hover:bg-brand-50 dark:border-brand-800 dark:hover:bg-brand-500/10 transition-colors"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
92
frontend/src/components/common/WorkflowInsights.tsx
Normal file
92
frontend/src/components/common/WorkflowInsights.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* WorkflowInsights Component
|
||||
* Shows actionable insights in a collapsible notification box
|
||||
* Placed between page title and navigation tabs
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { CheckCircleIcon, AlertIcon, InfoIcon, BoltIcon, CloseIcon } from '../../icons';
|
||||
|
||||
export interface WorkflowInsight {
|
||||
type: 'success' | 'warning' | 'info' | 'action';
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface WorkflowInsightsProps {
|
||||
insights: WorkflowInsight[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const WorkflowInsights: React.FC<WorkflowInsightsProps> = ({ insights, className = '' }) => {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
if (!insights || insights.length === 0 || isCollapsed) return null;
|
||||
|
||||
// Determine notification color based on most critical insight type
|
||||
const hasCritical = insights.some(i => i.type === 'warning' || i.type === 'action');
|
||||
const hasSuccess = insights.some(i => i.type === 'success');
|
||||
|
||||
const notificationColors = hasCritical
|
||||
? {
|
||||
bg: 'bg-amber-50 dark:bg-amber-500/10',
|
||||
border: 'border-amber-300 dark:border-amber-700',
|
||||
iconBg: 'bg-amber-100 dark:bg-amber-500/20',
|
||||
}
|
||||
: hasSuccess
|
||||
? {
|
||||
bg: 'bg-green-50 dark:bg-green-500/10',
|
||||
border: 'border-green-300 dark:border-green-700',
|
||||
iconBg: 'bg-green-100 dark:bg-green-500/20',
|
||||
}
|
||||
: {
|
||||
bg: 'bg-blue-50 dark:bg-blue-500/10',
|
||||
border: 'border-blue-300 dark:border-blue-700',
|
||||
iconBg: 'bg-blue-100 dark:bg-blue-500/20',
|
||||
};
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return <CheckCircleIcon className="w-5 h-5 text-green-600 dark:text-green-400" />;
|
||||
case 'warning':
|
||||
return <AlertIcon className="w-5 h-5 text-amber-600 dark:text-amber-400" />;
|
||||
case 'action':
|
||||
return <BoltIcon className="w-5 h-5 text-blue-600 dark:text-blue-400" />;
|
||||
default:
|
||||
return <InfoIcon className="w-5 h-5 text-blue-600 dark:text-blue-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<div className={`flex items-start gap-3 px-4 py-3 rounded-lg border ${notificationColors.bg} ${notificationColors.border} shadow-sm`}>
|
||||
{/* Icon */}
|
||||
<div className={`flex-shrink-0 p-1.5 rounded-md ${notificationColors.iconBg}`}>
|
||||
{getIcon(insights[0].type)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 space-y-2">
|
||||
{insights.map((insight, index) => (
|
||||
<div key={index} className="flex items-start gap-2">
|
||||
{insights.length > 1 && (
|
||||
<span className="text-gray-400 dark:text-gray-500 text-base font-normal mt-0.5">•</span>
|
||||
)}
|
||||
<p className="text-base font-normal text-gray-700 dark:text-gray-300">
|
||||
{insight.message}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={() => setIsCollapsed(true)}
|
||||
className="flex-shrink-0 p-1 rounded hover:bg-gray-200/50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<CloseIcon className="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user