Files
bakery-ia/frontend/src/pages/app/EnterpriseDashboardPage.tsx
2025-12-29 08:11:29 +01:00

544 lines
23 KiB
TypeScript

/*
* Enterprise Dashboard Page
* Main dashboard for enterprise parent tenants showing network-wide metrics
*/
import React, { useState, useEffect, useRef } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
useNetworkSummary,
useChildrenPerformance,
useDistributionOverview,
useForecastSummary
} from '../../api/hooks/useEnterpriseDashboard';
import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/Card';
import { Button } from '../../components/ui/Button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../../components/ui/Tabs';
import {
TrendingUp,
MapPin,
Truck,
Package,
BarChart3,
Network,
Activity,
Calendar,
Clock,
AlertTriangle,
PackageCheck,
Building2,
ArrowLeft,
ChevronRight,
Target,
Warehouse,
ShoppingCart,
ShieldCheck
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { LoadingSpinner } from '../../components/ui/LoadingSpinner';
import { ErrorBoundary } from 'react-error-boundary';
import { apiClient } from '../../api/client/apiClient';
import { useEnterprise } from '../../contexts/EnterpriseContext';
import { useTenant } from '../../stores/tenant.store';
import { useSSEEvents } from '../../hooks/useSSE';
import { useQueryClient } from '@tanstack/react-query';
// Components for enterprise dashboard
const NetworkSummaryCards = React.lazy(() => import('../../components/dashboard/NetworkSummaryCards'));
const DistributionMap = React.lazy(() => import('../../components/maps/DistributionMap'));
const PerformanceChart = React.lazy(() => import('../../components/charts/PerformanceChart'));
const NetworkOverviewTab = React.lazy(() => import('../../components/dashboard/NetworkOverviewTab'));
const NetworkPerformanceTab = React.lazy(() => import('../../components/dashboard/NetworkPerformanceTab'));
const OutletFulfillmentTab = React.lazy(() => import('../../components/dashboard/OutletFulfillmentTab'));
const ProductionTab = React.lazy(() => import('../../components/dashboard/ProductionTab'));
const DistributionTab = React.lazy(() => import('../../components/dashboard/DistributionTab'));
interface EnterpriseDashboardPageProps {
tenantId?: string;
}
const EnterpriseDashboardPage: React.FC<EnterpriseDashboardPageProps> = ({ tenantId: propTenantId }) => {
const { tenantId: urlTenantId } = useParams<{ tenantId: string }>();
const tenantId = propTenantId || urlTenantId;
const navigate = useNavigate();
const { t } = useTranslation('dashboard');
const { state: enterpriseState, drillDownToOutlet, returnToNetworkView, enterNetworkView } = useEnterprise();
const { switchTenant } = useTenant();
const [selectedMetric, setSelectedMetric] = useState('sales');
const [selectedPeriod, setSelectedPeriod] = useState(30);
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
const [activeTab, setActiveTab] = useState('overview');
const queryClient = useQueryClient();
// SSE Integration for real-time updates
const { events: sseEvents } = useSSEEvents({
channels: ['*.alerts', '*.notifications', 'recommendations']
});
// Refs for debouncing SSE-triggered invalidations
const invalidationTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastEventCountRef = useRef<number>(0);
// Invalidate enterprise data on relevant SSE events (debounced)
useEffect(() => {
// Skip if no new events since last check
if (sseEvents.length === 0 || !tenantId || sseEvents.length === lastEventCountRef.current) {
return;
}
const relevantEventTypes = [
'batch_completed', 'batch_started', 'batch_state_changed',
'delivery_received', 'delivery_overdue', 'delivery_arriving_soon',
'stock_receipt_incomplete', 'orchestration_run_completed',
'production_delay', 'batch_start_delayed', 'equipment_maintenance',
'network_alert', 'outlet_performance_update', 'distribution_route_update'
];
// Check if any event is relevant
const hasRelevantEvent = sseEvents.some(event =>
relevantEventTypes.includes(event.event_type)
);
if (hasRelevantEvent) {
// Clear existing timeout to debounce rapid events
if (invalidationTimeoutRef.current) {
clearTimeout(invalidationTimeoutRef.current);
}
// Debounce the invalidation to prevent multiple rapid refetches
invalidationTimeoutRef.current = setTimeout(() => {
lastEventCountRef.current = sseEvents.length;
// Invalidate all enterprise dashboard queries in a single batch
queryClient.invalidateQueries({
queryKey: ['enterprise', 'network-summary', tenantId],
refetchType: 'active',
});
queryClient.invalidateQueries({
queryKey: ['enterprise', 'children-performance', tenantId],
refetchType: 'active',
});
queryClient.invalidateQueries({
queryKey: ['enterprise', 'distribution-overview', tenantId],
refetchType: 'active',
});
queryClient.invalidateQueries({
queryKey: ['enterprise', 'forecast-summary', tenantId],
refetchType: 'active',
});
// Note: control-panel-data has its own debounced invalidation in useControlPanelData
}, 500); // 500ms debounce
}
// Cleanup timeout on unmount or dependency change
return () => {
if (invalidationTimeoutRef.current) {
clearTimeout(invalidationTimeoutRef.current);
}
};
}, [sseEvents, tenantId, queryClient]);
// Check if tenantId is available at the start
useEffect(() => {
if (!tenantId) {
console.error('No tenant ID available for enterprise dashboard');
navigate('/unauthorized');
}
}, [tenantId, navigate]);
// Initialize enterprise mode on mount
useEffect(() => {
if (tenantId && !enterpriseState.parentTenantId) {
enterNetworkView(tenantId);
}
}, [tenantId, enterpriseState.parentTenantId, enterNetworkView]);
// Check if user has enterprise tier access
useEffect(() => {
const checkAccess = async () => {
if (!tenantId) {
console.error('No tenant ID available for enterprise dashboard');
navigate('/unauthorized');
return;
}
try {
const response = await apiClient.get<{ tenant_type: string }>(`/tenants/${tenantId}`);
if (response.tenant_type !== 'parent') {
navigate('/unauthorized');
}
} catch (error) {
console.error('Access check failed:', error);
navigate('/unauthorized');
}
};
checkAccess();
}, [tenantId, navigate]);
// Fetch network summary data
const {
data: networkSummary,
isLoading: isNetworkSummaryLoading,
error: networkSummaryError
} = useNetworkSummary(tenantId!, {
refetchInterval: 60000, // Refetch every minute
enabled: !!tenantId, // Only fetch if tenantId is available
});
// Fetch children performance data
const {
data: childrenPerformance,
isLoading: isChildrenPerformanceLoading,
error: childrenPerformanceError
} = useChildrenPerformance(tenantId!, selectedMetric, selectedPeriod, {
enabled: !!tenantId, // Only fetch if tenantId is available
});
// Fetch distribution overview data
const {
data: distributionOverview,
isLoading: isDistributionLoading,
error: distributionError
} = useDistributionOverview(tenantId!, selectedDate, {
refetchInterval: 60000, // Refetch every minute
enabled: !!tenantId, // Only fetch if tenantId is available
});
// Fetch enterprise forecast summary
const {
data: forecastSummary,
isLoading: isForecastLoading,
error: forecastError
} = useForecastSummary(tenantId!, 7, {
enabled: !!tenantId, // Only fetch if tenantId is available
});
// Handle outlet drill-down
const handleOutletClick = async (outletId: string, outletName: string) => {
// Calculate network metrics if available
const networkMetrics = childrenPerformance?.rankings ? {
totalSales: childrenPerformance.rankings.reduce((sum, r) => sum + (selectedMetric === 'sales' ? r.metric_value : 0), 0),
totalProduction: 0,
totalInventoryValue: childrenPerformance.rankings.reduce((sum, r) => sum + (selectedMetric === 'inventory_value' ? r.metric_value : 0), 0),
averageSales: childrenPerformance.rankings.reduce((sum, r) => sum + (selectedMetric === 'sales' ? r.metric_value : 0), 0) / childrenPerformance.rankings.length,
averageProduction: 0,
averageInventoryValue: childrenPerformance.rankings.reduce((sum, r) => sum + (selectedMetric === 'inventory_value' ? r.metric_value : 0), 0) / childrenPerformance.rankings.length,
childCount: childrenPerformance.rankings.length
} : undefined;
drillDownToOutlet(outletId, outletName, networkMetrics);
await switchTenant(outletId);
navigate('/app/dashboard');
};
// Handle return to network view
const handleReturnToNetwork = async () => {
if (enterpriseState.parentTenantId) {
returnToNetworkView();
await switchTenant(enterpriseState.parentTenantId);
navigate(`/app/enterprise/${enterpriseState.parentTenantId}`);
}
};
// Error boundary fallback
const ErrorFallback = ({ error, resetErrorBoundary }: { error: Error; resetErrorBoundary: () => void }) => (
<div className="p-6 text-center">
<AlertTriangle className="mx-auto h-12 w-12 text-red-500 mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">Something went wrong</h3>
<p className="text-gray-500 mb-4">{error.message}</p>
<Button onClick={resetErrorBoundary}>Try again</Button>
</div>
);
if (isNetworkSummaryLoading || isChildrenPerformanceLoading || isDistributionLoading || isForecastLoading) {
return (
<div className="p-6 min-h-screen">
<div className="flex items-center justify-center h-96">
<LoadingSpinner text={t('enterprise.loading')} />
</div>
</div>
);
}
if (networkSummaryError || childrenPerformanceError || distributionError || forecastError) {
return (
<div className="p-6 min-h-screen">
<div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
<AlertTriangle className="mx-auto h-12 w-12 text-red-500 mb-4" />
<h3 className="text-lg font-medium text-red-800 mb-2">Error Loading Dashboard</h3>
<p className="text-red-600">
{networkSummaryError?.message ||
childrenPerformanceError?.message ||
distributionError?.message ||
forecastError?.message}
</p>
</div>
</div>
);
}
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<div className="px-4 sm:px-6 lg:px-8 py-6 min-h-screen bg-[var(--bg-secondary)]">
{/* Breadcrumb / Return to Network Banner */}
{enterpriseState.selectedOutletId && !enterpriseState.isNetworkView && (
<div
className="mb-6 rounded-lg p-4 border border-[var(--border-primary)]"
style={{
backgroundColor: 'var(--color-info-50)',
}}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Network className="w-5 h-5 text-[var(--color-info)]" />
<div className="flex items-center gap-2 text-sm">
<span className="font-medium text-[var(--color-info)]">Network Overview</span>
<ChevronRight className="w-4 h-4 text-[var(--color-info-300)]" />
<span className="text-[var(--text-primary)] font-semibold">{enterpriseState.selectedOutletName}</span>
</div>
</div>
<Button
onClick={handleReturnToNetwork}
variant="outline"
size="sm"
className="flex items-center gap-2"
>
<ArrowLeft className="w-4 h-4" />
Return to Network View
</Button>
</div>
{enterpriseState.networkMetrics && (
<div className="mt-3 pt-3 border-t grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 text-sm"
style={{ borderColor: 'var(--border-primary)' }}>
<div>
<span className="text-[var(--color-info)]">Network Average Sales:</span>
<span className="ml-2 font-semibold text-[var(--text-primary)]">{enterpriseState.networkMetrics.averageSales.toLocaleString()}</span>
</div>
<div>
<span className="text-[var(--color-info)]">Total Outlets:</span>
<span className="ml-2 font-semibold text-[var(--text-primary)]">{enterpriseState.networkMetrics.childCount}</span>
</div>
<div>
<span className="text-[var(--color-info)]">Network Total:</span>
<span className="ml-2 font-semibold text-[var(--text-primary)]">{enterpriseState.networkMetrics.totalSales.toLocaleString()}</span>
</div>
</div>
)}
</div>
)}
{/* Enhanced Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
{/* Title Section with Gradient Icon */}
<div className="flex items-center gap-4">
<div
className="w-16 h-16 rounded-2xl flex items-center justify-center shadow-lg"
style={{
background: 'linear-gradient(135deg, var(--color-info) 0%, var(--color-primary) 100%)',
}}
>
<Network className="w-8 h-8 text-white" />
</div>
<div>
<h1 className="text-4xl font-bold text-[var(--text-primary)]">
{t('enterprise.network_dashboard')}
</h1>
<p className="mt-1 text-[var(--text-secondary)]">
{t('enterprise.network_summary_description')}
</p>
</div>
</div>
</div>
</div>
{/* Main Tabs Structure */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="mb-6">
<TabsList className="grid grid-cols-1 sm:grid-cols-3 lg:grid-cols-8 gap-2 mb-8">
<TabsTrigger value="overview">
<Network className="w-4 h-4 mr-2" />
{t('enterprise.network_status')}
</TabsTrigger>
<TabsTrigger value="network-performance">
<Target className="w-4 h-4 mr-2" />
{t('enterprise.network_performance')}
</TabsTrigger>
<TabsTrigger value="fulfillment">
<Warehouse className="w-4 h-4 mr-2" />
{t('enterprise.outlet_fulfillment')}
</TabsTrigger>
<TabsTrigger value="distribution">
<Truck className="w-4 h-4 mr-2" />
{t('enterprise.distribution_map')}
</TabsTrigger>
<TabsTrigger value="forecast">
<TrendingUp className="w-4 h-4 mr-2" />
{t('enterprise.network_forecast')}
</TabsTrigger>
<TabsTrigger value="production">
<Package className="w-4 h-4 mr-2" />
{t('enterprise.production')}
</TabsTrigger>
</TabsList>
{/* Tab Content */}
<TabsContent value="overview">
<NetworkOverviewTab
tenantId={tenantId!}
onOutletClick={handleOutletClick}
/>
</TabsContent>
<TabsContent value="network-performance">
<NetworkPerformanceTab
tenantId={tenantId!}
onOutletClick={handleOutletClick}
/>
</TabsContent>
<TabsContent value="distribution">
<DistributionTab
tenantId={tenantId!}
selectedDate={selectedDate}
onDateChange={setSelectedDate}
/>
</TabsContent>
<TabsContent value="forecast">
{/* Forecast Summary */}
<div className="mb-8">
<Card>
<CardHeader className="flex flex-row items-center gap-2">
<TrendingUp className="w-5 h-5 text-[var(--color-primary)]" />
<CardTitle>{t('enterprise.network_forecast')}</CardTitle>
</CardHeader>
<CardContent>
{forecastSummary && forecastSummary.aggregated_forecasts ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Total Demand Card */}
<Card className="hover:shadow-lg transition-shadow duration-300">
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-3">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center shadow-md"
style={{ backgroundColor: 'var(--color-info-100)' }}
>
<Package className="w-5 h-5" style={{ color: 'var(--color-info-600)' }} />
</div>
<h3 className="font-semibold text-sm" style={{ color: 'var(--color-info-800)' }}>
{t('enterprise.total_demand')}
</h3>
</div>
<p className="text-3xl font-bold" style={{ color: 'var(--color-info-900)' }}>
{Object.values(forecastSummary.aggregated_forecasts).reduce((total: number, day: any) =>
total + Object.values(day).reduce((dayTotal: number, product: any) =>
dayTotal + (product.predicted_demand || 0), 0), 0
).toLocaleString()}
</p>
</CardContent>
</Card>
{/* Days Forecast Card */}
<Card className="hover:shadow-lg transition-shadow duration-300">
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-3">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center shadow-md"
style={{ backgroundColor: 'var(--color-success-100)' }}
>
<Calendar className="w-5 h-5" style={{ color: 'var(--color-success-600)' }} />
</div>
<h3 className="font-semibold text-sm" style={{ color: 'var(--color-success-800)' }}>
{t('enterprise.days_forecast')}
</h3>
</div>
<p className="text-3xl font-bold" style={{ color: 'var(--color-success-900)' }}>
{forecastSummary.days_forecast || 7}
</p>
</CardContent>
</Card>
{/* Average Daily Demand Card */}
<Card className="hover:shadow-lg transition-shadow duration-300">
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-3">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center shadow-md"
style={{ backgroundColor: 'var(--color-secondary-100)' }}
>
<Activity className="w-5 h-5" style={{ color: 'var(--color-secondary-600)' }} />
</div>
<h3 className="font-semibold text-sm" style={{ color: 'var(--color-secondary-800)' }}>
{t('enterprise.avg_daily_demand')}
</h3>
</div>
<p className="text-3xl font-bold" style={{ color: 'var(--color-secondary-900)' }}>
{forecastSummary.aggregated_forecasts
? Math.round(Object.values(forecastSummary.aggregated_forecasts).reduce((total: number, day: any) =>
total + Object.values(day).reduce((dayTotal: number, product: any) =>
dayTotal + (product.predicted_demand || 0), 0), 0) /
Object.keys(forecastSummary.aggregated_forecasts).length
).toLocaleString()
: 0}
</p>
</CardContent>
</Card>
{/* Last Updated Card */}
<Card className="hover:shadow-lg transition-shadow duration-300">
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-3">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center shadow-md"
style={{ backgroundColor: 'var(--color-warning-100)' }}
>
<Clock className="w-5 h-5" style={{ color: 'var(--color-warning-600)' }} />
</div>
<h3 className="font-semibold text-sm" style={{ color: 'var(--color-warning-800)' }}>
{t('enterprise.last_updated')}
</h3>
</div>
<p className="text-lg font-semibold" style={{ color: 'var(--color-warning-900)' }}>
{forecastSummary.last_updated ?
new Date(forecastSummary.last_updated).toLocaleTimeString() :
'N/A'}
</p>
</CardContent>
</Card>
</div>
) : (
<div className="flex items-center justify-center h-48 text-[var(--text-secondary)]">
{t('enterprise.no_forecast_data')}
</div>
)}
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="fulfillment">
<OutletFulfillmentTab
tenantId={tenantId!}
onOutletClick={handleOutletClick}
/>
</TabsContent>
<TabsContent value="production">
<ProductionTab tenantId={tenantId!} />
</TabsContent>
</Tabs>
</div>
</ErrorBoundary>
);
};
export default EnterpriseDashboardPage;