Files
bakery-ia/frontend/src/components/dashboard/OutletFulfillmentTab.tsx
2025-12-17 20:50:22 +01:00

603 lines
24 KiB
TypeScript

/*
* Outlet Fulfillment Tab Component for Enterprise Dashboard
* Shows outlet inventory coverage, stockout risk, and fulfillment status
*/
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/Card';
import { Button } from '../ui/Button';
import { Package, AlertTriangle, CheckCircle2, Activity, Clock, Warehouse, ShoppingCart, Truck, BarChart3, AlertCircle, ShieldCheck, PackageCheck, ArrowLeft } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import StatusCard from '../ui/StatusCard/StatusCard';
import { useSSEEvents } from '../../hooks/useSSE';
interface OutletFulfillmentTabProps {
tenantId: string;
onOutletClick?: (outletId: string, outletName: string) => void;
}
const OutletFulfillmentTab: React.FC<OutletFulfillmentTabProps> = ({ tenantId, onOutletClick }) => {
const { t } = useTranslation('dashboard');
const [selectedOutlet, setSelectedOutlet] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'summary' | 'detailed'>('summary');
// Real-time SSE events
const { events: sseEvents, isConnected: sseConnected } = useSSEEvents({
channels: ['*.alerts', '*.notifications', 'recommendations']
});
// State for real-time inventory data
const [inventoryData, setInventoryData] = useState([
{
id: 'outlet-madrid',
name: 'Madrid Central',
inventoryCoverage: 85,
stockoutRisk: 'low',
criticalItems: 2,
fulfillmentRate: 98,
lastUpdated: '2024-01-15T10:30:00',
status: 'normal',
products: [
{ id: 'baguette', name: 'Baguette', coverage: 92, risk: 'low', stock: 450, safetyStock: 300 },
{ id: 'croissant', name: 'Croissant', coverage: 78, risk: 'medium', stock: 280, safetyStock: 250 },
{ id: 'pain-au-chocolat', name: 'Pain au Chocolat', coverage: 65, risk: 'high', stock: 180, safetyStock: 200 }
]
},
{
id: 'outlet-barcelona',
name: 'Barcelona Coastal',
inventoryCoverage: 68,
stockoutRisk: 'medium',
criticalItems: 5,
fulfillmentRate: 92,
lastUpdated: '2024-01-15T10:25:00',
status: 'warning',
products: [
{ id: 'baguette', name: 'Baguette', coverage: 75, risk: 'medium', stock: 320, safetyStock: 300 },
{ id: 'croissant', name: 'Croissant', coverage: 58, risk: 'high', stock: 220, safetyStock: 250 },
{ id: 'ensaimada', name: 'Ensaimada', coverage: 45, risk: 'critical', stock: 120, safetyStock: 200 }
]
},
{
id: 'outlet-valencia',
name: 'Valencia Port',
inventoryCoverage: 72,
stockoutRisk: 'medium',
criticalItems: 3,
fulfillmentRate: 95,
lastUpdated: '2024-01-15T10:20:00',
status: 'warning',
products: [
{ id: 'baguette', name: 'Baguette', coverage: 88, risk: 'low', stock: 420, safetyStock: 300 },
{ id: 'croissant', name: 'Croissant', coverage: 65, risk: 'medium', stock: 240, safetyStock: 250 },
{ id: 'focaccia', name: 'Focaccia', coverage: 55, risk: 'high', stock: 160, safetyStock: 200 }
]
}
]);
// Process SSE events for inventory updates
useEffect(() => {
if (sseEvents.length === 0) return;
// Filter inventory-related events
const inventoryEvents = sseEvents.filter(event =>
event.event_type.includes('inventory_') ||
event.event_type.includes('stock_') ||
event.event_type === 'stock_receipt_incomplete' ||
event.entity_type === 'inventory'
);
if (inventoryEvents.length === 0) return;
// Update inventory data based on events
setInventoryData(prevData => {
return prevData.map(outlet => {
// Find events for this outlet
const outletEvents = inventoryEvents.filter(event =>
event.entity_id === outlet.id ||
event.event_metadata?.outlet_id === outlet.id
);
if (outletEvents.length === 0) return outlet;
// Calculate new inventory coverage based on events
let newCoverage = outlet.inventoryCoverage;
let newRisk = outlet.stockoutRisk;
let newStatus = outlet.status;
let newCriticalItems = outlet.criticalItems;
outletEvents.forEach(event => {
switch (event.event_type) {
case 'inventory_low':
case 'stock_receipt_incomplete':
newCoverage = Math.max(0, newCoverage - 10);
if (newCoverage < 50) newRisk = 'high';
if (newCoverage < 30) newRisk = 'critical';
newStatus = 'critical';
newCriticalItems += 1;
break;
case 'inventory_replenished':
case 'stock_received':
newCoverage = Math.min(100, newCoverage + 15);
if (newCoverage > 70) newRisk = 'low';
if (newCoverage > 50) newRisk = 'medium';
newStatus = newCoverage > 80 ? 'normal' : 'warning';
newCriticalItems = Math.max(0, newCriticalItems - 1);
break;
case 'inventory_adjustment':
// Adjust coverage based on event metadata
if (event.event_metadata?.coverage_change) {
newCoverage = Math.min(100, Math.max(0, newCoverage + event.event_metadata.coverage_change));
}
break;
}
});
return {
...outlet,
inventoryCoverage: newCoverage,
stockoutRisk: newRisk,
status: newStatus,
criticalItems: newCriticalItems,
lastUpdated: new Date().toISOString()
};
});
});
}, [sseEvents]);
// Calculate network-wide fulfillment metrics
const calculateNetworkMetrics = () => {
const totalOutlets = inventoryData.length;
const avgCoverage = inventoryData.reduce((sum, outlet) => sum + outlet.inventoryCoverage, 0) / totalOutlets;
const avgFulfillment = inventoryData.reduce((sum, outlet) => sum + outlet.fulfillmentRate, 0) / totalOutlets;
const criticalOutlets = inventoryData.filter(outlet => outlet.status === 'critical').length;
const warningOutlets = inventoryData.filter(outlet => outlet.status === 'warning').length;
const normalOutlets = inventoryData.filter(outlet => outlet.status === 'normal').length;
const totalCriticalItems = inventoryData.reduce((sum, outlet) => sum + outlet.criticalItems, 0);
return {
totalOutlets,
avgCoverage,
avgFulfillment,
criticalOutlets,
warningOutlets,
normalOutlets,
totalCriticalItems,
networkHealth: Math.round(avgCoverage * 0.6 + avgFulfillment * 0.4)
};
};
const networkMetrics = calculateNetworkMetrics();
// Get status configuration for outlets
const getOutletStatusConfig = (outletId: string) => {
const outlet = inventoryData.find(o => o.id === outletId);
if (!outlet) return null;
switch (outlet.status) {
case 'critical':
return {
color: '#ef4444', // red-500
text: t('enterprise.status_critical'),
icon: AlertCircle,
isCritical: true
};
case 'warning':
return {
color: '#f59e0b', // amber-500
text: outlet.stockoutRisk === 'high' ? t('enterprise.high_stockout_risk') : t('enterprise.medium_stockout_risk'),
icon: AlertTriangle,
isHighlight: true
};
default:
return {
color: '#10b981', // emerald-500
text: t('enterprise.status_normal'),
icon: CheckCircle2
};
}
};
// Get risk level configuration
const getRiskConfig = (riskLevel: string) => {
switch (riskLevel) {
case 'critical':
return { color: '#ef4444', text: t('enterprise.risk_critical'), icon: AlertCircle };
case 'high':
return { color: '#f59e0b', text: t('enterprise.risk_high'), icon: AlertTriangle };
case 'medium':
return { color: '#fbbf24', text: t('enterprise.risk_medium'), icon: AlertTriangle };
default:
return { color: '#10b981', text: t('enterprise.risk_low'), icon: CheckCircle2 };
}
};
return (
<div className="space-y-8">
{/* Fulfillment Header */}
<div className="mb-8">
<h2 className="text-2xl font-bold text-[var(--text-primary)] mb-4 flex items-center gap-2">
<Warehouse className="w-6 h-6 text-[var(--color-primary)]" />
{t('enterprise.outlet_fulfillment')}
</h2>
<p className="text-[var(--text-secondary)] mb-6">
{t('enterprise.fulfillment_description')}
</p>
{/* View Mode Selector */}
<div className="flex gap-2 mb-6">
<Button
variant={viewMode === 'summary' ? 'default' : 'outline'}
size="sm"
onClick={() => setViewMode('summary')}
>
<BarChart3 className="w-4 h-4 mr-2" />
{t('enterprise.summary_view')}
</Button>
<Button
variant={viewMode === 'detailed' ? 'default' : 'outline'}
size="sm"
onClick={() => setViewMode('detailed')}
>
<PackageCheck className="w-4 h-4 mr-2" />
{t('enterprise.detailed_view')}
</Button>
</div>
</div>
{/* Network Fulfillment Summary */}
<div className="mb-8">
<h3 className="text-xl font-semibold text-[var(--text-primary)] mb-4 flex items-center gap-2">
<ShieldCheck className="w-5 h-5 text-[var(--color-success)]" />
{t('enterprise.fulfillment_summary')}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{/* Network Health Score */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-[var(--text-secondary)]">
{t('enterprise.network_health_score')}
</CardTitle>
<Activity className="w-5 h-5 text-[var(--color-primary)]" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-[var(--color-primary)]">
{networkMetrics.networkHealth}%
</div>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{t('enterprise.overall_fulfillment_health')}
</p>
</CardContent>
</Card>
{/* Average Inventory Coverage */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-[var(--text-secondary)]">
{t('enterprise.avg_inventory_coverage')}
</CardTitle>
<Package className="w-5 h-5 text-[var(--color-success)]" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-[var(--color-success)]">
{networkMetrics.avgCoverage}%
</div>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{t('enterprise.across_all_outlets')}
</p>
</CardContent>
</Card>
{/* Fulfillment Rate */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-[var(--text-secondary)]">
{t('enterprise.fulfillment_rate')}
</CardTitle>
<ShoppingCart className="w-5 h-5 text-[var(--color-info)]" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-[var(--color-info)]">
{networkMetrics.avgFulfillment}%
</div>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{t('enterprise.order_fulfillment_rate')}
</p>
</CardContent>
</Card>
{/* Critical Items */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-[var(--text-secondary)]">
{t('enterprise.critical_items')}
</CardTitle>
<AlertTriangle className="w-5 h-5 text-[var(--color-warning)]" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-[var(--color-warning)]">
{networkMetrics.totalCriticalItems}
</div>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{t('enterprise.items_at_risk')}
</p>
</CardContent>
</Card>
</div>
</div>
{/* Outlet Status Overview */}
<div className="mb-8">
<h3 className="text-xl font-semibold text-[var(--text-primary)] mb-4 flex items-center gap-2">
<Warehouse className="w-5 h-5 text-[var(--color-primary)]" />
{t('enterprise.outlet_status_overview')}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{inventoryData.map((outlet) => {
const statusConfig = getOutletStatusConfig(outlet.id);
return (
<StatusCard
key={outlet.id}
id={outlet.id}
statusIndicator={statusConfig || {
color: '#6b7280',
text: t('enterprise.no_data'),
icon: Clock
}}
title={outlet.name}
subtitle={`${t('enterprise.inventory_coverage')}: ${outlet.inventoryCoverage}%`}
primaryValue={`${outlet.fulfillmentRate}%`}
primaryValueLabel={t('enterprise.fulfillment_rate')}
secondaryInfo={{
label: t('enterprise.critical_items'),
value: `${outlet.criticalItems}`
}}
progress={{
label: t('enterprise.inventory_coverage'),
percentage: outlet.inventoryCoverage,
color: statusConfig?.color || '#6b7280'
}}
metadata={[
`${t('enterprise.stockout_risk')}: ${t(`enterprise.risk_${outlet.stockoutRisk}`)}`,
`${t('enterprise.last_updated')}: ${new Date(outlet.lastUpdated).toLocaleTimeString()}`,
sseConnected ? `🟢 ${t('enterprise.live_updates')}` : `🟡 ${t('enterprise.offline')}`
]}
actions={onOutletClick ? [{
label: t('enterprise.view_details'),
icon: PackageCheck,
variant: 'outline',
onClick: () => {
setSelectedOutlet(outlet.id);
setViewMode('detailed');
onOutletClick(outlet.id, outlet.name);
},
priority: 'primary'
}] : []}
onClick={() => {
setSelectedOutlet(outlet.id);
setViewMode('detailed');
}}
/>
);
})}
</div>
</div>
{/* Detailed View - Product Level Inventory */}
{viewMode === 'detailed' && selectedOutlet && (
<div className="mb-8">
<h3 className="text-xl font-semibold text-[var(--text-primary)] mb-4 flex items-center gap-2">
<PackageCheck className="w-5 h-5 text-[var(--color-info)]" />
{t('enterprise.product_level_inventory')}
</h3>
<div className="mb-4">
<Button
onClick={() => setViewMode('summary')}
variant="outline"
size="sm"
>
<ArrowLeft className="w-4 h-4 mr-2" />
{t('enterprise.back_to_summary')}
</Button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{inventoryData
.find(outlet => outlet.id === selectedOutlet)
?.products.map((product) => {
const riskConfig = getRiskConfig(product.risk);
return (
<StatusCard
key={product.id}
id={product.id}
statusIndicator={riskConfig}
title={product.name}
subtitle={`${t('enterprise.current_stock')}: ${product.stock} units`}
primaryValue={`${product.coverage}%`}
primaryValueLabel={t('enterprise.inventory_coverage')}
secondaryInfo={{
label: t('enterprise.safety_stock'),
value: `${product.safetyStock} units`
}}
progress={{
label: t('enterprise.coverage_of_safety'),
percentage: Math.min(100, Math.round((product.stock / product.safetyStock) * 100)),
color: riskConfig.color
}}
metadata={[
`${t('enterprise.risk_level')}: ${riskConfig.text}`,
`${t('enterprise.stock_above_safety')}: ${product.stock >= product.safetyStock ? t('enterprise.yes') : t('enterprise.no')}`
]}
actions={[
{
label: t('enterprise.transfer_stock'),
icon: Truck,
variant: 'outline',
onClick: () => {
// In Phase 2, this will navigate to transfer page
console.log(`Transfer stock for ${product.name}`);
},
priority: 'primary'
}
]}
/>
);
})}
</div>
</div>
)}
{/* Fulfillment Recommendations */}
<div className="mb-8">
<h3 className="text-xl font-semibold text-[var(--text-primary)] mb-4 flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-[var(--color-warning)]" />
{t('enterprise.fulfillment_recommendations')}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Critical Outlets */}
{networkMetrics.criticalOutlets > 0 && (
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<AlertCircle className="w-6 h-6 text-[var(--color-danger)]" />
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{t('enterprise.critical_outlets')}</h3>
</div>
<p className="text-[var(--text-secondary)] mb-4">
{t('enterprise.critical_outlets_description', {
count: networkMetrics.criticalOutlets
})}
</p>
<Button variant="outline" size="sm">
{t('enterprise.prioritize_transfers')}
</Button>
</CardContent>
</Card>
)}
{/* Inventory Optimization */}
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<Package className="w-6 h-6 text-[var(--color-primary)]" />
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{t('enterprise.inventory_optimization')}</h3>
</div>
<p className="text-[var(--text-secondary)] mb-4">
{networkMetrics.avgCoverage < 70
? t('enterprise.low_coverage_recommendation')
: t('enterprise.good_coverage_recommendation')}
</p>
<Button variant="outline" size="sm">
{t('enterprise.run_optimization')}
</Button>
</CardContent>
</Card>
{/* Fulfillment Excellence */}
{networkMetrics.avgFulfillment > 95 && (
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<ShieldCheck className="w-6 h-6 text-[var(--color-success)]" />
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{t('enterprise.fulfillment_excellence')}</h3>
</div>
<p className="text-[var(--text-secondary)] mb-4">
{t('enterprise.high_fulfillment_congrats', {
rate: networkMetrics.avgFulfillment
})}
</p>
<Button variant="outline" size="sm">
{t('enterprise.maintain_excellence')}
</Button>
</CardContent>
</Card>
)}
</div>
</div>
{/* Real-time Inventory Alerts */}
<div className="mb-8">
<h3 className="text-xl font-semibold text-[var(--text-primary)] mb-4 flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-[var(--color-info)]" />
{t('enterprise.real_time_inventory_alerts')}
</h3>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<div className="flex items-center gap-2">
<CardTitle className="text-sm font-medium text-[var(--text-secondary)]">
{t('enterprise.recent_inventory_events')}
</CardTitle>
{sseConnected ? (
<div className="flex items-center gap-1 text-xs text-[var(--color-success)]">
<span className="w-2 h-2 rounded-full bg-[var(--color-success)] animate-pulse"></span>
{t('enterprise.live_updates')}
</div>
) : (
<div className="flex items-center gap-1 text-xs text-[var(--color-warning)]">
<span className="w-2 h-2 rounded-full bg-[var(--color-warning)]"></span>
{t('enterprise.offline')}
</div>
)}
</div>
</CardHeader>
<CardContent>
{sseConnected ? (
<div className="space-y-4">
{inventoryData
.filter(outlet => outlet.status !== 'normal')
.map((outlet, index) => {
const statusConfig = getOutletStatusConfig(outlet.id);
const EventIcon = statusConfig?.icon || AlertTriangle;
const color = statusConfig?.color || 'text-[var(--color-warning)]';
return (
<div key={index} className="flex items-start gap-3 p-3 rounded-lg border border-[var(--border-primary)]">
<div className={`p-2 rounded-lg ${color.replace('text', 'bg')}`}>
<EventIcon className={`w-5 h-5 ${color}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<p className="font-medium text-[var(--text-primary)]">
{outlet.name} - {statusConfig?.text}
</p>
<p className="text-xs text-[var(--text-tertiary)] whitespace-nowrap">
{new Date(outlet.lastUpdated).toLocaleTimeString()}
</p>
</div>
<p className="text-sm text-[var(--text-secondary)] mt-1">
{t('enterprise.inventory_coverage')}: {outlet.inventoryCoverage}% | {t('enterprise.critical_items')}: {outlet.criticalItems}
</p>
<p className="text-xs text-[var(--text-tertiary)] mt-1">
{t('enterprise.fulfillment_rate')}: {outlet.fulfillmentRate}%
</p>
</div>
</div>
);
})}
{inventoryData.filter(outlet => outlet.status !== 'normal').length === 0 && (
<div className="text-center py-8 text-[var(--color-success)]">
<CheckCircle2 className="w-8 h-8 mx-auto mb-2" />
<p>{t('enterprise.all_outlets_healthy')}</p>
</div>
)}
</div>
) : (
<div className="text-center py-8 text-[var(--text-secondary)]">
{t('enterprise.waiting_for_updates')}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
};
export default OutletFulfillmentTab;