fix demo session 2
This commit is contained in:
@@ -307,7 +307,7 @@ export const useChildrenPerformance = (
|
||||
export const useDistributionOverview = (
|
||||
parentTenantId: string,
|
||||
date: string,
|
||||
options?: { enabled?: boolean }
|
||||
options?: { enabled?: boolean; refetchInterval?: number }
|
||||
): UseQueryResult<DistributionOverview> => {
|
||||
return useQuery({
|
||||
queryKey: ['enterprise', 'distribution-overview', parentTenantId, date],
|
||||
@@ -331,6 +331,7 @@ export const useDistributionOverview = (
|
||||
},
|
||||
staleTime: 30000, // 30s cache
|
||||
enabled: options?.enabled ?? true,
|
||||
refetchInterval: options?.refetchInterval,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ const DistributionTab: React.FC<DistributionTabProps> = ({ tenantId, selectedDat
|
||||
if (sseEvents.length === 0) return;
|
||||
|
||||
// Filter delivery and distribution-related events
|
||||
const deliveryEvents = sseEvents.filter(event =>
|
||||
const deliveryEvents = sseEvents.filter((event: any) =>
|
||||
event.event_type.includes('delivery_') ||
|
||||
event.event_type.includes('route_') ||
|
||||
event.event_type.includes('shipment_') ||
|
||||
@@ -125,39 +125,7 @@ const DistributionTab: React.FC<DistributionTabProps> = ({ tenantId, selectedDat
|
||||
|
||||
const isLoading = isDistributionLoading;
|
||||
|
||||
// Mock route data - in Phase 2 this will come from real API
|
||||
const mockRoutes = [
|
||||
{
|
||||
id: 'route-1',
|
||||
name: 'Madrid → Barcelona',
|
||||
status: 'in_transit',
|
||||
distance: '620 km',
|
||||
duration: '6h 30m',
|
||||
stops: 3,
|
||||
optimizationSavings: '12 km (1.9%)',
|
||||
vehicles: ['TRUCK-001', 'TRUCK-002']
|
||||
},
|
||||
{
|
||||
id: 'route-2',
|
||||
name: 'Barcelona → Valencia',
|
||||
status: 'completed',
|
||||
distance: '350 km',
|
||||
duration: '4h 15m',
|
||||
stops: 2,
|
||||
optimizationSavings: '8 km (2.3%)',
|
||||
vehicles: ['VAN-005']
|
||||
},
|
||||
{
|
||||
id: 'route-3',
|
||||
name: 'Central → Outlets (Daily)',
|
||||
status: 'pending',
|
||||
distance: '180 km',
|
||||
duration: '3h 00m',
|
||||
stops: 5,
|
||||
optimizationSavings: '25 km (13.9%)',
|
||||
vehicles: ['TRUCK-003', 'VAN-006', 'VAN-007']
|
||||
}
|
||||
];
|
||||
// No mockRoutes anymore, using distributionOverview.route_sequences
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
@@ -354,7 +322,7 @@ const DistributionTab: React.FC<DistributionTabProps> = ({ tenantId, selectedDat
|
||||
{t('enterprise.active_routes')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{mockRoutes.map((route) => {
|
||||
{(distributionOverview?.route_sequences || []).map((route: any) => {
|
||||
// Determine status configuration
|
||||
const getStatusConfig = () => {
|
||||
switch (route.status) {
|
||||
@@ -364,15 +332,15 @@ const DistributionTab: React.FC<DistributionTabProps> = ({ tenantId, selectedDat
|
||||
text: t('enterprise.route_completed'),
|
||||
icon: CheckCircle2
|
||||
};
|
||||
case 'delayed':
|
||||
case 'overdue':
|
||||
case 'failed':
|
||||
case 'cancelled':
|
||||
return {
|
||||
color: '#ef4444', // red-500
|
||||
text: t('enterprise.route_delayed'),
|
||||
icon: AlertTriangle,
|
||||
isCritical: true
|
||||
};
|
||||
case 'in_transit':
|
||||
case 'in_progress':
|
||||
return {
|
||||
color: '#3b82f6', // blue-500
|
||||
text: t('enterprise.route_in_transit'),
|
||||
@@ -390,29 +358,35 @@ const DistributionTab: React.FC<DistributionTabProps> = ({ tenantId, selectedDat
|
||||
|
||||
const statusConfig = getStatusConfig();
|
||||
|
||||
// Format optimization savings
|
||||
const savings = route.vrp_optimization_savings || {};
|
||||
const distanceSavedText = savings.distance_saved_km
|
||||
? `${savings.distance_saved_km.toFixed(1)} km`
|
||||
: '0 km';
|
||||
|
||||
return (
|
||||
<StatusCard
|
||||
key={route.id}
|
||||
id={route.id}
|
||||
statusIndicator={statusConfig}
|
||||
title={route.name}
|
||||
subtitle={`${t('enterprise.distance')}: ${route.distance}`}
|
||||
primaryValue={route.duration}
|
||||
title={`${t('enterprise.route')} #${route.route_number || 'N/A'}`}
|
||||
subtitle={`${t('enterprise.distance')}: ${route.total_distance_km?.toFixed(1) || 0} km`}
|
||||
primaryValue={`${route.estimated_duration_minutes || 0} min`}
|
||||
primaryValueLabel={t('enterprise.estimated_duration')}
|
||||
secondaryInfo={{
|
||||
label: t('enterprise.stops'),
|
||||
value: `${route.stops}`
|
||||
value: `${route.route_sequence?.length || 0}`
|
||||
}}
|
||||
progress={{
|
||||
label: t('enterprise.optimization'),
|
||||
percentage: route.status === 'completed' ? 100 :
|
||||
route.status === 'in_transit' ? 75 :
|
||||
route.status === 'delayed' ? 50 : 25,
|
||||
route.status === 'in_progress' ? 75 :
|
||||
route.status === 'failed' ? 50 : 25,
|
||||
color: statusConfig.color
|
||||
}}
|
||||
metadata={[
|
||||
`${t('enterprise.optimization_savings')}: ${route.optimizationSavings}`,
|
||||
`${t('enterprise.vehicles')}: ${route.vehicles.join(', ')}`
|
||||
`${t('enterprise.optimization_savings')}: ${distanceSavedText}`,
|
||||
`${t('enterprise.vehicles')}: ${route.vehicle_id || t('common:not_available', 'N/A')}`
|
||||
]}
|
||||
actions={[
|
||||
{
|
||||
@@ -420,19 +394,25 @@ const DistributionTab: React.FC<DistributionTabProps> = ({ tenantId, selectedDat
|
||||
icon: Map,
|
||||
variant: 'outline',
|
||||
onClick: () => {
|
||||
// In Phase 2, this will navigate to route tracking page
|
||||
console.log(`Track route ${route.name}`);
|
||||
console.log(`Track route ${route.route_number}`);
|
||||
},
|
||||
priority: 'primary'
|
||||
}
|
||||
]}
|
||||
onClick={() => {
|
||||
// In Phase 2, this will navigate to route detail page
|
||||
console.log(`View route ${route.name}`);
|
||||
console.log(`View route ${route.route_number}`);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{(!distributionOverview?.route_sequences || distributionOverview.route_sequences.length === 0) && (
|
||||
<div className="col-span-1 sm:col-span-2 lg:col-span-3 py-12">
|
||||
<div className="text-center text-[var(--text-secondary)]">
|
||||
<Truck className="w-12 h-12 mx-auto mb-4 opacity-20" />
|
||||
<p>{t('enterprise.no_active_routes')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -82,26 +82,25 @@ const DistributionMap: React.FC<DistributionMapProps> = ({
|
||||
shipments = { pending: 0, in_transit: 0, delivered: 0, failed: 0 }
|
||||
}) => {
|
||||
const { t } = useTranslation('dashboard');
|
||||
const [selectedRoute, setSelectedRoute] = useState<RouteData | null>(null);
|
||||
|
||||
const renderMapVisualization = () => {
|
||||
if (!routes || routes.length === 0) {
|
||||
return (
|
||||
<div className="h-96 flex items-center justify-center rounded-xl border-2 border-dashed" style={{ borderColor: 'var(--border-secondary)', backgroundColor: 'var(--bg-secondary)' }}>
|
||||
<div className="h-full min-h-[400px] flex items-center justify-center rounded-xl border-2 border-dashed" style={{ borderColor: 'var(--border-secondary)', backgroundColor: 'var(--bg-secondary)' }}>
|
||||
<div className="text-center px-6">
|
||||
<div
|
||||
className="w-24 h-24 rounded-full flex items-center justify-center mb-4 mx-auto shadow-lg"
|
||||
className="w-20 h-20 rounded-full flex items-center justify-center mb-4 mx-auto shadow-sm"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-info-100)',
|
||||
backgroundColor: 'var(--color-info-50)',
|
||||
}}
|
||||
>
|
||||
<MapPin className="w-12 h-12" style={{ color: 'var(--color-info-600)' }} />
|
||||
<MapPin className="w-10 h-10" style={{ color: 'var(--color-info-500)' }} />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('enterprise.no_active_routes')}
|
||||
<h3 className="text-lg font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('enterprise.no_active_routes', 'Sin rutas activas')}
|
||||
</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('enterprise.no_shipments_today')}
|
||||
{t('enterprise.no_shipments_today', 'No hay envíos programados para hoy')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,21 +117,21 @@ const DistributionMap: React.FC<DistributionMapProps> = ({
|
||||
|
||||
if (activeRoutes.length === 0) {
|
||||
return (
|
||||
<div className="h-96 flex items-center justify-center rounded-xl border-2 border-dashed" style={{ borderColor: 'var(--border-secondary)', backgroundColor: 'var(--bg-secondary)' }}>
|
||||
<div className="h-full min-h-[400px] flex items-center justify-center rounded-xl border-2 border-dashed" style={{ borderColor: 'var(--border-secondary)', backgroundColor: 'var(--bg-secondary)' }}>
|
||||
<div className="text-center px-6">
|
||||
<div
|
||||
className="w-24 h-24 rounded-full flex items-center justify-center mb-4 mx-auto shadow-lg"
|
||||
className="w-20 h-20 rounded-full flex items-center justify-center mb-4 mx-auto shadow-sm"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-success-100)',
|
||||
backgroundColor: 'var(--color-success-50)',
|
||||
}}
|
||||
>
|
||||
<CheckCircle className="w-12 h-12" style={{ color: 'var(--color-success-600)' }} />
|
||||
<CheckCircle className="w-10 h-10" style={{ color: 'var(--color-success-500)' }} />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('enterprise.all_routes_completed')}
|
||||
<h3 className="text-lg font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('enterprise.all_routes_completed', 'Todas las rutas completadas')}
|
||||
</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('enterprise.no_active_deliveries')}
|
||||
{t('enterprise.no_active_deliveries', 'No hay entregas pendientes en este momento')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -146,11 +145,11 @@ const DistributionMap: React.FC<DistributionMapProps> = ({
|
||||
|
||||
// Real Leaflet map with routes
|
||||
return (
|
||||
<div className="relative h-96 rounded-xl overflow-hidden border-2" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
<div className="relative h-[500px] w-full rounded-xl overflow-hidden border border-[var(--border-primary)] shadow-sm">
|
||||
<MapContainer
|
||||
center={[centerLat, centerLng]}
|
||||
zoom={6}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
style={{ height: '100%', width: '100%', zIndex: 0 }}
|
||||
scrollWheelZoom={true}
|
||||
>
|
||||
<TileLayer
|
||||
@@ -159,7 +158,7 @@ const DistributionMap: React.FC<DistributionMapProps> = ({
|
||||
/>
|
||||
|
||||
{/* Render each route */}
|
||||
{activeRoutes.map((route, routeIdx) => {
|
||||
{activeRoutes.map((route) => {
|
||||
const points = route.route_sequence || [];
|
||||
const routeColor = route.status === 'in_progress' ? '#3b82f6' : '#f59e0b';
|
||||
|
||||
@@ -191,13 +190,32 @@ const DistributionMap: React.FC<DistributionMapProps> = ({
|
||||
icon={createRouteMarker(markerColor, point.sequence)}
|
||||
>
|
||||
<Popup>
|
||||
<div className="min-w-[200px]">
|
||||
<div className="font-semibold text-base mb-1">{point.name}</div>
|
||||
<div className="text-sm text-gray-600 mb-2">{point.address}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
<div>Route: {route.route_number}</div>
|
||||
<div>Stop {point.sequence} of {points.length}</div>
|
||||
<div className="capitalize mt-1">Status: {point.status}</div>
|
||||
<div className="min-w-[180px] p-1">
|
||||
<div className="font-semibold text-sm mb-1 text-[var(--text-primary)]">{point.name}</div>
|
||||
<div className="text-xs text-[var(--text-secondary)] mb-2">{point.address}</div>
|
||||
<div className="flex flex-col gap-1 border-t border-[var(--border-primary)] pt-2 mt-2">
|
||||
<div className="flex justify-between text-[10px] text-[var(--text-tertiary)]">
|
||||
<span>Ruta:</span>
|
||||
<span className="font-medium text-[var(--text-secondary)]">{route.route_number}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] text-[var(--text-tertiary)]">
|
||||
<span>Parada:</span>
|
||||
<span className="font-medium text-[var(--text-secondary)]">{point.sequence} / {points.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] text-[var(--text-tertiary)]">
|
||||
<span>Estado:</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="px-1.5 py-0 text-[9px] capitalize"
|
||||
style={{
|
||||
backgroundColor: markerColor + '10',
|
||||
color: markerColor,
|
||||
borderColor: markerColor + '30'
|
||||
}}
|
||||
>
|
||||
{point.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
@@ -209,398 +227,66 @@ const DistributionMap: React.FC<DistributionMapProps> = ({
|
||||
})}
|
||||
</MapContainer>
|
||||
|
||||
{/* Status Legend Overlay */}
|
||||
{/* Status Legend Overlay - Refined alignment */}
|
||||
<div
|
||||
className="absolute bottom-4 right-4 glass-effect p-4 rounded-lg shadow-lg backdrop-blur-sm space-y-2 z-[1000]"
|
||||
className="absolute bottom-6 right-6 p-4 rounded-xl shadow-xl z-[400] overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
border: '1px solid var(--border-primary)'
|
||||
backgroundColor: 'rgba(var(--bg-primary-rgb), 0.9)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
border: '1px solid var(--border-primary)',
|
||||
}}
|
||||
>
|
||||
<div className="text-xs font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
{activeRoutes.length} {t('enterprise.active_routes')}
|
||||
<div className="text-[10px] uppercase tracking-wider font-bold mb-3 text-[var(--text-tertiary)]">
|
||||
{activeRoutes.length} {t('enterprise.active_routes', 'Rutas Activas')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: '#22c55e' }}></div>
|
||||
<span className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('enterprise.delivered')}: {shipments.delivered}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: '#3b82f6' }}></div>
|
||||
<span className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('enterprise.in_transit')}: {shipments.in_transit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: '#f59e0b' }}></div>
|
||||
<span className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('enterprise.pending')}: {shipments.pending}
|
||||
</span>
|
||||
</div>
|
||||
{shipments.failed > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: '#ef4444' }}></div>
|
||||
<span className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('enterprise.failed')}: {shipments.failed}
|
||||
</span>
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: '#22c55e' }}></div>
|
||||
<span className="text-xs text-[var(--text-secondary)]">
|
||||
{t('enterprise.delivered', 'Entregadas')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs font-bold text-[var(--text-primary)]">{shipments.delivered}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: '#3b82f6' }}></div>
|
||||
<span className="text-xs text-[var(--text-secondary)]">
|
||||
{t('enterprise.in_transit', 'En Tránsito')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs font-bold text-[var(--text-primary)]">{shipments.in_transit}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: '#f59e0b' }}></div>
|
||||
<span className="text-xs text-[var(--text-secondary)]">
|
||||
{t('enterprise.pending', 'Pendientes')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs font-bold text-[var(--text-primary)]">{shipments.pending}</span>
|
||||
</div>
|
||||
{shipments.failed > 0 && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: '#ef4444' }}></div>
|
||||
<span className="text-xs text-[var(--text-secondary)]">
|
||||
{t('enterprise.failed', 'Fallidas')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs font-bold text-[var(--text-primary)]">{shipments.failed}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'delivered':
|
||||
return <CheckCircle className="w-4 h-4 text-green-500" />;
|
||||
case 'in_transit':
|
||||
return <Truck className="w-4 h-4 text-blue-500" />;
|
||||
case 'pending':
|
||||
return <Clock className="w-4 h-4 text-yellow-500" />;
|
||||
case 'failed':
|
||||
return <AlertTriangle className="w-4 h-4 text-red-500" />;
|
||||
default:
|
||||
return <Clock className="w-4 h-4 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'delivered':
|
||||
return 'bg-green-100 text-green-800 border-green-200';
|
||||
case 'in_transit':
|
||||
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
case 'failed':
|
||||
return 'bg-red-100 text-red-800 border-red-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Shipment Status Summary - Hero Icon Pattern */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
|
||||
{/* Pending Status Card */}
|
||||
<div className="relative group">
|
||||
<div
|
||||
className="p-6 rounded-xl border-2 transition-all duration-300 hover:shadow-xl hover:-translate-y-1 cursor-pointer"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-warning-50)',
|
||||
borderColor: 'var(--color-warning-200)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-14 h-14 rounded-full flex items-center justify-center mb-3 shadow-md"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-warning-100)',
|
||||
}}
|
||||
>
|
||||
<Clock className="w-7 h-7" style={{ color: 'var(--color-warning-600)' }} />
|
||||
</div>
|
||||
<p className="text-4xl font-bold mb-1" style={{ color: 'var(--color-warning-900)' }}>
|
||||
{shipments?.pending || 0}
|
||||
</p>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--color-warning-700)' }}>
|
||||
{t('enterprise.pending')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* In Transit Status Card */}
|
||||
<div className="relative group">
|
||||
<div
|
||||
className="p-6 rounded-xl border-2 transition-all duration-300 hover:shadow-xl hover:-translate-y-1 cursor-pointer"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-info-50)',
|
||||
borderColor: 'var(--color-info-200)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-14 h-14 rounded-full flex items-center justify-center mb-3 shadow-md"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-info-100)',
|
||||
}}
|
||||
>
|
||||
<Truck className="w-7 h-7" style={{ color: 'var(--color-info-600)' }} />
|
||||
</div>
|
||||
<p className="text-4xl font-bold mb-1" style={{ color: 'var(--color-info-900)' }}>
|
||||
{shipments?.in_transit || 0}
|
||||
</p>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--color-info-700)' }}>
|
||||
{t('enterprise.in_transit')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delivered Status Card */}
|
||||
<div className="relative group">
|
||||
<div
|
||||
className="p-6 rounded-xl border-2 transition-all duration-300 hover:shadow-xl hover:-translate-y-1 cursor-pointer"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-success-50)',
|
||||
borderColor: 'var(--color-success-200)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-14 h-14 rounded-full flex items-center justify-center mb-3 shadow-md"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-success-100)',
|
||||
}}
|
||||
>
|
||||
<CheckCircle className="w-7 h-7" style={{ color: 'var(--color-success-600)' }} />
|
||||
</div>
|
||||
<p className="text-4xl font-bold mb-1" style={{ color: 'var(--color-success-900)' }}>
|
||||
{shipments?.delivered || 0}
|
||||
</p>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--color-success-700)' }}>
|
||||
{t('enterprise.delivered')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Failed Status Card */}
|
||||
<div className="relative group">
|
||||
<div
|
||||
className="p-6 rounded-xl border-2 transition-all duration-300 hover:shadow-xl hover:-translate-y-1 cursor-pointer"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-error-50)',
|
||||
borderColor: 'var(--color-error-200)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-14 h-14 rounded-full flex items-center justify-center mb-3 shadow-md"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-error-100)',
|
||||
}}
|
||||
>
|
||||
<AlertTriangle className="w-7 h-7" style={{ color: 'var(--color-error-600)' }} />
|
||||
</div>
|
||||
<p className="text-4xl font-bold mb-1" style={{ color: 'var(--color-error-900)' }}>
|
||||
{shipments?.failed || 0}
|
||||
</p>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--color-error-700)' }}>
|
||||
{t('enterprise.failed')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map Visualization */}
|
||||
<div className="w-full">
|
||||
{renderMapVisualization()}
|
||||
|
||||
{/* Route Details Panel - Timeline Pattern */}
|
||||
<div className="mt-6">
|
||||
<h3 className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('enterprise.active_routes')} ({routes.filter(r => r.status === 'in_progress' || r.status === 'planned').length})
|
||||
</h3>
|
||||
|
||||
{routes.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{routes
|
||||
.filter(route => route.status === 'in_progress' || route.status === 'planned')
|
||||
.map(route => (
|
||||
<div
|
||||
key={route.id}
|
||||
className="p-5 rounded-xl border-2 transition-all duration-300 hover:shadow-lg"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
borderColor: 'var(--border-primary)'
|
||||
}}
|
||||
>
|
||||
{/* Route Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-12 h-12 rounded-lg flex items-center justify-center shadow-md"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-info-100)',
|
||||
}}
|
||||
>
|
||||
<Truck className="w-6 h-6" style={{ color: 'var(--color-info-600)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-base" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('enterprise.route')} {route.route_number}
|
||||
</h4>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{route.total_distance_km.toFixed(1)} km • {Math.ceil(route.estimated_duration_minutes / 60)}h
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Badge
|
||||
className="px-3 py-1"
|
||||
style={{
|
||||
backgroundColor: route.status === 'in_progress'
|
||||
? 'var(--color-info-100)'
|
||||
: 'var(--color-warning-100)',
|
||||
color: route.status === 'in_progress'
|
||||
? 'var(--color-info-900)'
|
||||
: 'var(--color-warning-900)',
|
||||
borderColor: route.status === 'in_progress'
|
||||
? 'var(--color-info-300)'
|
||||
: 'var(--color-warning-300)',
|
||||
borderWidth: '1px'
|
||||
}}
|
||||
>
|
||||
{getStatusIcon(route.status)}
|
||||
<span className="ml-1 capitalize font-medium">
|
||||
{t(`enterprise.route_status.${route.status}`) || route.status.replace('_', ' ')}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Timeline of Stops */}
|
||||
{route.route_sequence && route.route_sequence.length > 0 && (
|
||||
<div className="ml-6 border-l-2 pl-6 space-y-3" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
{route.route_sequence.map((point, idx) => {
|
||||
const getPointStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'delivered':
|
||||
return 'var(--color-success)';
|
||||
case 'in_transit':
|
||||
return 'var(--color-info)';
|
||||
case 'failed':
|
||||
return 'var(--color-error)';
|
||||
default:
|
||||
return 'var(--color-warning)';
|
||||
}
|
||||
};
|
||||
|
||||
const getPointBadgeStyle = (status: string) => {
|
||||
switch (status) {
|
||||
case 'delivered':
|
||||
return {
|
||||
backgroundColor: 'var(--color-success-100)',
|
||||
color: 'var(--color-success-900)',
|
||||
borderColor: 'var(--color-success-300)'
|
||||
};
|
||||
case 'in_transit':
|
||||
return {
|
||||
backgroundColor: 'var(--color-info-100)',
|
||||
color: 'var(--color-info-900)',
|
||||
borderColor: 'var(--color-info-300)'
|
||||
};
|
||||
case 'failed':
|
||||
return {
|
||||
backgroundColor: 'var(--color-error-100)',
|
||||
color: 'var(--color-error-900)',
|
||||
borderColor: 'var(--color-error-300)'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
backgroundColor: 'var(--color-warning-100)',
|
||||
color: 'var(--color-warning-900)',
|
||||
borderColor: 'var(--color-warning-300)'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={idx} className="relative">
|
||||
{/* Timeline dot */}
|
||||
<div
|
||||
className="absolute -left-[29px] w-4 h-4 rounded-full border-2 shadow-sm"
|
||||
style={{
|
||||
backgroundColor: getPointStatusColor(point.status),
|
||||
borderColor: 'var(--bg-primary)'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Stop info */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium mb-0.5" style={{ color: 'var(--text-primary)' }}>
|
||||
{point.sequence}. {point.name}
|
||||
</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{point.address}
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="px-2 py-0.5 text-xs flex-shrink-0"
|
||||
style={{
|
||||
...getPointBadgeStyle(point.status),
|
||||
borderWidth: '1px'
|
||||
}}
|
||||
>
|
||||
{getStatusIcon(point.status)}
|
||||
<span className="ml-1 capitalize">
|
||||
{t(`enterprise.stop_status.${point.status}`) || point.status}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8" style={{ color: 'var(--text-secondary)' }}>
|
||||
<p className="text-sm">
|
||||
{t('enterprise.no_routes_planned')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selected Route Detail Panel (would be modal in real implementation) */}
|
||||
{selectedRoute && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-lg max-w-md w-full p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-semibold">{t('enterprise.route_details')}</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedRoute(null)}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span>{t('enterprise.route_number')}</span>
|
||||
<span className="font-medium">{selectedRoute.route_number}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t('enterprise.total_distance')}</span>
|
||||
<span>{selectedRoute.total_distance_km.toFixed(1)} km</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t('enterprise.estimated_duration')}</span>
|
||||
<span>{Math.ceil(selectedRoute.estimated_duration_minutes / 60)}h {selectedRoute.estimated_duration_minutes % 60}m</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t('enterprise.status')}</span>
|
||||
<Badge className={getStatusColor(selectedRoute.status)}>
|
||||
{getStatusIcon(selectedRoute.status)}
|
||||
<span className="ml-1 capitalize">
|
||||
{t(`enterprise.route_status.${selectedRoute.status}`) || selectedRoute.status}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full mt-4"
|
||||
onClick={() => setSelectedRoute(null)}
|
||||
>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
MoreVertical,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
X
|
||||
X,
|
||||
Clock,
|
||||
Eye,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
@@ -23,6 +25,11 @@ import {
|
||||
Badge,
|
||||
Input,
|
||||
Tabs,
|
||||
EmptyState,
|
||||
StatusCard,
|
||||
SearchAndFilter,
|
||||
type FilterConfig,
|
||||
getStatusColor,
|
||||
} from '../../../../components/ui';
|
||||
import { TabsList, TabsTrigger, TabsContent } from '../../../../components/ui/Tabs';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
@@ -71,11 +78,75 @@ const DistributionPage: React.FC = () => {
|
||||
},
|
||||
];
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
|
||||
const handleNewRoute = () => {
|
||||
// Navigate to create route page or open modal
|
||||
console.log('New route clicked');
|
||||
};
|
||||
|
||||
// Route status configuration
|
||||
const getRouteStatusConfig = (status: string) => {
|
||||
const configs: Record<string, any> = {
|
||||
pending: {
|
||||
color: getStatusColor('warning'),
|
||||
text: t('operations:status.pending', 'Pendiente'),
|
||||
icon: Clock,
|
||||
isCritical: true
|
||||
},
|
||||
in_progress: {
|
||||
color: getStatusColor('info'),
|
||||
text: t('operations:status.in_progress', 'En Progreso'),
|
||||
icon: Truck,
|
||||
isCritical: false
|
||||
},
|
||||
completed: {
|
||||
color: getStatusColor('success'),
|
||||
text: t('operations:status.completed', 'Completada'),
|
||||
icon: CheckCircle,
|
||||
isCritical: false
|
||||
},
|
||||
failed: {
|
||||
color: getStatusColor('error'),
|
||||
text: t('operations:status.failed', 'Fallida'),
|
||||
icon: AlertTriangle,
|
||||
isCritical: true
|
||||
}
|
||||
};
|
||||
|
||||
return configs[status] || {
|
||||
color: getStatusColor('neutral'),
|
||||
text: status,
|
||||
icon: Package,
|
||||
isCritical: false
|
||||
};
|
||||
};
|
||||
|
||||
// Filter routes
|
||||
const filteredRoutes = distributionData?.route_sequences?.filter((route: any) => {
|
||||
const matchesSearch = route.route_number?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesStatus = !statusFilter || route.status === statusFilter;
|
||||
return matchesSearch && matchesStatus;
|
||||
}) || [];
|
||||
|
||||
const filterConfig: FilterConfig[] = [
|
||||
{
|
||||
key: 'status',
|
||||
type: 'dropdown',
|
||||
label: t('operations:filters.status', 'Estado'),
|
||||
value: statusFilter,
|
||||
onChange: (value) => setStatusFilter(value as string),
|
||||
placeholder: t('operations:filters.all_statuses', 'Todos los estados'),
|
||||
options: [
|
||||
{ value: 'pending', label: t('operations:status.pending', 'Pendiente') },
|
||||
{ value: 'in_progress', label: t('operations:status.in_progress', 'En Progreso') },
|
||||
{ value: 'completed', label: t('operations:status.completed', 'Completada') },
|
||||
{ value: 'failed', label: t('operations:status.failed', 'Fallida') }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
if (!tenant) return null;
|
||||
|
||||
// Prepare shipment status data safely
|
||||
@@ -112,270 +183,141 @@ const DistributionPage: React.FC = () => {
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Stats Grid */}
|
||||
{/* Stats Grid - Follows Procurement style */}
|
||||
<StatsGrid
|
||||
stats={stats}
|
||||
columns={4}
|
||||
/>
|
||||
|
||||
{/* Main Content Areas */}
|
||||
<div className="space-y-6">
|
||||
{/* Tabs Navigation */}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value: 'overview' | 'routes' | 'shipments') => setActiveTab(value)}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="flex w-full">
|
||||
<TabsTrigger value="overview" className="flex-1">
|
||||
{t('operations:distribution.tabs.overview', 'Vista General')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="routes" className="flex-1">
|
||||
{t('operations:distribution.tabs.routes', 'Listado de Rutas')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="shipments" className="flex-1">
|
||||
{t('operations:distribution.tabs.shipments', 'Listado de Envíos')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{/* Tabs Navigation - Simplified and cleaned up */}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value: string) => setActiveTab(value as 'overview' | 'routes' | 'shipments')}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="mb-6">
|
||||
<TabsTrigger value="overview">
|
||||
{t('operations:distribution.tabs.overview', 'Vista General')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="routes">
|
||||
{t('operations:distribution.tabs.routes', 'Listado de Rutas')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="shipments">
|
||||
{t('operations:distribution.tabs.shipments', 'Listado de Envíos')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Content based on Active Tab */}
|
||||
<TabsContent value="overview" className="space-y-6 mt-6">
|
||||
{/* Map Section */}
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="sticky top-0 z-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-[var(--color-info-50)] dark:bg-[var(--color-info-900)]">
|
||||
<MapPin className="w-5 h-5 text-[var(--color-info)] dark:text-[var(--color-info-300)]" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-lg">
|
||||
{t('operations:map.title', 'Mapa de Distribución')}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('operations:map.description', 'Visualización en tiempo real de la flota')}
|
||||
</p>
|
||||
</div>
|
||||
{/* Content based on Active Tab */}
|
||||
<TabsContent value="overview" className="space-y-6 outline-none">
|
||||
{/* Map Section - Integrated with Card Header style */}
|
||||
<Card className="overflow-hidden border-[var(--border-primary)]">
|
||||
<CardHeader className="border-b border-[var(--border-primary)] bg-[var(--bg-secondary)] py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-[var(--color-info-50)] dark:bg-[var(--color-info-900)]">
|
||||
<MapPin className="w-5 h-5 text-[var(--color-info)] dark:text-[var(--color-info-300)]" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="flex items-center gap-1 border-[var(--border-primary)]">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||
{t('operations:map.live', 'En Vivo')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="p-4 bg-[var(--bg-secondary)]">
|
||||
<div className="aspect-video rounded-lg overflow-hidden bg-[var(--bg-tertiary)] flex items-center justify-center">
|
||||
<DistributionMap
|
||||
routes={distributionData?.route_sequences || []}
|
||||
shipments={shipmentStatus}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent Activity / Quick List */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
{t('operations:distribution.active_routes', 'Rutas en Progreso')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{distributionData?.route_sequences?.filter((r: any) => r.status === 'in_progress').length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{distributionData.route_sequences
|
||||
.filter((r: any) => r.status === 'in_progress')
|
||||
.map((route: any) => (
|
||||
<div
|
||||
key={route.id}
|
||||
className="flex items-center justify-between p-4 bg-[var(--bg-secondary)] border rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-full bg-[var(--color-info-50)] dark:bg-[var(--color-info-900)]">
|
||||
<Truck
|
||||
className="w-4 h-4 text-[var(--color-info)] dark:text-[var(--color-info-300)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-[var(--text-primary)]">
|
||||
{t('operations:distribution.route_prefix', 'Ruta')} {route.route_number}
|
||||
</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{route.formatted_driver_name || t('operations:distribution.no_driver', 'Sin conductor asignado')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="info">
|
||||
{t('operations:distribution.status.in_progress', 'En Ruta')}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-[var(--text-secondary)]">
|
||||
{t('operations:distribution.no_active_routes', 'No hay rutas en progreso actualmente.')}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
{t('operations:distribution.pending_deliveries', 'Entregas Pendientes')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{distributionData?.status_counts?.pending > 0 ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 bg-[var(--bg-secondary)] border rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-full bg-[var(--color-warning-50)] dark:bg-[var(--color-warning-900)]">
|
||||
<Package
|
||||
className="w-4 h-4 text-[var(--color-warning)] dark:text-[var(--color-warning-300)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-[var(--text-primary)]">
|
||||
{t('operations:distribution.pending_count', 'Entregas Pendientes')}
|
||||
</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('operations:distribution.pending_desc', 'Aún por distribuir')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="warning">
|
||||
{distributionData.status_counts.pending}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-[var(--text-secondary)]">
|
||||
{t('operations:distribution.no_pending', 'No hay entregas pendientes.')}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="routes" className="space-y-6 mt-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="text-lg">
|
||||
{t('operations:distribution.routes_list', 'Listado de Rutas')}
|
||||
<CardTitle className="text-lg font-bold">
|
||||
{t('operations:map.title', 'Mapa de Distribución')}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-1">
|
||||
{t('operations:distribution.routes_desc', 'Gestión y seguimiento de rutas de distribución')}
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('operations:map.description', 'Visualización en tiempo real de la flota')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Input
|
||||
placeholder={t('operations:distribution.search_routes', 'Buscar rutas...')}
|
||||
leftIcon={<Search className="w-4 h-4 text-[var(--text-tertiary)]" />}
|
||||
className="w-full sm:w-64"
|
||||
/>
|
||||
<Button variant="outline" size="sm" leftIcon={<Filter className="w-4 h-4" />}>
|
||||
{t('operations:actions.filters', 'Filtros')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(distributionData?.route_sequences?.length || 0) > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="text-xs text-[var(--text-secondary)] uppercase bg-[var(--bg-secondary)] border-b">
|
||||
<tr>
|
||||
<th className="px-4 py-3">{t('operations:distribution.table.route', 'Ruta')}</th>
|
||||
<th className="px-4 py-3">{t('operations:distribution.table.status', 'Estado')}</th>
|
||||
<th className="px-4 py-3">{t('operations:distribution.table.distance', 'Distancia')}</th>
|
||||
<th className="px-4 py-3">{t('operations:distribution.table.duration', 'Duración Est.')}</th>
|
||||
<th className="px-4 py-3">{t('operations:distribution.table.stops', 'Paradas')}</th>
|
||||
<th className="px-4 py-3 text-right">{t('operations:distribution.table.actions', 'Acciones')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--border-primary)]">
|
||||
{distributionData.route_sequences.map((route: any) => (
|
||||
<tr key={route.id} className="hover:bg-[var(--bg-secondary)] transition-colors">
|
||||
<td className="px-4 py-3 font-medium text-[var(--text-primary)]">
|
||||
{t('operations:distribution.route_prefix', 'Ruta')} {route.route_number}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={
|
||||
route.status === 'completed' ? 'success' :
|
||||
route.status === 'in_progress' ? 'info' :
|
||||
route.status === 'pending' ? 'warning' : 'default'
|
||||
}>
|
||||
{route.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{route.total_distance_km?.toFixed(1) || '-'} km
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{route.estimated_duration_minutes || '-'} min
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{route.route_points?.length || 0}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={MoreVertical}
|
||||
aria-label={t('operations:actions.more_options', 'Más opciones')}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 border-2 border-dashed border-[var(--border-primary)] rounded-lg">
|
||||
<p className="text-[var(--text-secondary)]">
|
||||
{t('operations:distribution.no_routes_found', 'No se encontraron rutas para esta fecha.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<Badge variant="outline" className="flex items-center gap-1.5 border-[var(--border-primary)] shadow-sm py-1">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||
<span className="text-xs font-semibold">{t('operations:map.live', 'En Vivo')}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="p-1 sm:p-2 bg-[var(--bg-primary)]">
|
||||
<DistributionMap
|
||||
routes={distributionData?.route_sequences || []}
|
||||
shipments={shipmentStatus}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Similar structure for Shipments tab, simplified for now */}
|
||||
<TabsContent value="shipments" className="space-y-6 mt-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
{t('operations:distribution.shipments_list', 'Gestión de Envíos')}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('operations:distribution.shipments_desc', 'Funcionalidad de listado detallado de envíos próximamente.')}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center py-12 border-2 border-dashed border-[var(--border-primary)] rounded-lg">
|
||||
<Package className="w-12 h-12 text-[var(--text-tertiary)] mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||
{t('operations:distribution.shipments_title', 'Gestión de Envíos')}
|
||||
</h3>
|
||||
<p className="text-[var(--text-secondary)]">
|
||||
{t('operations:distribution.shipments_desc', 'Funcionalidad de listado detallado de envíos próximamente.')}
|
||||
</p>
|
||||
</div>
|
||||
{/* Secondary sections at the bottom if needed, but keeping it clean for now */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Summary of important routes or alerts could go here */}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="routes" className="space-y-6 mt-0 outline-none">
|
||||
<SearchAndFilter
|
||||
searchValue={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
searchPlaceholder={t('operations:distribution.search_routes', 'Buscar rutas...')}
|
||||
filters={filterConfig}
|
||||
/>
|
||||
|
||||
{filteredRoutes.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredRoutes.map((route: any) => {
|
||||
const statusConfig = getRouteStatusConfig(route.status);
|
||||
return (
|
||||
<StatusCard
|
||||
key={route.id}
|
||||
id={route.id}
|
||||
statusIndicator={statusConfig}
|
||||
title={`${t('operations:distribution.route_number', 'Ruta')} ${route.route_number}`}
|
||||
subtitle={`${t('operations:distribution.vehicle', 'Vehículo')}: ${route.vehicle_id || t('common:not_available', 'N/A')}`}
|
||||
primaryValue={`${route.route_sequence?.length || 0}`}
|
||||
primaryValueLabel={t('operations:distribution.stops', 'Paradas')}
|
||||
metadata={[
|
||||
`${t('operations:distribution.distance', 'Distancia')}: ${route.total_distance_km?.toFixed(1) || 0} km`,
|
||||
`${t('operations:distribution.duration', 'Duración')}: ${route.estimated_duration_minutes || 0} min`,
|
||||
`${t('operations:distribution.savings', 'Ahorro')}: ${route.vrp_optimization_savings?.distance_saved_km?.toFixed(1) || 0} km`
|
||||
]}
|
||||
actions={[
|
||||
{
|
||||
label: t('common:actions.view', 'Ver'),
|
||||
icon: Eye,
|
||||
onClick: () => console.log('View route', route.id),
|
||||
variant: 'primary'
|
||||
}
|
||||
]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="border-[var(--border-primary)]">
|
||||
<CardContent className="py-12">
|
||||
<EmptyState
|
||||
icon={Truck}
|
||||
title={t('operations:distribution.no_routes_found', 'No se encontraron rutas')}
|
||||
description={t('operations:distribution.no_routes_desc', 'No hay rutas de distribución que coincidan con los filtros.')}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="shipments" className="space-y-6 mt-0 outline-none">
|
||||
<SearchAndFilter
|
||||
searchValue={''}
|
||||
onSearchChange={() => { }}
|
||||
searchPlaceholder={t('operations:distribution.search_shipments', 'Buscar envíos...')}
|
||||
filters={[]}
|
||||
/>
|
||||
|
||||
<Card className="border-[var(--border-primary)]">
|
||||
<CardContent className="py-12">
|
||||
<EmptyState
|
||||
icon={Package}
|
||||
title={t('operations:distribution.shipments_list', 'Gestión de Envíos')}
|
||||
description={t('operations:distribution.shipments_desc', 'Funcionalidad de listado detallado de envíos próximamente.')}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
55
services/inventory/app/api/internal.py
Normal file
55
services/inventory/app/api/internal.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
Internal API for Inventory Service
|
||||
Handles internal service-to-service operations
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from uuid import UUID
|
||||
import structlog
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.config import settings
|
||||
from app.models import Ingredient
|
||||
|
||||
logger = structlog.get_logger()
|
||||
router = APIRouter(prefix="/internal", tags=["internal"])
|
||||
|
||||
|
||||
async def verify_internal_api_key(x_internal_api_key: str = Header(None)):
|
||||
"""Verify internal API key for service-to-service communication"""
|
||||
required_key = settings.INTERNAL_API_KEY
|
||||
if x_internal_api_key != required_key:
|
||||
logger.warning("Unauthorized internal API access attempted")
|
||||
raise HTTPException(status_code=403, detail="Invalid internal API key")
|
||||
return True
|
||||
|
||||
|
||||
@router.get("/count")
|
||||
async def get_ingredient_count(
|
||||
tenant_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: bool = Depends(verify_internal_api_key)
|
||||
):
|
||||
"""
|
||||
Get count of active ingredients for onboarding status check.
|
||||
Internal endpoint for tenant service.
|
||||
"""
|
||||
try:
|
||||
count = await db.scalar(
|
||||
select(func.count()).select_from(Ingredient)
|
||||
.where(
|
||||
Ingredient.tenant_id == UUID(tenant_id),
|
||||
Ingredient.is_active == True
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"count": count or 0,
|
||||
"tenant_id": tenant_id
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get ingredient count", tenant_id=tenant_id, error=str(e))
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get ingredient count: {str(e)}")
|
||||
@@ -611,34 +611,3 @@ async def delete_demo_tenant_data(
|
||||
status_code=500,
|
||||
detail=f"Failed to delete demo data: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/count")
|
||||
async def get_ingredient_count(
|
||||
tenant_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: bool = Depends(verify_internal_api_key)
|
||||
):
|
||||
"""
|
||||
Get count of active ingredients for onboarding status check.
|
||||
Internal endpoint for tenant service.
|
||||
"""
|
||||
try:
|
||||
from sqlalchemy import select, func
|
||||
|
||||
count = await db.scalar(
|
||||
select(func.count()).select_from(Ingredient)
|
||||
.where(
|
||||
Ingredient.tenant_id == UUID(tenant_id),
|
||||
Ingredient.is_active == True
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"count": count or 0,
|
||||
"tenant_id": tenant_id
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get ingredient count", tenant_id=tenant_id, error=str(e))
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get ingredient count: {str(e)}")
|
||||
@@ -33,7 +33,8 @@ from app.api import (
|
||||
sustainability,
|
||||
audit,
|
||||
ml_insights,
|
||||
enterprise_inventory
|
||||
enterprise_inventory,
|
||||
internal
|
||||
)
|
||||
from app.api.internal_alert_trigger import router as internal_alert_trigger_router
|
||||
from app.api.internal_demo import router as internal_demo_router
|
||||
@@ -215,6 +216,7 @@ service.add_router(dashboard.router)
|
||||
service.add_router(analytics.router)
|
||||
service.add_router(sustainability.router)
|
||||
service.add_router(internal_demo.router, tags=["internal-demo"])
|
||||
service.add_router(internal.router)
|
||||
service.add_router(ml_insights.router) # ML insights endpoint
|
||||
service.add_router(ml_insights.internal_router) # Internal ML insights endpoint for demo cloning
|
||||
service.add_router(internal_alert_trigger_router) # Internal alert trigger for demo cloning
|
||||
|
||||
@@ -231,7 +231,7 @@ async def clone_demo_data(
|
||||
raise HTTPException(status_code=500, detail=f"Failed to clone orchestration runs: {str(e)}")
|
||||
|
||||
|
||||
@router.delete("/internal/demo/tenant/{virtual_tenant_id}")
|
||||
@router.delete("/tenant/{virtual_tenant_id}")
|
||||
async def delete_demo_data(
|
||||
virtual_tenant_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -280,7 +280,7 @@ async def delete_demo_data(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/internal/demo/clone/health")
|
||||
@router.get("/clone/health")
|
||||
async def health_check(_: bool = Depends(verify_internal_api_key)):
|
||||
"""Health check for demo cloning endpoint"""
|
||||
return {"status": "healthy", "service": "orchestrator"}
|
||||
|
||||
56
services/recipes/app/api/internal.py
Normal file
56
services/recipes/app/api/internal.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
Internal API for Recipes Service
|
||||
Handles internal service-to-service operations
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from uuid import UUID
|
||||
import structlog
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.config import settings
|
||||
from app.models.recipes import Recipe, RecipeStatus
|
||||
|
||||
logger = structlog.get_logger()
|
||||
router = APIRouter(prefix="/internal", tags=["internal"])
|
||||
|
||||
|
||||
async def verify_internal_api_key(x_internal_api_key: str = Header(None)):
|
||||
"""Verify internal API key for service-to-service communication"""
|
||||
required_key = settings.INTERNAL_API_KEY
|
||||
if x_internal_api_key != required_key:
|
||||
logger.warning("Unauthorized internal API access attempted")
|
||||
raise HTTPException(status_code=403, detail="Invalid internal API key")
|
||||
return True
|
||||
|
||||
|
||||
@router.get("/count")
|
||||
async def get_recipe_count(
|
||||
tenant_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: bool = Depends(verify_internal_api_key)
|
||||
):
|
||||
"""
|
||||
Get count of recipes for onboarding status check.
|
||||
Counts DRAFT and ACTIVE recipes (excludes ARCHIVED/DISCONTINUED).
|
||||
Internal endpoint for tenant service.
|
||||
"""
|
||||
try:
|
||||
count = await db.scalar(
|
||||
select(func.count()).select_from(Recipe)
|
||||
.where(
|
||||
Recipe.tenant_id == UUID(tenant_id),
|
||||
Recipe.status.in_([RecipeStatus.DRAFT, RecipeStatus.ACTIVE, RecipeStatus.TESTING])
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"count": count or 0,
|
||||
"tenant_id": tenant_id
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get recipe count", tenant_id=tenant_id, error=str(e))
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get recipe count: {str(e)}")
|
||||
@@ -434,34 +434,3 @@ async def delete_demo_tenant_data(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/count")
|
||||
async def get_recipe_count(
|
||||
tenant_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: bool = Depends(verify_internal_api_key)
|
||||
):
|
||||
"""
|
||||
Get count of recipes for onboarding status check.
|
||||
Counts DRAFT and ACTIVE recipes (excludes ARCHIVED/DISCONTINUED).
|
||||
Internal endpoint for tenant service.
|
||||
"""
|
||||
try:
|
||||
from sqlalchemy import select, func
|
||||
from app.models.recipes import RecipeStatus
|
||||
|
||||
count = await db.scalar(
|
||||
select(func.count()).select_from(Recipe)
|
||||
.where(
|
||||
Recipe.tenant_id == UUID(tenant_id),
|
||||
Recipe.status.in_([RecipeStatus.DRAFT, RecipeStatus.ACTIVE, RecipeStatus.TESTING])
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"count": count or 0,
|
||||
"tenant_id": tenant_id
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get recipe count", tenant_id=tenant_id, error=str(e))
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get recipe count: {str(e)}")
|
||||
|
||||
@@ -14,7 +14,7 @@ from .core.database import db_manager
|
||||
from shared.service_base import StandardFastAPIService
|
||||
|
||||
# Import API routers
|
||||
from .api import recipes, recipe_quality_configs, recipe_operations, audit, internal_demo
|
||||
from .api import recipes, recipe_quality_configs, recipe_operations, audit, internal_demo, internal
|
||||
|
||||
# Import models to register them with SQLAlchemy metadata
|
||||
from .models import recipes as recipe_models
|
||||
@@ -122,6 +122,7 @@ service.add_router(recipes.router)
|
||||
service.add_router(recipe_quality_configs.router)
|
||||
service.add_router(recipe_operations.router)
|
||||
service.add_router(internal_demo.router, tags=["internal-demo"])
|
||||
service.add_router(internal.router)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
55
services/suppliers/app/api/internal.py
Normal file
55
services/suppliers/app/api/internal.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
Internal API for Suppliers Service
|
||||
Handles internal service-to-service operations
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from uuid import UUID
|
||||
import structlog
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.config import settings
|
||||
from app.models.suppliers import Supplier, SupplierStatus
|
||||
|
||||
logger = structlog.get_logger()
|
||||
router = APIRouter(prefix="/internal", tags=["internal"])
|
||||
|
||||
|
||||
async def verify_internal_api_key(x_internal_api_key: str = Header(None)):
|
||||
"""Verify internal API key for service-to-service communication"""
|
||||
required_key = settings.INTERNAL_API_KEY
|
||||
if x_internal_api_key != required_key:
|
||||
logger.warning("Unauthorized internal API access attempted")
|
||||
raise HTTPException(status_code=403, detail="Invalid internal API key")
|
||||
return True
|
||||
|
||||
|
||||
@router.get("/count")
|
||||
async def get_supplier_count(
|
||||
tenant_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: bool = Depends(verify_internal_api_key)
|
||||
):
|
||||
"""
|
||||
Get count of active suppliers for onboarding status check.
|
||||
Internal endpoint for tenant service.
|
||||
"""
|
||||
try:
|
||||
count = await db.scalar(
|
||||
select(func.count()).select_from(Supplier)
|
||||
.where(
|
||||
Supplier.tenant_id == UUID(tenant_id),
|
||||
Supplier.status == SupplierStatus.active
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"count": count or 0,
|
||||
"tenant_id": tenant_id
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get supplier count", tenant_id=tenant_id, error=str(e))
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get supplier count: {str(e)}")
|
||||
@@ -409,33 +409,3 @@ async def delete_demo_tenant_data(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/count")
|
||||
async def get_supplier_count(
|
||||
tenant_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: bool = Depends(verify_internal_api_key)
|
||||
):
|
||||
"""
|
||||
Get count of active suppliers for onboarding status check.
|
||||
Internal endpoint for tenant service.
|
||||
"""
|
||||
try:
|
||||
from sqlalchemy import select, func
|
||||
from app.models.suppliers import SupplierStatus
|
||||
|
||||
count = await db.scalar(
|
||||
select(func.count()).select_from(Supplier)
|
||||
.where(
|
||||
Supplier.tenant_id == UUID(tenant_id),
|
||||
Supplier.status == SupplierStatus.active
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"count": count or 0,
|
||||
"tenant_id": tenant_id
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get supplier count", tenant_id=tenant_id, error=str(e))
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get supplier count: {str(e)}")
|
||||
|
||||
@@ -11,7 +11,7 @@ from app.core.database import database_manager
|
||||
from shared.service_base import StandardFastAPIService
|
||||
|
||||
# Import API routers
|
||||
from app.api import suppliers, supplier_operations, analytics, audit, internal_demo
|
||||
from app.api import suppliers, supplier_operations, analytics, audit, internal_demo, internal
|
||||
# REMOVED: purchase_orders, deliveries - PO and delivery management moved to Procurement Service
|
||||
# from app.api import purchase_orders, deliveries
|
||||
|
||||
@@ -110,6 +110,7 @@ service.add_router(supplier_operations.router) # /suppliers/operations/...
|
||||
service.add_router(analytics.router) # /suppliers/analytics/...
|
||||
service.add_router(suppliers.router) # /suppliers/{supplier_id} - catch-all, must be last
|
||||
service.add_router(internal_demo.router, tags=["internal-demo"])
|
||||
service.add_router(internal.router)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user