Files
bakery-ia/frontend/src/components/dashboard/OutletFulfillmentTab.tsx
2026-01-02 13:27:48 +01:00

670 lines
27 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';
import { useChildTenants } from '../../api/hooks/useEnterpriseDashboard';
import { inventoryService } from '../../api/services/inventory';
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');
// Get child tenants data
const { data: childTenants, isLoading: isChildTenantsLoading } = useChildTenants(tenantId);
// State for real-time inventory data
const [inventoryData, setInventoryData] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
// Combine loading states
const isLoading = isChildTenantsLoading || loading;
// Real-time SSE events
const { events: sseEvents, isConnected: sseConnected } = useSSEEvents({
channels: ['*.alerts', '*.notifications', 'recommendations']
});
// Process SSE events for inventory updates
useEffect(() => {
if (sseEvents.length === 0 || inventoryData.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, inventoryData]);
// Fetch inventory data for each child tenant individually
useEffect(() => {
if (!childTenants) {
setInventoryData([]);
setLoading(true);
return;
}
const fetchAllInventoryData = async () => {
setLoading(true);
try {
const promises = childTenants.map(async (tenant) => {
try {
// Using the imported service directly
const inventoryData = await inventoryService.getDashboardSummary(tenant.id);
return { tenant, inventoryData };
} catch (error) {
console.error(`Error fetching inventory for tenant ${tenant.id}:`, error);
return { tenant, inventoryData: null };
}
});
const results = await Promise.all(promises);
const processedData = results.map(({ tenant, inventoryData }) => {
// Calculate inventory metrics
const totalValue = inventoryData?.total_value || 0;
const outOfStockCount = inventoryData?.out_of_stock_count || 0;
const lowStockCount = inventoryData?.low_stock_count || 0;
const adequateStockCount = inventoryData?.adequate_stock_count || 0;
const totalIngredients = inventoryData?.total_ingredients || 0;
// Calculate coverage percentage (simplified calculation)
const coverage = totalIngredients > 0
? Math.min(100, Math.round(((adequateStockCount + lowStockCount) / totalIngredients) * 100))
: 100;
// Determine risk level based on out-of-stock and low-stock items
let riskLevel = 'low';
if (outOfStockCount > 5 || (outOfStockCount > 0 && lowStockCount > 10)) {
riskLevel = 'critical';
} else if (outOfStockCount > 0 || lowStockCount > 5) {
riskLevel = 'high';
} else if (lowStockCount > 2) {
riskLevel = 'medium';
}
// Determine status based on risk level
let status = 'normal';
if (riskLevel === 'critical') status = 'critical';
else if (riskLevel === 'high' || riskLevel === 'medium') status = 'warning';
return {
id: tenant.id,
name: tenant.name,
inventoryCoverage: coverage,
stockoutRisk: riskLevel,
criticalItems: outOfStockCount,
fulfillmentRate: 95, // Placeholder - would come from actual fulfillment data
lastUpdated: new Date().toISOString(),
status: status,
products: [] // Will be populated if detailed view is needed
};
});
setInventoryData(processedData);
} catch (error) {
console.error('Error fetching inventory data:', error);
setInventoryData([]);
} finally {
setLoading(false);
}
};
fetchAllInventoryData();
}, [childTenants]);
// 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>
{isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{[...Array(3)].map((_, index) => (
<Card key={index} className="animate-pulse">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="h-4 bg-[var(--bg-tertiary)] rounded w-3/4"></div>
<div className="w-8 h-8 rounded-full bg-[var(--bg-tertiary)]"></div>
</div>
<div className="h-6 bg-[var(--bg-tertiary)] rounded w-1/2 mb-2"></div>
<div className="h-4 bg-[var(--bg-tertiary)] rounded w-full mb-4"></div>
<div className="h-2 bg-[var(--bg-tertiary)] rounded w-full mb-2"></div>
<div className="h-2 bg-[var(--bg-tertiary)] rounded w-3/4"></div>
</CardContent>
</Card>
))}
</div>
) : (
<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');
}}
/>
);
})}
{inventoryData.length === 0 && (
<div className="col-span-full py-12">
<div className="text-center text-[var(--text-secondary)]">
<Warehouse className="w-12 h-12 mx-auto mb-4 opacity-20" />
<p>{t('enterprise.no_outlets')}</p>
</div>
</div>
)}
</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;