177 lines
6.9 KiB
TypeScript
177 lines
6.9 KiB
TypeScript
/**
|
|
* Standardized Page Header Component
|
|
* Used across all Planner and Writer module pages
|
|
* Includes: Page title, last updated, site/sector info, and selectors
|
|
*/
|
|
import React, { ReactNode, useEffect, useRef } from 'react';
|
|
import { useSiteStore } from '../../store/siteStore';
|
|
import { useSectorStore } from '../../store/sectorStore';
|
|
import SiteAndSectorSelector from './SiteAndSectorSelector';
|
|
import { trackLoading } from './LoadingStateMonitor';
|
|
import { useErrorHandler } from '../../hooks/useErrorHandler';
|
|
|
|
interface PageHeaderProps {
|
|
title: string;
|
|
lastUpdated?: Date;
|
|
showRefresh?: boolean;
|
|
onRefresh?: () => void;
|
|
className?: string;
|
|
badge?: {
|
|
icon: ReactNode;
|
|
color: 'blue' | 'green' | 'purple' | 'orange' | 'red' | 'indigo';
|
|
};
|
|
hideSiteSector?: boolean; // Hide site/sector selector and info for global pages
|
|
navigation?: ReactNode; // Module navigation tabs
|
|
}
|
|
|
|
export default function PageHeader({
|
|
title,
|
|
lastUpdated,
|
|
showRefresh = false,
|
|
onRefresh,
|
|
className = "",
|
|
badge,
|
|
hideSiteSector = false,
|
|
navigation,
|
|
}: PageHeaderProps) {
|
|
const { activeSite } = useSiteStore();
|
|
const { activeSector, loadSectorsForSite } = useSectorStore();
|
|
const { addError } = useErrorHandler('PageHeader');
|
|
const lastSiteId = useRef<number | null>(null);
|
|
const isLoadingSector = useRef(false);
|
|
|
|
// Load sectors when active site changes - only for pages that need site/sector context
|
|
useEffect(() => {
|
|
// Skip sector loading for pages that hide site/sector selector (account/billing pages)
|
|
if (hideSiteSector) return;
|
|
|
|
const currentSiteId = activeSite?.id ?? null;
|
|
|
|
// Only load if:
|
|
// 1. We have a site ID
|
|
// 2. The site is active (inactive sites can't have accessible sectors)
|
|
// 3. It's different from the last one we loaded
|
|
// 4. We're not already loading
|
|
if (currentSiteId && activeSite?.is_active && currentSiteId !== lastSiteId.current && !isLoadingSector.current) {
|
|
lastSiteId.current = currentSiteId;
|
|
isLoadingSector.current = true;
|
|
trackLoading('sector-loading', true);
|
|
|
|
// Add timeout to prevent infinite loading
|
|
const timeoutId = setTimeout(() => {
|
|
if (isLoadingSector.current) {
|
|
console.error('PageHeader: Sector loading timeout after 35 seconds');
|
|
trackLoading('sector-loading', false);
|
|
isLoadingSector.current = false;
|
|
addError(new Error('Sector loading timeout - check network connection'), 'PageHeader.loadSectorsForSite');
|
|
}
|
|
}, 35000);
|
|
|
|
loadSectorsForSite(currentSiteId)
|
|
.catch((error) => {
|
|
// Don't log 403/404 errors as they're expected for inactive sites
|
|
if (error.status !== 403 && error.status !== 404) {
|
|
console.error('PageHeader: Error loading sectors:', error);
|
|
addError(error, 'PageHeader.loadSectorsForSite');
|
|
}
|
|
})
|
|
.finally(() => {
|
|
clearTimeout(timeoutId);
|
|
trackLoading('sector-loading', false);
|
|
isLoadingSector.current = false;
|
|
});
|
|
} else if (currentSiteId && !activeSite?.is_active) {
|
|
// Site is inactive - clear sectors and reset lastSiteId
|
|
lastSiteId.current = null;
|
|
const { useSectorStore } = require('../../store/sectorStore');
|
|
useSectorStore.getState().clearActiveSector();
|
|
}
|
|
}, [activeSite?.id, activeSite?.is_active, hideSiteSector, loadSectorsForSite, addError]);
|
|
|
|
const badgeColors = {
|
|
blue: 'bg-blue-600 dark:bg-blue-500',
|
|
green: 'bg-green-600 dark:bg-green-500',
|
|
purple: 'bg-purple-600 dark:bg-purple-500',
|
|
orange: 'bg-orange-600 dark:bg-orange-500',
|
|
red: 'bg-red-600 dark:bg-red-500',
|
|
indigo: 'bg-indigo-600 dark:bg-indigo-500',
|
|
};
|
|
|
|
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>
|
|
)}
|
|
<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>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|