demo seed change
This commit is contained in:
@@ -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
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
181
frontend/src/components/dashboard/blocks/AIInsightsBlock.tsx
Normal file
181
frontend/src/components/dashboard/blocks/AIInsightsBlock.tsx
Normal 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;
|
||||
@@ -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)]">
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -8,3 +8,4 @@ export { SystemStatusBlock } from './SystemStatusBlock';
|
||||
export { PendingPurchasesBlock } from './PendingPurchasesBlock';
|
||||
export { PendingDeliveriesBlock } from './PendingDeliveriesBlock';
|
||||
export { ProductionStatusBlock } from './ProductionStatusBlock';
|
||||
export { AIInsightsBlock } from './AIInsightsBlock';
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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']) => {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user