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 = (
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,
});
};

View File

@@ -22,7 +22,7 @@ interface DistributionTabProps {
const DistributionTab: React.FC<DistributionTabProps> = ({ tenantId, selectedDate, onDateChange }) => {
const { t } = useTranslation('dashboard');
const { currencySymbol } = useTenantCurrency();
// Get distribution data
const {
data: distributionOverview,
@@ -34,8 +34,8 @@ const DistributionTab: React.FC<DistributionTabProps> = ({ tenantId, selectedDat
});
// Real-time SSE events
const { events: sseEvents, isConnected: sseConnected } = useSSEEvents({
channels: ['*.alerts', '*.notifications', 'recommendations']
const { events: sseEvents, isConnected: sseConnected } = useSSEEvents({
channels: ['*.alerts', '*.notifications', 'recommendations']
});
// State for real-time delivery status
@@ -63,8 +63,8 @@ const DistributionTab: React.FC<DistributionTabProps> = ({ tenantId, selectedDat
if (sseEvents.length === 0) return;
// Filter delivery and distribution-related events
const deliveryEvents = sseEvents.filter(event =>
event.event_type.includes('delivery_') ||
const deliveryEvents = sseEvents.filter((event: any) =>
event.event_type.includes('delivery_') ||
event.event_type.includes('route_') ||
event.event_type.includes('shipment_') ||
event.entity_type === 'delivery' ||
@@ -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">
@@ -167,7 +135,7 @@ const DistributionTab: React.FC<DistributionTabProps> = ({ tenantId, selectedDat
<Truck className="w-6 h-6 text-[var(--color-primary)]" />
{t('enterprise.distribution_summary')}
</h2>
{/* Date selector */}
<div className="mb-4 flex items-center gap-4">
<div className="flex items-center gap-2">
@@ -219,7 +187,7 @@ const DistributionTab: React.FC<DistributionTabProps> = ({ tenantId, selectedDat
{deliveryStatus.onTime}
</div>
<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')}`
: t('enterprise.no_deliveries')}
</p>
@@ -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,
percentage: route.status === 'completed' ? 100 :
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>

View File

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

View File

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

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

View File

@@ -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

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

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
# 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__":

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
# 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__":