demo seed change

This commit is contained in:
Urtzi Alfaro
2025-12-13 23:57:54 +01:00
parent f3688dfb04
commit ff830a3415
299 changed files with 20328 additions and 19485 deletions

View File

@@ -46,10 +46,43 @@ function AppContent() {
<Toaster
position="top-right"
toastOptions={{
// Default toast options
duration: 4000,
style: {
background: '#363636',
color: '#fff',
background: 'white',
color: 'black',
border: '1px solid #e5e7eb',
borderRadius: '0.5rem',
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
minWidth: '300px',
},
success: {
style: {
background: '#f0fdf4', // bg-green-50 equivalent
color: '#166534', // text-green-800 equivalent
border: '1px solid #bbf7d0', // border-green-200 equivalent
},
},
error: {
style: {
background: '#fef2f2', // bg-red-50 equivalent
color: '#b91c1c', // text-red-800 equivalent
border: '1px solid #fecaca', // border-red-200 equivalent
},
},
warning: {
style: {
background: '#fffbf0', // bg-yellow-50 equivalent
color: '#92400e', // text-yellow-800 equivalent
border: '1px solid #fde68a', // border-yellow-200 equivalent
},
},
info: {
style: {
background: '#eff6ff', // bg-blue-50 equivalent
color: '#1e40af', // text-blue-800 equivalent
border: '1px solid #bfdbfe', // border-blue-200 equivalent
},
},
}}
/>

View File

@@ -15,6 +15,7 @@ import * as orchestratorService from '../services/orchestrator';
import { suppliersService } from '../services/suppliers';
import { useBatchNotifications, useDeliveryNotifications, useOrchestrationNotifications } from '../../hooks/useEventNotifications';
import { useSSEEvents } from '../../hooks/useSSE';
import { parseISO } from 'date-fns';
// ============================================================
// Types
@@ -27,6 +28,7 @@ export interface DashboardData {
productionBatches: any[];
deliveries: any[];
orchestrationSummary: OrchestrationSummary | null;
aiInsights: any[]; // AI-generated insights for professional/enterprise tiers
// Computed/derived data
preventedIssues: any[];
@@ -70,7 +72,8 @@ export function useDashboardData(tenantId: string) {
queryKey: ['dashboard-data', tenantId],
queryFn: async () => {
const today = new Date().toISOString().split('T')[0];
const now = new Date();
const now = new Date(); // Keep for local time display
const nowUTC = new Date(); // UTC time for accurate comparison with API dates
// Parallel fetch ALL data needed by all 4 blocks (including suppliers for PO enrichment)
const [alertsResponse, pendingPOs, productionResponse, deliveriesResponse, orchestration, suppliers] = await Promise.all([
@@ -158,20 +161,20 @@ export function useDashboardData(tenantId: string) {
const overdueDeliveries = deliveries.filter((d: any) => {
if (!isPending(d.status)) return false;
const expectedDate = new Date(d.expected_delivery_date);
return expectedDate < now;
const expectedDate = parseISO(d.expected_delivery_date); // Proper UTC parsing
return expectedDate < nowUTC;
}).map((d: any) => ({
...d,
hoursOverdue: Math.ceil((now.getTime() - new Date(d.expected_delivery_date).getTime()) / (1000 * 60 * 60)),
hoursOverdue: Math.ceil((nowUTC.getTime() - parseISO(d.expected_delivery_date).getTime()) / (1000 * 60 * 60)),
}));
const pendingDeliveriesFiltered = deliveries.filter((d: any) => {
if (!isPending(d.status)) return false;
const expectedDate = new Date(d.expected_delivery_date);
return expectedDate >= now;
const expectedDate = parseISO(d.expected_delivery_date); // Proper UTC parsing
return expectedDate >= nowUTC;
}).map((d: any) => ({
...d,
hoursUntil: Math.ceil((new Date(d.expected_delivery_date).getTime() - now.getTime()) / (1000 * 60 * 60)),
hoursUntil: Math.ceil((parseISO(d.expected_delivery_date).getTime() - nowUTC.getTime()) / (1000 * 60 * 60)),
}));
// Filter production batches by status
@@ -180,10 +183,10 @@ export function useDashboardData(tenantId: string) {
if (status !== 'PENDING' && status !== 'SCHEDULED') return false;
const plannedStart = b.planned_start_time;
if (!plannedStart) return false;
return new Date(plannedStart) < now;
return parseISO(plannedStart) < nowUTC;
}).map((b: any) => ({
...b,
hoursLate: Math.ceil((now.getTime() - new Date(b.planned_start_time).getTime()) / (1000 * 60 * 60)),
hoursLate: Math.ceil((nowUTC.getTime() - parseISO(b.planned_start_time).getTime()) / (1000 * 60 * 60)),
}));
const runningBatches = productionBatches.filter((b: any) =>
@@ -195,7 +198,32 @@ export function useDashboardData(tenantId: string) {
if (status !== 'PENDING' && status !== 'SCHEDULED') return false;
const plannedStart = b.planned_start_time;
if (!plannedStart) return true; // No planned start, count as pending
return new Date(plannedStart) >= now;
return parseISO(plannedStart) >= nowUTC;
});
// Create set of batch IDs that we already show in the UI (late or running)
const lateBatchIds = new Set(lateToStartBatches.map((b: any) => b.id));
const runningBatchIds = new Set(runningBatches.map((b: any) => b.id));
// Filter alerts to exclude those for batches already shown in the UI
// This prevents duplicate display: batch card + separate alert for the same batch
const deduplicatedAlerts = alerts.filter((a: any) => {
const eventType = a.event_type || '';
const batchId = a.event_metadata?.batch_id || a.entity_links?.production_batch;
if (!batchId) return true; // Keep alerts not related to batches
// Filter out batch_start_delayed alerts for batches shown in "late to start" section
if (eventType.includes('batch_start_delayed') && lateBatchIds.has(batchId)) {
return false; // Already shown as late batch
}
// Filter out production_delay alerts for batches shown in "running" section
if (eventType.includes('production_delay') && runningBatchIds.has(batchId)) {
return false; // Already shown as running batch (with progress bar showing delay)
}
return true;
});
// Build orchestration summary
@@ -218,11 +246,12 @@ export function useDashboardData(tenantId: string) {
return {
// Raw data
alerts,
alerts: deduplicatedAlerts,
pendingPOs: enrichedPendingPOs,
productionBatches,
deliveries,
orchestrationSummary,
aiInsights: [], // AI-generated insights for professional/enterprise tiers
// Computed
preventedIssues,
@@ -283,7 +312,7 @@ export function useDashboardRealtimeSync(tenantId: string) {
if (deliveryNotifications.length === 0 || !tenantId) return;
const latest = deliveryNotifications[0];
if (['delivery_received', 'delivery_overdue'].includes(latest.event_type)) {
if (['delivery_received', 'delivery_overdue', 'delivery_arriving_soon', 'stock_receipt_incomplete'].includes(latest.event_type)) {
queryClient.invalidateQueries({
queryKey: ['dashboard-data', tenantId],
refetchType: 'active',

View File

@@ -14,6 +14,7 @@ import { ProcurementService } from '../services/procurement-service';
import * as orchestratorService from '../services/orchestrator'; // Only for orchestration run info
import { ProductionStatus } from '../types/production';
import { apiClient } from '../client';
import { parseISO } from 'date-fns';
// ============================================================
// Types
@@ -327,7 +328,8 @@ export function useSharedDashboardData(tenantId: string) {
]);
// Calculate late-to-start batches (batches that should have started but haven't)
const now = new Date();
const now = new Date(); // Local time for display
const nowUTC = new Date(); // UTC time for accurate comparison with API dates
const allBatches = prodBatches?.batches || [];
const lateToStart = allBatches.filter((b: any) => {
// Only check PENDING or SCHEDULED batches (not started yet)
@@ -338,16 +340,18 @@ export function useSharedDashboardData(tenantId: string) {
if (!plannedStart) return false;
// Check if planned start time is in the past (late to start)
return new Date(plannedStart) < now;
return parseISO(plannedStart) < nowUTC;
});
// Calculate overdue deliveries (pending deliveries with past due date)
const allDelivs = deliveries?.deliveries || [];
const isPending = (s: string) =>
s === 'PENDING' || s === 'sent_to_supplier' || s === 'confirmed';
const overdueDelivs = allDelivs.filter((d: any) =>
isPending(d.status) && new Date(d.expected_delivery_date) < now
);
// FIX: Use UTC timestamps for consistent time zone handling
const overdueDelivs = allDelivs.filter((d: any) => {
const expectedDate = parseISO(d.expected_delivery_date); // Proper UTC parsing
return isPending(d.status) && expectedDate.getTime() < nowUTC.getTime();
});
return {
overdueDeliveries: overdueDelivs.length,
@@ -1019,7 +1023,7 @@ export function useExecutionProgress(tenantId: string) {
if (!aTime || !bTime) return 0;
return new Date(aTime).getTime() - new Date(bTime).getTime();
return parseISO(aTime).getTime() - parseISO(bTime).getTime();
});
const nextBatchDetail = sortedPendingBatches.length > 0 ? {
@@ -1065,10 +1069,12 @@ export function useExecutionProgress(tenantId: string) {
const pendingDeliveriesData = allDeliveries.filter((d: any) => isPending(d.status));
// Identify overdue deliveries (pending deliveries with past due date)
// FIX: Use UTC timestamps to avoid time zone issues
const overdueDeliveriesData = pendingDeliveriesData.filter((d: any) => {
const expectedDate = new Date(d.expected_delivery_date);
const now = new Date();
return expectedDate < now;
const expectedDate = parseISO(d.expected_delivery_date); // Proper UTC parsing
const nowUTC = new Date(); // UTC time for accurate comparison
// Compare UTC timestamps instead of local time
return expectedDate.getTime() < nowUTC.getTime();
});
// Calculate counts
@@ -1080,17 +1086,17 @@ export function useExecutionProgress(tenantId: string) {
// Convert raw delivery data to the expected format for the UI
const processedDeliveries = allDeliveries.map((d: any) => {
const itemCount = d.line_items?.length || 0;
const expectedDate = new Date(d.expected_delivery_date);
const now = new Date();
const expectedDate = parseISO(d.expected_delivery_date); // Proper UTC parsing
const nowUTC = new Date(); // UTC time for accurate comparison
let hoursUntil = 0;
let hoursOverdue = 0;
if (expectedDate < now) {
if (expectedDate < nowUTC) {
// Calculate hours overdue
hoursOverdue = Math.ceil((now.getTime() - expectedDate.getTime()) / (1000 * 60 * 60));
hoursOverdue = Math.ceil((nowUTC.getTime() - expectedDate.getTime()) / (1000 * 60 * 60));
} else {
// Calculate hours until delivery
hoursUntil = Math.ceil((expectedDate.getTime() - now.getTime()) / (1000 * 60 * 60));
hoursUntil = Math.ceil((expectedDate.getTime() - nowUTC.getTime()) / (1000 * 60 * 60));
}
return {
@@ -1110,9 +1116,18 @@ export function useExecutionProgress(tenantId: string) {
});
// Separate into specific lists for the UI
// FIX: Use UTC timestamps for consistent time zone handling
const receivedDeliveriesList = processedDeliveries.filter((d: any) => isDelivered(d.status));
const pendingDeliveriesList = processedDeliveries.filter((d: any) => isPending(d.status) && new Date(d.expectedDeliveryDate) >= new Date());
const overdueDeliveriesList = processedDeliveries.filter((d: any) => isPending(d.status) && new Date(d.expectedDeliveryDate) < new Date());
const pendingDeliveriesList = processedDeliveries.filter((d: any) => {
const expectedDate = new Date(d.expectedDeliveryDate);
const now = new Date();
return isPending(d.status) && expectedDate.getTime() >= now.getTime();
});
const overdueDeliveriesList = processedDeliveries.filter((d: any) => {
const expectedDate = new Date(d.expectedDeliveryDate);
const now = new Date();
return isPending(d.status) && expectedDate.getTime() < now.getTime();
});
// Determine delivery status
let deliveryStatus: 'no_deliveries' | 'completed' | 'on_track' | 'at_risk' = 'no_deliveries';

View File

@@ -142,6 +142,7 @@ export interface ProductionBatchResponse {
quality_notes: string | null;
delay_reason: string | null;
cancellation_reason: string | null;
reasoning_data?: Record<string, any> | null;
created_at: string;
updated_at: string;
completed_at: string | null;

View File

@@ -0,0 +1,181 @@
/**
* AIInsightsBlock - AI Insights Dashboard Block
*
* Displays AI-generated insights for professional/enterprise tiers
* Shows top 2-3 insights with links to full AI Insights page
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Lightbulb, ArrowRight, BarChart2, TrendingUp, TrendingDown, Shield, AlertTriangle } from 'lucide-react';
interface AIInsight {
id: string;
title: string;
description: string;
type: 'cost_optimization' | 'waste_reduction' | 'safety_stock' | 'demand_forecast' | 'risk_alert';
impact: 'high' | 'medium' | 'low';
impact_value?: string;
impact_currency?: string;
created_at: string;
}
interface AIInsightsBlockProps {
insights: AIInsight[];
loading?: boolean;
onViewAll: () => void;
}
export function AIInsightsBlock({ insights = [], loading = false, onViewAll }: AIInsightsBlockProps) {
const { t } = useTranslation(['dashboard', 'common']);
// Get icon based on insight type
const getInsightIcon = (type: string) => {
switch (type) {
case 'cost_optimization': return <TrendingUp className="w-5 h-5 text-[var(--color-success-600)]" />;
case 'waste_reduction': return <TrendingDown className="w-5 h-5 text-[var(--color-success-600)]" />;
case 'safety_stock': return <Shield className="w-5 h-5 text-[var(--color-info-600)]" />;
case 'demand_forecast': return <BarChart2 className="w-5 h-5 text-[var(--color-primary-600)]" />;
case 'risk_alert': return <AlertTriangle className="w-5 h-5 text-[var(--color-error-600)]" />;
default: return <Lightbulb className="w-5 h-5 text-[var(--color-primary-600)]" />;
}
};
// Get impact color based on level
const getImpactColor = (impact: string) => {
switch (impact) {
case 'high': return 'bg-[var(--color-error-100)] text-[var(--color-error-700)]';
case 'medium': return 'bg-[var(--color-warning-100)] text-[var(--color-warning-700)]';
case 'low': return 'bg-[var(--color-info-100)] text-[var(--color-info-700)]';
default: return 'bg-[var(--bg-secondary)] text-[var(--text-secondary)]';
}
};
// Get impact label
const getImpactLabel = (impact: string) => {
switch (impact) {
case 'high': return t('dashboard:ai_insights.impact_high');
case 'medium': return t('dashboard:ai_insights.impact_medium');
case 'low': return t('dashboard:ai_insights.impact_low');
default: return '';
}
};
if (loading) {
return (
<div className="rounded-xl shadow-lg p-6 border border-[var(--border-primary)] bg-[var(--bg-primary)] animate-pulse">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 bg-[var(--bg-secondary)] rounded-full"></div>
<div className="flex-1 space-y-2">
<div className="h-5 bg-[var(--bg-secondary)] rounded w-1/3"></div>
<div className="h-4 bg-[var(--bg-secondary)] rounded w-1/4"></div>
</div>
</div>
<div className="space-y-4">
<div className="h-16 bg-[var(--bg-secondary)] rounded"></div>
<div className="h-16 bg-[var(--bg-secondary)] rounded"></div>
</div>
</div>
);
}
// Show top 3 insights
const topInsights = insights.slice(0, 3);
return (
<div className="rounded-xl shadow-lg border border-[var(--border-primary)] bg-[var(--bg-primary)] overflow-hidden">
{/* Header */}
<div className="p-6 pb-4">
<div className="flex items-center gap-4">
{/* Icon */}
<div className="w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 bg-[var(--color-primary-100)]">
<Lightbulb className="w-6 h-6 text-[var(--color-primary-600)]" />
</div>
{/* Title & Description */}
<div className="flex-1">
<h2 className="text-xl font-bold text-[var(--text-primary)]">
{t('dashboard:ai_insights.title')}
</h2>
<p className="text-sm text-[var(--text-secondary)]">
{t('dashboard:ai_insights.subtitle')}
</p>
</div>
{/* View All Button */}
<button
onClick={onViewAll}
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-[var(--border-primary)] text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] transition-colors text-sm font-medium"
>
<span>{t('dashboard:ai_insights.view_all')}</span>
<ArrowRight className="w-4 h-4" />
</button>
</div>
</div>
{/* Insights List */}
{topInsights.length > 0 ? (
<div className="border-t border-[var(--border-primary)]">
{topInsights.map((insight, index) => (
<div
key={insight.id || index}
className={`p-4 ${index < topInsights.length - 1 ? 'border-b border-[var(--border-primary)]' : ''}`}
>
<div className="flex items-start gap-3">
{/* Icon */}
<div className="flex-shrink-0 mt-1">
{getInsightIcon(insight.type)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-[var(--text-primary)] text-sm">
{insight.title}
</h3>
{/* Impact Badge */}
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${getImpactColor(insight.impact)}`}>
{getImpactLabel(insight.impact)}
</span>
</div>
<p className="text-sm text-[var(--text-secondary)] mb-2">
{insight.description}
</p>
{/* Impact Value */}
{insight.impact_value && (
<div className="flex items-center gap-2">
{insight.type === 'cost_optimization' && (
<span className="text-sm font-semibold text-[var(--color-success-600)]">
{insight.impact_currency}{insight.impact_value} {t('dashboard:ai_insights.savings')}
</span>
)}
{insight.type === 'waste_reduction' && (
<span className="text-sm font-semibold text-[var(--color-success-600)]">
{insight.impact_value} {t('dashboard:ai_insights.reduction')}
</span>
)}
</div>
)}
</div>
</div>
</div>
))}
</div>
) : (
/* Empty State */
<div className="px-6 pb-6">
<div className="flex items-center gap-3 p-4 rounded-lg bg-[var(--color-info-50)] border border-[var(--color-info-100)]">
<Lightbulb className="w-6 h-6 text-[var(--color-info-600)]" />
<p className="text-sm text-[var(--color-info-700)]">
{t('dashboard:ai_insights.no_insights')}
</p>
</div>
</div>
)}
</div>
);
}
export default AIInsightsBlock;

View File

@@ -26,6 +26,7 @@ interface ProductionStatusBlockProps {
lateToStartBatches?: any[];
runningBatches?: any[];
pendingBatches?: any[];
alerts?: any[]; // Add alerts prop for production-related alerts
onStartBatch?: (batchId: string) => Promise<void>;
onViewBatch?: (batchId: string) => void;
loading?: boolean;
@@ -35,6 +36,7 @@ export function ProductionStatusBlock({
lateToStartBatches = [],
runningBatches = [],
pendingBatches = [],
alerts = [],
onStartBatch,
onViewBatch,
loading,
@@ -43,6 +45,28 @@ export function ProductionStatusBlock({
const [expandedReasoningId, setExpandedReasoningId] = useState<string | null>(null);
const [processingId, setProcessingId] = useState<string | null>(null);
// Filter production-related alerts and deduplicate by ID
const productionAlerts = React.useMemo(() => {
const filtered = alerts.filter((alert: any) => {
const eventType = alert.event_type || '';
return eventType.includes('production.') ||
eventType.includes('equipment_maintenance') ||
eventType.includes('production_delay') ||
eventType.includes('batch_start_delayed');
});
// Deduplicate by alert ID to prevent duplicates from API + SSE
const uniqueAlerts = new Map<string, any>();
filtered.forEach((alert: any) => {
const alertId = alert.id || alert.event_id;
if (alertId && !uniqueAlerts.has(alertId)) {
uniqueAlerts.set(alertId, alert);
}
});
return Array.from(uniqueAlerts.values());
}, [alerts]);
if (loading) {
return (
<div className="rounded-xl shadow-lg p-6 border border-[var(--border-primary)] bg-[var(--bg-primary)] animate-pulse">
@@ -64,11 +88,12 @@ export function ProductionStatusBlock({
const hasLate = lateToStartBatches.length > 0;
const hasRunning = runningBatches.length > 0;
const hasPending = pendingBatches.length > 0;
const hasAnyProduction = hasLate || hasRunning || hasPending;
const hasAlerts = productionAlerts.length > 0;
const hasAnyProduction = hasLate || hasRunning || hasPending || hasAlerts;
const totalCount = lateToStartBatches.length + runningBatches.length + pendingBatches.length;
// Determine header status
const status = hasLate ? 'error' : hasRunning ? 'info' : hasPending ? 'warning' : 'success';
// Determine header status - prioritize alerts and late batches
const status = hasAlerts || hasLate ? 'error' : hasRunning ? 'info' : hasPending ? 'warning' : 'success';
const statusStyles = {
success: {
@@ -115,10 +140,19 @@ export function ProductionStatusBlock({
if (typeof reasoningData === 'string') return reasoningData;
if (reasoningData.type === 'forecast_demand') {
return t('dashboard:new_dashboard.production_status.reasoning.forecast_demand', {
product: reasoningData.parameters?.product_name || batch.product_name,
demand: reasoningData.parameters?.predicted_demand || batch.planned_quantity,
});
// Check if this is enhanced reasoning with factors
if (reasoningData.parameters?.factors && reasoningData.parameters.factors.length > 0) {
return t('dashboard:new_dashboard.production_status.reasoning.forecast_demand_enhanced', {
product: reasoningData.parameters?.product_name || batch.product_name,
demand: reasoningData.parameters?.predicted_demand || batch.planned_quantity,
variance: reasoningData.parameters?.variance_percent || 0,
});
} else {
return t('dashboard:new_dashboard.production_status.reasoning.forecast_demand', {
product: reasoningData.parameters?.product_name || batch.product_name,
demand: reasoningData.parameters?.predicted_demand || batch.planned_quantity,
});
}
}
if (reasoningData.type === 'customer_order') {
@@ -127,11 +161,79 @@ export function ProductionStatusBlock({
});
}
if (reasoningData.summary) return reasoningData.summary;
return null;
};
// Get factor icon based on type (handles both uppercase and lowercase formats)
const getFactorIcon = (factorType: string) => {
const normalizedFactor = factorType?.toLowerCase() || '';
if (normalizedFactor.includes('historical') || normalizedFactor === 'historical_pattern') return '📊';
if (normalizedFactor.includes('sunny') || normalizedFactor === 'weather_sunny') return '☀️';
if (normalizedFactor.includes('rainy') || normalizedFactor === 'weather_rainy') return '🌧️';
if (normalizedFactor.includes('cold') || normalizedFactor === 'weather_cold') return '❄️';
if (normalizedFactor.includes('hot') || normalizedFactor === 'weather_hot') return '🔥';
if (normalizedFactor.includes('weekend') || normalizedFactor === 'weekend_boost') return '📅';
if (normalizedFactor.includes('inventory') || normalizedFactor === 'inventory_level') return '📦';
if (normalizedFactor.includes('seasonal') || normalizedFactor === 'seasonal_trend') return '🍂';
return '';
};
// Get factor translation key (handles both uppercase and lowercase formats)
const getFactorTranslationKey = (factorType: string) => {
const normalizedFactor = factorType?.toLowerCase().replace(/\s+/g, '_') || '';
// Direct mapping for exact matches
const factorMap: Record<string, string> = {
'historical_pattern': 'historical_pattern',
'historical_sales_pattern': 'historical_pattern',
'weather_sunny': 'weather_sunny',
'weather_impact_sunny': 'weather_sunny',
'weather_rainy': 'weather_rainy',
'weather_cold': 'weather_cold',
'weather_hot': 'weather_hot',
'weekend_boost': 'weekend_boost',
'inventory_level': 'inventory_level',
'current_inventory_trigger': 'inventory_level',
'seasonal_trend': 'seasonal_trend',
'seasonal_trend_adjustment': 'seasonal_trend',
};
// Check for direct match
if (factorMap[normalizedFactor]) {
return `dashboard:new_dashboard.production_status.factors.${factorMap[normalizedFactor]}`;
}
// Fallback to partial matching
if (normalizedFactor.includes('historical')) {
return 'dashboard:new_dashboard.production_status.factors.historical_pattern';
}
if (normalizedFactor.includes('sunny')) {
return 'dashboard:new_dashboard.production_status.factors.weather_sunny';
}
if (normalizedFactor.includes('rainy')) {
return 'dashboard:new_dashboard.production_status.factors.weather_rainy';
}
if (normalizedFactor.includes('cold')) {
return 'dashboard:new_dashboard.production_status.factors.weather_cold';
}
if (normalizedFactor.includes('hot')) {
return 'dashboard:new_dashboard.production_status.factors.weather_hot';
}
if (normalizedFactor.includes('weekend')) {
return 'dashboard:new_dashboard.production_status.factors.weekend_boost';
}
if (normalizedFactor.includes('inventory')) {
return 'dashboard:new_dashboard.production_status.factors.inventory_level';
}
if (normalizedFactor.includes('seasonal')) {
return 'dashboard:new_dashboard.production_status.factors.seasonal_trend';
}
return 'dashboard:new_dashboard.production_status.factors.general';
};
// Format time
const formatTime = (isoString: string | null | undefined) => {
if (!isoString) return '--:--';
@@ -153,6 +255,156 @@ export function ProductionStatusBlock({
return Math.round(((now - start) / (end - start)) * 100);
};
// Render an alert item
const renderAlertItem = (alert: any, index: number, total: number) => {
const alertId = alert.id || alert.event_id;
const eventType = alert.event_type || '';
const priorityLevel = alert.priority_level || 'standard';
const businessImpact = alert.business_impact || {};
const urgency = alert.urgency || {};
const metadata = alert.event_metadata || {};
// Determine alert icon and type
let icon = <AlertTriangle className="w-4 h-4 text-[var(--color-error-600)]" />;
let alertTitle = '';
let alertDescription = '';
if (eventType.includes('equipment_maintenance')) {
icon = <AlertTriangle className="w-4 h-4 text-[var(--color-warning-600)]" />;
alertTitle = alert.title || t('dashboard:new_dashboard.production_status.alerts.equipment_maintenance');
alertDescription = alert.description || alert.message || '';
} else if (eventType.includes('production_delay')) {
icon = <Clock className="w-4 h-4 text-[var(--color-error-600)]" />;
alertTitle = alert.title || t('dashboard:new_dashboard.production_status.alerts.production_delay');
alertDescription = alert.description || alert.message || '';
} else if (eventType.includes('batch_start_delayed')) {
icon = <AlertTriangle className="w-4 h-4 text-[var(--color-warning-600)]" />;
alertTitle = alert.title || t('dashboard:new_dashboard.production_status.alerts.batch_delayed');
alertDescription = alert.description || alert.message || '';
} else {
alertTitle = alert.title || t('dashboard:new_dashboard.production_status.alerts.generic');
alertDescription = alert.description || alert.message || '';
}
// Priority badge styling
const priorityStyles = {
critical: 'bg-[var(--color-error-100)] text-[var(--color-error-700)]',
important: 'bg-[var(--color-warning-100)] text-[var(--color-warning-700)]',
standard: 'bg-[var(--color-info-100)] text-[var(--color-info-700)]',
info: 'bg-[var(--bg-tertiary)] text-[var(--text-tertiary)]',
};
// Format time ago
const formatTimeAgo = (isoString: string) => {
const date = new Date(isoString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
if (diffMins < 1) return t('common:time.just_now');
if (diffMins < 60) return t('common:time.minutes_ago', { count: diffMins });
if (diffHours < 24) return t('common:time.hours_ago', { count: diffHours });
return t('common:time.days_ago', { count: Math.floor(diffHours / 24) });
};
return (
<div
key={alertId || index}
className={`p-4 ${index < total - 1 ? 'border-b border-[var(--border-primary)]' : ''}`}
>
<div className="flex items-start gap-4">
<div className="flex-1 min-w-0">
{/* Header */}
<div className="flex items-center gap-2 mb-2 flex-wrap">
{icon}
<span className="font-semibold text-[var(--text-primary)]">
{alertTitle}
</span>
<div className={`px-2 py-0.5 rounded-full text-xs font-medium ${priorityStyles[priorityLevel as keyof typeof priorityStyles] || priorityStyles.standard}`}>
{t(`dashboard:new_dashboard.production_status.priority.${priorityLevel}`)}
</div>
{alert.created_at && (
<span className="text-xs text-[var(--text-tertiary)]">
{formatTimeAgo(alert.created_at)}
</span>
)}
</div>
{/* Description */}
<p className="text-sm text-[var(--text-secondary)] mb-2">
{alertDescription}
</p>
{/* Additional Details */}
<div className="flex flex-wrap gap-3 text-xs">
{/* Business Impact */}
{businessImpact.affected_orders > 0 && (
<div className="flex items-center gap-1 text-[var(--text-tertiary)]">
<span>📦</span>
<span>
{t('dashboard:new_dashboard.production_status.alerts.affected_orders', {
count: businessImpact.affected_orders
})}
</span>
</div>
)}
{businessImpact.production_delay_hours > 0 && (
<div className="flex items-center gap-1 text-[var(--text-tertiary)]">
<Clock className="w-3 h-3" />
<span>
{t('dashboard:new_dashboard.production_status.alerts.delay_hours', {
hours: Math.round(businessImpact.production_delay_hours * 10) / 10
})}
</span>
</div>
)}
{businessImpact.financial_impact_eur > 0 && (
<div className="flex items-center gap-1 text-[var(--text-tertiary)]">
<span>💰</span>
<span>
{t('dashboard:new_dashboard.production_status.alerts.financial_impact', {
amount: Math.round(businessImpact.financial_impact_eur)
})}
</span>
</div>
)}
{/* Urgency Info */}
{urgency.hours_until_consequence !== undefined && urgency.hours_until_consequence < 24 && (
<div className="flex items-center gap-1 text-[var(--color-warning-600)] font-medium">
<Timer className="w-3 h-3" />
<span>
{t('dashboard:new_dashboard.production_status.alerts.urgent_in', {
hours: Math.round(urgency.hours_until_consequence * 10) / 10
})}
</span>
</div>
)}
{/* Product/Batch Info from metadata */}
{metadata.product_name && (
<div className="flex items-center gap-1 text-[var(--text-tertiary)]">
<span>🥖</span>
<span>{metadata.product_name}</span>
</div>
)}
{metadata.batch_number && (
<div className="flex items-center gap-1 text-[var(--text-tertiary)]">
<span>#</span>
<span>{metadata.batch_number}</span>
</div>
)}
</div>
</div>
</div>
</div>
);
};
// Render a batch item
const renderBatchItem = (batch: any, type: 'late' | 'running' | 'pending', index: number, total: number) => {
const batchId = batch.id || batch.batch_id;
@@ -289,11 +541,123 @@ export function ProductionStatusBlock({
<div className="mt-3 p-3 rounded-lg bg-[var(--color-primary-50)] border border-[var(--color-primary-100)]">
<div className="flex items-start gap-2">
<Brain className="w-4 h-4 text-[var(--color-primary-600)] mt-0.5 flex-shrink-0" />
<div>
<div className="w-full">
<p className="text-sm font-medium text-[var(--color-primary-700)] mb-1">
{t('dashboard:new_dashboard.production_status.ai_reasoning')}
</p>
<p className="text-sm text-[var(--color-primary-600)]">{reasoning}</p>
<p className="text-sm text-[var(--color-primary-600)] mb-2">{reasoning}</p>
{/* Weather Data Display */}
{batch.reasoning_data?.parameters?.weather_data && (
<div className="mb-3 p-2 rounded-lg bg-[var(--bg-primary)] border border-[var(--color-primary-200)]">
<div className="flex items-center gap-3">
<span className="text-2xl">
{batch.reasoning_data.parameters.weather_data.condition === 'sunny' && '☀️'}
{batch.reasoning_data.parameters.weather_data.condition === 'rainy' && '🌧️'}
{batch.reasoning_data.parameters.weather_data.condition === 'cold' && '❄️'}
{batch.reasoning_data.parameters.weather_data.condition === 'hot' && '🔥'}
</span>
<div className="flex-1">
<p className="text-xs font-semibold text-[var(--text-primary)] uppercase">
{t('dashboard:new_dashboard.production_status.weather_forecast')}
</p>
<p className="text-sm text-[var(--text-secondary)]">
{t(`dashboard:new_dashboard.production_status.weather_conditions.${batch.reasoning_data.parameters.weather_data.condition}`, {
temp: batch.reasoning_data.parameters.weather_data.temperature,
humidity: batch.reasoning_data.parameters.weather_data.humidity
})}
</p>
</div>
<div className="text-right">
<p className="text-xs text-[var(--text-tertiary)]">
{t('dashboard:new_dashboard.production_status.demand_impact')}
</p>
<p className={`text-sm font-semibold ${
batch.reasoning_data.parameters.weather_data.impact_factor > 1
? 'text-[var(--color-success-600)]'
: 'text-[var(--color-warning-600)]'
}`}>
{batch.reasoning_data.parameters.weather_data.impact_factor > 1 ? '+' : ''}
{Math.round((batch.reasoning_data.parameters.weather_data.impact_factor - 1) * 100)}%
</p>
</div>
</div>
</div>
)}
{/* Enhanced reasoning with factors */}
{batch.reasoning_data?.parameters?.factors && batch.reasoning_data.parameters.factors.length > 0 && (
<div className="mt-2 space-y-2">
<p className="text-xs font-medium text-[var(--color-primary-800)] uppercase tracking-wide">
{t('dashboard:new_dashboard.production_status.factors_title')}
</p>
<div className="space-y-2">
{batch.reasoning_data.parameters.factors.map((factor: any, factorIndex: number) => (
<div key={factorIndex} className="flex items-center gap-2">
<span className="text-lg">{getFactorIcon(factor.factor)}</span>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-[var(--text-secondary)]">
{t(getFactorTranslationKey(factor.factor))}
</span>
<span className="text-xs text-[var(--text-tertiary)]">
({Math.round(factor.weight * 100)}%)
</span>
</div>
<div className="w-full bg-[var(--bg-tertiary)] rounded-full h-1.5 mt-1">
<div
className="bg-[var(--color-primary-500)] h-1.5 rounded-full transition-all"
style={{ width: `${Math.round(factor.weight * 100)}%` }}
/>
</div>
</div>
<span className={`text-sm font-semibold ${
factor.contribution >= 0
? 'text-[var(--color-success-600)]'
: 'text-[var(--color-error-600)]'
}`}>
{factor.contribution >= 0 ? '+' : ''}{Math.round(factor.contribution)}
</span>
</div>
))}
</div>
{/* Confidence and variance info */}
<div className="mt-2 pt-2 border-t border-[var(--color-primary-100)] flex flex-wrap items-center gap-4 text-xs">
{batch.reasoning_data.metadata?.confidence_score && (
<div className="flex items-center gap-1 text-[var(--text-secondary)]">
<span className="text-[var(--color-primary-600)]">🎯</span>
<span>
{t('dashboard:new_dashboard.production_status.confidence', {
confidence: Math.round(batch.reasoning_data.metadata.confidence_score * 100)
})}
</span>
</div>
)}
{batch.reasoning_data.parameters?.variance_percent && (
<div className="flex items-center gap-1 text-[var(--text-secondary)]">
<span className="text-[var(--color-primary-600)]">📈</span>
<span>
{t('dashboard:new_dashboard.production_status.variance', {
variance: batch.reasoning_data.parameters.variance_percent
})}
</span>
</div>
)}
{batch.reasoning_data.parameters?.historical_average && (
<div className="flex items-center gap-1 text-[var(--text-secondary)]">
<span className="text-[var(--color-primary-600)]">📊</span>
<span>
{t('dashboard:new_dashboard.production_status.historical_avg', {
avg: Math.round(batch.reasoning_data.parameters.historical_average)
})}
</span>
</div>
)}
</div>
</div>
)}
</div>
</div>
</div>
@@ -354,6 +718,21 @@ export function ProductionStatusBlock({
{/* Content */}
{hasAnyProduction ? (
<div className="border-t border-[var(--border-primary)]">
{/* Production Alerts Section */}
{hasAlerts && (
<div className="bg-[var(--color-error-50)]">
<div className="px-6 py-3 border-b border-[var(--color-error-100)]">
<h3 className="text-sm font-semibold text-[var(--color-error-700)] flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
{t('dashboard:new_dashboard.production_status.alerts_section')}
</h3>
</div>
{productionAlerts.map((alert, index) =>
renderAlertItem(alert, index, productionAlerts.length)
)}
</div>
)}
{/* Late to Start Section */}
{hasLate && (
<div className="bg-[var(--color-error-50)]">

View File

@@ -66,8 +66,8 @@ export function SystemStatusBlock({ data, loading }: SystemStatusBlockProps) {
const diffMinutes = Math.floor(diffMs / (1000 * 60));
if (diffMinutes < 1) return t('common:time.just_now', 'Just now');
if (diffMinutes < 60) return t('common:time.minutes_ago', '{{count}} min ago', { count: diffMinutes });
if (diffHours < 24) return t('common:time.hours_ago', '{{count}}h ago', { count: diffHours });
if (diffMinutes < 60) return t('common:time.minutes_ago', '{count} min ago', { count: diffMinutes });
if (diffHours < 24) return t('common:time.hours_ago', '{count}h ago', { count: diffHours });
return date.toLocaleDateString();
};

View File

@@ -8,3 +8,4 @@ export { SystemStatusBlock } from './SystemStatusBlock';
export { PendingPurchasesBlock } from './PendingPurchasesBlock';
export { PendingDeliveriesBlock } from './PendingDeliveriesBlock';
export { ProductionStatusBlock } from './ProductionStatusBlock';
export { AIInsightsBlock } from './AIInsightsBlock';

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Clock, Timer, CheckCircle, AlertCircle, Package, Play, Pause, X, Eye } from 'lucide-react';
import { Clock, Timer, CheckCircle, AlertCircle, Package, Play, Pause, X, Eye, Info } from 'lucide-react';
import { StatusCard, StatusIndicatorConfig } from '../../ui/StatusCard/StatusCard';
import { statusColors } from '../../../styles/colors';
import { ProductionBatchResponse, ProductionStatus, ProductionPriority } from '../../../api/types/production';
@@ -258,6 +258,39 @@ export const ProductionStatusCard: React.FC<ProductionStatusCardProps> = ({
metadata.push(safeText(qualityInfo, qualityInfo, 50));
}
// Add reasoning information if available
if (batch.reasoning_data) {
const { trigger_type, trigger_description, factors, consequence, confidence_score, variance, prediction_details } = batch.reasoning_data;
// Add trigger information
if (trigger_type) {
let triggerLabel = t(`reasoning:triggers.${trigger_type.toLowerCase()}`);
if (triggerLabel === `reasoning:triggers.${trigger_type.toLowerCase()}`) {
triggerLabel = trigger_type;
}
metadata.push(`Causa: ${triggerLabel}`);
}
// Add factors
if (factors && Array.isArray(factors) && factors.length > 0) {
const factorLabels = factors.map(factor => {
const factorLabel = t(`reasoning:factors.${factor.toLowerCase()}`);
return factorLabel === `reasoning:factors.${factor.toLowerCase()}` ? factor : factorLabel;
}).join(', ');
metadata.push(`Factores: ${factorLabels}`);
}
// Add confidence score
if (confidence_score) {
metadata.push(`Confianza: ${confidence_score}%`);
}
// Add variance information
if (variance) {
metadata.push(`Varianza: ${variance}%`);
}
}
if (batch.priority === ProductionPriority.URGENT) {
metadata.push('⚡ Orden urgente');
}

View File

@@ -2,6 +2,7 @@ import React, { createContext, useContext, useEffect, useRef, useState, ReactNod
import { useAuthStore } from '../stores/auth.store';
import { useCurrentTenant } from '../stores/tenant.store';
import { showToast } from '../utils/toast';
import i18n from '../i18n';
interface SSEEvent {
type: string;
@@ -151,14 +152,41 @@ export const SSEProvider: React.FC<SSEProviderProps> = ({ children }) => {
toastType = 'info';
}
// Show toast with enriched data
const title = data.title || 'Alerta';
// Translate title and message using i18n keys
let title = 'Alerta';
let message = 'Nueva alerta';
if (data.i18n?.title_key) {
// Extract namespace from key (e.g., "alerts.critical_stock_shortage.title" -> namespace: "alerts", key: "critical_stock_shortage.title")
const titleParts = data.i18n.title_key.split('.');
const titleNamespace = titleParts[0];
const titleKey = titleParts.slice(1).join('.');
title = String(i18n.t(titleKey, {
ns: titleNamespace,
...data.i18n.title_params,
defaultValue: data.i18n.title_key
}));
}
if (data.i18n?.message_key) {
// Extract namespace from key (e.g., "alerts.critical_stock_shortage.message_generic" -> namespace: "alerts", key: "critical_stock_shortage.message_generic")
const messageParts = data.i18n.message_key.split('.');
const messageNamespace = messageParts[0];
const messageKey = messageParts.slice(1).join('.');
message = String(i18n.t(messageKey, {
ns: messageNamespace,
...data.i18n.message_params,
defaultValue: data.i18n.message_key
}));
}
const duration = data.priority_level === 'critical' ? 0 : 5000;
// Add financial impact to message if available
let message = data.message;
if (data.business_impact?.financial_impact_eur) {
message = `${data.message} • €${data.business_impact.financial_impact_eur} en riesgo`;
message = `${message} • €${data.business_impact.financial_impact_eur} en riesgo`;
}
showToast[toastType](message, { title, duration });
@@ -176,6 +204,209 @@ export const SSEProvider: React.FC<SSEProviderProps> = ({ children }) => {
}
});
// Handle notification events (from various services)
eventSource.addEventListener('notification', (event) => {
try {
const data = JSON.parse(event.data);
const sseEvent: SSEEvent = {
type: 'notification',
data,
timestamp: data.timestamp || new Date().toISOString(),
};
setLastEvent(sseEvent);
// Determine toast type based on notification priority or type
let toastType: 'info' | 'success' | 'warning' | 'error' = 'info';
// Use type_class if available from the new event architecture
if (data.type_class) {
if (data.type_class === 'success' || data.type_class === 'completed') {
toastType = 'success';
} else if (data.type_class === 'error') {
toastType = 'error';
} else if (data.type_class === 'warning') {
toastType = 'warning';
} else if (data.type_class === 'info') {
toastType = 'info';
}
} else {
// Fallback to priority_level for legacy compatibility
if (data.priority_level === 'critical') {
toastType = 'error';
} else if (data.priority_level === 'important') {
toastType = 'warning';
} else if (data.priority_level === 'standard') {
toastType = 'info';
}
}
// Translate title and message using i18n keys
let title = 'Notificación';
let message = 'Nueva notificación recibida';
if (data.i18n?.title_key) {
// Extract namespace from key
const titleParts = data.i18n.title_key.split('.');
const titleNamespace = titleParts[0];
const titleKey = titleParts.slice(1).join('.');
title = String(i18n.t(titleKey, {
ns: titleNamespace,
...data.i18n.title_params,
defaultValue: data.i18n.title_key
}));
} else if (data.title || data.subject) {
// Fallback to legacy fields if i18n not available
title = data.title || data.subject;
}
if (data.i18n?.message_key) {
// Extract namespace from key
const messageParts = data.i18n.message_key.split('.');
const messageNamespace = messageParts[0];
const messageKey = messageParts.slice(1).join('.');
message = String(i18n.t(messageKey, {
ns: messageNamespace,
...data.i18n.message_params,
defaultValue: data.i18n.message_key
}));
} else if (data.message || data.content || data.description) {
// Fallback to legacy fields if i18n not available
message = data.message || data.content || data.description;
}
// Add entity context to message if available
if (data.entity_links && Object.keys(data.entity_links).length > 0) {
const entityInfo = Object.entries(data.entity_links)
.map(([type, id]) => `${type}: ${id}`)
.join(', ');
message = `${message} (${entityInfo})`;
}
// Add state change information if available
if (data.old_state && data.new_state) {
message = `${message} - ${data.old_state}${data.new_state}`;
}
const duration = data.priority_level === 'critical' ? 0 : 5000;
showToast[toastType](message, { title, duration });
// Trigger listeners with notification data
// Wrap in queueMicrotask to prevent setState during render warnings
const listeners = eventListenersRef.current.get('notification');
if (listeners) {
listeners.forEach(callback => {
queueMicrotask(() => callback(data));
});
}
} catch (error) {
console.error('Error parsing notification event:', error);
}
});
// Handle recommendation events (AI-driven insights)
eventSource.addEventListener('recommendation', (event) => {
try {
const data = JSON.parse(event.data);
const sseEvent: SSEEvent = {
type: 'recommendation',
data,
timestamp: data.timestamp || new Date().toISOString(),
};
setLastEvent(sseEvent);
// Recommendations are typically positive insights
let toastType: 'info' | 'success' | 'warning' | 'error' = 'info';
// Use type_class if available from the new event architecture
if (data.type_class) {
if (data.type_class === 'opportunity' || data.type_class === 'insight') {
toastType = 'success';
} else if (data.type_class === 'error') {
toastType = 'error';
} else if (data.type_class === 'warning') {
toastType = 'warning';
} else if (data.type_class === 'info') {
toastType = 'info';
}
} else {
// Fallback to priority_level for legacy compatibility
if (data.priority_level === 'critical') {
toastType = 'error';
} else if (data.priority_level === 'important') {
toastType = 'warning';
} else {
toastType = 'info';
}
}
// Translate title and message using i18n keys
let title = 'Recomendación';
let message = 'Nueva recomendación del sistema AI';
if (data.i18n?.title_key) {
// Extract namespace from key
const titleParts = data.i18n.title_key.split('.');
const titleNamespace = titleParts[0];
const titleKey = titleParts.slice(1).join('.');
title = String(i18n.t(titleKey, {
ns: titleNamespace,
...data.i18n.title_params,
defaultValue: data.i18n.title_key
}));
} else if (data.title) {
// Fallback to legacy field if i18n not available
title = data.title;
}
if (data.i18n?.message_key) {
// Extract namespace from key
const messageParts = data.i18n.message_key.split('.');
const messageNamespace = messageParts[0];
const messageKey = messageParts.slice(1).join('.');
message = String(i18n.t(messageKey, {
ns: messageNamespace,
...data.i18n.message_params,
defaultValue: data.i18n.message_key
}));
} else if (data.message) {
// Fallback to legacy field if i18n not available
message = data.message;
}
// Add estimated impact if available
if (data.estimated_impact) {
const impact = data.estimated_impact;
if (impact.savings_eur) {
message = `${message} • €${impact.savings_eur} de ahorro estimado`;
} else if (impact.risk_reduction_percent) {
message = `${message}${impact.risk_reduction_percent}% reducción de riesgo`;
}
}
const duration = 5000; // Recommendations are typically informational
showToast[toastType](message, { title, duration });
// Trigger listeners with recommendation data
// Wrap in queueMicrotask to prevent setState during render warnings
const listeners = eventListenersRef.current.get('recommendation');
if (listeners) {
listeners.forEach(callback => {
queueMicrotask(() => callback(data));
});
}
} catch (error) {
console.error('Error parsing recommendation event:', error);
}
});
eventSource.onerror = (error) => {
console.error('SSE connection error:', error);
setIsConnected(false);

View File

@@ -119,7 +119,11 @@
"now": "Now",
"recently": "Recently",
"soon": "Soon",
"later": "Later"
"later": "Later",
"just_now": "Just now",
"minutes_ago": "{count, plural, one {# minute ago} other {# minutes ago}}",
"hours_ago": "{count, plural, one {# hour ago} other {# hours ago}}",
"days_ago": "{count, plural, one {# day ago} other {# days ago}}"
},
"units": {
"kg": "kg",

View File

@@ -409,6 +409,24 @@
"failed": "Failed",
"distribution_routes": "Distribution Routes"
},
"ai_insights": {
"title": "AI Insights",
"subtitle": "Strategic recommendations from your AI assistant",
"view_all": "View All Insights",
"no_insights": "No AI insights available yet",
"impact_high": "High Impact",
"impact_medium": "Medium Impact",
"impact_low": "Low Impact",
"savings": "potential savings",
"reduction": "reduction potential",
"types": {
"cost_optimization": "Cost Optimization",
"waste_reduction": "Waste Reduction",
"safety_stock": "Safety Stock",
"demand_forecast": "Demand Forecast",
"risk_alert": "Risk Alert"
}
},
"new_dashboard": {
"system_status": {
"title": "System Status",
@@ -476,8 +494,50 @@
"ai_reasoning": "AI scheduled this batch because:",
"reasoning": {
"forecast_demand": "Predicted demand of {demand} units for {product}",
"forecast_demand_enhanced": "Predicted demand of {demand} units for {product} (+{variance}% vs historical)",
"customer_order": "Customer order from {customer}"
},
"weather_forecast": "Weather Forecast",
"weather_conditions": {
"sunny": "Sunny, {temp}°C, {humidity}% humidity",
"rainy": "Rainy, {temp}°C, {humidity}% humidity",
"cold": "Cold, {temp}°C, {humidity}% humidity",
"hot": "Hot, {temp}°C, {humidity}% humidity"
},
"demand_impact": "Demand Impact",
"factors_title": "Prediction Factors",
"factors": {
"historical_pattern": "Historical Pattern",
"weather_sunny": "Sunny Weather",
"weather_rainy": "Rainy Weather",
"weather_cold": "Cold Weather",
"weather_hot": "Hot Weather",
"weekend_boost": "Weekend Demand",
"inventory_level": "Inventory Level",
"seasonal_trend": "Seasonal Trend",
"general": "Other Factor"
},
"confidence": "Confidence: {confidence}%",
"variance": "Variance: +{variance}%",
"historical_avg": "Hist. avg: {avg} units",
"alerts_section": "Production Alerts",
"alerts": {
"equipment_maintenance": "Equipment Maintenance Required",
"production_delay": "Production Delay",
"batch_delayed": "Batch Start Delayed",
"generic": "Production Alert",
"active": "Active",
"affected_orders": "{count, plural, one {# order} other {# orders}} affected",
"delay_hours": "{hours}h delay",
"financial_impact": "€{amount} impact",
"urgent_in": "Urgent in {hours}h"
},
"priority": {
"critical": "Critical",
"important": "Important",
"standard": "Standard",
"info": "Info"
}
}
}
}
}

View File

@@ -14,6 +14,7 @@
},
"productionBatch": {
"forecast_demand": "Scheduled based on forecast: {predicted_demand} {product_name} needed (current stock: {current_stock}). Confidence: {confidence_score}%.",
"forecast_demand_enhanced": "Scheduled based on enhanced forecast: {predicted_demand} {product_name} needed ({variance}% variance from historical average). Confidence: {confidence_score}%.",
"customer_order": "Customer order for {customer_name}: {order_quantity} {product_name} (Order #{order_number}) - delivery {delivery_date}.",
"stock_replenishment": "Stock replenishment for {product_name} - current level below minimum.",
"seasonal_preparation": "Seasonal preparation batch for {product_name}.",
@@ -177,5 +178,25 @@
"inventory_replenishment": "Regular inventory replenishment",
"production_schedule": "Scheduled production batch",
"other": "Standard replenishment"
},
"factors": {
"historical_pattern": "Historical Pattern",
"weather_sunny": "Sunny Weather",
"weather_rainy": "Rainy Weather",
"weather_cold": "Cold Weather",
"weather_hot": "Hot Weather",
"weekend_boost": "Weekend Demand",
"inventory_level": "Inventory Level",
"seasonal_trend": "Seasonal Trend",
"general": "Other Factor",
"weather_impact_sunny": "Sunny Weather Impact",
"seasonal_trend_adjustment": "Seasonal Trend Adjustment",
"historical_sales_pattern": "Historical Sales Pattern",
"current_inventory_trigger": "Current Inventory Trigger"
},
"dashboard": {
"factors_title": "Key Factors Influencing This Decision",
"confidence": "Confidence: {confidence}%",
"variance": "Variance: {variance}% from historical average"
}
}

View File

@@ -119,7 +119,11 @@
"now": "Ahora",
"recently": "Recientemente",
"soon": "Pronto",
"later": "Más tarde"
"later": "Más tarde",
"just_now": "Ahora mismo",
"minutes_ago": "{count, plural, one {hace # minuto} other {hace # minutos}}",
"hours_ago": "{count, plural, one {hace # hora} other {hace # horas}}",
"days_ago": "{count, plural, one {hace # día} other {hace # días}}"
},
"units": {
"kg": "kg",

View File

@@ -458,6 +458,24 @@
"failed": "Fallida",
"distribution_routes": "Rutas de Distribución"
},
"ai_insights": {
"title": "Insights de IA",
"subtitle": "Recomendaciones estratégicas de tu asistente de IA",
"view_all": "Ver Todos los Insights",
"no_insights": "Aún no hay insights de IA disponibles",
"impact_high": "Alto Impacto",
"impact_medium": "Impacto Medio",
"impact_low": "Bajo Impacto",
"savings": "ahorro potencial",
"reduction": "potencial de reducción",
"types": {
"cost_optimization": "Optimización de Costos",
"waste_reduction": "Reducción de Desperdicio",
"safety_stock": "Stock de Seguridad",
"demand_forecast": "Pronóstico de Demanda",
"risk_alert": "Alerta de Riesgo"
}
},
"new_dashboard": {
"system_status": {
"title": "Estado del Sistema",
@@ -525,8 +543,50 @@
"ai_reasoning": "IA programó este lote porque:",
"reasoning": {
"forecast_demand": "Demanda prevista de {demand} unidades para {product}",
"forecast_demand_enhanced": "Demanda prevista de {demand} unidades para {product} (+{variance}% vs histórico)",
"customer_order": "Pedido del cliente {customer}"
},
"weather_forecast": "Previsión Meteorológica",
"weather_conditions": {
"sunny": "Soleado, {temp}°C, {humidity}% humedad",
"rainy": "Lluvioso, {temp}°C, {humidity}% humedad",
"cold": "Frío, {temp}°C, {humidity}% humedad",
"hot": "Caluroso, {temp}°C, {humidity}% humedad"
},
"demand_impact": "Impacto en Demanda",
"factors_title": "Factores de Predicción",
"factors": {
"historical_pattern": "Patrón Histórico",
"weather_sunny": "Tiempo Soleado",
"weather_rainy": "Tiempo Lluvioso",
"weather_cold": "Tiempo Frío",
"weather_hot": "Tiempo Caluroso",
"weekend_boost": "Demanda de Fin de Semana",
"inventory_level": "Nivel de Inventario",
"seasonal_trend": "Tendencia Estacional",
"general": "Otro Factor"
},
"confidence": "Confianza: {confidence}%",
"variance": "Variación: +{variance}%",
"historical_avg": "Media hist.: {avg} unidades",
"alerts_section": "Alertas de Producción",
"alerts": {
"equipment_maintenance": "Mantenimiento de Equipo Requerido",
"production_delay": "Retraso en Producción",
"batch_delayed": "Lote con Inicio Retrasado",
"generic": "Alerta de Producción",
"active": "Activo",
"affected_orders": "{count, plural, one {# pedido} other {# pedidos}} afectados",
"delay_hours": "{hours}h de retraso",
"financial_impact": "€{amount} de impacto",
"urgent_in": "Urgente en {hours}h"
},
"priority": {
"critical": "Crítico",
"important": "Importante",
"standard": "Estándar",
"info": "Info"
}
}
}
}
}

View File

@@ -14,6 +14,7 @@
},
"productionBatch": {
"forecast_demand": "Programado según pronóstico: {predicted_demand} {product_name} necesarios (stock actual: {current_stock}). Confianza: {confidence_score}%.",
"forecast_demand_enhanced": "Programado según pronóstico mejorado: {predicted_demand} {product_name} necesarios ({variance}% variación del promedio histórico). Confianza: {confidence_score}%.",
"customer_order": "Pedido de cliente para {customer_name}: {order_quantity} {product_name} (Pedido #{order_number}) - entrega {delivery_date}.",
"stock_replenishment": "Reposición de stock para {product_name} - nivel actual por debajo del mínimo.",
"seasonal_preparation": "Lote de preparación estacional para {product_name}.",
@@ -177,5 +178,25 @@
"inventory_replenishment": "Reposición regular de inventario",
"production_schedule": "Lote de producción programado",
"other": "Reposición estándar"
},
"factors": {
"historical_pattern": "Patrón Histórico",
"weather_sunny": "Tiempo Soleado",
"weather_rainy": "Tiempo Lluvioso",
"weather_cold": "Tiempo Frío",
"weather_hot": "Tiempo Caluroso",
"weekend_boost": "Demanda de Fin de Semana",
"inventory_level": "Nivel de Inventario",
"seasonal_trend": "Tendencia Estacional",
"general": "Otro Factor",
"weather_impact_sunny": "Impacto del Tiempo Soleado",
"seasonal_trend_adjustment": "Ajuste de Tendencia Estacional",
"historical_sales_pattern": "Patrón de Ventas Histórico",
"current_inventory_trigger": "Activador de Inventario Actual"
},
"dashboard": {
"factors_title": "Factores Clave que Influencian esta Decisión",
"confidence": "Confianza: {confidence}%",
"variance": "Variación: {variance}% del promedio histórico"
}
}

View File

@@ -117,7 +117,11 @@
"now": "Orain",
"recently": "Duela gutxi",
"soon": "Laster",
"later": "Geroago"
"later": "Geroago",
"just_now": "Orain bertan",
"minutes_ago": "{count, plural, one {duela # minutu} other {duela # minutu}}",
"hours_ago": "{count, plural, one {duela # ordu} other {duela # ordu}}",
"days_ago": "{count, plural, one {duela # egun} other {duela # egun}}"
},
"units": {
"kg": "kg",

View File

@@ -122,10 +122,6 @@
"acknowledged": "Onartu",
"resolved": "Ebatzi"
},
"types": {
"alert": "Alerta",
"recommendation": "Gomendioa"
},
"recommended_actions": "Gomendatutako Ekintzak",
"additional_details": "Xehetasun Gehigarriak",
"mark_as_read": "Irakurritako gisa markatu",
@@ -463,7 +459,49 @@
"ai_reasoning": "IAk lote hau programatu zuen zeren:",
"reasoning": {
"forecast_demand": "{product}-rentzat {demand} unitateko eskaria aurreikusita",
"forecast_demand_enhanced": "{product}-rentzat {demand} unitateko eskaria aurreikusita (+{variance}% historikoarekin alderatuta)",
"customer_order": "{customer} bezeroaren eskaera"
},
"weather_forecast": "Eguraldi Iragarpena",
"weather_conditions": {
"sunny": "Eguzkitsua, {temp}°C, %{humidity} hezetasuna",
"rainy": "Euritsua, {temp}°C, %{humidity} hezetasuna",
"cold": "Hotza, {temp}°C, %{humidity} hezetasuna",
"hot": "Beroa, {temp}°C, %{humidity} hezetasuna"
},
"demand_impact": "Eskarian Eragina",
"factors_title": "Aurreikuspen Faktoreak",
"factors": {
"historical_pattern": "Eredu Historikoa",
"weather_sunny": "Eguraldi Eguzkitsua",
"weather_rainy": "Eguraldi Euritsua",
"weather_cold": "Eguraldi Hotza",
"weather_hot": "Eguraldi Beroa",
"weekend_boost": "Asteburuaren Eskaria",
"inventory_level": "Inbentario Maila",
"seasonal_trend": "Sasoi Joera",
"general": "Beste Faktore bat"
},
"confidence": "Konfiantza: %{confidence}",
"variance": "Aldakuntza: +%{variance}",
"historical_avg": "Batez bestekoa: {avg} unitate",
"alerts_section": "Ekoizpen Alertak",
"alerts": {
"equipment_maintenance": "Ekipoen Mantentze-Lanak Behar",
"production_delay": "Ekoizpenaren Atzerapena",
"batch_delayed": "Lotearen Hasiera Atzeratuta",
"generic": "Ekoizpen Alerta",
"active": "Aktiboa",
"affected_orders": "{count, plural, one {# eskaera} other {# eskaera}} kaltetuak",
"delay_hours": "{hours}h atzerapena",
"financial_impact": "€{amount} eragina",
"urgent_in": "Presazkoa {hours}h-tan"
},
"priority": {
"critical": "Kritikoa",
"important": "Garrantzitsua",
"standard": "Estandarra",
"info": "Informazioa"
}
}
}

View File

@@ -1,4 +1,7 @@
{
"orchestration": {
"daily_summary": "{purchase_orders_count, plural, =0 {} =1 {1 erosketa agindu sortu} other {{purchase_orders_count} erosketa agindu sortu}}{purchase_orders_count, plural, =0 {} other { eta }}{production_batches_count, plural, =0 {ekoizpen loterik ez} =1 {1 ekoizpen lote programatu} other {{production_batches_count} ekoizpen lote programatu}}. {critical_items_count, plural, =0 {Guztia stockean.} =1 {Artikulu kritiko 1 arreta behar du} other {{critical_items_count} artikulu kritiko arreta behar dute}}{total_financial_impact_eur, select, 0 {} other { (€{total_financial_impact_eur} arriskuan)}}{min_depletion_hours, select, 0 {} other { - {min_depletion_hours}h stock amaitu arte}}."
},
"purchaseOrder": {
"low_stock_detection": "{supplier_name}-rentzat stock baxua. {product_names_joined}-ren egungo stocka {days_until_stockout} egunetan amaituko da.",
"low_stock_detection_detailed": "{critical_product_count, plural, =1 {{critical_products_0} {min_depletion_hours} ordutan amaituko da} other {{critical_product_count} produktu kritiko urri}}. {supplier_name}-ren {supplier_lead_time_days} eguneko entregarekin, {order_urgency, select, critical {BEREHALA} urgent {GAUR} important {laster} other {orain}} eskatu behar dugu {affected_batches_count, plural, =0 {ekoizpen atzerapenak} =1 {{affected_batches_0} lotearen etetea} other {{affected_batches_count} loteen etetea}} saihesteko{potential_loss_eur, select, 0 {} other { (€{potential_loss_eur} arriskuan)}}.",
@@ -11,6 +14,7 @@
},
"productionBatch": {
"forecast_demand": "Aurreikuspenen arabera programatua: {predicted_demand} {product_name} behar dira (egungo stocka: {current_stock}). Konfiantza: {confidence_score}%.",
"forecast_demand_enhanced": "Aurreikuspen hobetuaren arabera programatua: {predicted_demand} {product_name} behar dira ({variance}% aldaketa batez besteko historikoarekiko). Konfiantza: {confidence_score}%.",
"customer_order": "{customer_name}-rentzat bezeroaren eskaera: {order_quantity} {product_name} (Eskaera #{order_number}) - entrega {delivery_date}.",
"stock_replenishment": "{product_name}-rentzat stockaren birjartzea - egungo maila minimoa baino txikiagoa.",
"seasonal_preparation": "{product_name}-rentzat denboraldiko prestaketa lotea.",
@@ -174,5 +178,25 @@
"inventory_replenishment": "Inbentario berritze erregularra",
"production_schedule": "Ekoizpen sorta programatua",
"other": "Berritze estandarra"
},
"factors": {
"historical_pattern": "Eredu Historikoa",
"weather_sunny": "Eguraldi Eguzkitsua",
"weather_rainy": "Eguraldi Euritsua",
"weather_cold": "Eguraldi Hotza",
"weather_hot": "Eguraldi Beroa",
"weekend_boost": "Asteburuaren Eskaria",
"inventory_level": "Inbentario Maila",
"seasonal_trend": "Sasoi Joera",
"general": "Beste Faktore bat",
"weather_impact_sunny": "Eguraldi Eguzkitsuaren Eragina",
"seasonal_trend_adjustment": "Sasoi Joeraren Doikuntza",
"historical_sales_pattern": "Salmenta Eredu Historikoa",
"current_inventory_trigger": "Egungo Inbentario Aktibatzailea"
},
"dashboard": {
"factors_title": "Erabaki hau eragiten duten faktore gakoak",
"confidence": "Konfiantza: {confidence}%",
"variance": "Aldaketa: % {variance} batez besteko historikoarekiko"
}
}

View File

@@ -36,6 +36,7 @@ import {
PendingPurchasesBlock,
PendingDeliveriesBlock,
ProductionStatusBlock,
AIInsightsBlock,
} from '../../components/dashboard/blocks';
import { UnifiedPurchaseOrderModal } from '../../components/domain/procurement/UnifiedPurchaseOrderModal';
import { UnifiedAddWizard } from '../../components/domain/unified-wizard';
@@ -50,7 +51,7 @@ import { useSubscription } from '../../api/hooks/subscription';
import { SUBSCRIPTION_TIERS } from '../../api/types/subscription';
// Rename the existing component to BakeryDashboard
export function BakeryDashboard() {
export function BakeryDashboard({ plan }: { plan?: string }) {
const { t } = useTranslation(['dashboard', 'common', 'alerts']);
const { currentTenant } = useTenant();
const tenantId = currentTenant?.id || '';
@@ -415,10 +416,25 @@ export function BakeryDashboard() {
lateToStartBatches={dashboardData?.lateToStartBatches || []}
runningBatches={dashboardData?.runningBatches || []}
pendingBatches={dashboardData?.pendingBatches || []}
alerts={dashboardData?.alerts || []}
loading={dashboardLoading}
onStartBatch={handleStartBatch}
/>
</div>
{/* BLOCK 5: AI Insights (Professional/Enterprise only) */}
{(plan === SUBSCRIPTION_TIERS.PROFESSIONAL || plan === SUBSCRIPTION_TIERS.ENTERPRISE) && (
<div data-tour="ai-insights">
<AIInsightsBlock
insights={dashboardData?.aiInsights || []}
loading={dashboardLoading}
onViewAll={() => {
// Navigate to AI Insights page
window.location.href = '/app/analytics/ai-insights';
}}
/>
</div>
)}
</div>
</>
)}
@@ -480,7 +496,7 @@ export function DashboardPage() {
return <EnterpriseDashboardPage tenantId={tenantId} />;
}
return <BakeryDashboard />;
return <BakeryDashboard plan={plan} />;
}
export default DashboardPage;

View File

@@ -193,7 +193,7 @@ const MaquinariaPage: React.FC = () => {
maintenance: { color: getStatusColor('info'), text: t('equipment_status.maintenance'), icon: Wrench },
down: { color: getStatusColor('error'), text: t('equipment_status.down'), icon: AlertTriangle }
};
return configs[status];
return configs[status] || { color: getStatusColor('other'), text: status, icon: Settings };
};
const getTypeIcon = (type: Equipment['type']) => {

View File

@@ -1,5 +1,5 @@
import React, { useState, useMemo } from 'react';
import { Plus, Clock, AlertCircle, CheckCircle, Timer, ChefHat, Eye, Edit, Package, PlusCircle, Play } from 'lucide-react';
import { Plus, Clock, AlertCircle, CheckCircle, Timer, ChefHat, Eye, Edit, Package, PlusCircle, Play, Info } from 'lucide-react';
import { Button, StatsGrid, EditViewModal, Toggle, SearchAndFilter, type FilterConfig, EmptyState } from '../../../../components/ui';
import { statusColors } from '../../../../styles/colors';
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
@@ -666,6 +666,58 @@ const ProductionPage: React.FC = () => {
}
]
},
{
title: 'Detalles del Razonamiento',
icon: Info,
fields: [
{
label: 'Causa Principal',
value: selectedBatch.reasoning_data?.trigger_type
? t(`reasoning:triggers.${selectedBatch.reasoning_data.trigger_type.toLowerCase()}`)
: 'No especificado',
span: 2
},
{
label: 'Descripción del Razonamiento',
value: selectedBatch.reasoning_data?.trigger_description || 'No especificado',
type: 'textarea',
span: 2
},
{
label: 'Factores Clave',
value: selectedBatch.reasoning_data?.factors && Array.isArray(selectedBatch.reasoning_data.factors)
? selectedBatch.reasoning_data.factors.map(factor =>
t(`reasoning:factors.${factor.toLowerCase()}`) || factor
).join(', ')
: 'No especificados',
span: 2
},
{
label: 'Consecuencias Potenciales',
value: selectedBatch.reasoning_data?.consequence || 'No especificado',
type: 'textarea',
span: 2
},
{
label: 'Nivel de Confianza',
value: selectedBatch.reasoning_data?.confidence_score
? `${selectedBatch.reasoning_data.confidence_score}%`
: 'No especificado'
},
{
label: 'Variación Histórica',
value: selectedBatch.reasoning_data?.variance
? `${selectedBatch.reasoning_data.variance}%`
: 'No especificado'
},
{
label: 'Detalles de la Predicción',
value: selectedBatch.reasoning_data?.prediction_details || 'No especificado',
type: 'textarea',
span: 2
}
]
},
{
title: 'Calidad y Costos',
icon: CheckCircle,
@@ -733,6 +785,10 @@ const ProductionPage: React.FC = () => {
'Estado': 'status',
'Prioridad': 'priority',
'Personal Asignado': 'staff_assigned',
// Reasoning section editable fields
'Descripción del Razonamiento': 'reasoning_data.trigger_description',
'Consecuencias Potenciales': 'reasoning_data.consequence',
'Detalles de la Predicción': 'reasoning_data.prediction_details',
// Schedule - most fields are read-only datetime
// Quality and Costs
'Notas de Producción': 'production_notes',
@@ -744,6 +800,7 @@ const ProductionPage: React.FC = () => {
['Producto', 'Número de Lote', 'Cantidad Planificada', 'Cantidad Producida', 'Estado', 'Prioridad', 'Personal Asignado', 'Equipos Utilizados'],
['Inicio Planificado', 'Fin Planificado', 'Duración Planificada', 'Inicio Real', 'Fin Real', 'Duración Real'],
[], // Process Stage Tracker section - no editable fields
['Causa Principal', 'Descripción del Razonamiento', 'Factores Clave', 'Consecuencias Potenciales', 'Nivel de Confianza', 'Variación Histórica', 'Detalles de la Predicción'], // Reasoning section
['Puntuación de Calidad', 'Rendimiento', 'Costo Estimado', 'Costo Real', 'Notas de Producción', 'Notas de Calidad']
];
@@ -760,10 +817,22 @@ const ProductionPage: React.FC = () => {
processedValue = parseFloat(value as string) || 0;
}
setSelectedBatch({
...selectedBatch,
[propertyName]: processedValue
});
// Handle nested reasoning_data fields
if (propertyName.startsWith('reasoning_data.')) {
const nestedProperty = propertyName.split('.')[1];
setSelectedBatch({
...selectedBatch,
reasoning_data: {
...(selectedBatch.reasoning_data || {}),
[nestedProperty]: processedValue
}
});
} else {
setSelectedBatch({
...selectedBatch,
[propertyName]: processedValue
});
}
}
}}
/>

View File

@@ -37,6 +37,11 @@ const success = (message: string, options?: ToastOptions): string => {
return toast.success(fullMessage, {
duration,
id: options?.id,
style: {
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start'
}
});
};
@@ -55,6 +60,11 @@ const error = (message: string, options?: ToastOptions): string => {
return toast.error(fullMessage, {
duration,
id: options?.id,
style: {
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start'
}
});
};
@@ -74,6 +84,11 @@ const warning = (message: string, options?: ToastOptions): string => {
duration,
id: options?.id,
icon: '⚠️',
style: {
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start'
}
});
};
@@ -93,6 +108,11 @@ const info = (message: string, options?: ToastOptions): string => {
duration,
id: options?.id,
icon: '',
style: {
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start'
}
});
};
@@ -111,6 +131,11 @@ const loading = (message: string, options?: ToastOptions): string => {
return toast.loading(fullMessage, {
duration,
id: options?.id,
style: {
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start'
}
});
};