fix demo session 2

This commit is contained in:
Urtzi Alfaro
2026-01-02 12:18:46 +01:00
parent cf0176673c
commit 0a1951051f
14 changed files with 497 additions and 810 deletions

View File

@@ -307,7 +307,7 @@ export const useChildrenPerformance = (
export const useDistributionOverview = ( export const useDistributionOverview = (
parentTenantId: string, parentTenantId: string,
date: string, date: string,
options?: { enabled?: boolean } options?: { enabled?: boolean; refetchInterval?: number }
): UseQueryResult<DistributionOverview> => { ): UseQueryResult<DistributionOverview> => {
return useQuery({ return useQuery({
queryKey: ['enterprise', 'distribution-overview', parentTenantId, date], queryKey: ['enterprise', 'distribution-overview', parentTenantId, date],
@@ -331,6 +331,7 @@ export const useDistributionOverview = (
}, },
staleTime: 30000, // 30s cache staleTime: 30000, // 30s cache
enabled: options?.enabled ?? true, enabled: options?.enabled ?? true,
refetchInterval: options?.refetchInterval,
}); });
}; };

View File

@@ -22,7 +22,7 @@ interface DistributionTabProps {
const DistributionTab: React.FC<DistributionTabProps> = ({ tenantId, selectedDate, onDateChange }) => { const DistributionTab: React.FC<DistributionTabProps> = ({ tenantId, selectedDate, onDateChange }) => {
const { t } = useTranslation('dashboard'); const { t } = useTranslation('dashboard');
const { currencySymbol } = useTenantCurrency(); const { currencySymbol } = useTenantCurrency();
// Get distribution data // Get distribution data
const { const {
data: distributionOverview, data: distributionOverview,
@@ -34,8 +34,8 @@ const DistributionTab: React.FC<DistributionTabProps> = ({ tenantId, selectedDat
}); });
// Real-time SSE events // Real-time SSE events
const { events: sseEvents, isConnected: sseConnected } = useSSEEvents({ const { events: sseEvents, isConnected: sseConnected } = useSSEEvents({
channels: ['*.alerts', '*.notifications', 'recommendations'] channels: ['*.alerts', '*.notifications', 'recommendations']
}); });
// State for real-time delivery status // State for real-time delivery status
@@ -63,8 +63,8 @@ const DistributionTab: React.FC<DistributionTabProps> = ({ tenantId, selectedDat
if (sseEvents.length === 0) return; if (sseEvents.length === 0) return;
// Filter delivery and distribution-related events // 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('delivery_') ||
event.event_type.includes('route_') || event.event_type.includes('route_') ||
event.event_type.includes('shipment_') || event.event_type.includes('shipment_') ||
event.entity_type === 'delivery' || event.entity_type === 'delivery' ||
@@ -125,39 +125,7 @@ const DistributionTab: React.FC<DistributionTabProps> = ({ tenantId, selectedDat
const isLoading = isDistributionLoading; const isLoading = isDistributionLoading;
// Mock route data - in Phase 2 this will come from real API // No mockRoutes anymore, using distributionOverview.route_sequences
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']
}
];
return ( return (
<div className="space-y-8"> <div className="space-y-8">
@@ -167,7 +135,7 @@ const DistributionTab: React.FC<DistributionTabProps> = ({ tenantId, selectedDat
<Truck className="w-6 h-6 text-[var(--color-primary)]" /> <Truck className="w-6 h-6 text-[var(--color-primary)]" />
{t('enterprise.distribution_summary')} {t('enterprise.distribution_summary')}
</h2> </h2>
{/* Date selector */} {/* Date selector */}
<div className="mb-4 flex items-center gap-4"> <div className="mb-4 flex items-center gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -219,7 +187,7 @@ const DistributionTab: React.FC<DistributionTabProps> = ({ tenantId, selectedDat
{deliveryStatus.onTime} {deliveryStatus.onTime}
</div> </div>
<p className="text-xs text-[var(--text-secondary)] mt-1"> <p className="text-xs text-[var(--text-secondary)] mt-1">
{deliveryStatus.total > 0 {deliveryStatus.total > 0
? `${Math.round((deliveryStatus.onTime / deliveryStatus.total) * 100)}% ${t('enterprise.on_time_rate')}` ? `${Math.round((deliveryStatus.onTime / deliveryStatus.total) * 100)}% ${t('enterprise.on_time_rate')}`
: t('enterprise.no_deliveries')} : t('enterprise.no_deliveries')}
</p> </p>
@@ -354,7 +322,7 @@ const DistributionTab: React.FC<DistributionTabProps> = ({ tenantId, selectedDat
{t('enterprise.active_routes')} {t('enterprise.active_routes')}
</h2> </h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"> <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 // Determine status configuration
const getStatusConfig = () => { const getStatusConfig = () => {
switch (route.status) { switch (route.status) {
@@ -364,15 +332,15 @@ const DistributionTab: React.FC<DistributionTabProps> = ({ tenantId, selectedDat
text: t('enterprise.route_completed'), text: t('enterprise.route_completed'),
icon: CheckCircle2 icon: CheckCircle2
}; };
case 'delayed': case 'failed':
case 'overdue': case 'cancelled':
return { return {
color: '#ef4444', // red-500 color: '#ef4444', // red-500
text: t('enterprise.route_delayed'), text: t('enterprise.route_delayed'),
icon: AlertTriangle, icon: AlertTriangle,
isCritical: true isCritical: true
}; };
case 'in_transit': case 'in_progress':
return { return {
color: '#3b82f6', // blue-500 color: '#3b82f6', // blue-500
text: t('enterprise.route_in_transit'), text: t('enterprise.route_in_transit'),
@@ -390,29 +358,35 @@ const DistributionTab: React.FC<DistributionTabProps> = ({ tenantId, selectedDat
const statusConfig = getStatusConfig(); 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 ( return (
<StatusCard <StatusCard
key={route.id} key={route.id}
id={route.id} id={route.id}
statusIndicator={statusConfig} statusIndicator={statusConfig}
title={route.name} title={`${t('enterprise.route')} #${route.route_number || 'N/A'}`}
subtitle={`${t('enterprise.distance')}: ${route.distance}`} subtitle={`${t('enterprise.distance')}: ${route.total_distance_km?.toFixed(1) || 0} km`}
primaryValue={route.duration} primaryValue={`${route.estimated_duration_minutes || 0} min`}
primaryValueLabel={t('enterprise.estimated_duration')} primaryValueLabel={t('enterprise.estimated_duration')}
secondaryInfo={{ secondaryInfo={{
label: t('enterprise.stops'), label: t('enterprise.stops'),
value: `${route.stops}` value: `${route.route_sequence?.length || 0}`
}} }}
progress={{ progress={{
label: t('enterprise.optimization'), label: t('enterprise.optimization'),
percentage: route.status === 'completed' ? 100 : percentage: route.status === 'completed' ? 100 :
route.status === 'in_transit' ? 75 : route.status === 'in_progress' ? 75 :
route.status === 'delayed' ? 50 : 25, route.status === 'failed' ? 50 : 25,
color: statusConfig.color color: statusConfig.color
}} }}
metadata={[ metadata={[
`${t('enterprise.optimization_savings')}: ${route.optimizationSavings}`, `${t('enterprise.optimization_savings')}: ${distanceSavedText}`,
`${t('enterprise.vehicles')}: ${route.vehicles.join(', ')}` `${t('enterprise.vehicles')}: ${route.vehicle_id || t('common:not_available', 'N/A')}`
]} ]}
actions={[ actions={[
{ {
@@ -420,19 +394,25 @@ const DistributionTab: React.FC<DistributionTabProps> = ({ tenantId, selectedDat
icon: Map, icon: Map,
variant: 'outline', variant: 'outline',
onClick: () => { onClick: () => {
// In Phase 2, this will navigate to route tracking page console.log(`Track route ${route.route_number}`);
console.log(`Track route ${route.name}`);
}, },
priority: 'primary' priority: 'primary'
} }
]} ]}
onClick={() => { onClick={() => {
// In Phase 2, this will navigate to route detail page console.log(`View route ${route.route_number}`);
console.log(`View route ${route.name}`);
}} }}
/> />
); );
})} })}
{(!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>
</div> </div>

View File

@@ -82,26 +82,25 @@ const DistributionMap: React.FC<DistributionMapProps> = ({
shipments = { pending: 0, in_transit: 0, delivered: 0, failed: 0 } shipments = { pending: 0, in_transit: 0, delivered: 0, failed: 0 }
}) => { }) => {
const { t } = useTranslation('dashboard'); const { t } = useTranslation('dashboard');
const [selectedRoute, setSelectedRoute] = useState<RouteData | null>(null);
const renderMapVisualization = () => { const renderMapVisualization = () => {
if (!routes || routes.length === 0) { if (!routes || routes.length === 0) {
return ( 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="text-center px-6">
<div <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={{ 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> </div>
<h3 className="text-xl font-semibold mb-2" style={{ color: 'var(--text-primary)' }}> <h3 className="text-lg font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
{t('enterprise.no_active_routes')} {t('enterprise.no_active_routes', 'Sin rutas activas')}
</h3> </h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}> <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> </p>
</div> </div>
</div> </div>
@@ -118,21 +117,21 @@ const DistributionMap: React.FC<DistributionMapProps> = ({
if (activeRoutes.length === 0) { if (activeRoutes.length === 0) {
return ( 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="text-center px-6">
<div <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={{ 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> </div>
<h3 className="text-xl font-semibold mb-2" style={{ color: 'var(--text-primary)' }}> <h3 className="text-lg font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
{t('enterprise.all_routes_completed')} {t('enterprise.all_routes_completed', 'Todas las rutas completadas')}
</h3> </h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}> <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> </p>
</div> </div>
</div> </div>
@@ -146,11 +145,11 @@ const DistributionMap: React.FC<DistributionMapProps> = ({
// Real Leaflet map with routes // Real Leaflet map with routes
return ( 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 <MapContainer
center={[centerLat, centerLng]} center={[centerLat, centerLng]}
zoom={6} zoom={6}
style={{ height: '100%', width: '100%' }} style={{ height: '100%', width: '100%', zIndex: 0 }}
scrollWheelZoom={true} scrollWheelZoom={true}
> >
<TileLayer <TileLayer
@@ -159,7 +158,7 @@ const DistributionMap: React.FC<DistributionMapProps> = ({
/> />
{/* Render each route */} {/* Render each route */}
{activeRoutes.map((route, routeIdx) => { {activeRoutes.map((route) => {
const points = route.route_sequence || []; const points = route.route_sequence || [];
const routeColor = route.status === 'in_progress' ? '#3b82f6' : '#f59e0b'; const routeColor = route.status === 'in_progress' ? '#3b82f6' : '#f59e0b';
@@ -191,13 +190,32 @@ const DistributionMap: React.FC<DistributionMapProps> = ({
icon={createRouteMarker(markerColor, point.sequence)} icon={createRouteMarker(markerColor, point.sequence)}
> >
<Popup> <Popup>
<div className="min-w-[200px]"> <div className="min-w-[180px] p-1">
<div className="font-semibold text-base mb-1">{point.name}</div> <div className="font-semibold text-sm mb-1 text-[var(--text-primary)]">{point.name}</div>
<div className="text-sm text-gray-600 mb-2">{point.address}</div> <div className="text-xs text-[var(--text-secondary)] mb-2">{point.address}</div>
<div className="text-xs text-gray-500"> <div className="flex flex-col gap-1 border-t border-[var(--border-primary)] pt-2 mt-2">
<div>Route: {route.route_number}</div> <div className="flex justify-between text-[10px] text-[var(--text-tertiary)]">
<div>Stop {point.sequence} of {points.length}</div> <span>Ruta:</span>
<div className="capitalize mt-1">Status: {point.status}</div> <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>
</div> </div>
</Popup> </Popup>
@@ -209,398 +227,66 @@ const DistributionMap: React.FC<DistributionMapProps> = ({
})} })}
</MapContainer> </MapContainer>
{/* Status Legend Overlay */} {/* Status Legend Overlay - Refined alignment */}
<div <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={{ style={{
backgroundColor: 'rgba(255, 255, 255, 0.95)', backgroundColor: 'rgba(var(--bg-primary-rgb), 0.9)',
border: '1px solid var(--border-primary)' backdropFilter: 'blur(8px)',
border: '1px solid var(--border-primary)',
}} }}
> >
<div className="text-xs font-semibold mb-2" style={{ color: 'var(--text-primary)' }}> <div className="text-[10px] uppercase tracking-wider font-bold mb-3 text-[var(--text-tertiary)]">
{activeRoutes.length} {t('enterprise.active_routes')} {activeRoutes.length} {t('enterprise.active_routes', 'Rutas Activas')}
</div> </div>
<div className="flex items-center gap-2"> <div className="space-y-2.5">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: '#22c55e' }}></div> <div className="flex items-center justify-between gap-4">
<span className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}> <div className="flex items-center gap-2">
{t('enterprise.delivered')}: {shipments.delivered} <div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: '#22c55e' }}></div>
</span> <span className="text-xs text-[var(--text-secondary)]">
</div> {t('enterprise.delivered', 'Entregadas')}
<div className="flex items-center gap-2"> </span>
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: '#3b82f6' }}></div> </div>
<span className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}> <span className="text-xs font-bold text-[var(--text-primary)]">{shipments.delivered}</span>
{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> </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>
</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 ( return (
<div className="space-y-6"> <div className="w-full">
{/* 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 */}
{renderMapVisualization()} {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> </div>
); );
}; };

View File

@@ -11,7 +11,9 @@ import {
MoreVertical, MoreVertical,
CheckCircle, CheckCircle,
AlertTriangle, AlertTriangle,
X X,
Clock,
Eye,
} from 'lucide-react'; } from 'lucide-react';
import { import {
Button, Button,
@@ -23,6 +25,11 @@ import {
Badge, Badge,
Input, Input,
Tabs, Tabs,
EmptyState,
StatusCard,
SearchAndFilter,
type FilterConfig,
getStatusColor,
} from '../../../../components/ui'; } from '../../../../components/ui';
import { TabsList, TabsTrigger, TabsContent } from '../../../../components/ui/Tabs'; import { TabsList, TabsTrigger, TabsContent } from '../../../../components/ui/Tabs';
import { PageHeader } from '../../../../components/layout'; import { PageHeader } from '../../../../components/layout';
@@ -71,11 +78,75 @@ const DistributionPage: React.FC = () => {
}, },
]; ];
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const handleNewRoute = () => { const handleNewRoute = () => {
// Navigate to create route page or open modal // Navigate to create route page or open modal
console.log('New route clicked'); 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; if (!tenant) return null;
// Prepare shipment status data safely // Prepare shipment status data safely
@@ -112,270 +183,141 @@ const DistributionPage: React.FC = () => {
]} ]}
/> />
{/* Stats Grid */} {/* Stats Grid - Follows Procurement style */}
<StatsGrid <StatsGrid
stats={stats} stats={stats}
columns={4} columns={4}
/> />
{/* Main Content Areas */} {/* Tabs Navigation - Simplified and cleaned up */}
<div className="space-y-6"> <Tabs
{/* Tabs Navigation */} value={activeTab}
<Tabs onValueChange={(value: string) => setActiveTab(value as 'overview' | 'routes' | 'shipments')}
value={activeTab} className="w-full"
onValueChange={(value: 'overview' | 'routes' | 'shipments') => setActiveTab(value)} >
className="w-full" <TabsList className="mb-6">
> <TabsTrigger value="overview">
<TabsList className="flex w-full"> {t('operations:distribution.tabs.overview', 'Vista General')}
<TabsTrigger value="overview" className="flex-1"> </TabsTrigger>
{t('operations:distribution.tabs.overview', 'Vista General')} <TabsTrigger value="routes">
</TabsTrigger> {t('operations:distribution.tabs.routes', 'Listado de Rutas')}
<TabsTrigger value="routes" className="flex-1"> </TabsTrigger>
{t('operations:distribution.tabs.routes', 'Listado de Rutas')} <TabsTrigger value="shipments">
</TabsTrigger> {t('operations:distribution.tabs.shipments', 'Listado de Envíos')}
<TabsTrigger value="shipments" className="flex-1"> </TabsTrigger>
{t('operations:distribution.tabs.shipments', 'Listado de Envíos')} </TabsList>
</TabsTrigger>
</TabsList>
{/* Content based on Active Tab */} {/* Content based on Active Tab */}
<TabsContent value="overview" className="space-y-6 mt-6"> <TabsContent value="overview" className="space-y-6 outline-none">
{/* Map Section */} {/* Map Section - Integrated with Card Header style */}
<Card className="overflow-hidden"> <Card className="overflow-hidden border-[var(--border-primary)]">
<CardHeader className="sticky top-0 z-10"> <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 justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-[var(--color-info-50)] dark:bg-[var(--color-info-900)]"> <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)]" /> <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>
</div> </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> <div>
<CardTitle className="text-lg"> <CardTitle className="text-lg font-bold">
{t('operations:distribution.routes_list', 'Listado de Rutas')} {t('operations:map.title', 'Mapa de Distribución')}
</CardTitle> </CardTitle>
<p className="text-sm text-[var(--text-secondary)] mt-1"> <p className="text-sm text-[var(--text-secondary)]">
{t('operations:distribution.routes_desc', 'Gestión y seguimiento de rutas de distribución')} {t('operations:map.description', 'Visualización en tiempo real de la flota')}
</p> </p>
</div> </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> </div>
</CardHeader> <Badge variant="outline" className="flex items-center gap-1.5 border-[var(--border-primary)] shadow-sm py-1">
<CardContent> <div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
{(distributionData?.route_sequences?.length || 0) > 0 ? ( <span className="text-xs font-semibold">{t('operations:map.live', 'En Vivo')}</span>
<div className="overflow-x-auto"> </Badge>
<table className="w-full text-sm text-left"> </div>
<thead className="text-xs text-[var(--text-secondary)] uppercase bg-[var(--bg-secondary)] border-b"> </CardHeader>
<tr> <CardContent className="p-0">
<th className="px-4 py-3">{t('operations:distribution.table.route', 'Ruta')}</th> <div className="p-1 sm:p-2 bg-[var(--bg-primary)]">
<th className="px-4 py-3">{t('operations:distribution.table.status', 'Estado')}</th> <DistributionMap
<th className="px-4 py-3">{t('operations:distribution.table.distance', 'Distancia')}</th> routes={distributionData?.route_sequences || []}
<th className="px-4 py-3">{t('operations:distribution.table.duration', 'Duración Est.')}</th> shipments={shipmentStatus}
<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> </div>
</tr> </CardContent>
</thead> </Card>
<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>
{/* Similar structure for Shipments tab, simplified for now */} {/* Secondary sections at the bottom if needed, but keeping it clean for now */}
<TabsContent value="shipments" className="space-y-6 mt-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card> {/* Summary of important routes or alerts could go here */}
<CardHeader> </div>
<CardTitle className="text-lg"> </TabsContent>
{t('operations:distribution.shipments_list', 'Gestión de Envíos')}
</CardTitle> <TabsContent value="routes" className="space-y-6 mt-0 outline-none">
<p className="text-sm text-[var(--text-secondary)]"> <SearchAndFilter
{t('operations:distribution.shipments_desc', 'Funcionalidad de listado detallado de envíos próximamente.')} searchValue={searchTerm}
</p> onSearchChange={setSearchTerm}
</CardHeader> searchPlaceholder={t('operations:distribution.search_routes', 'Buscar rutas...')}
<CardContent> filters={filterConfig}
<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"> {filteredRoutes.length > 0 ? (
{t('operations:distribution.shipments_title', 'Gestión de Envíos')} <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
</h3> {filteredRoutes.map((route: any) => {
<p className="text-[var(--text-secondary)]"> const statusConfig = getRouteStatusConfig(route.status);
{t('operations:distribution.shipments_desc', 'Funcionalidad de listado detallado de envíos próximamente.')} return (
</p> <StatusCard
</div> 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> </CardContent>
</Card> </Card>
</TabsContent> )}
</Tabs> </TabsContent>
</div>
<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> </div>
); );
}; };

View 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)}")

View File

@@ -610,35 +610,4 @@ async def delete_demo_tenant_data(
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail=f"Failed to delete demo data: {str(e)}" 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)}")

View File

@@ -33,7 +33,8 @@ from app.api import (
sustainability, sustainability,
audit, audit,
ml_insights, ml_insights,
enterprise_inventory enterprise_inventory,
internal
) )
from app.api.internal_alert_trigger import router as internal_alert_trigger_router from app.api.internal_alert_trigger import router as internal_alert_trigger_router
from app.api.internal_demo import router as internal_demo_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(analytics.router)
service.add_router(sustainability.router) service.add_router(sustainability.router)
service.add_router(internal_demo.router, tags=["internal-demo"]) 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.router) # ML insights endpoint
service.add_router(ml_insights.internal_router) # Internal ML insights endpoint for demo cloning 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 service.add_router(internal_alert_trigger_router) # Internal alert trigger for demo cloning

View File

@@ -231,7 +231,7 @@ async def clone_demo_data(
raise HTTPException(status_code=500, detail=f"Failed to clone orchestration runs: {str(e)}") 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( async def delete_demo_data(
virtual_tenant_id: str, virtual_tenant_id: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -280,7 +280,7 @@ async def delete_demo_data(
raise HTTPException(status_code=500, detail=str(e)) 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)): async def health_check(_: bool = Depends(verify_internal_api_key)):
"""Health check for demo cloning endpoint""" """Health check for demo cloning endpoint"""
return {"status": "healthy", "service": "orchestrator"} return {"status": "healthy", "service": "orchestrator"}

View 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)}")

View File

@@ -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)}")

View File

@@ -14,7 +14,7 @@ from .core.database import db_manager
from shared.service_base import StandardFastAPIService from shared.service_base import StandardFastAPIService
# Import API routers # 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 # Import models to register them with SQLAlchemy metadata
from .models import recipes as recipe_models 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_quality_configs.router)
service.add_router(recipe_operations.router) service.add_router(recipe_operations.router)
service.add_router(internal_demo.router, tags=["internal-demo"]) service.add_router(internal_demo.router, tags=["internal-demo"])
service.add_router(internal.router)
if __name__ == "__main__": if __name__ == "__main__":

View 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)}")

View File

@@ -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)}")

View File

@@ -11,7 +11,7 @@ from app.core.database import database_manager
from shared.service_base import StandardFastAPIService from shared.service_base import StandardFastAPIService
# Import API routers # 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 # REMOVED: purchase_orders, deliveries - PO and delivery management moved to Procurement Service
# from app.api import purchase_orders, deliveries # 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(analytics.router) # /suppliers/analytics/...
service.add_router(suppliers.router) # /suppliers/{supplier_id} - catch-all, must be last 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_demo.router, tags=["internal-demo"])
service.add_router(internal.router)
if __name__ == "__main__": if __name__ == "__main__":