diff --git a/frontend/src/api/hooks/production.ts b/frontend/src/api/hooks/production.ts index 944eff6c..f795a29a 100644 --- a/frontend/src/api/hooks/production.ts +++ b/frontend/src/api/hooks/production.ts @@ -56,11 +56,11 @@ export const useProductionDashboard = ( export const useDailyProductionRequirements = ( tenantId: string, date?: string, - options?: Omit, 'queryKey' | 'queryFn'> + options?: Omit, 'queryKey' | 'queryFn'> ) => { - return useQuery({ + return useQuery({ queryKey: productionKeys.dailyRequirements(tenantId, date), - queryFn: () => productionService.getDailyRequirements(tenantId, date), + queryFn: () => productionService.getDailyProductionPlan(tenantId, date), enabled: !!tenantId, staleTime: 5 * 60 * 1000, // 5 minutes ...options, @@ -70,11 +70,13 @@ export const useDailyProductionRequirements = ( export const useProductionRequirements = ( tenantId: string, date?: string, - options?: Omit, 'queryKey' | 'queryFn'> + options?: Omit, 'queryKey' | 'queryFn'> ) => { - return useQuery({ + const queryDate = date || new Date().toISOString().split('T')[0]; + + return useQuery({ queryKey: productionKeys.requirements(tenantId, date), - queryFn: () => productionService.getProductionRequirements(tenantId, date), + queryFn: () => productionService.getProductionRequirements(tenantId, queryDate), enabled: !!tenantId, staleTime: 5 * 60 * 1000, // 5 minutes ...options, @@ -117,11 +119,11 @@ export const useProductionSchedule = ( tenantId: string, startDate?: string, endDate?: string, - options?: Omit, 'queryKey' | 'queryFn'> + options?: Omit, 'queryKey' | 'queryFn'> ) => { - return useQuery({ + return useQuery({ queryKey: productionKeys.schedule(tenantId, startDate, endDate), - queryFn: () => productionService.getProductionSchedule(tenantId, startDate, endDate), + queryFn: () => productionService.getSchedules(tenantId, { start_date: startDate, end_date: endDate }), enabled: !!tenantId, staleTime: 5 * 60 * 1000, // 5 minutes ...options, @@ -131,11 +133,11 @@ export const useProductionSchedule = ( export const useCapacityStatus = ( tenantId: string, date?: string, - options?: Omit, 'queryKey' | 'queryFn'> + options?: Omit, 'queryKey' | 'queryFn'> ) => { - return useQuery({ + return useQuery({ queryKey: productionKeys.capacity(tenantId, date), - queryFn: () => productionService.getCapacityStatus(tenantId, date), + queryFn: () => date ? productionService.getCapacityByDate(tenantId, date) : productionService.getCapacity(tenantId), enabled: !!tenantId, staleTime: 5 * 60 * 1000, // 5 minutes ...options, @@ -146,11 +148,11 @@ export const useYieldMetrics = ( tenantId: string, startDate: string, endDate: string, - options?: Omit, 'queryKey' | 'queryFn'> + options?: Omit, 'queryKey' | 'queryFn'> ) => { - return useQuery({ + return useQuery({ queryKey: productionKeys.yieldMetrics(tenantId, startDate, endDate), - queryFn: () => productionService.getYieldMetrics(tenantId, startDate, endDate), + queryFn: () => productionService.getYieldTrends(tenantId, startDate, endDate), enabled: !!tenantId && !!startDate && !!endDate, staleTime: 15 * 60 * 1000, // 15 minutes (metrics are less frequently changing) ...options, diff --git a/frontend/src/api/types/production.ts b/frontend/src/api/types/production.ts index baddfafa..56102f80 100644 --- a/frontend/src/api/types/production.ts +++ b/frontend/src/api/types/production.ts @@ -384,4 +384,76 @@ export interface BatchStatistics { on_time_rate: number; period_start: string; period_end: string; +} + +// Additional types needed for hooks +export interface DailyProductionRequirements { + date: string; + total_planned_units: number; + total_completed_units: number; + products: Array<{ + product_id: string; + product_name: string; + planned_quantity: number; + completed_quantity: number; + required_materials: Array<{ + ingredient_id: string; + ingredient_name: string; + required_amount: number; + unit: string; + }>; + }>; +} + +export interface ProductionScheduleData { + schedules: Array<{ + id: string; + date: string; + shift_start: string; + shift_end: string; + total_batches_planned: number; + staff_count: number; + utilization_percentage: number; + is_active: boolean; + is_finalized: boolean; + }>; +} + +export interface ProductionCapacityStatus { + date: string; + total_capacity: number; + utilized_capacity: number; + utilization_percentage: number; + equipment_utilization: Array<{ + equipment_id: string; + equipment_name: string; + capacity: number; + utilization: number; + status: 'operational' | 'maintenance' | 'down'; + }>; +} + +export interface ProductionRequirements { + date: string; + products: Array<{ + product_id: string; + product_name: string; + required_quantity: number; + planned_quantity: number; + priority: ProductionPriority; + }>; +} + +export interface ProductionYieldMetrics { + start_date: string; + end_date: string; + overall_yield: number; + products: Array<{ + product_id: string; + product_name: string; + average_yield: number; + best_yield: number; + worst_yield: number; + batch_count: number; + }>; } \ No newline at end of file diff --git a/frontend/src/components/analytics/production/AnalyticsChart.tsx b/frontend/src/components/analytics/production/AnalyticsChart.tsx new file mode 100644 index 00000000..aecfb485 --- /dev/null +++ b/frontend/src/components/analytics/production/AnalyticsChart.tsx @@ -0,0 +1,362 @@ +import React, { useRef, useEffect } from 'react'; + +export type ChartType = 'line' | 'bar' | 'area' | 'pie' | 'doughnut'; + +export interface ChartDataPoint { + x: string | number; + y: number; + label?: string; + color?: string; +} + +export interface ChartSeries { + id: string; + name: string; + type: ChartType; + data: ChartDataPoint[]; + color: string; + visible?: boolean; +} + +export interface AnalyticsChartProps { + series: ChartSeries[]; + height?: number; + className?: string; + showLegend?: boolean; + showGrid?: boolean; + animate?: boolean; +} + +export const AnalyticsChart: React.FC = ({ + series, + height = 300, + className = '', + showLegend = true, + showGrid = true, + animate = true +}) => { + const canvasRef = useRef(null); + + useEffect(() => { + if (!canvasRef.current || series.length === 0) return; + + const canvas = canvasRef.current; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // Set canvas size + const rect = canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + canvas.width = rect.width * dpr; + canvas.height = rect.height * dpr; + ctx.scale(dpr, dpr); + + // Clear canvas + ctx.clearRect(0, 0, rect.width, rect.height); + + // Chart settings + const padding = 40; + const chartWidth = rect.width - 2 * padding; + const chartHeight = rect.height - 2 * padding - (showLegend ? 40 : 0); + + // Get visible series + const visibleSeries = series.filter(s => s.visible !== false); + if (visibleSeries.length === 0) return; + + // Draw based on chart type + const primarySeries = visibleSeries[0]; + + if (primarySeries.type === 'pie' || primarySeries.type === 'doughnut') { + drawPieChart(ctx, primarySeries, rect.width, rect.height - (showLegend ? 40 : 0)); + } else { + drawCartesianChart(ctx, visibleSeries, padding, chartWidth, chartHeight, showGrid); + } + + // Draw legend + if (showLegend) { + drawLegend(ctx, visibleSeries, rect.width, rect.height); + } + }, [series, height, showLegend, showGrid]); + + const drawPieChart = (ctx: CanvasRenderingContext2D, series: ChartSeries, width: number, height: number) => { + const centerX = width / 2; + const centerY = height / 2; + const radius = Math.min(width, height) / 3; + const innerRadius = series.type === 'doughnut' ? radius * 0.6 : 0; + + const total = series.data.reduce((sum, point) => sum + point.y, 0); + let startAngle = -Math.PI / 2; + + series.data.forEach((point, index) => { + const sliceAngle = (point.y / total) * 2 * Math.PI; + const color = point.color || series.color || getDefaultColor(index); + + // Draw slice + ctx.fillStyle = color; + ctx.beginPath(); + ctx.moveTo(centerX, centerY); + ctx.arc(centerX, centerY, radius, startAngle, startAngle + sliceAngle); + if (innerRadius > 0) { + ctx.arc(centerX, centerY, innerRadius, startAngle + sliceAngle, startAngle, true); + } + ctx.closePath(); + ctx.fill(); + + // Draw slice border + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 2; + ctx.stroke(); + + // Draw labels + const labelAngle = startAngle + sliceAngle / 2; + const labelRadius = radius + 20; + const labelX = centerX + Math.cos(labelAngle) * labelRadius; + const labelY = centerY + Math.sin(labelAngle) * labelRadius; + + ctx.fillStyle = '#374151'; + ctx.font = '12px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(point.label || point.x.toString(), labelX, labelY); + + const percentage = ((point.y / total) * 100).toFixed(1); + ctx.fillText(`${percentage}%`, labelX, labelY + 15); + + startAngle += sliceAngle; + }); + + // Draw center label for doughnut + if (series.type === 'doughnut') { + ctx.fillStyle = '#374151'; + ctx.font = 'bold 16px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(total.toString(), centerX, centerY - 5); + ctx.font = '12px sans-serif'; + ctx.fillText('Total', centerX, centerY + 10); + } + }; + + const drawCartesianChart = ( + ctx: CanvasRenderingContext2D, + seriesList: ChartSeries[], + padding: number, + chartWidth: number, + chartHeight: number, + showGrid: boolean + ) => { + // Get all data points to determine scales + const allData = seriesList.flatMap(s => s.data); + const maxY = Math.max(...allData.map(d => d.y)); + const minY = Math.min(0, Math.min(...allData.map(d => d.y))); + + // Draw grid + if (showGrid) { + ctx.strokeStyle = '#e2e8f0'; + ctx.lineWidth = 1; + + // Horizontal grid lines + for (let i = 0; i <= 5; i++) { + const y = padding + (i * chartHeight) / 5; + ctx.beginPath(); + ctx.moveTo(padding, y); + ctx.lineTo(padding + chartWidth, y); + ctx.stroke(); + + // Y-axis labels + const value = maxY - (i * (maxY - minY)) / 5; + ctx.fillStyle = '#64748b'; + ctx.font = '12px sans-serif'; + ctx.textAlign = 'right'; + ctx.fillText(Math.round(value).toString(), padding - 10, y + 4); + } + } + + // Draw axes + ctx.strokeStyle = '#374151'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(padding, padding); + ctx.lineTo(padding, padding + chartHeight); + ctx.lineTo(padding + chartWidth, padding + chartHeight); + ctx.stroke(); + + // Draw each series + seriesList.forEach((series, seriesIndex) => { + ctx.strokeStyle = series.color || getDefaultColor(seriesIndex); + ctx.fillStyle = series.color || getDefaultColor(seriesIndex); + + if (series.type === 'line') { + drawLineChart(ctx, series, padding, chartWidth, chartHeight, maxY, minY); + } else if (series.type === 'bar') { + drawBarChart(ctx, series, padding, chartWidth, chartHeight, maxY, minY, seriesIndex, seriesList.length); + } else if (series.type === 'area') { + drawAreaChart(ctx, series, padding, chartWidth, chartHeight, maxY, minY); + } + }); + }; + + const drawLineChart = ( + ctx: CanvasRenderingContext2D, + series: ChartSeries, + padding: number, + chartWidth: number, + chartHeight: number, + maxY: number, + minY: number + ) => { + if (series.data.length === 0) return; + + ctx.lineWidth = 3; + ctx.beginPath(); + + series.data.forEach((point, index) => { + const x = padding + (index * chartWidth) / (series.data.length - 1 || 1); + const y = padding + chartHeight - ((point.y - minY) / (maxY - minY)) * chartHeight; + + if (index === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + + // Draw points + ctx.fillStyle = ctx.strokeStyle; + ctx.beginPath(); + ctx.arc(x, y, 4, 0, 2 * Math.PI); + ctx.fill(); + }); + + ctx.stroke(); + }; + + const drawBarChart = ( + ctx: CanvasRenderingContext2D, + series: ChartSeries, + padding: number, + chartWidth: number, + chartHeight: number, + maxY: number, + minY: number, + seriesIndex: number, + totalSeries: number + ) => { + const barGroupWidth = chartWidth / series.data.length; + const barWidth = barGroupWidth / totalSeries - 4; + + series.data.forEach((point, index) => { + const x = padding + index * barGroupWidth + seriesIndex * (barWidth + 2) + 2; + const barHeight = ((point.y - minY) / (maxY - minY)) * chartHeight; + const y = padding + chartHeight - barHeight; + + ctx.fillRect(x, y, barWidth, barHeight); + + // Draw value labels + ctx.fillStyle = '#374151'; + ctx.font = '12px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(point.y.toString(), x + barWidth / 2, y - 5); + + // Draw x-axis labels + if (seriesIndex === 0) { + ctx.fillText( + point.label || point.x.toString(), + padding + index * barGroupWidth + barGroupWidth / 2, + padding + chartHeight + 20 + ); + } + }); + }; + + const drawAreaChart = ( + ctx: CanvasRenderingContext2D, + series: ChartSeries, + padding: number, + chartWidth: number, + chartHeight: number, + maxY: number, + minY: number + ) => { + if (series.data.length === 0) return; + + // Create gradient + const gradient = ctx.createLinearGradient(0, padding, 0, padding + chartHeight); + gradient.addColorStop(0, `${series.color}40`); + gradient.addColorStop(1, `${series.color}10`); + + ctx.fillStyle = gradient; + ctx.strokeStyle = series.color; + ctx.lineWidth = 2; + + ctx.beginPath(); + + // Start from bottom left + ctx.moveTo(padding, padding + chartHeight); + + series.data.forEach((point, index) => { + const x = padding + (index * chartWidth) / (series.data.length - 1 || 1); + const y = padding + chartHeight - ((point.y - minY) / (maxY - minY)) * chartHeight; + ctx.lineTo(x, y); + }); + + // Close the path at bottom right + ctx.lineTo(padding + chartWidth, padding + chartHeight); + ctx.closePath(); + ctx.fill(); + + // Draw the line on top + ctx.beginPath(); + series.data.forEach((point, index) => { + const x = padding + (index * chartWidth) / (series.data.length - 1 || 1); + const y = padding + chartHeight - ((point.y - minY) / (maxY - minY)) * chartHeight; + + if (index === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + }); + ctx.stroke(); + }; + + const drawLegend = (ctx: CanvasRenderingContext2D, seriesList: ChartSeries[], width: number, height: number) => { + const legendY = height - 30; + let legendX = width / 2 - (seriesList.length * 80) / 2; + + seriesList.forEach((series, index) => { + // Draw legend color box + ctx.fillStyle = series.color || getDefaultColor(index); + ctx.fillRect(legendX, legendY, 12, 12); + + // Draw legend text + ctx.fillStyle = '#374151'; + ctx.font = '12px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText(series.name, legendX + 18, legendY + 9); + + legendX += Math.max(80, ctx.measureText(series.name).width + 30); + }); + }; + + const getDefaultColor = (index: number): string => { + const colors = [ + '#d97706', // orange + '#0284c7', // blue + '#16a34a', // green + '#dc2626', // red + '#7c3aed', // purple + '#0891b2', // cyan + '#ea580c', // orange variant + '#059669' // emerald + ]; + return colors[index % colors.length]; + }; + + return ( +
+ +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/analytics/production/AnalyticsWidget.tsx b/frontend/src/components/analytics/production/AnalyticsWidget.tsx new file mode 100644 index 00000000..ec3f85d5 --- /dev/null +++ b/frontend/src/components/analytics/production/AnalyticsWidget.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { Card } from '../../ui'; +import { LucideIcon } from 'lucide-react'; + +export interface AnalyticsWidgetProps { + title: string; + subtitle?: string; + icon?: LucideIcon; + loading?: boolean; + error?: string; + className?: string; + children: React.ReactNode; + actions?: React.ReactNode; +} + +export const AnalyticsWidget: React.FC = ({ + title, + subtitle, + icon: Icon, + loading = false, + error, + className = '', + children, + actions +}) => { + if (error) { + return ( + +
+
+ {Icon && ( +
+ +
+ )} +
+

{title}

+ {subtitle &&

{subtitle}

} +
+
+ {actions} +
+
+
+ {Icon && } +
+

Error: {error}

+
+
+ ); + } + + if (loading) { + return ( + +
+
+ {Icon && ( +
+ +
+ )} +
+

{title}

+ {subtitle &&

{subtitle}

} +
+
+ {actions} +
+
+
+
+
+
+
+
+
+
+
+
+ ); + } + + return ( + +
+
+ {Icon && ( +
+ +
+ )} +
+

{title}

+ {subtitle &&

{subtitle}

} +
+
+ {actions} +
+ {children} +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/analytics/production/widgets/AIInsightsWidget.tsx b/frontend/src/components/analytics/production/widgets/AIInsightsWidget.tsx new file mode 100644 index 00000000..dc5c56d5 --- /dev/null +++ b/frontend/src/components/analytics/production/widgets/AIInsightsWidget.tsx @@ -0,0 +1,382 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Brain, TrendingUp, AlertTriangle, Target, Zap, DollarSign, Clock } from 'lucide-react'; +import { AnalyticsWidget } from '../AnalyticsWidget'; +import { Badge, Button } from '../../../ui'; +import { useCurrentTenant } from '../../../../stores/tenant.store'; + +interface AIInsight { + id: string; + type: 'optimization' | 'prediction' | 'anomaly' | 'recommendation'; + priority: 'low' | 'medium' | 'high' | 'critical'; + confidence: number; // percentage + title: string; + description: string; + impact: { + type: 'cost_savings' | 'efficiency_gain' | 'quality_improvement' | 'risk_mitigation'; + value: number; + unit: 'euros' | 'percentage' | 'hours' | 'units'; + }; + actionable: boolean; + category: 'production' | 'quality' | 'maintenance' | 'energy' | 'scheduling'; + equipment?: string; + timeline: string; + status: 'new' | 'acknowledged' | 'in_progress' | 'implemented' | 'dismissed'; +} + +export const AIInsightsWidget: React.FC = () => { + const { t } = useTranslation('production'); + const currentTenant = useCurrentTenant(); + + // Mock AI insights data - replace with real AI API call + const aiInsights: AIInsight[] = [ + { + id: '1', + type: 'optimization', + priority: 'high', + confidence: 92, + title: 'Optimización de Horarios de Horneado', + description: 'Ajustar los horarios de horneado para aprovechar las tarifas eléctricas más bajas puede reducir los costos de energía en un 15%.', + impact: { + type: 'cost_savings', + value: 45, + unit: 'euros' + }, + actionable: true, + category: 'energy', + equipment: 'Horno Principal', + timeline: 'Esta semana', + status: 'new' + }, + { + id: '2', + type: 'prediction', + priority: 'medium', + confidence: 87, + title: 'Aumento de Demanda de Croissants Predicho', + description: 'Los datos meteorológicos y de eventos sugieren un aumento del 40% en la demanda de croissants este fin de semana.', + impact: { + type: 'efficiency_gain', + value: 25, + unit: 'percentage' + }, + actionable: true, + category: 'production', + timeline: 'Este fin de semana', + status: 'acknowledged' + }, + { + id: '3', + type: 'anomaly', + priority: 'critical', + confidence: 96, + title: 'Declive en Puntuación de Calidad Detectado', + description: 'La calidad del pan ha disminuido un 8% en los últimos 3 días. Verificar los niveles de humedad de la harina.', + impact: { + type: 'quality_improvement', + value: 12, + unit: 'percentage' + }, + actionable: true, + category: 'quality', + timeline: 'Inmediatamente', + status: 'in_progress' + }, + { + id: '4', + type: 'recommendation', + priority: 'medium', + confidence: 89, + title: 'Mantenimiento Preventivo Recomendado', + description: 'La Mezcladora #2 muestra patrones de desgaste temprano. Programar mantenimiento para prevenir averías.', + impact: { + type: 'risk_mitigation', + value: 8, + unit: 'hours' + }, + actionable: true, + category: 'maintenance', + equipment: 'Mezcladora A', + timeline: 'Próxima semana', + status: 'new' + }, + { + id: '5', + type: 'optimization', + priority: 'low', + confidence: 78, + title: 'Optimización de Secuencia de Productos', + description: 'Reordenar la secuencia de productos puede mejorar la eficiencia general en un 5%.', + impact: { + type: 'efficiency_gain', + value: 5, + unit: 'percentage' + }, + actionable: true, + category: 'scheduling', + timeline: 'Próximo mes', + status: 'dismissed' + } + ]; + + const getTypeIcon = (type: AIInsight['type']) => { + switch (type) { + case 'optimization': return Target; + case 'prediction': return TrendingUp; + case 'anomaly': return AlertTriangle; + case 'recommendation': return Brain; + default: return Brain; + } + }; + + const getTypeColor = (type: AIInsight['type']) => { + switch (type) { + case 'optimization': return 'text-green-600'; + case 'prediction': return 'text-blue-600'; + case 'anomaly': return 'text-red-600'; + case 'recommendation': return 'text-purple-600'; + default: return 'text-gray-600'; + } + }; + + const getPriorityBadgeVariant = (priority: AIInsight['priority']) => { + switch (priority) { + case 'critical': return 'error'; + case 'high': return 'warning'; + case 'medium': return 'info'; + case 'low': return 'default'; + default: return 'default'; + } + }; + + const getStatusBadgeVariant = (status: AIInsight['status']) => { + switch (status) { + case 'new': return 'warning'; + case 'acknowledged': return 'info'; + case 'in_progress': return 'info'; + case 'implemented': return 'success'; + case 'dismissed': return 'default'; + default: return 'default'; + } + }; + + const getImpactIcon = (type: AIInsight['impact']['type']) => { + switch (type) { + case 'cost_savings': return DollarSign; + case 'efficiency_gain': return Zap; + case 'quality_improvement': return Target; + case 'risk_mitigation': return Clock; + default: return Target; + } + }; + + const formatImpactValue = (impact: AIInsight['impact']) => { + switch (impact.unit) { + case 'euros': return `€${impact.value}`; + case 'percentage': return `${impact.value}%`; + case 'hours': return `${impact.value}h`; + case 'units': return `${impact.value} unidades`; + default: return impact.value.toString(); + } + }; + + const activeInsights = aiInsights.filter(i => i.status !== 'dismissed'); + const highPriorityInsights = activeInsights.filter(i => i.priority === 'high' || i.priority === 'critical'); + const implementedInsights = aiInsights.filter(i => i.status === 'implemented'); + const avgConfidence = activeInsights.reduce((sum, insight) => sum + insight.confidence, 0) / activeInsights.length; + + const totalPotentialSavings = aiInsights + .filter(i => i.impact.type === 'cost_savings' && i.status !== 'dismissed') + .reduce((sum, insight) => sum + insight.impact.value, 0); + + return ( + + + + + } + > +
+ {/* AI Insights Overview Stats */} +
+
+ +

{activeInsights.length}

+

{t('ai.stats.active_insights')}

+
+
+ +

{highPriorityInsights.length}

+

{t('ai.stats.high_priority')}

+
+
+
+ +
+

€{totalPotentialSavings}

+

{t('ai.stats.potential_savings')}

+
+
+
+ % +
+

{avgConfidence.toFixed(0)}%

+

{t('ai.stats.avg_confidence')}

+
+
+ + {/* AI Status */} +
+
+
+ + {t('ai.status.active')} + + + {t('ai.last_updated')} + +
+
+ + {/* High Priority Insights */} + {highPriorityInsights.length > 0 && ( +
+

+ + {t('ai.high_priority_insights')} ({highPriorityInsights.length}) +

+
+ {highPriorityInsights.slice(0, 3).map((insight) => { + const TypeIcon = getTypeIcon(insight.type); + const ImpactIcon = getImpactIcon(insight.impact.type); + + return ( +
+
+
+ +
+
+

{insight.title}

+ + {t(`ai.priority.${insight.priority}`)} + +
+

{insight.description}

+
+ {t('ai.confidence')}: {insight.confidence}% + {t('ai.timeline')}: {insight.timeline} + {insight.equipment && {t('ai.equipment')}: {insight.equipment}} +
+
+
+ + {t(`ai.status.${insight.status}`)} + +
+ + {/* Impact */} +
+
+ + + {t(`ai.impact.${insight.impact.type}`)} + +
+ + {formatImpactValue(insight.impact)} + +
+ + {insight.actionable && insight.status === 'new' && ( +
+ + +
+ )} +
+ ); + })} +
+
+ )} + + {/* All Insights */} +
+

+ + {t('ai.all_insights')} ({activeInsights.length}) +

+
+ {activeInsights.map((insight) => { + const TypeIcon = getTypeIcon(insight.type); + const ImpactIcon = getImpactIcon(insight.impact.type); + + return ( +
+
+
+ +
+
+

{insight.title}

+ + {t(`ai.priority.${insight.priority}`)} + +
+
+ {insight.confidence}% {t('ai.confidence')} + + {insight.timeline} + +
+ + {formatImpactValue(insight.impact)} +
+
+
+
+ + {t(`ai.status.${insight.status}`)} + +
+
+ ); + })} +
+
+ + {/* AI Performance Summary */} +
+
+ +
+

+ {t('ai.performance.summary')} +

+

+ {implementedInsights.length} {t('ai.performance.insights_implemented')}, + {totalPotentialSavings > 0 && ` €${totalPotentialSavings} ${t('ai.performance.in_savings_identified')}`} +

+
+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/analytics/production/widgets/CapacityUtilizationWidget.tsx b/frontend/src/components/analytics/production/widgets/CapacityUtilizationWidget.tsx new file mode 100644 index 00000000..2bb8d259 --- /dev/null +++ b/frontend/src/components/analytics/production/widgets/CapacityUtilizationWidget.tsx @@ -0,0 +1,253 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { BarChart3, TrendingUp, AlertTriangle, Zap } from 'lucide-react'; +import { AnalyticsWidget } from '../AnalyticsWidget'; +import { AnalyticsChart, ChartSeries } from '../AnalyticsChart'; +import { Button, ProgressBar } from '../../../ui'; +import { useProductionDashboard, useCapacityStatus } from '../../../../api/hooks/production'; +import { useCurrentTenant } from '../../../../stores/tenant.store'; + +interface CapacityData { + resource: string; + utilization: number; + capacity: number; + allocated: number; + available: number; + type: 'oven' | 'mixer' | 'staff' | 'station'; +} + +export const CapacityUtilizationWidget: React.FC = () => { + const { t } = useTranslation('production'); + const currentTenant = useCurrentTenant(); + const tenantId = currentTenant?.id || ''; + const today = new Date().toISOString().split('T')[0]; + + const { data: dashboard, isLoading: dashboardLoading, error: dashboardError } = useProductionDashboard(tenantId); + const { data: capacity, isLoading: capacityLoading, error: capacityError } = useCapacityStatus(tenantId, today); + + const isLoading = dashboardLoading || capacityLoading; + const error = dashboardError?.message || capacityError?.message; + + const overallUtilization = dashboard?.capacity_utilization || 0; + + // Mock capacity data by resource type (in real implementation, this would be processed from capacity API) + const getCapacityByResource = (): CapacityData[] => { + return [ + { + resource: t('equipment.oven_capacity'), + utilization: 89, + capacity: 24, + allocated: 21, + available: 3, + type: 'oven' + }, + { + resource: t('equipment.mixer_capacity'), + utilization: 67, + capacity: 12, + allocated: 8, + available: 4, + type: 'mixer' + }, + { + resource: t('schedule.staff_capacity'), + utilization: 85, + capacity: 8, + allocated: 7, + available: 1, + type: 'staff' + }, + { + resource: t('schedule.work_stations'), + utilization: 75, + capacity: 6, + allocated: 4, + available: 2, + type: 'station' + } + ]; + }; + + const capacityData = getCapacityByResource(); + + // Heatmap data for hourly utilization + const getHourlyUtilizationData = (): ChartSeries[] => { + const hours = Array.from({ length: 12 }, (_, i) => i + 6); // 6 AM to 6 PM + const mockData = hours.map(hour => ({ + x: `${hour}:00`, + y: Math.max(20, Math.min(100, 50 + Math.sin(hour / 2) * 30 + Math.random() * 20)), + label: `${hour}:00` + })); + + return [ + { + id: 'hourly-utilization', + name: t('insights.hourly_utilization'), + type: 'area', + color: '#d97706', + data: mockData + } + ]; + }; + + const getUtilizationStatus = (utilization: number) => { + if (utilization >= 95) return { color: 'text-red-600', status: 'critical', bgColor: 'bg-red-100 dark:bg-red-900/20' }; + if (utilization >= 85) return { color: 'text-orange-600', status: 'high', bgColor: 'bg-orange-100 dark:bg-orange-900/20' }; + if (utilization >= 70) return { color: 'text-green-600', status: 'optimal', bgColor: 'bg-green-100 dark:bg-green-900/20' }; + return { color: 'text-blue-600', status: 'low', bgColor: 'bg-blue-100 dark:bg-blue-900/20' }; + }; + + const getResourceIcon = (type: CapacityData['type']) => { + switch (type) { + case 'oven': return '🔥'; + case 'mixer': return '🥄'; + case 'staff': return '👥'; + case 'station': return '🏭'; + default: return '📊'; + } + }; + + const getProgressBarVariant = (utilization: number) => { + if (utilization >= 95) return 'error'; + if (utilization >= 85) return 'warning'; + if (utilization >= 70) return 'success'; + return 'info'; + }; + + const overallStatus = getUtilizationStatus(overallUtilization); + + return ( + + + {t('actions.optimize')} + + } + > +
+ {/* Overall Utilization */} +
+
+ + + {overallUtilization.toFixed(1)}% + +
+
+ + {t(`status.${overallStatus.status}`)} + +
+
+ + {/* Resource Breakdown */} +
+

+ {t('insights.resource_breakdown')} +

+
+ {capacityData.map((resource, index) => { + const status = getUtilizationStatus(resource.utilization); + + return ( +
+
+
+ {getResourceIcon(resource.type)} + + {resource.resource} + +
+ + {resource.utilization}% + +
+ + + +
+ + {resource.allocated}/{resource.capacity} {t('common.allocated')} + + + {resource.available} {t('common.available')} + +
+
+ ); + })} +
+
+ + {/* Hourly Heatmap */} +
+

+ {t('insights.hourly_utilization_pattern')} +

+ +
+ + {/* Alerts & Recommendations */} +
+ {capacityData.some(r => r.utilization >= 95) && ( +
+ +
+

+ {t('alerts.capacity_critical')} +

+

+ {t('alerts.capacity_critical_description')} +

+
+
+ )} + + {capacityData.some(r => r.utilization >= 85 && r.utilization < 95) && ( +
+ +
+

+ {t('alerts.capacity_high')} +

+

+ {t('alerts.capacity_high_description')} +

+
+
+ )} +
+ + {/* Peak Hours Summary */} +
+
+
+

10:00 - 12:00

+

{t('insights.peak_hours')}

+
+
+

14:00 - 16:00

+

{t('insights.off_peak_optimal')}

+
+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/analytics/production/widgets/CostPerUnitWidget.tsx b/frontend/src/components/analytics/production/widgets/CostPerUnitWidget.tsx new file mode 100644 index 00000000..99af0b44 --- /dev/null +++ b/frontend/src/components/analytics/production/widgets/CostPerUnitWidget.tsx @@ -0,0 +1,296 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { DollarSign, TrendingUp, TrendingDown, PieChart } from 'lucide-react'; +import { AnalyticsWidget } from '../AnalyticsWidget'; +import { AnalyticsChart, ChartSeries } from '../AnalyticsChart'; +import { Button } from '../../../ui'; +import { useActiveBatches } from '../../../../api/hooks/production'; +import { useCurrentTenant } from '../../../../stores/tenant.store'; + +interface ProductCostData { + product: string; + estimatedCost: number; + actualCost: number; + variance: number; + variancePercent: number; + units: number; + costPerUnit: number; +} + +export const CostPerUnitWidget: React.FC = () => { + const { t } = useTranslation('production'); + const currentTenant = useCurrentTenant(); + const tenantId = currentTenant?.id || ''; + + const { data: batchesData, isLoading, error } = useActiveBatches(tenantId); + const batches = batchesData?.batches || []; + + // Process batches to calculate cost per unit data + const getProductCostData = (): ProductCostData[] => { + const productMap = new Map(); + + batches.forEach(batch => { + const key = batch.product_name; + if (!productMap.has(key)) { + productMap.set(key, { + product: batch.product_name, + estimatedCosts: [], + actualCosts: [], + quantities: [] + }); + } + + const data = productMap.get(key); + if (batch.estimated_cost) data.estimatedCosts.push(batch.estimated_cost); + if (batch.actual_cost) data.actualCosts.push(batch.actual_cost); + if (batch.actual_quantity || batch.planned_quantity) { + data.quantities.push(batch.actual_quantity || batch.planned_quantity); + } + }); + + return Array.from(productMap.values()).map(data => { + const avgEstimated = data.estimatedCosts.reduce((a: number, b: number) => a + b, 0) / (data.estimatedCosts.length || 1); + const avgActual = data.actualCosts.reduce((a: number, b: number) => a + b, 0) / (data.actualCosts.length || 1); + const totalUnits = data.quantities.reduce((a: number, b: number) => a + b, 0); + const variance = avgActual - avgEstimated; + const variancePercent = avgEstimated > 0 ? (variance / avgEstimated) * 100 : 0; + const costPerUnit = totalUnits > 0 ? avgActual / totalUnits : avgActual; + + return { + product: data.product, + estimatedCost: avgEstimated, + actualCost: avgActual, + variance, + variancePercent, + units: totalUnits, + costPerUnit + }; + }).filter(item => item.actualCost > 0); + }; + + const productCostData = getProductCostData(); + const totalCosts = productCostData.reduce((sum, item) => sum + item.actualCost, 0); + const averageCostPerUnit = productCostData.length > 0 + ? productCostData.reduce((sum, item) => sum + item.costPerUnit, 0) / productCostData.length + : 0; + + // Create chart data for cost comparison + const getCostComparisonChartData = (): ChartSeries[] => { + if (productCostData.length === 0) return []; + + const estimatedData = productCostData.map(item => ({ + x: item.product, + y: item.estimatedCost, + label: item.product + })); + + const actualData = productCostData.map(item => ({ + x: item.product, + y: item.actualCost, + label: item.product + })); + + return [ + { + id: 'estimated-cost', + name: t('cost.estimated_cost'), + type: 'bar', + color: '#94a3b8', + data: estimatedData + }, + { + id: 'actual-cost', + name: t('cost.actual_cost'), + type: 'bar', + color: '#d97706', + data: actualData + } + ]; + }; + + // Create pie chart for cost distribution + const getCostDistributionChartData = (): ChartSeries[] => { + if (productCostData.length === 0) return []; + + const pieData = productCostData.map((item, index) => ({ + x: item.product, + y: item.actualCost, + label: item.product, + color: getProductColor(index) + })); + + return [ + { + id: 'cost-distribution', + name: t('cost.cost_distribution'), + type: 'pie', + color: '#d97706', + data: pieData + } + ]; + }; + + const getProductColor = (index: number): string => { + const colors = ['#d97706', '#0284c7', '#16a34a', '#dc2626', '#7c3aed', '#0891b2']; + return colors[index % colors.length]; + }; + + const getVarianceStatus = (variancePercent: number) => { + if (Math.abs(variancePercent) <= 5) return { color: 'text-green-600', status: 'on_target' }; + if (variancePercent > 5) return { color: 'text-red-600', status: 'over_budget' }; + return { color: 'text-blue-600', status: 'under_budget' }; + }; + + return ( + + + {t('cost.view_breakdown')} + + } + > +
+ {/* Overall Metrics */} +
+
+
+ + + €{averageCostPerUnit.toFixed(2)} + +
+

{t('cost.average_cost_per_unit')}

+
+
+
+ + + €{totalCosts.toFixed(0)} + +
+

{t('cost.total_production_cost')}

+
+
+ + {productCostData.length === 0 ? ( +
+ +

{t('cost.no_cost_data_available')}

+
+ ) : ( + <> + {/* Cost Comparison Chart */} +
+

+ {t('cost.estimated_vs_actual')} +

+ +
+ + {/* Product Cost Details */} +
+

+ {t('cost.product_cost_breakdown')} +

+
+ {productCostData.map((item, index) => { + const varianceStatus = getVarianceStatus(item.variancePercent); + const VarianceIcon = item.variance >= 0 ? TrendingUp : TrendingDown; + + return ( +
+
+
+
+ + {item.product} + +
+ + €{item.costPerUnit.toFixed(2)} + +
+ +
+
+

{t('cost.estimated')}

+

+ €{item.estimatedCost.toFixed(2)} +

+
+
+

{t('cost.actual')}

+

+ €{item.actualCost.toFixed(2)} +

+
+
+

{t('cost.variance')}

+
+ + + {item.variancePercent > 0 ? '+' : ''}{item.variancePercent.toFixed(1)}% + +
+
+
+ +
+ {t('cost.units_produced')}: {item.units} +
+
+ ); + })} +
+
+ + {/* Cost Distribution Pie Chart */} +
+

+ {t('cost.cost_distribution')} +

+ +
+ + {/* Cost Optimization Insights */} +
+
+ +
+

+ {t('cost.optimization_opportunity')} +

+

+ {productCostData.some(item => item.variancePercent > 10) + ? t('cost.high_variance_detected') + : t('cost.costs_within_expected_range') + } +

+
+
+
+ + )} +
+ + ); +}; \ No newline at end of file diff --git a/frontend/src/components/analytics/production/widgets/EquipmentEfficiencyWidget.tsx b/frontend/src/components/analytics/production/widgets/EquipmentEfficiencyWidget.tsx new file mode 100644 index 00000000..0c1da4c1 --- /dev/null +++ b/frontend/src/components/analytics/production/widgets/EquipmentEfficiencyWidget.tsx @@ -0,0 +1,389 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Zap, TrendingUp, TrendingDown, BarChart3, Target } from 'lucide-react'; +import { AnalyticsWidget } from '../AnalyticsWidget'; +import { AnalyticsChart, ChartSeries } from '../AnalyticsChart'; +import { Button } from '../../../ui'; +import { useCurrentTenant } from '../../../../stores/tenant.store'; + +interface EquipmentEfficiencyData { + equipmentId: string; + equipmentName: string; + type: 'oven' | 'mixer' | 'proofer' | 'packaging'; + currentEfficiency: number; + targetEfficiency: number; + trend: 'up' | 'down' | 'stable'; + energyConsumption: number; // kWh + productionOutput: number; // units per hour + oee: number; // Overall Equipment Effectiveness + availability: number; + performance: number; + quality: number; + downtimeMinutes: number; + weeklyData: Array<{ + day: string; + efficiency: number; + energyConsumption: number; + output: number; + }>; +} + +export const EquipmentEfficiencyWidget: React.FC = () => { + const { t } = useTranslation('production'); + const currentTenant = useCurrentTenant(); + + // Mock efficiency data - replace with real API call + const efficiencyData: EquipmentEfficiencyData[] = [ + { + equipmentId: '1', + equipmentName: 'Horno Principal', + type: 'oven', + currentEfficiency: 94, + targetEfficiency: 95, + trend: 'up', + energyConsumption: 45.2, + productionOutput: 120, + oee: 89, + availability: 95, + performance: 96, + quality: 98, + downtimeMinutes: 15, + weeklyData: [ + { day: 'Lun', efficiency: 92, energyConsumption: 44, output: 118 }, + { day: 'Mar', efficiency: 93, energyConsumption: 45, output: 119 }, + { day: 'Mié', efficiency: 94, energyConsumption: 45.2, output: 120 }, + { day: 'Jue', efficiency: 94, energyConsumption: 45.1, output: 121 }, + { day: 'Vie', efficiency: 95, energyConsumption: 45.3, output: 122 }, + { day: 'Sáb', efficiency: 94, energyConsumption: 44.8, output: 119 }, + { day: 'Dom', efficiency: 93, energyConsumption: 44.5, output: 117 } + ] + }, + { + equipmentId: '2', + equipmentName: 'Mezcladora A', + type: 'mixer', + currentEfficiency: 87, + targetEfficiency: 90, + trend: 'down', + energyConsumption: 12.5, + productionOutput: 85, + oee: 82, + availability: 92, + performance: 89, + quality: 95, + downtimeMinutes: 45, + weeklyData: [ + { day: 'Lun', efficiency: 89, energyConsumption: 12, output: 88 }, + { day: 'Mar', efficiency: 88, energyConsumption: 12.2, output: 87 }, + { day: 'Mié', efficiency: 87, energyConsumption: 12.5, output: 85 }, + { day: 'Jue', efficiency: 86, energyConsumption: 12.8, output: 84 }, + { day: 'Vie', efficiency: 87, energyConsumption: 12.6, output: 86 }, + { day: 'Sáb', efficiency: 88, energyConsumption: 12.3, output: 87 }, + { day: 'Dom', efficiency: 87, energyConsumption: 12.4, output: 85 } + ] + }, + { + equipmentId: '3', + equipmentName: 'Cámara de Fermentación 1', + type: 'proofer', + currentEfficiency: 96, + targetEfficiency: 95, + trend: 'stable', + energyConsumption: 8.3, + productionOutput: 95, + oee: 94, + availability: 98, + performance: 97, + quality: 99, + downtimeMinutes: 5, + weeklyData: [ + { day: 'Lun', efficiency: 96, energyConsumption: 8.2, output: 94 }, + { day: 'Mar', efficiency: 96, energyConsumption: 8.3, output: 95 }, + { day: 'Mié', efficiency: 96, energyConsumption: 8.3, output: 95 }, + { day: 'Jue', efficiency: 97, energyConsumption: 8.4, output: 96 }, + { day: 'Vie', efficiency: 96, energyConsumption: 8.3, output: 95 }, + { day: 'Sáb', efficiency: 95, energyConsumption: 8.2, output: 94 }, + { day: 'Dom', efficiency: 96, energyConsumption: 8.3, output: 95 } + ] + } + ]; + + const getTrendIcon = (trend: EquipmentEfficiencyData['trend']) => { + switch (trend) { + case 'up': return TrendingUp; + case 'down': return TrendingDown; + case 'stable': return BarChart3; + default: return BarChart3; + } + }; + + const getTrendColor = (trend: EquipmentEfficiencyData['trend']) => { + switch (trend) { + case 'up': return 'text-green-600'; + case 'down': return 'text-red-600'; + case 'stable': return 'text-blue-600'; + default: return 'text-gray-600'; + } + }; + + const getEfficiencyStatus = (current: number, target: number) => { + const percentage = (current / target) * 100; + if (percentage >= 100) return { status: 'excellent', color: 'text-green-600', bgColor: 'bg-green-100 dark:bg-green-900/20' }; + if (percentage >= 90) return { status: 'good', color: 'text-blue-600', bgColor: 'bg-blue-100 dark:bg-blue-900/20' }; + if (percentage >= 80) return { status: 'warning', color: 'text-orange-600', bgColor: 'bg-orange-100 dark:bg-orange-900/20' }; + return { status: 'critical', color: 'text-red-600', bgColor: 'bg-red-100 dark:bg-red-900/20' }; + }; + + // Calculate overall metrics + const avgEfficiency = efficiencyData.reduce((sum, item) => sum + item.currentEfficiency, 0) / efficiencyData.length; + const avgOEE = efficiencyData.reduce((sum, item) => sum + item.oee, 0) / efficiencyData.length; + const totalEnergyConsumption = efficiencyData.reduce((sum, item) => sum + item.energyConsumption, 0); + const totalDowntime = efficiencyData.reduce((sum, item) => sum + item.downtimeMinutes, 0); + + // Create efficiency comparison chart + const getEfficiencyComparisonChartData = (): ChartSeries[] => { + const currentData = efficiencyData.map(item => ({ + x: item.equipmentName, + y: item.currentEfficiency, + label: `${item.equipmentName}: ${item.currentEfficiency}%` + })); + + const targetData = efficiencyData.map(item => ({ + x: item.equipmentName, + y: item.targetEfficiency, + label: `Target: ${item.targetEfficiency}%` + })); + + return [ + { + id: 'current-efficiency', + name: t('equipment.efficiency.current'), + type: 'bar', + color: '#16a34a', + data: currentData + }, + { + id: 'target-efficiency', + name: t('equipment.efficiency.target'), + type: 'bar', + color: '#d97706', + data: targetData + } + ]; + }; + + // Create weekly efficiency trend chart + const getWeeklyTrendChartData = (): ChartSeries[] => { + return efficiencyData.map((equipment, index) => ({ + id: `weekly-${equipment.equipmentId}`, + name: equipment.equipmentName, + type: 'line', + color: ['#16a34a', '#3b82f6', '#f59e0b'][index % 3], + data: equipment.weeklyData.map(day => ({ + x: day.day, + y: day.efficiency, + label: `${day.day}: ${day.efficiency}%` + })) + })); + }; + + // Create OEE breakdown chart + const getOEEBreakdownChartData = (): ChartSeries[] => { + const avgAvailability = efficiencyData.reduce((sum, item) => sum + item.availability, 0) / efficiencyData.length; + const avgPerformance = efficiencyData.reduce((sum, item) => sum + item.performance, 0) / efficiencyData.length; + const avgQuality = efficiencyData.reduce((sum, item) => sum + item.quality, 0) / efficiencyData.length; + + return [ + { + id: 'oee-breakdown', + name: 'OEE Components', + type: 'doughnut', + color: '#3b82f6', + data: [ + { x: t('equipment.oee.availability'), y: avgAvailability, label: `${t('equipment.oee.availability')}: ${avgAvailability.toFixed(1)}%` }, + { x: t('equipment.oee.performance'), y: avgPerformance, label: `${t('equipment.oee.performance')}: ${avgPerformance.toFixed(1)}%` }, + { x: t('equipment.oee.quality'), y: avgQuality, label: `${t('equipment.oee.quality')}: ${avgQuality.toFixed(1)}%` } + ] + } + ]; + }; + + return ( + + + +
+ } + > +
+ {/* Efficiency Overview Stats */} +
+
+ +

{avgEfficiency.toFixed(1)}%

+

{t('equipment.efficiency.average')}

+
+
+ +

{avgOEE.toFixed(1)}%

+

{t('equipment.oee.overall')}

+
+
+
+ kWh +
+

{totalEnergyConsumption.toFixed(1)}

+

{t('equipment.energy_consumption')}

+
+
+
+ min +
+

{totalDowntime}

+

{t('equipment.downtime.total')}

+
+
+ + {/* Equipment Efficiency List */} +
+

+ + {t('equipment.efficiency.by_equipment')} +

+
+ {efficiencyData.map((item) => { + const TrendIcon = getTrendIcon(item.trend); + const trendColor = getTrendColor(item.trend); + const status = getEfficiencyStatus(item.currentEfficiency, item.targetEfficiency); + + return ( +
+
+
+ +
+

{item.equipmentName}

+
+ OEE: {item.oee}% + + {item.energyConsumption} kWh + + {item.productionOutput} {t('equipment.units_per_hour')} +
+
+
+
+ +
+
+ {item.currentEfficiency}% +
+
+ {t('equipment.efficiency.target')}: {item.targetEfficiency}% +
+
+
+
+ + {/* Progress bar */} +
+
+
+ + {/* OEE Components */} +
+
+

{item.availability}%

+

{t('equipment.oee.availability')}

+
+
+

{item.performance}%

+

{t('equipment.oee.performance')}

+
+
+

{item.quality}%

+

{t('equipment.oee.quality')}

+
+
+
+ ); + })} +
+
+ + {/* Efficiency Charts */} +
+ {/* Current vs Target Efficiency */} +
+

+ {t('equipment.efficiency.current_vs_target')} +

+ +
+ + {/* OEE Breakdown */} +
+

+ {t('equipment.oee.breakdown')} +

+ +
+
+ + {/* Weekly Efficiency Trends */} +
+

+ {t('equipment.efficiency.weekly_trends')} +

+ +
+ + {/* Efficiency Recommendations */} +
+
+ +
+

+ {t('equipment.efficiency.recommendations')} +

+
    +
  • • {t('equipment.efficiency.recommendation_1')}: Mezcladora A {t('equipment.efficiency.needs_maintenance')}
  • +
  • • {t('equipment.efficiency.recommendation_2')}: {t('equipment.efficiency.optimize_energy_consumption')}
  • +
  • • {t('equipment.efficiency.recommendation_3')}: {t('equipment.efficiency.schedule_preventive_maintenance')}
  • +
+
+
+
+
+ + ); +}; \ No newline at end of file diff --git a/frontend/src/components/analytics/production/widgets/EquipmentStatusWidget.tsx b/frontend/src/components/analytics/production/widgets/EquipmentStatusWidget.tsx new file mode 100644 index 00000000..a5e6f9fd --- /dev/null +++ b/frontend/src/components/analytics/production/widgets/EquipmentStatusWidget.tsx @@ -0,0 +1,285 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Settings, AlertTriangle, CheckCircle, Clock, Zap } from 'lucide-react'; +import { AnalyticsWidget } from '../AnalyticsWidget'; +import { AnalyticsChart, ChartSeries } from '../AnalyticsChart'; +import { Badge, Button } from '../../../ui'; +import { useCurrentTenant } from '../../../../stores/tenant.store'; + +interface EquipmentStatus { + id: string; + name: string; + type: 'oven' | 'mixer' | 'proofer' | 'packaging'; + status: 'operational' | 'warning' | 'maintenance' | 'down'; + efficiency: number; + temperature?: number; + uptime: number; + lastMaintenance: string; + nextMaintenance: string; + alertCount: number; +} + +export const EquipmentStatusWidget: React.FC = () => { + const { t } = useTranslation('production'); + const currentTenant = useCurrentTenant(); + + // Mock equipment data - replace with real API call + const equipment: EquipmentStatus[] = [ + { + id: '1', + name: 'Horno Principal', + type: 'oven', + status: 'operational', + efficiency: 94, + temperature: 180, + uptime: 98.5, + lastMaintenance: '2024-01-15', + nextMaintenance: '2024-02-15', + alertCount: 0 + }, + { + id: '2', + name: 'Mezcladora A', + type: 'mixer', + status: 'warning', + efficiency: 87, + uptime: 85.2, + lastMaintenance: '2024-01-10', + nextMaintenance: '2024-02-10', + alertCount: 2 + }, + { + id: '3', + name: 'Cámara de Fermentación 1', + type: 'proofer', + status: 'operational', + efficiency: 96, + temperature: 28, + uptime: 99.1, + lastMaintenance: '2024-01-20', + nextMaintenance: '2024-02-20', + alertCount: 0 + }, + { + id: '4', + name: 'Empaquetadora', + type: 'packaging', + status: 'maintenance', + efficiency: 0, + uptime: 0, + lastMaintenance: '2024-01-25', + nextMaintenance: '2024-01-26', + alertCount: 1 + } + ]; + + const getStatusColor = (status: EquipmentStatus['status']) => { + switch (status) { + case 'operational': return 'text-green-600'; + case 'warning': return 'text-orange-600'; + case 'maintenance': return 'text-blue-600'; + case 'down': return 'text-red-600'; + default: return 'text-gray-600'; + } + }; + + const getStatusBadgeVariant = (status: EquipmentStatus['status']) => { + switch (status) { + case 'operational': return 'success'; + case 'warning': return 'warning'; + case 'maintenance': return 'info'; + case 'down': return 'error'; + default: return 'default'; + } + }; + + const getStatusIcon = (status: EquipmentStatus['status']) => { + switch (status) { + case 'operational': return CheckCircle; + case 'warning': return AlertTriangle; + case 'maintenance': return Clock; + case 'down': return AlertTriangle; + default: return Settings; + } + }; + + const operationalCount = equipment.filter(e => e.status === 'operational').length; + const warningCount = equipment.filter(e => e.status === 'warning').length; + const maintenanceCount = equipment.filter(e => e.status === 'maintenance').length; + const downCount = equipment.filter(e => e.status === 'down').length; + const avgEfficiency = equipment.reduce((sum, e) => sum + e.efficiency, 0) / equipment.length; + + // Equipment efficiency chart data + const getEfficiencyChartData = (): ChartSeries[] => { + const data = equipment.map(item => ({ + x: item.name, + y: item.efficiency, + label: `${item.name}: ${item.efficiency}%` + })); + + return [ + { + id: 'equipment-efficiency', + name: t('equipment.efficiency'), + type: 'bar', + color: '#16a34a', + data + } + ]; + }; + + // Status distribution chart + const getStatusDistributionChartData = (): ChartSeries[] => { + const statusData = [ + { x: t('equipment.status.operational'), y: operationalCount, label: `${operationalCount} ${t('equipment.status.operational')}` }, + { x: t('equipment.status.warning'), y: warningCount, label: `${warningCount} ${t('equipment.status.warning')}` }, + { x: t('equipment.status.maintenance'), y: maintenanceCount, label: `${maintenanceCount} ${t('equipment.status.maintenance')}` }, + { x: t('equipment.status.down'), y: downCount, label: `${downCount} ${t('equipment.status.down')}` } + ].filter(item => item.y > 0); + + return [ + { + id: 'status-distribution', + name: t('equipment.status.distribution'), + type: 'doughnut', + color: '#3b82f6', + data: statusData + } + ]; + }; + + return ( + + + +
+ } + > +
+ {/* Equipment Overview Stats */} +
+
+ +

{operationalCount}

+

{t('equipment.stats.operational')}

+
+
+ +

{warningCount}

+

{t('equipment.stats.needs_attention')}

+
+
+
+ {avgEfficiency.toFixed(0)}% +
+

{avgEfficiency.toFixed(1)}%

+

{t('equipment.stats.avg_efficiency')}

+
+
+ +

+ {equipment.reduce((sum, e) => sum + e.alertCount, 0)} +

+

{t('equipment.stats.alerts')}

+
+
+ + {/* Equipment List */} +
+

+ + {t('equipment.status.equipment_list')} ({equipment.length}) +

+
+ {equipment.map((item) => { + const StatusIcon = getStatusIcon(item.status); + return ( +
+
+
+ +
+

{item.name}

+
+ {t('equipment.efficiency')}: {item.efficiency}% + {t('equipment.uptime')}: {item.uptime}% + {item.temperature && ( + {t('equipment.temperature')}: {item.temperature}°C + )} + {item.alertCount > 0 && ( + {item.alertCount} {t('equipment.unread_alerts')} + )} +
+
+
+ + {t(`equipment.status.${item.status}`)} + +
+
+ ); + })} +
+
+ + {/* Equipment Charts */} +
+ {/* Efficiency Chart */} +
+

+ {t('equipment.efficiency')} {t('stats.by_equipment')} +

+ +
+ + {/* Status Distribution */} +
+

+ {t('equipment.status.distribution')} +

+ +
+
+ + {/* Maintenance Alerts */} + {equipment.some(e => e.alertCount > 0 || e.status === 'maintenance') && ( +
+
+ +
+

+ {t('equipment.alerts.maintenance_required')} +

+

+ {equipment.filter(e => e.status === 'maintenance').length} {t('equipment.alerts.equipment_in_maintenance')}, + {equipment.reduce((sum, e) => sum + e.alertCount, 0)} {t('equipment.alerts.active_alerts')} +

+
+
+
+ )} +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/analytics/production/widgets/LiveBatchTrackerWidget.tsx b/frontend/src/components/analytics/production/widgets/LiveBatchTrackerWidget.tsx new file mode 100644 index 00000000..ef0f7c58 --- /dev/null +++ b/frontend/src/components/analytics/production/widgets/LiveBatchTrackerWidget.tsx @@ -0,0 +1,259 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Activity, Clock, AlertCircle, CheckCircle, Play, Pause } from 'lucide-react'; +import { AnalyticsWidget } from '../AnalyticsWidget'; +import { Badge, ProgressBar, Button } from '../../../ui'; +import { useActiveBatches } from '../../../../api/hooks/production'; +import { useCurrentTenant } from '../../../../stores/tenant.store'; +import { ProductionStatus } from '../../../../api/types/production'; + +export const LiveBatchTrackerWidget: React.FC = () => { + const { t } = useTranslation('production'); + const currentTenant = useCurrentTenant(); + const tenantId = currentTenant?.id || ''; + + const { data: batchesData, isLoading, error } = useActiveBatches(tenantId); + const batches = batchesData?.batches || []; + + const getStatusIcon = (status: ProductionStatus) => { + switch (status) { + case ProductionStatus.COMPLETED: + return CheckCircle; + case ProductionStatus.IN_PROGRESS: + return Play; + case ProductionStatus.ON_HOLD: + return Pause; + case ProductionStatus.FAILED: + return AlertCircle; + default: + return Clock; + } + }; + + const getStatusBadgeVariant = (status: ProductionStatus) => { + switch (status) { + case ProductionStatus.COMPLETED: + return 'success'; + case ProductionStatus.IN_PROGRESS: + return 'info'; + case ProductionStatus.PENDING: + return 'warning'; + case ProductionStatus.ON_HOLD: + return 'warning'; + case ProductionStatus.CANCELLED: + case ProductionStatus.FAILED: + return 'error'; + default: + return 'default'; + } + }; + + const calculateProgress = (batch: any) => { + if (batch.status === ProductionStatus.COMPLETED) return 100; + if (batch.status === ProductionStatus.PENDING) return 0; + + if (batch.actual_start_time && batch.planned_duration_minutes) { + const startTime = new Date(batch.actual_start_time).getTime(); + const now = new Date().getTime(); + const elapsed = (now - startTime) / (1000 * 60); // minutes + const progress = Math.min((elapsed / batch.planned_duration_minutes) * 100, 100); + return Math.round(progress); + } + + return 25; // Default for in-progress without timing info + }; + + const calculateETA = (batch: any) => { + if (batch.status === ProductionStatus.COMPLETED) return null; + if (!batch.actual_start_time || !batch.planned_duration_minutes) return null; + + const startTime = new Date(batch.actual_start_time).getTime(); + const eta = new Date(startTime + batch.planned_duration_minutes * 60 * 1000); + const now = new Date(); + + if (eta < now) { + const delay = Math.round((now.getTime() - eta.getTime()) / (1000 * 60)); + return `${delay}m ${t('schedule.delayed')}`; + } + + return eta.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' }); + }; + + const getProgressBarVariant = (status: ProductionStatus, progress: number) => { + if (status === ProductionStatus.COMPLETED) return 'success'; + if (status === ProductionStatus.FAILED || status === ProductionStatus.CANCELLED) return 'error'; + if (progress > 90) return 'warning'; + return 'info'; + }; + + return ( + + + {t('actions.refresh')} + + } + > +
+ {batches.length === 0 ? ( +
+ +

+ {t('messages.no_active_batches')} +

+

{t('messages.no_active_batches_description')}

+
+ ) : ( +
+ {batches.map((batch) => { + const StatusIcon = getStatusIcon(batch.status); + const progress = calculateProgress(batch); + const eta = calculateETA(batch); + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

+ {batch.product_name} +

+

+ {t('batch.batch_number')}: {batch.batch_number} +

+
+ {batch.planned_quantity} {t('common.units')} + {batch.actual_quantity && ( + + {t('batch.actual')}: {batch.actual_quantity} {t('common.units')} + + )} +
+
+
+ + {t(`status.${batch.status.toLowerCase()}`)} + +
+ + {/* Progress Bar */} + {batch.status !== ProductionStatus.PENDING && ( +
+
+ + {t('tracker.progress')} + + + {progress}% + +
+ +
+ )} + + {/* Details */} +
+
+ {batch.priority && ( + + {t(`priority.${batch.priority.toLowerCase()}`)} + + )} + {batch.is_rush_order && ( + + {t('batch.rush_order')} + + )} +
+
+ + + {eta ? ( + + ETA: {eta} + + ) : ( + t('tracker.pending_start') + )} + +
+
+ + {/* Equipment & Staff */} + {(batch.equipment_used?.length > 0 || batch.staff_assigned?.length > 0) && ( +
+ {batch.equipment_used?.length > 0 && ( +
+ {t('batch.equipment')}: {batch.equipment_used.join(', ')} +
+ )} + {batch.staff_assigned?.length > 0 && ( +
+ {t('batch.staff')}: {batch.staff_assigned.length} {t('common.assigned')} +
+ )} +
+ )} +
+ ); + })} +
+ )} + + {/* Summary */} + {batches.length > 0 && ( +
+
+
+

+ {batches.filter(b => b.status === ProductionStatus.COMPLETED).length} +

+

{t('status.completed')}

+
+
+

+ {batches.filter(b => b.status === ProductionStatus.IN_PROGRESS).length} +

+

{t('status.in_progress')}

+
+
+

+ {batches.filter(b => b.status === ProductionStatus.PENDING).length} +

+

{t('status.pending')}

+
+
+

+ {batches.filter(b => [ProductionStatus.FAILED, ProductionStatus.CANCELLED].includes(b.status)).length} +

+

{t('tracker.issues')}

+
+
+
+ )} +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/analytics/production/widgets/MaintenanceScheduleWidget.tsx b/frontend/src/components/analytics/production/widgets/MaintenanceScheduleWidget.tsx new file mode 100644 index 00000000..36b827a8 --- /dev/null +++ b/frontend/src/components/analytics/production/widgets/MaintenanceScheduleWidget.tsx @@ -0,0 +1,305 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Calendar, Clock, Wrench, AlertCircle, CheckCircle2 } from 'lucide-react'; +import { AnalyticsWidget } from '../AnalyticsWidget'; +import { Badge, Button } from '../../../ui'; +import { useCurrentTenant } from '../../../../stores/tenant.store'; + +interface MaintenanceTask { + id: string; + equipmentId: string; + equipmentName: string; + type: 'preventive' | 'corrective' | 'inspection'; + priority: 'low' | 'medium' | 'high' | 'urgent'; + status: 'scheduled' | 'in_progress' | 'completed' | 'overdue'; + scheduledDate: string; + completedDate?: string; + estimatedDuration: number; // in hours + actualDuration?: number; + technician?: string; + description: string; + cost?: number; +} + +export const MaintenanceScheduleWidget: React.FC = () => { + const { t } = useTranslation('production'); + const currentTenant = useCurrentTenant(); + + // Mock maintenance data - replace with real API call + const maintenanceTasks: MaintenanceTask[] = [ + { + id: '1', + equipmentId: '1', + equipmentName: 'Horno Principal', + type: 'preventive', + priority: 'high', + status: 'scheduled', + scheduledDate: '2024-01-28', + estimatedDuration: 4, + description: 'Limpieza profunda y calibración de temperatura', + cost: 150 + }, + { + id: '2', + equipmentId: '2', + equipmentName: 'Mezcladora A', + type: 'corrective', + priority: 'urgent', + status: 'overdue', + scheduledDate: '2024-01-25', + estimatedDuration: 2, + description: 'Reparación de motor y reemplazo de correas', + cost: 280 + }, + { + id: '3', + equipmentId: '3', + equipmentName: 'Cámara de Fermentación 1', + type: 'inspection', + priority: 'medium', + status: 'in_progress', + scheduledDate: '2024-01-26', + estimatedDuration: 1, + technician: 'Carlos López', + description: 'Inspección rutinaria de sistemas de control', + cost: 80 + }, + { + id: '4', + equipmentId: '4', + equipmentName: 'Empaquetadora', + type: 'preventive', + priority: 'medium', + status: 'completed', + scheduledDate: '2024-01-24', + completedDate: '2024-01-24', + estimatedDuration: 3, + actualDuration: 2.5, + technician: 'Ana García', + description: 'Mantenimiento preventivo mensual', + cost: 120 + }, + { + id: '5', + equipmentId: '1', + equipmentName: 'Horno Principal', + type: 'inspection', + priority: 'low', + status: 'scheduled', + scheduledDate: '2024-02-05', + estimatedDuration: 0.5, + description: 'Inspección de seguridad semanal' + } + ]; + + const getStatusColor = (status: MaintenanceTask['status']) => { + switch (status) { + case 'completed': return 'text-green-600'; + case 'in_progress': return 'text-blue-600'; + case 'scheduled': return 'text-orange-600'; + case 'overdue': return 'text-red-600'; + default: return 'text-gray-600'; + } + }; + + const getStatusBadgeVariant = (status: MaintenanceTask['status']) => { + switch (status) { + case 'completed': return 'success'; + case 'in_progress': return 'info'; + case 'scheduled': return 'warning'; + case 'overdue': return 'error'; + default: return 'default'; + } + }; + + const getPriorityColor = (priority: MaintenanceTask['priority']) => { + switch (priority) { + case 'urgent': return 'text-red-600'; + case 'high': return 'text-orange-600'; + case 'medium': return 'text-blue-600'; + case 'low': return 'text-gray-600'; + default: return 'text-gray-600'; + } + }; + + const getTypeIcon = (type: MaintenanceTask['type']) => { + switch (type) { + case 'preventive': return Calendar; + case 'corrective': return Wrench; + case 'inspection': return CheckCircle2; + default: return Wrench; + } + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('es-ES', { + day: '2-digit', + month: '2-digit', + year: 'numeric' + }); + }; + + const scheduledTasks = maintenanceTasks.filter(t => t.status === 'scheduled'); + const overdueTasks = maintenanceTasks.filter(t => t.status === 'overdue'); + const inProgressTasks = maintenanceTasks.filter(t => t.status === 'in_progress'); + const completedTasks = maintenanceTasks.filter(t => t.status === 'completed'); + + const totalCost = maintenanceTasks.reduce((sum, task) => sum + (task.cost || 0), 0); + const avgDuration = maintenanceTasks.reduce((sum, task) => sum + task.estimatedDuration, 0) / maintenanceTasks.length; + + return ( + + + + + } + > +
+ {/* Maintenance Overview Stats */} +
+
+ +

{scheduledTasks.length}

+

{t('equipment.maintenance.scheduled')}

+
+
+ +

{overdueTasks.length}

+

{t('equipment.maintenance.overdue')}

+
+
+ +

{avgDuration.toFixed(1)}h

+

{t('equipment.maintenance.avg_duration')}

+
+
+
+ +
+

€{totalCost}

+

{t('equipment.maintenance.total_cost')}

+
+
+ + {/* Priority Tasks */} + {overdueTasks.length > 0 && ( +
+
+ +
+

+ {t('equipment.maintenance.overdue_tasks')} ({overdueTasks.length}) +

+

+ {t('equipment.maintenance.immediate_attention_required')} +

+
+
+
+ )} + + {/* Maintenance Tasks List */} +
+

+ + {t('equipment.maintenance.tasks')} ({maintenanceTasks.length}) +

+
+ {maintenanceTasks.map((task) => { + const TypeIcon = getTypeIcon(task.type); + return ( +
+
+
+ +
+
+

{task.equipmentName}

+ + {t(`priority.${task.priority}`)} + +
+

{task.description}

+
+ {t('equipment.maintenance.scheduled')}: {formatDate(task.scheduledDate)} + {t('equipment.maintenance.duration')}: {task.estimatedDuration}h + {task.cost && {t('equipment.maintenance.cost')}: €{task.cost}} + {task.technician && {t('equipment.maintenance.technician')}: {task.technician}} +
+
+
+
+ + {t(`equipment.maintenance.status.${task.status}`)} + + + {t(`equipment.maintenance.type.${task.type}`)} + +
+
+
+ ); + })} +
+
+ + {/* Weekly Schedule Preview */} +
+

+ + {t('equipment.maintenance.this_week')} +

+
+ {Array.from({ length: 7 }, (_, i) => { + const date = new Date(); + date.setDate(date.getDate() + i); + const dateStr = date.toISOString().split('T')[0]; + const tasksForDay = maintenanceTasks.filter(task => task.scheduledDate === dateStr); + + return ( +
+

+ {date.toLocaleDateString('es-ES', { weekday: 'short' })} +

+

+ {date.getDate()} +

+ {tasksForDay.length > 0 && ( +
+ )} +
+ ); + })} +
+
+ + {/* Maintenance Insights */} +
+
+ +
+

+ {t('equipment.maintenance.insights.title')} +

+

+ {completedTasks.length} {t('equipment.maintenance.insights.completed_this_month')}, + {scheduledTasks.length} {t('equipment.maintenance.insights.scheduled_next_week')} +

+
+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/analytics/production/widgets/OnTimeCompletionWidget.tsx b/frontend/src/components/analytics/production/widgets/OnTimeCompletionWidget.tsx new file mode 100644 index 00000000..6c027a1b --- /dev/null +++ b/frontend/src/components/analytics/production/widgets/OnTimeCompletionWidget.tsx @@ -0,0 +1,195 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Clock, TrendingUp, TrendingDown, Target } from 'lucide-react'; +import { AnalyticsWidget } from '../AnalyticsWidget'; +import { AnalyticsChart, ChartSeries } from '../AnalyticsChart'; +import { Button } from '../../../ui'; +import { useProductionDashboard } from '../../../../api/hooks/production'; +import { useCurrentTenant } from '../../../../stores/tenant.store'; + +export const OnTimeCompletionWidget: React.FC = () => { + const { t } = useTranslation('production'); + const currentTenant = useCurrentTenant(); + const tenantId = currentTenant?.id || ''; + + const { data: dashboard, isLoading, error } = useProductionDashboard(tenantId); + + // Mock historical data for the chart (in a real implementation, this would come from an analytics API) + const getHistoricalCompletionData = (): ChartSeries[] => { + const mockData = [ + { x: 'Lun', y: 92, label: 'Lunes' }, + { x: 'Mar', y: 89, label: 'Martes' }, + { x: 'Mié', y: 95, label: 'Miércoles' }, + { x: 'Jue', y: 87, label: 'Jueves' }, + { x: 'Vie', y: 94, label: 'Viernes' }, + { x: 'Sáb', y: 98, label: 'Sábado' }, + { x: 'Dom', y: dashboard?.on_time_completion_rate || 91, label: 'Domingo' }, + ]; + + return [ + { + id: 'completion-rate', + name: t('stats.on_time_completion'), + type: 'line', + color: '#10b981', + data: mockData + } + ]; + }; + + const currentRate = dashboard?.on_time_completion_rate || 0; + const targetRate = 95; // Target completion rate + const weekAverage = 93; // Mock week average + const trend = currentRate - weekAverage; + + const getCompletionRateStatus = (rate: number) => { + if (rate >= 95) return { color: 'text-green-600', icon: TrendingUp, status: 'excellent' }; + if (rate >= 90) return { color: 'text-blue-600', icon: TrendingUp, status: 'good' }; + if (rate >= 85) return { color: 'text-orange-600', icon: TrendingDown, status: 'warning' }; + return { color: 'text-red-600', icon: TrendingDown, status: 'critical' }; + }; + + const rateStatus = getCompletionRateStatus(currentRate); + const RateIcon = rateStatus.icon; + + const getPerformanceInsight = () => { + if (currentRate >= targetRate) { + return { + message: t('insights.on_time_excellent'), + color: 'text-green-600', + bgColor: 'bg-green-50 dark:bg-green-900/20' + }; + } else if (currentRate >= 90) { + return { + message: t('insights.on_time_good_room_improvement'), + color: 'text-blue-600', + bgColor: 'bg-blue-50 dark:bg-blue-900/20' + }; + } else if (currentRate >= 85) { + return { + message: t('insights.on_time_needs_attention'), + color: 'text-orange-600', + bgColor: 'bg-orange-50 dark:bg-orange-900/20' + }; + } else { + return { + message: t('insights.on_time_critical_delays'), + color: 'text-red-600', + bgColor: 'bg-red-50 dark:bg-red-900/20' + }; + } + }; + + const insight = getPerformanceInsight(); + + return ( + + + {t('actions.analyze_delays')} + + } + > +
+ {/* Current Rate Display */} +
+
+ + + {currentRate.toFixed(1)}% + +
+
+
= 0 ? 'text-green-600' : 'text-red-600'}`}> + {trend >= 0 ? ( + + ) : ( + + )} + {Math.abs(trend).toFixed(1)}% {t('insights.vs_week_avg')} +
+
+ {t('insights.target')}: {targetRate}% +
+
+
+ + {/* Progress towards target */} +
+
+ {t('insights.progress_to_target')} + + {Math.min(currentRate, targetRate).toFixed(1)}% / {targetRate}% + +
+
+
= targetRate ? 'bg-green-500' : + currentRate >= 90 ? 'bg-blue-500' : + currentRate >= 85 ? 'bg-orange-500' : 'bg-red-500' + }`} + style={{ width: `${Math.min((currentRate / targetRate) * 100, 100)}%` }} + /> +
+
+ + {/* Weekly Trend Chart */} +
+

+ {t('insights.weekly_trend')} +

+ +
+ + {/* Performance Insight */} +
+
+ +
+

+ {t('insights.performance_insight')} +

+

+ {insight.message} +

+
+
+
+ + {/* Key Metrics */} +
+
+

+ {Math.round(currentRate * 0.85)} +

+

{t('insights.batches_on_time')}

+
+
+

+ {Math.round((100 - currentRate) * 0.15)} +

+

{t('insights.batches_delayed')}

+
+
+

+ {Math.round(12 * (100 - currentRate) / 100)} +

+

{t('insights.avg_delay_minutes')}

+
+
+
+ + ); +}; \ No newline at end of file diff --git a/frontend/src/components/analytics/production/widgets/PredictiveMaintenanceWidget.tsx b/frontend/src/components/analytics/production/widgets/PredictiveMaintenanceWidget.tsx new file mode 100644 index 00000000..ca2cb762 --- /dev/null +++ b/frontend/src/components/analytics/production/widgets/PredictiveMaintenanceWidget.tsx @@ -0,0 +1,437 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Brain, AlertTriangle, TrendingDown, Calendar, Wrench, Target } from 'lucide-react'; +import { AnalyticsWidget } from '../AnalyticsWidget'; +import { AnalyticsChart, ChartSeries } from '../AnalyticsChart'; +import { Badge, Button } from '../../../ui'; +import { useCurrentTenant } from '../../../../stores/tenant.store'; + +interface PredictiveMaintenanceAlert { + id: string; + equipmentId: string; + equipmentName: string; + equipmentType: 'oven' | 'mixer' | 'proofer' | 'packaging'; + alertType: 'wear_prediction' | 'failure_risk' | 'performance_degradation' | 'component_replacement'; + severity: 'low' | 'medium' | 'high' | 'critical'; + confidence: number; // percentage + predictedFailureDate: string; + currentCondition: number; // percentage (100% = perfect, 0% = failed) + degradationRate: number; // percentage per week + affectedComponents: string[]; + recommendedActions: string[]; + estimatedCost: number; + potentialDowntime: number; // hours + riskScore: number; // 0-100 + dataPoints: Array<{ + date: string; + condition: number; + vibration?: number; + temperature?: number; + efficiency?: number; + }>; +} + +export const PredictiveMaintenanceWidget: React.FC = () => { + const { t } = useTranslation('production'); + const currentTenant = useCurrentTenant(); + + // Mock predictive maintenance data - replace with real ML API call + const maintenanceAlerts: PredictiveMaintenanceAlert[] = [ + { + id: '1', + equipmentId: '2', + equipmentName: 'Mezcladora A', + equipmentType: 'mixer', + alertType: 'wear_prediction', + severity: 'high', + confidence: 94, + predictedFailureDate: '2024-02-15', + currentCondition: 78, + degradationRate: 3.2, + affectedComponents: ['Motor principal', 'Correas de transmisión', 'Rodamientos'], + recommendedActions: [ + 'Reemplazar correas de transmisión', + 'Lubricar rodamientos', + 'Inspeccionar motor' + ], + estimatedCost: 280, + potentialDowntime: 4, + riskScore: 85, + dataPoints: [ + { date: '2024-01-01', condition: 95, vibration: 1.2, temperature: 35, efficiency: 94 }, + { date: '2024-01-07', condition: 91, vibration: 1.5, temperature: 37, efficiency: 92 }, + { date: '2024-01-14', condition: 87, vibration: 1.8, temperature: 39, efficiency: 89 }, + { date: '2024-01-21', condition: 83, vibration: 2.1, temperature: 41, efficiency: 87 }, + { date: '2024-01-28', condition: 78, vibration: 2.4, temperature: 43, efficiency: 85 } + ] + }, + { + id: '2', + equipmentId: '1', + equipmentName: 'Horno Principal', + equipmentType: 'oven', + alertType: 'performance_degradation', + severity: 'medium', + confidence: 87, + predictedFailureDate: '2024-03-10', + currentCondition: 89, + degradationRate: 1.8, + affectedComponents: ['Sistema de calefacción', 'Sensores de temperatura'], + recommendedActions: [ + 'Calibrar sensores de temperatura', + 'Limpiar sistema de calefacción', + 'Verificar aislamiento térmico' + ], + estimatedCost: 150, + potentialDowntime: 2, + riskScore: 65, + dataPoints: [ + { date: '2024-01-01', condition: 98, temperature: 180, efficiency: 96 }, + { date: '2024-01-07', condition: 95, temperature: 178, efficiency: 95 }, + { date: '2024-01-14', condition: 93, temperature: 176, efficiency: 94 }, + { date: '2024-01-21', condition: 91, temperature: 174, efficiency: 93 }, + { date: '2024-01-28', condition: 89, temperature: 172, efficiency: 92 } + ] + }, + { + id: '3', + equipmentId: '4', + equipmentName: 'Empaquetadora', + equipmentType: 'packaging', + alertType: 'component_replacement', + severity: 'low', + confidence: 76, + predictedFailureDate: '2024-04-20', + currentCondition: 92, + degradationRate: 1.1, + affectedComponents: ['Cinta transportadora', 'Sistema de sellado'], + recommendedActions: [ + 'Inspeccionar cinta transportadora', + 'Ajustar sistema de sellado', + 'Reemplazar filtros' + ], + estimatedCost: 120, + potentialDowntime: 1.5, + riskScore: 35, + dataPoints: [ + { date: '2024-01-01', condition: 98, efficiency: 97 }, + { date: '2024-01-07', condition: 96, efficiency: 96 }, + { date: '2024-01-14', condition: 95, efficiency: 95 }, + { date: '2024-01-21', condition: 94, efficiency: 94 }, + { date: '2024-01-28', condition: 92, efficiency: 93 } + ] + } + ]; + + const getSeverityColor = (severity: PredictiveMaintenanceAlert['severity']) => { + switch (severity) { + case 'critical': return 'text-red-600'; + case 'high': return 'text-orange-600'; + case 'medium': return 'text-yellow-600'; + case 'low': return 'text-blue-600'; + default: return 'text-gray-600'; + } + }; + + const getSeverityBadgeVariant = (severity: PredictiveMaintenanceAlert['severity']) => { + switch (severity) { + case 'critical': return 'error'; + case 'high': return 'warning'; + case 'medium': return 'warning'; + case 'low': return 'info'; + default: return 'default'; + } + }; + + const getAlertTypeIcon = (type: PredictiveMaintenanceAlert['alertType']) => { + switch (type) { + case 'wear_prediction': return TrendingDown; + case 'failure_risk': return AlertTriangle; + case 'performance_degradation': return Target; + case 'component_replacement': return Wrench; + default: return AlertTriangle; + } + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('es-ES', { + day: '2-digit', + month: '2-digit', + year: 'numeric' + }); + }; + + const getDaysUntilFailure = (failureDate: string) => { + const today = new Date(); + const failure = new Date(failureDate); + const diffTime = failure.getTime() - today.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + return diffDays; + }; + + const criticalAlerts = maintenanceAlerts.filter(alert => alert.severity === 'critical'); + const highRiskAlerts = maintenanceAlerts.filter(alert => alert.riskScore > 70); + const totalEstimatedCost = maintenanceAlerts.reduce((sum, alert) => sum + alert.estimatedCost, 0); + const totalPotentialDowntime = maintenanceAlerts.reduce((sum, alert) => sum + alert.potentialDowntime, 0); + const avgConfidence = maintenanceAlerts.reduce((sum, alert) => sum + alert.confidence, 0) / maintenanceAlerts.length; + + // Create equipment condition trend chart + const getConditionTrendChartData = (): ChartSeries[] => { + return maintenanceAlerts.map((alert, index) => ({ + id: `condition-${alert.equipmentId}`, + name: alert.equipmentName, + type: 'line', + color: ['#ef4444', '#f97316', '#eab308'][index % 3], + data: alert.dataPoints.map(point => ({ + x: new Date(point.date).toLocaleDateString('es-ES', { month: 'short', day: 'numeric' }), + y: point.condition, + label: `${point.condition}%` + })) + })); + }; + + // Create risk score distribution chart + const getRiskDistributionChartData = (): ChartSeries[] => { + const lowRisk = maintenanceAlerts.filter(a => a.riskScore <= 40).length; + const mediumRisk = maintenanceAlerts.filter(a => a.riskScore > 40 && a.riskScore <= 70).length; + const highRisk = maintenanceAlerts.filter(a => a.riskScore > 70).length; + + return [ + { + id: 'risk-distribution', + name: 'Risk Distribution', + type: 'doughnut', + color: '#3b82f6', + data: [ + { x: t('ai.risk.low'), y: lowRisk, label: `${t('ai.risk.low')}: ${lowRisk}` }, + { x: t('ai.risk.medium'), y: mediumRisk, label: `${t('ai.risk.medium')}: ${mediumRisk}` }, + { x: t('ai.risk.high'), y: highRisk, label: `${t('ai.risk.high')}: ${highRisk}` } + ].filter(item => item.y > 0) + } + ]; + }; + + return ( + + + +
+ } + > +
+ {/* Predictive Maintenance Overview Stats */} +
+
+ +

{highRiskAlerts.length}

+

{t('ai.predictive_maintenance.high_risk')}

+
+
+
+ +
+

€{totalEstimatedCost}

+

{t('ai.predictive_maintenance.estimated_cost')}

+
+
+
+ h +
+

{totalPotentialDowntime}

+

{t('ai.predictive_maintenance.potential_downtime')}

+
+
+
+ % +
+

{avgConfidence.toFixed(0)}%

+

{t('ai.predictive_maintenance.avg_confidence')}

+
+
+ + {/* Critical Alerts */} + {highRiskAlerts.length > 0 && ( +
+
+ +
+

+ {t('ai.predictive_maintenance.high_risk_equipment')} ({highRiskAlerts.length}) +

+

+ {t('ai.predictive_maintenance.immediate_attention_required')} +

+
+
+
+ )} + + {/* Maintenance Predictions */} +
+

+ + {t('ai.predictive_maintenance.predictions')} ({maintenanceAlerts.length}) +

+
+ {maintenanceAlerts.map((alert) => { + const AlertTypeIcon = getAlertTypeIcon(alert.alertType); + const daysUntilFailure = getDaysUntilFailure(alert.predictedFailureDate); + + return ( +
+
+
+ +
+
+

{alert.equipmentName}

+ + {t(`ai.severity.${alert.severity}`)} + +
+
+ {t('ai.predictive_maintenance.confidence')}: {alert.confidence}% + {t('ai.predictive_maintenance.risk_score')}: {alert.riskScore}/100 + {t('ai.predictive_maintenance.days_until_failure')}: {daysUntilFailure} +
+

+ {t(`ai.predictive_maintenance.alert_type.${alert.alertType}`)} +

+
+
+
+
{alert.currentCondition}%
+
{t('ai.predictive_maintenance.condition')}
+
+
+ + {/* Condition Progress Bar */} +
+
+ {t('ai.predictive_maintenance.current_condition')} + {alert.currentCondition}% +
+
+
80 ? 'bg-green-500' : + alert.currentCondition > 60 ? 'bg-yellow-500' : + alert.currentCondition > 40 ? 'bg-orange-500' : 'bg-red-500' + }`} + style={{ width: `${alert.currentCondition}%` }} + /> +
+
+ + {/* Affected Components */} +
+

+ {t('ai.predictive_maintenance.affected_components')}: +

+
+ {alert.affectedComponents.map((component, index) => ( + + {component} + + ))} +
+
+ + {/* Recommended Actions */} +
+

+ {t('ai.predictive_maintenance.recommended_actions')}: +

+
    + {alert.recommendedActions.map((action, index) => ( +
  • • {action}
  • + ))} +
+
+ + {/* Cost and Downtime */} +
+
+ + + {t('ai.predictive_maintenance.estimated_cost')}: €{alert.estimatedCost} + + + + {t('ai.predictive_maintenance.potential_downtime')}: {alert.potentialDowntime}h + +
+
+ + +
+
+
+ ); + })} +
+
+ + {/* Charts */} +
+ {/* Equipment Condition Trends */} +
+

+ {t('ai.predictive_maintenance.condition_trends')} +

+ +
+ + {/* Risk Distribution */} +
+

+ {t('ai.predictive_maintenance.risk_distribution')} +

+ +
+
+ + {/* ML Model Status */} +
+
+ +
+

+ {t('ai.predictive_maintenance.model_status')} +

+

+ {t('ai.predictive_maintenance.last_training')}: {t('ai.predictive_maintenance.yesterday')}. + {t('ai.predictive_maintenance.accuracy')}: 94%. {t('ai.predictive_maintenance.next_training')}: {t('ai.predictive_maintenance.in_7_days')}. +

+
+
+
+
+ + ); +}; \ No newline at end of file diff --git a/frontend/src/components/analytics/production/widgets/QualityScoreTrendsWidget.tsx b/frontend/src/components/analytics/production/widgets/QualityScoreTrendsWidget.tsx new file mode 100644 index 00000000..22c83d16 --- /dev/null +++ b/frontend/src/components/analytics/production/widgets/QualityScoreTrendsWidget.tsx @@ -0,0 +1,257 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Star, TrendingUp, TrendingDown, Award } from 'lucide-react'; +import { AnalyticsWidget } from '../AnalyticsWidget'; +import { AnalyticsChart, ChartSeries } from '../AnalyticsChart'; +import { Button } from '../../../ui'; +import { useActiveBatches } from '../../../../api/hooks/production'; +import { useCurrentTenant } from '../../../../stores/tenant.store'; + +export const QualityScoreTrendsWidget: React.FC = () => { + const { t } = useTranslation('production'); + const currentTenant = useCurrentTenant(); + const tenantId = currentTenant?.id || ''; + + const { data: batchesData, isLoading, error } = useActiveBatches(tenantId); + const batches = batchesData?.batches || []; + + // Calculate quality score data + const getQualityData = () => { + const qualityScores = batches + .filter(batch => batch.quality_score) + .map(batch => batch.quality_score!); + + if (qualityScores.length === 0) { + return { + averageScore: 0, + totalChecks: 0, + passRate: 0, + trend: 0 + }; + } + + const averageScore = qualityScores.reduce((sum, score) => sum + score, 0) / qualityScores.length; + const totalChecks = qualityScores.length; + const passRate = (qualityScores.filter(score => score >= 7).length / totalChecks) * 100; + + // Mock trend calculation (would be compared with previous period in real implementation) + const trend = 2.3; // +2.3 points vs last week + + return { + averageScore, + totalChecks, + passRate, + trend + }; + }; + + const qualityData = getQualityData(); + + // Create daily quality trend chart + const getQualityTrendChartData = (): ChartSeries[] => { + const days = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom']; + const scores = days.map((day, index) => ({ + x: day, + y: Math.max(6, 8.5 + Math.sin(index) * 0.8 + (Math.random() - 0.5) * 0.5), + label: day + })); + + return [ + { + id: 'quality-trend', + name: t('quality.daily_quality_score'), + type: 'line', + color: '#16a34a', + data: scores + } + ]; + }; + + // Create quality distribution chart + const getQualityDistributionChartData = (): ChartSeries[] => { + // Mock quality score distribution + const distribution = [ + { range: '9-10', count: Math.round(qualityData.totalChecks * 0.35), label: t('quality.excellent') }, + { range: '8-9', count: Math.round(qualityData.totalChecks * 0.30), label: t('quality.good') }, + { range: '7-8', count: Math.round(qualityData.totalChecks * 0.20), label: t('quality.acceptable') }, + { range: '6-7', count: Math.round(qualityData.totalChecks * 0.10), label: t('quality.poor') }, + { range: '<6', count: Math.round(qualityData.totalChecks * 0.05), label: t('quality.failed') } + ].filter(item => item.count > 0); + + return [ + { + id: 'quality-distribution', + name: t('quality.score_distribution'), + type: 'bar', + color: '#d97706', + data: distribution.map(item => ({ + x: item.range, + y: item.count, + label: item.label + })) + } + ]; + }; + + const getScoreColor = (score: number) => { + if (score >= 9) return 'text-green-600'; + if (score >= 8) return 'text-blue-600'; + if (score >= 7) return 'text-orange-600'; + return 'text-red-600'; + }; + + const getScoreStatus = (score: number) => { + if (score >= 9) return { status: 'excellent', bgColor: 'bg-green-100 dark:bg-green-900/20' }; + if (score >= 8) return { status: 'good', bgColor: 'bg-blue-100 dark:bg-blue-900/20' }; + if (score >= 7) return { status: 'acceptable', bgColor: 'bg-orange-100 dark:bg-orange-900/20' }; + return { status: 'needs_improvement', bgColor: 'bg-red-100 dark:bg-red-900/20' }; + }; + + const scoreStatus = getScoreStatus(qualityData.averageScore); + const TrendIcon = qualityData.trend >= 0 ? TrendingUp : TrendingDown; + + return ( + + + {t('quality.actions.view_trends')} + + } + > +
+ {/* Quality Score Display */} +
+
+ + + {qualityData.averageScore.toFixed(1)}/10 + +
+
+ + {t(`quality.${scoreStatus.status}`)} + +
+
+ = 0 ? 'text-green-600' : 'text-red-600'}`} /> + = 0 ? 'text-green-600' : 'text-red-600'}`}> + {qualityData.trend > 0 ? '+' : ''}{qualityData.trend.toFixed(1)} {t('quality.vs_last_week')} + +
+
+ + {/* Key Metrics */} +
+
+
+ {qualityData.totalChecks} +
+

{t('stats.total_checks')}

+
+
+
+ {qualityData.passRate.toFixed(1)}% +
+

{t('stats.pass_rate')}

+
+
+ + {qualityData.totalChecks === 0 ? ( +
+ +

{t('quality.no_quality_data')}

+
+ ) : ( + <> + {/* Weekly Quality Trend */} +
+

+ {t('quality.weekly_quality_trends')} +

+ +
+ + {/* Quality Score Distribution */} +
+

+ {t('quality.score_distribution')} +

+ +
+ + {/* Quality Insights */} +
+
+

+ {Math.round(qualityData.totalChecks * 0.35)} +

+

{t('quality.excellent_scores')}

+
+
+

+ {Math.round(qualityData.totalChecks * 0.30)} +

+

{t('quality.good_scores')}

+
+
+

+ {Math.round(qualityData.totalChecks * 0.15)} +

+

{t('quality.needs_improvement')}

+
+
+ + {/* Quality Recommendations */} + {qualityData.averageScore < 8 && ( +
+
+ +
+

+ {t('quality.recommendations.improve_quality')} +

+

+ {t('quality.recommendations.focus_consistency')} +

+
+
+
+ )} + + {qualityData.averageScore >= 9 && ( +
+
+ +
+

+ {t('quality.recommendations.excellent_quality')} +

+

+ {t('quality.recommendations.maintain_standards')} +

+
+
+
+ )} + + )} +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/analytics/production/widgets/TodaysScheduleSummaryWidget.tsx b/frontend/src/components/analytics/production/widgets/TodaysScheduleSummaryWidget.tsx new file mode 100644 index 00000000..61a68f55 --- /dev/null +++ b/frontend/src/components/analytics/production/widgets/TodaysScheduleSummaryWidget.tsx @@ -0,0 +1,176 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Calendar, Clock, Users, Target, AlertCircle } from 'lucide-react'; +import { AnalyticsWidget } from '../AnalyticsWidget'; +import { Badge, Button } from '../../../ui'; +import { useProductionDashboard, useProductionSchedule } from '../../../../api/hooks/production'; +import { useCurrentTenant } from '../../../../stores/tenant.store'; +import { ProductionStatus } from '../../../../api/types/production'; + +export const TodaysScheduleSummaryWidget: React.FC = () => { + const { t } = useTranslation('production'); + const currentTenant = useCurrentTenant(); + const tenantId = currentTenant?.id || ''; + + const { data: dashboard, isLoading: dashboardLoading, error: dashboardError } = useProductionDashboard(tenantId); + const today = new Date().toISOString().split('T')[0]; + const { data: schedule, isLoading: scheduleLoading, error: scheduleError } = useProductionSchedule(tenantId, today, today); + + const isLoading = dashboardLoading || scheduleLoading; + const error = dashboardError?.message || scheduleError?.message; + + const todaysSchedule = schedule?.schedules?.[0]; + const plannedBatches = dashboard?.todays_production_plan || []; + + const getStatusBadgeVariant = (status: ProductionStatus) => { + switch (status) { + case ProductionStatus.COMPLETED: + return 'success'; + case ProductionStatus.IN_PROGRESS: + return 'info'; + case ProductionStatus.PENDING: + return 'warning'; + case ProductionStatus.CANCELLED: + case ProductionStatus.FAILED: + return 'error'; + default: + return 'default'; + } + }; + + const getPriorityColor = (priority?: string) => { + if (!priority) return 'text-gray-600'; + + switch (priority) { + case 'URGENT': + return 'text-red-600'; + case 'HIGH': + return 'text-orange-600'; + case 'MEDIUM': + return 'text-blue-600'; + case 'LOW': + return 'text-gray-600'; + default: + return 'text-gray-600'; + } + }; + + const formatTime = (timeString: string) => { + return new Date(timeString).toLocaleTimeString('es-ES', { + hour: '2-digit', + minute: '2-digit' + }); + }; + + return ( + + + {t('actions.optimize')} + + } + > +
+ {/* Schedule Overview */} + {todaysSchedule && ( +
+
+ +

{t('schedule.shift_hours')}

+

+ {formatTime(todaysSchedule.shift_start)} - {formatTime(todaysSchedule.shift_end)} +

+
+
+ +

{t('stats.planned_batches')}

+

{todaysSchedule.total_batches_planned}

+
+
+ +

{t('schedule.staff_count')}

+

{todaysSchedule.staff_count}

+
+
+ +

{t('schedule.capacity_utilization')}

+

+ {todaysSchedule.utilization_percentage?.toFixed(1) || 0}% +

+
+
+ )} + + {/* Planned Batches */} +
+

+ + {t('schedule.planned_batches')} ({plannedBatches.length}) +

+ + {plannedBatches.length === 0 ? ( +
+ +

{t('messages.no_batches_planned')}

+
+ ) : ( +
+ {plannedBatches.map((batch, index) => ( +
+
+
+

+ {batch.product_name} +

+ {batch.priority && ( + + {t(`priority.${batch.priority.toLowerCase()}`)} + + )} +
+

+ {t('batch.planned_quantity')}: {batch.planned_quantity} {t('common.units')} +

+
+
+ + {batch.status ? t(`status.${batch.status.toLowerCase()}`) : t('status.unknown')} + +
+
+ ))} +
+ )} +
+ + {/* Schedule Status */} + {todaysSchedule && ( +
+
+
+ + {todaysSchedule.is_active ? t('schedule.active') : t('schedule.inactive')} + +
+
+ {todaysSchedule.is_finalized ? ( + {t('schedule.finalized')} + ) : ( + {t('schedule.draft')} + )} +
+
+ )} +
+ + ); +}; \ No newline at end of file diff --git a/frontend/src/components/analytics/production/widgets/TopDefectTypesWidget.tsx b/frontend/src/components/analytics/production/widgets/TopDefectTypesWidget.tsx new file mode 100644 index 00000000..c6beeea5 --- /dev/null +++ b/frontend/src/components/analytics/production/widgets/TopDefectTypesWidget.tsx @@ -0,0 +1,329 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { AlertCircle, Eye, Camera, CheckSquare } from 'lucide-react'; +import { AnalyticsWidget } from '../AnalyticsWidget'; +import { AnalyticsChart, ChartSeries } from '../AnalyticsChart'; +import { Button, Badge } from '../../../ui'; +import { useActiveBatches } from '../../../../api/hooks/production'; +import { useCurrentTenant } from '../../../../stores/tenant.store'; + +interface DefectType { + type: string; + count: number; + percentage: number; + severity: 'low' | 'medium' | 'high'; + trend: 'up' | 'down' | 'stable'; + estimatedCost: number; +} + +export const TopDefectTypesWidget: React.FC = () => { + const { t } = useTranslation('production'); + const currentTenant = useCurrentTenant(); + const tenantId = currentTenant?.id || ''; + + const { data: batchesData, isLoading, error } = useActiveBatches(tenantId); + const batches = batchesData?.batches || []; + + // Calculate defect data from batches + const getDefectData = (): DefectType[] => { + const totalDefects = batches.reduce((sum, batch) => sum + (batch.defect_quantity || 0), 0); + + if (totalDefects === 0) return []; + + // Mock defect type distribution (in real implementation, this would come from quality check defect_types) + const defectTypes = [ + { + type: 'burnt', + count: Math.round(totalDefects * 0.32), + percentage: 32, + severity: 'high' as const, + trend: 'down' as const, + estimatedCost: 45.60 + }, + { + type: 'underproofed', + count: Math.round(totalDefects * 0.24), + percentage: 24, + severity: 'medium' as const, + trend: 'stable' as const, + estimatedCost: 28.90 + }, + { + type: 'misshapen', + count: Math.round(totalDefects * 0.19), + percentage: 19, + severity: 'medium' as const, + trend: 'up' as const, + estimatedCost: 22.40 + }, + { + type: 'color_issues', + count: Math.round(totalDefects * 0.15), + percentage: 15, + severity: 'low' as const, + trend: 'stable' as const, + estimatedCost: 15.20 + }, + { + type: 'texture_problems', + count: Math.round(totalDefects * 0.10), + percentage: 10, + severity: 'medium' as const, + trend: 'down' as const, + estimatedCost: 12.80 + } + ].filter(defect => defect.count > 0); + + return defectTypes; + }; + + const defectData = getDefectData(); + const totalDefects = defectData.reduce((sum, defect) => sum + defect.count, 0); + const totalDefectCost = defectData.reduce((sum, defect) => sum + defect.estimatedCost, 0); + + // Create defect distribution pie chart + const getDefectDistributionChartData = (): ChartSeries[] => { + if (defectData.length === 0) return []; + + const colors = ['#dc2626', '#ea580c', '#d97706', '#ca8a04', '#65a30d']; + const pieData = defectData.map((defect, index) => ({ + x: t(`quality.defects.${defect.type}`), + y: defect.count, + label: t(`quality.defects.${defect.type}`), + color: colors[index % colors.length] + })); + + return [ + { + id: 'defect-distribution', + name: t('quality.defect_distribution'), + type: 'pie', + color: '#dc2626', + data: pieData + } + ]; + }; + + // Create defect trend over time + const getDefectTrendChartData = (): ChartSeries[] => { + if (defectData.length === 0) return []; + + const days = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom']; + + return defectData.slice(0, 3).map((defect, seriesIndex) => { + const colors = ['#dc2626', '#ea580c', '#d97706']; + const baseValue = defect.count / 7; // Average per day + + const trendData = days.map((day, index) => { + let value = baseValue; + // Apply trend + if (defect.trend === 'up') value *= (1 + index * 0.05); + else if (defect.trend === 'down') value *= (1 - index * 0.05); + + return { + x: day, + y: Math.max(0, value + (Math.random() - 0.5) * baseValue * 0.3), + label: day + }; + }); + + return { + id: `defect-trend-${defect.type}`, + name: t(`quality.defects.${defect.type}`), + type: 'line' as const, + color: colors[seriesIndex], + data: trendData + }; + }); + }; + + const getSeverityBadgeVariant = (severity: DefectType['severity']) => { + switch (severity) { + case 'high': return 'error'; + case 'medium': return 'warning'; + case 'low': return 'info'; + default: return 'default'; + } + }; + + const getTrendIcon = (trend: DefectType['trend']) => { + switch (trend) { + case 'up': return '↗️'; + case 'down': return '↘️'; + case 'stable': return '➡️'; + default: return '➡️'; + } + }; + + const getTrendColor = (trend: DefectType['trend']) => { + switch (trend) { + case 'up': return 'text-red-600'; + case 'down': return 'text-green-600'; + case 'stable': return 'text-blue-600'; + default: return 'text-gray-600'; + } + }; + + return ( + + + {t('quality.actions.view_details')} + + } + > +
+ {/* Overall Defect Metrics */} +
+
+
+ + + {totalDefects} + +
+

{t('quality.total_defects')}

+
+
+
+ + €{totalDefectCost.toFixed(0)} + +
+

{t('quality.estimated_cost')}

+
+
+ + {defectData.length === 0 ? ( +
+ +

+ {t('quality.no_defects_detected')} +

+

{t('quality.excellent_quality_standards')}

+
+ ) : ( + <> + {/* Top Defect Types List */} +
+

+ {t('quality.defect_breakdown')} +

+
+ {defectData.map((defect, index) => ( +
+
+
+ #{index + 1} +
+
+

+ {t(`quality.defects.${defect.type}`)} +

+
+ {defect.count} {t('quality.incidents')} + + €{defect.estimatedCost.toFixed(2)} {t('quality.cost')} + + {getTrendIcon(defect.trend)} {t(`quality.trend.${defect.trend}`)} + +
+
+
+
+ + {defect.percentage}% + + + {t(`quality.severity.${defect.severity}`)} + +
+
+ ))} +
+
+ + {/* Defect Distribution Chart */} +
+

+ {t('quality.defect_distribution')} +

+ +
+ + {/* Defect Trends Over Time */} + {defectData.length > 0 && ( +
+

+ {t('quality.top_defects_weekly_trend')} +

+ +
+ )} + + {/* Defect Prevention Insights */} +
+ {defectData.some(d => d.severity === 'high') && ( +
+ +
+

+ {t('quality.recommendations.critical_defects')} +

+

+ {t('quality.recommendations.immediate_action_required')} +

+
+
+ )} + +
+ +
+

+ {t('quality.recommendations.documentation')} +

+

+ {t('quality.recommendations.photo_documentation_helps')} +

+
+
+
+ + {/* Action Items */} +
+
+ {t('quality.recommended_actions')} +
+
+ {defectData[0] && ( +

• {t('quality.actions.focus_on')} {t(`quality.defects.${defectData[0].type}`).toLowerCase()}

+ )} + {defectData.some(d => d.trend === 'up') && ( +

• {t('quality.actions.investigate_increasing_defects')}

+ )} +

• {t('quality.actions.review_process_controls')}

+
+
+ + )} +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/analytics/production/widgets/WasteDefectTrackerWidget.tsx b/frontend/src/components/analytics/production/widgets/WasteDefectTrackerWidget.tsx new file mode 100644 index 00000000..d924d6d2 --- /dev/null +++ b/frontend/src/components/analytics/production/widgets/WasteDefectTrackerWidget.tsx @@ -0,0 +1,323 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { AlertTriangle, Trash2, Target, TrendingDown } from 'lucide-react'; +import { AnalyticsWidget } from '../AnalyticsWidget'; +import { AnalyticsChart, ChartSeries } from '../AnalyticsChart'; +import { Button, Badge } from '../../../ui'; +import { useActiveBatches } from '../../../../api/hooks/production'; +import { useCurrentTenant } from '../../../../stores/tenant.store'; + +interface WasteSource { + source: string; + count: number; + percentage: number; + cost: number; + severity: 'low' | 'medium' | 'high'; +} + +export const WasteDefectTrackerWidget: React.FC = () => { + const { t } = useTranslation('production'); + const currentTenant = useCurrentTenant(); + const tenantId = currentTenant?.id || ''; + + const { data: batchesData, isLoading, error } = useActiveBatches(tenantId); + const batches = batchesData?.batches || []; + + // Calculate waste and defect data from batches + const getWasteData = () => { + const totalUnits = batches.reduce((sum, batch) => sum + (batch.actual_quantity || batch.planned_quantity || 0), 0); + const totalWaste = batches.reduce((sum, batch) => sum + (batch.waste_quantity || 0), 0); + const totalDefects = batches.reduce((sum, batch) => sum + (batch.defect_quantity || 0), 0); + + const wastePercentage = totalUnits > 0 ? (totalWaste / totalUnits) * 100 : 0; + const defectPercentage = totalUnits > 0 ? (totalDefects / totalUnits) * 100 : 0; + + return { + totalUnits, + totalWaste, + totalDefects, + wastePercentage, + defectPercentage + }; + }; + + const wasteData = getWasteData(); + + // Mock waste sources data (in real implementation, this would come from quality check defect types) + const getWasteSources = (): WasteSource[] => { + return [ + { + source: t('quality.defects.burnt'), + count: Math.round(wasteData.totalWaste * 0.35), + percentage: 35, + cost: 45.20, + severity: 'high' + }, + { + source: t('quality.defects.misshapen'), + count: Math.round(wasteData.totalWaste * 0.28), + percentage: 28, + cost: 32.15, + severity: 'medium' + }, + { + source: t('quality.defects.underproofed'), + count: Math.round(wasteData.totalWaste * 0.20), + percentage: 20, + cost: 28.90, + severity: 'medium' + }, + { + source: t('quality.defects.temperature_issues'), + count: Math.round(wasteData.totalWaste * 0.12), + percentage: 12, + cost: 18.70, + severity: 'low' + }, + { + source: t('quality.defects.expired'), + count: Math.round(wasteData.totalWaste * 0.05), + percentage: 5, + cost: 8.40, + severity: 'low' + } + ].filter(source => source.count > 0); + }; + + const wasteSources = getWasteSources(); + const totalWasteCost = wasteSources.reduce((sum, source) => sum + source.cost, 0); + + // Create pie chart for waste sources + const getWasteSourcesChartData = (): ChartSeries[] => { + if (wasteSources.length === 0) return []; + + const colors = ['#dc2626', '#ea580c', '#d97706', '#ca8a04', '#65a30d']; + const pieData = wasteSources.map((source, index) => ({ + x: source.source, + y: source.count, + label: source.source, + color: colors[index % colors.length] + })); + + return [ + { + id: 'waste-sources', + name: t('quality.waste_sources'), + type: 'pie', + color: '#dc2626', + data: pieData + } + ]; + }; + + // Create trend chart for waste over time + const getWasteTrendChartData = (): ChartSeries[] => { + const days = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom']; + const wasteData = days.map((day, index) => ({ + x: day, + y: Math.max(1, 8 - index + Math.random() * 3), // Decreasing trend with some variation + label: day + })); + + const defectData = days.map((day, index) => ({ + x: day, + y: Math.max(0.5, 5 - index * 0.5 + Math.random() * 2), + label: day + })); + + return [ + { + id: 'waste-trend', + name: t('quality.waste_percentage'), + type: 'line', + color: '#dc2626', + data: wasteData + }, + { + id: 'defect-trend', + name: t('quality.defect_percentage'), + type: 'line', + color: '#ea580c', + data: defectData + } + ]; + }; + + const getSeverityBadgeVariant = (severity: WasteSource['severity']) => { + switch (severity) { + case 'high': return 'error'; + case 'medium': return 'warning'; + case 'low': return 'info'; + default: return 'default'; + } + }; + + const getWasteStatus = () => { + const totalWastePercentage = wasteData.wastePercentage + wasteData.defectPercentage; + if (totalWastePercentage <= 3) return { status: 'excellent', color: 'text-green-600', bgColor: 'bg-green-100 dark:bg-green-900/20' }; + if (totalWastePercentage <= 5) return { status: 'good', color: 'text-blue-600', bgColor: 'bg-blue-100 dark:bg-blue-900/20' }; + if (totalWastePercentage <= 8) return { status: 'warning', color: 'text-orange-600', bgColor: 'bg-orange-100 dark:bg-orange-900/20' }; + return { status: 'critical', color: 'text-red-600', bgColor: 'bg-red-100 dark:bg-red-900/20' }; + }; + + const wasteStatus = getWasteStatus(); + const totalWastePercentage = wasteData.wastePercentage + wasteData.defectPercentage; + + return ( + + + {t('quality.actions.reduce_waste')} + + } + > +
+ {/* Overall Waste Metrics */} +
+
+
+ + + {totalWastePercentage.toFixed(1)}% + +
+

{t('quality.total_waste')}

+
+
+
+ + + {wasteData.totalDefects} + +
+

{t('quality.total_defects')}

+
+
+
+ + + €{totalWasteCost.toFixed(0)} + +
+

{t('cost.waste_cost')}

+
+
+ + {/* Waste Status */} +
+
+ + + {t(`quality.status.${wasteStatus.status}`)} + +
+
+ + {wasteSources.length === 0 ? ( +
+ +

{t('quality.no_waste_data')}

+
+ ) : ( + <> + {/* Waste Sources Breakdown */} +
+

+ {t('quality.top_waste_sources')} +

+
+ {wasteSources.slice(0, 5).map((source, index) => ( +
+
+
+
+

+ {source.source} +

+

+ {source.count} {t('common.units')} • €{source.cost.toFixed(2)} +

+
+
+
+ + {source.percentage}% + + + {t(`quality.severity.${source.severity}`)} + +
+
+ ))} +
+
+ + {/* Waste Sources Pie Chart */} +
+

+ {t('quality.waste_distribution')} +

+ +
+ + {/* Weekly Waste Trend */} +
+

+ {t('quality.weekly_waste_trend')} +

+ +
+ + {/* Reduction Recommendations */} +
+ {wasteSources.filter(s => s.severity === 'high').length > 0 && ( +
+ +
+

+ {t('quality.recommendations.high_waste_detected')} +

+

+ {t('quality.recommendations.check_temperature_timing')} +

+
+
+ )} + +
+ +
+

+ {t('quality.recommendations.improvement_opportunity')} +

+

+ {totalWastePercentage > 5 + ? t('quality.recommendations.reduce_waste_target', { target: '3%' }) + : t('quality.recommendations.maintain_quality_standards') + } +

+
+
+
+ + )} +
+ + ); +}; \ No newline at end of file diff --git a/frontend/src/components/analytics/production/widgets/YieldPerformanceWidget.tsx b/frontend/src/components/analytics/production/widgets/YieldPerformanceWidget.tsx new file mode 100644 index 00000000..1b7de6e2 --- /dev/null +++ b/frontend/src/components/analytics/production/widgets/YieldPerformanceWidget.tsx @@ -0,0 +1,321 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Target, TrendingUp, Award, BarChart3 } from 'lucide-react'; +import { AnalyticsWidget } from '../AnalyticsWidget'; +import { AnalyticsChart, ChartSeries } from '../AnalyticsChart'; +import { Button, Badge } from '../../../ui'; +import { useActiveBatches } from '../../../../api/hooks/production'; +import { useCurrentTenant } from '../../../../stores/tenant.store'; + +interface ProductYieldData { + product: string; + averageYield: number; + bestYield: number; + worstYield: number; + batchCount: number; + trend: 'up' | 'down' | 'stable'; + performance: 'excellent' | 'good' | 'average' | 'poor'; +} + +export const YieldPerformanceWidget: React.FC = () => { + const { t } = useTranslation('production'); + const currentTenant = useCurrentTenant(); + const tenantId = currentTenant?.id || ''; + + const { data: batchesData, isLoading, error } = useActiveBatches(tenantId); + const batches = batchesData?.batches || []; + + // Calculate yield data from batches + const getProductYieldData = (): ProductYieldData[] => { + const productMap = new Map(); + + batches.forEach(batch => { + if (batch.yield_percentage) { + const product = batch.product_name; + if (!productMap.has(product)) { + productMap.set(product, []); + } + productMap.get(product)!.push(batch.yield_percentage); + } + }); + + return Array.from(productMap.entries()).map(([product, yields]) => { + const averageYield = yields.reduce((sum, yieldValue) => sum + yieldValue, 0) / yields.length; + const bestYield = Math.max(...yields); + const worstYield = Math.min(...yields); + + // Simulate trend calculation (in real implementation, this would compare with historical data) + const trend: 'up' | 'down' | 'stable' = + averageYield > 92 ? 'up' : + averageYield < 88 ? 'down' : 'stable'; + + const performance: 'excellent' | 'good' | 'average' | 'poor' = + averageYield >= 95 ? 'excellent' : + averageYield >= 90 ? 'good' : + averageYield >= 85 ? 'average' : 'poor'; + + return { + product, + averageYield, + bestYield, + worstYield, + batchCount: yields.length, + trend, + performance + }; + }).sort((a, b) => b.averageYield - a.averageYield); + }; + + const productYieldData = getProductYieldData(); + const overallYield = productYieldData.length > 0 + ? productYieldData.reduce((sum, item) => sum + item.averageYield, 0) / productYieldData.length + : 0; + + // Create yield comparison chart + const getYieldComparisonChartData = (): ChartSeries[] => { + if (productYieldData.length === 0) return []; + + const averageData = productYieldData.map(item => ({ + x: item.product, + y: item.averageYield, + label: item.product + })); + + const bestData = productYieldData.map(item => ({ + x: item.product, + y: item.bestYield, + label: item.product + })); + + return [ + { + id: 'average-yield', + name: t('stats.average_yield'), + type: 'bar', + color: '#d97706', + data: averageData + }, + { + id: 'best-yield', + name: t('stats.best_yield'), + type: 'bar', + color: '#16a34a', + data: bestData + } + ]; + }; + + // Create yield trend over time + const getYieldTrendChartData = (): ChartSeries[] => { + const days = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom']; + const trendData = days.map((day, index) => ({ + x: day, + y: Math.max(85, 90 + index * 0.5 + Math.random() * 3), // Slightly improving trend + label: day + })); + + return [ + { + id: 'yield-trend', + name: t('stats.yield_trend'), + type: 'line', + color: '#16a34a', + data: trendData + } + ]; + }; + + const getPerformanceBadgeVariant = (performance: ProductYieldData['performance']) => { + switch (performance) { + case 'excellent': return 'success'; + case 'good': return 'info'; + case 'average': return 'warning'; + case 'poor': return 'error'; + default: return 'default'; + } + }; + + const getTrendIcon = (trend: ProductYieldData['trend']) => { + switch (trend) { + case 'up': return TrendingUp; + case 'down': return TrendingUp; // We'll rotate it in CSS for down + case 'stable': return BarChart3; + default: return BarChart3; + } + }; + + const getTrendColor = (trend: ProductYieldData['trend']) => { + switch (trend) { + case 'up': return 'text-green-600'; + case 'down': return 'text-red-600'; + case 'stable': return 'text-blue-600'; + default: return 'text-gray-600'; + } + }; + + const getYieldStatus = () => { + if (overallYield >= 95) return { status: 'excellent', color: 'text-green-600', bgColor: 'bg-green-100 dark:bg-green-900/20' }; + if (overallYield >= 90) return { status: 'good', color: 'text-blue-600', bgColor: 'bg-blue-100 dark:bg-blue-900/20' }; + if (overallYield >= 85) return { status: 'average', color: 'text-orange-600', bgColor: 'bg-orange-100 dark:bg-orange-900/20' }; + return { status: 'poor', color: 'text-red-600', bgColor: 'bg-red-100 dark:bg-red-900/20' }; + }; + + const yieldStatus = getYieldStatus(); + + return ( + + + {t('actions.optimize_yields')} + + } + > +
+ {/* Overall Yield Status */} +
+
+ + + {overallYield.toFixed(1)}% + +
+
+ + {t(`performance.${yieldStatus.status}`)} + +
+
+ + {productYieldData.length === 0 ? ( +
+ +

{t('stats.no_yield_data')}

+
+ ) : ( + <> + {/* Yield Leaderboard */} +
+

+ + {t('stats.product_leaderboard')} +

+
+ {productYieldData.map((item, index) => { + const TrendIcon = getTrendIcon(item.trend); + const trendColor = getTrendColor(item.trend); + + return ( +
+
+
+ {index + 1} +
+
+

+ {item.product} +

+
+ {item.batchCount} {t('stats.batches')} + + {item.bestYield.toFixed(1)}% {t('stats.best')} +
+
+
+
+
+ +
+
+
+ {item.averageYield.toFixed(1)}% +
+ + {t(`performance.${item.performance}`)} + +
+
+
+ ); + })} +
+
+ + {/* Yield Comparison Chart */} +
+

+ {t('stats.yield_comparison')} +

+ +
+ + {/* Weekly Yield Trend */} +
+

+ {t('stats.weekly_yield_trend')} +

+ +
+ + {/* Yield Insights */} +
+
+

+ {productYieldData.filter(p => p.performance === 'excellent').length} +

+

{t('performance.excellent')}

+
+
+

+ {productYieldData.filter(p => p.performance === 'good').length} +

+

{t('performance.good')}

+
+
+

+ {productYieldData.filter(p => p.performance === 'average' || p.performance === 'poor').length} +

+

{t('performance.needs_improvement')}

+
+
+ + {/* Improvement Recommendations */} +
+
+ +
+

+ {t('recommendations.yield_improvement')} +

+

+ {overallYield < 90 + ? t('recommendations.focus_on_low_performers') + : t('recommendations.maintain_high_standards') + } +

+
+
+
+ + )} +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/analytics/production/widgets/index.ts b/frontend/src/components/analytics/production/widgets/index.ts new file mode 100644 index 00000000..70193171 --- /dev/null +++ b/frontend/src/components/analytics/production/widgets/index.ts @@ -0,0 +1,38 @@ +// Bakery Operations Overview Widgets +export { TodaysScheduleSummaryWidget } from './TodaysScheduleSummaryWidget'; +export { LiveBatchTrackerWidget } from './LiveBatchTrackerWidget'; +export { OnTimeCompletionWidget } from './OnTimeCompletionWidget'; +export { CapacityUtilizationWidget } from './CapacityUtilizationWidget'; + +// Cost & Efficiency Monitoring Widgets +export { CostPerUnitWidget } from './CostPerUnitWidget'; +export { WasteDefectTrackerWidget } from './WasteDefectTrackerWidget'; +export { YieldPerformanceWidget } from './YieldPerformanceWidget'; + +// Quality Assurance Panel Widgets +export { QualityScoreTrendsWidget } from './QualityScoreTrendsWidget'; +export { TopDefectTypesWidget } from './TopDefectTypesWidget'; + +// Equipment & Maintenance Widgets +export { EquipmentStatusWidget } from './EquipmentStatusWidget'; +export { MaintenanceScheduleWidget } from './MaintenanceScheduleWidget'; +export { EquipmentEfficiencyWidget } from './EquipmentEfficiencyWidget'; + +// AI Insights & Predictive Analytics Widgets +export { AIInsightsWidget } from './AIInsightsWidget'; +export { PredictiveMaintenanceWidget } from './PredictiveMaintenanceWidget'; + +import React from 'react'; + +// Widget Types +export interface WidgetConfig { + id: string; + title: string; + component: React.ComponentType; + category: 'operations' | 'cost' | 'quality' | 'equipment' | 'ai'; + size: 'sm' | 'md' | 'lg' | 'xl'; + minSubscription?: 'basic' | 'professional' | 'enterprise'; +} + +// Export widget configurations (temporarily commented out due to import issues) +// export const WIDGET_CONFIGS: WidgetConfig[] = []; \ No newline at end of file diff --git a/frontend/src/components/domain/production/BatchTracker.tsx b/frontend/src/components/domain/production/BatchTracker.tsx deleted file mode 100644 index 77bb8dcf..00000000 --- a/frontend/src/components/domain/production/BatchTracker.tsx +++ /dev/null @@ -1,680 +0,0 @@ -import React, { useState, useCallback, useEffect } from 'react'; -import { Card, Button, Badge, Input, Modal, Table, Select, DatePicker } from '../../ui'; -import { productionService, type ProductionBatchResponse, ProductionBatchStatus, ProductionPriorityEnum } from '../../../api'; -import type { ProductionBatch, QualityCheck } from '../../../types/production.types'; - -interface BatchTrackerProps { - className?: string; - batchId?: string; - onStageUpdate?: (batch: ProductionBatch, newStage: ProductionStage) => void; - onQualityCheckRequired?: (batch: ProductionBatch, stage: ProductionStage) => void; -} - -interface ProductionStage { - id: string; - name: string; - spanishName: string; - icon: string; - estimatedMinutes: number; - requiresQualityCheck: boolean; - criticalControlPoint: boolean; - completedAt?: string; - notes?: string; - nextStages: string[]; - temperature?: { - min: number; - max: number; - unit: string; - }; -} - -const PRODUCTION_STAGES: Record = { - mixing: { - id: 'mixing', - name: 'Mixing', - spanishName: 'Amasado', - icon: '🥄', - estimatedMinutes: 15, - requiresQualityCheck: true, - criticalControlPoint: true, - nextStages: ['resting'], - temperature: { min: 22, max: 26, unit: '°C' }, - }, - resting: { - id: 'resting', - name: 'Resting', - spanishName: 'Reposo', - icon: '⏰', - estimatedMinutes: 60, - requiresQualityCheck: false, - criticalControlPoint: false, - nextStages: ['shaping', 'fermentation'], - }, - shaping: { - id: 'shaping', - name: 'Shaping', - spanishName: 'Formado', - icon: '✋', - estimatedMinutes: 20, - requiresQualityCheck: true, - criticalControlPoint: false, - nextStages: ['fermentation'], - }, - fermentation: { - id: 'fermentation', - name: 'Fermentation', - spanishName: 'Fermentado', - icon: '🫧', - estimatedMinutes: 120, - requiresQualityCheck: true, - criticalControlPoint: true, - nextStages: ['baking'], - temperature: { min: 28, max: 32, unit: '°C' }, - }, - baking: { - id: 'baking', - name: 'Baking', - spanishName: 'Horneado', - icon: '🔥', - estimatedMinutes: 45, - requiresQualityCheck: true, - criticalControlPoint: true, - nextStages: ['cooling'], - temperature: { min: 180, max: 220, unit: '°C' }, - }, - cooling: { - id: 'cooling', - name: 'Cooling', - spanishName: 'Enfriado', - icon: '❄️', - estimatedMinutes: 90, - requiresQualityCheck: false, - criticalControlPoint: false, - nextStages: ['packaging'], - temperature: { min: 18, max: 25, unit: '°C' }, - }, - packaging: { - id: 'packaging', - name: 'Packaging', - spanishName: 'Empaquetado', - icon: '📦', - estimatedMinutes: 15, - requiresQualityCheck: true, - criticalControlPoint: false, - nextStages: ['completed'], - }, - completed: { - id: 'completed', - name: 'Completed', - spanishName: 'Completado', - icon: '✅', - estimatedMinutes: 0, - requiresQualityCheck: false, - criticalControlPoint: false, - nextStages: [], - }, -}; - -const STATUS_COLORS = { - [ProductionBatchStatus.PLANNED]: 'bg-[var(--color-info)]/10 text-[var(--color-info)] border-[var(--color-info)]/20', - [ProductionBatchStatus.IN_PROGRESS]: 'bg-yellow-100 text-yellow-800 border-yellow-200', - [ProductionBatchStatus.COMPLETED]: 'bg-[var(--color-success)]/10 text-[var(--color-success)] border-green-200', - [ProductionBatchStatus.CANCELLED]: 'bg-[var(--color-error)]/10 text-[var(--color-error)] border-red-200', - [ProductionBatchStatus.ON_HOLD]: 'bg-[var(--bg-tertiary)] text-[var(--text-primary)] border-[var(--border-primary)]', -}; - -const PRIORITY_COLORS = { - [ProductionPriorityEnum.LOW]: 'bg-[var(--bg-tertiary)] text-[var(--text-primary)]', - [ProductionPriorityEnum.NORMAL]: 'bg-[var(--color-info)]/10 text-[var(--color-info)]', - [ProductionPriorityEnum.HIGH]: 'bg-[var(--color-primary)]/10 text-[var(--color-primary)]', - [ProductionPriorityEnum.URGENT]: 'bg-[var(--color-error)]/10 text-[var(--color-error)]', -}; - -export const BatchTracker: React.FC = ({ - className = '', - batchId, - onStageUpdate, - onQualityCheckRequired, -}) => { - const [batches, setBatches] = useState([]); - const [selectedBatch, setSelectedBatch] = useState(null); - const [loading, setLoading] = useState(false); - const [currentStage, setCurrentStage] = useState('mixing'); - const [stageNotes, setStageNotes] = useState>({}); - const [isStageModalOpen, setIsStageModalOpen] = useState(false); - const [selectedStageForUpdate, setSelectedStageForUpdate] = useState(null); - const [alerts, setAlerts] = useState>([]); - - const loadBatches = useCallback(async () => { - setLoading(true); - try { - const response = await productionService.getProductionBatches({ - status: ProductionBatchStatus.IN_PROGRESS, - }); - - if (response.success && response.data) { - setBatches(response.data.items || []); - - if (batchId) { - const specificBatch = response.data.items.find(b => b.id === batchId); - if (specificBatch) { - setSelectedBatch(specificBatch); - } - } else if (response.data.items.length > 0) { - setSelectedBatch(response.data.items[0]); - } - } - } catch (error) { - console.error('Error loading batches:', error); - } finally { - setLoading(false); - } - }, [batchId]); - - useEffect(() => { - loadBatches(); - - // Mock alerts for demonstration - setAlerts([ - { - id: '1', - batchId: 'batch-1', - stage: 'fermentation', - type: 'overdue', - message: 'El fermentado ha superado el tiempo estimado en 30 minutos', - severity: 'medium', - timestamp: new Date().toISOString(), - }, - { - id: '2', - batchId: 'batch-2', - stage: 'baking', - type: 'temperature', - message: 'Temperatura del horno fuera del rango óptimo (185°C)', - severity: 'high', - timestamp: new Date().toISOString(), - }, - ]); - }, [loadBatches]); - - const getCurrentStageInfo = (batch: ProductionBatchResponse): { stage: ProductionStage; progress: number } => { - // This would typically come from the batch data - // For demo purposes, we'll simulate based on batch status - let stageId = 'mixing'; - let progress = 0; - - if (batch.status === ProductionBatchStatus.IN_PROGRESS) { - // Simulate current stage based on time elapsed - const startTime = new Date(batch.actual_start_date || batch.planned_start_date); - const now = new Date(); - const elapsedMinutes = (now.getTime() - startTime.getTime()) / (1000 * 60); - - let cumulativeTime = 0; - const stageKeys = Object.keys(PRODUCTION_STAGES).slice(0, -1); // Exclude completed - - for (const key of stageKeys) { - const stage = PRODUCTION_STAGES[key]; - cumulativeTime += stage.estimatedMinutes; - if (elapsedMinutes <= cumulativeTime) { - stageId = key; - const stageStartTime = cumulativeTime - stage.estimatedMinutes; - progress = Math.min(100, ((elapsedMinutes - stageStartTime) / stage.estimatedMinutes) * 100); - break; - } - } - } - - return { - stage: PRODUCTION_STAGES[stageId], - progress: Math.max(0, progress), - }; - }; - - const getTimeRemaining = (batch: ProductionBatchResponse, stage: ProductionStage): string => { - const startTime = new Date(batch.actual_start_date || batch.planned_start_date); - const now = new Date(); - const elapsedMinutes = (now.getTime() - startTime.getTime()) / (1000 * 60); - - // Calculate when this stage should complete based on cumulative time - let cumulativeTime = 0; - const stageKeys = Object.keys(PRODUCTION_STAGES); - const currentStageIndex = stageKeys.indexOf(stage.id); - - for (let i = 0; i <= currentStageIndex; i++) { - cumulativeTime += PRODUCTION_STAGES[stageKeys[i]].estimatedMinutes; - } - - const remainingMinutes = Math.max(0, cumulativeTime - elapsedMinutes); - const hours = Math.floor(remainingMinutes / 60); - const minutes = Math.floor(remainingMinutes % 60); - - if (hours > 0) { - return `${hours}h ${minutes}m restantes`; - } else { - return `${minutes}m restantes`; - } - }; - - const updateStage = async (batchId: string, newStage: string, notes?: string) => { - try { - // This would update the batch stage in the backend - const updatedBatch = batches.find(b => b.id === batchId); - if (updatedBatch && onStageUpdate) { - onStageUpdate(updatedBatch as unknown as ProductionBatch, PRODUCTION_STAGES[newStage]); - } - - // Update local state - if (notes) { - setStageNotes(prev => ({ - ...prev, - [`${batchId}-${newStage}`]: notes, - })); - } - - await loadBatches(); - } catch (error) { - console.error('Error updating stage:', error); - } - }; - - const handleQualityCheck = (batch: ProductionBatchResponse, stage: ProductionStage) => { - if (onQualityCheckRequired) { - onQualityCheckRequired(batch as unknown as ProductionBatch, stage); - } - }; - - const renderStageProgress = (batch: ProductionBatchResponse) => { - const { stage: currentStage, progress } = getCurrentStageInfo(batch); - const stages = Object.values(PRODUCTION_STAGES).slice(0, -1); // Exclude completed - - return ( -
-
-

Progreso del lote

- - {batch.status === ProductionBatchStatus.IN_PROGRESS && 'En progreso'} - {batch.status === ProductionBatchStatus.PLANNED && 'Planificado'} - {batch.status === ProductionBatchStatus.COMPLETED && 'Completado'} - -
- -
- {stages.map((stage, index) => { - const isActive = stage.id === currentStage.id; - const isCompleted = stages.findIndex(s => s.id === currentStage.id) > index; - const isOverdue = false; // Would be calculated based on actual timing - - return ( - { - setSelectedStageForUpdate(stage); - setIsStageModalOpen(true); - }} - > -
-
- {stage.icon} - {stage.spanishName} -
- {stage.criticalControlPoint && ( - - PCC - - )} -
- - {isActive && ( -
-
- Progreso - {Math.round(progress)}% -
-
-
-
-

- {getTimeRemaining(batch, stage)} -

-
- )} - - {isCompleted && ( -
- - Completado -
- )} - - {!isActive && !isCompleted && ( -

- ~{stage.estimatedMinutes}min -

- )} - - {stage.temperature && isActive && ( -

- 🌡️ {stage.temperature.min}-{stage.temperature.max}{stage.temperature.unit} -

- )} - - ); - })} -
-
- ); - }; - - const renderBatchDetails = (batch: ProductionBatchResponse) => ( - -
-
-

{batch.recipe?.name || 'Producto'}

-

Lote #{batch.batch_number}

- - {batch.priority === ProductionPriorityEnum.LOW && 'Baja'} - {batch.priority === ProductionPriorityEnum.NORMAL && 'Normal'} - {batch.priority === ProductionPriorityEnum.HIGH && 'Alta'} - {batch.priority === ProductionPriorityEnum.URGENT && 'Urgente'} - -
- -
-

Cantidad planificada

-

{batch.planned_quantity} unidades

- {batch.actual_quantity && ( - <> -

Cantidad real

-

{batch.actual_quantity} unidades

- - )} -
- -
-

Inicio planificado

-

- {new Date(batch.planned_start_date).toLocaleString('es-ES')} -

- {batch.actual_start_date && ( - <> -

Inicio real

-

- {new Date(batch.actual_start_date).toLocaleString('es-ES')} -

- - )} -
-
- - {batch.notes && ( -
-

{batch.notes}

-
- )} -
- ); - - const renderAlerts = () => ( - -

- 🚨 Alertas activas -

- - {alerts.length === 0 ? ( -

No hay alertas activas

- ) : ( -
- {alerts.map((alert) => ( -
-
-
-

- Lote #{batches.find(b => b.id === alert.batchId)?.batch_number} - {PRODUCTION_STAGES[alert.stage]?.spanishName} -

-

{alert.message}

-
- - {alert.severity === 'high' && 'Alta'} - {alert.severity === 'medium' && 'Media'} - {alert.severity === 'low' && 'Baja'} - -
-

- {new Date(alert.timestamp).toLocaleTimeString('es-ES')} -

-
- ))} -
- )} -
- ); - - return ( -
-
-
-

Seguimiento de Lotes

-

Rastrea el progreso de los lotes a través de las etapas de producción

-
- - {selectedBatch && ( -
- - - -
- )} -
- - {loading ? ( -
-
-
- ) : ( - <> - {selectedBatch ? ( -
- {renderBatchDetails(selectedBatch)} - {renderStageProgress(selectedBatch)} - {renderAlerts()} -
- ) : ( - -

No hay lotes en producción actualmente

- -
- )} - - )} - - {/* Stage Update Modal */} - { - setIsStageModalOpen(false); - setSelectedStageForUpdate(null); - }} - title={`${selectedStageForUpdate?.spanishName} - Lote #${selectedBatch?.batch_number}`} - > - {selectedStageForUpdate && selectedBatch && ( -
-
-

- {selectedStageForUpdate.icon} - {selectedStageForUpdate.spanishName} -

-

- Duración estimada: {selectedStageForUpdate.estimatedMinutes} minutos -

- - {selectedStageForUpdate.temperature && ( -

- 🌡️ Temperatura: {selectedStageForUpdate.temperature.min}-{selectedStageForUpdate.temperature.max}{selectedStageForUpdate.temperature.unit} -

- )} - - {selectedStageForUpdate.criticalControlPoint && ( - - Punto Crítico de Control (PCC) - - )} -
- - {selectedStageForUpdate.requiresQualityCheck && ( -
-

- ⚠️ Esta etapa requiere control de calidad antes de continuar -

- -
- )} - -
- - setStageNotes(prev => ({ - ...prev, - [`${selectedBatch.id}-${selectedStageForUpdate.id}`]: e.target.value, - }))} - /> -
- -
- - - -
-
- )} -
- - {/* Quick Stats */} -
- -
-
-

Lotes activos

-

{batches.length}

-
- 📊 -
-
- - -
-
-

Alertas activas

-

{alerts.length}

-
- 🚨 -
-
- - -
-
-

En horneado

-

3

-
- 🔥 -
-
- - -
-
-

Completados hoy

-

12

-
- -
-
-
-
- ); -}; - -export default BatchTracker; \ No newline at end of file diff --git a/frontend/src/components/domain/production/EquipmentManager.tsx b/frontend/src/components/domain/production/EquipmentManager.tsx deleted file mode 100644 index d87e0136..00000000 --- a/frontend/src/components/domain/production/EquipmentManager.tsx +++ /dev/null @@ -1,632 +0,0 @@ -import React, { useState, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Card, CardHeader, CardBody } from '../../ui/Card'; -import { Button } from '../../ui/Button'; -import { Input } from '../../ui/Input'; -import { Badge } from '../../ui/Badge'; -import { Modal } from '../../ui/Modal'; -import { Tabs, TabsList, TabsTrigger, TabsContent } from '../../ui/Tabs'; -import { StatsGrid } from '../../ui/Stats'; -import { - Settings, - AlertTriangle, - CheckCircle, - Wrench, - Calendar, - Clock, - Thermometer, - Activity, - Zap, - TrendingUp, - Search, - Plus, - Filter, - Download, - BarChart3, - Bell, - MapPin, - User -} from 'lucide-react'; -import { useCurrentTenant } from '../../../stores/tenant.store'; - -export interface Equipment { - id: string; - name: string; - type: 'oven' | 'mixer' | 'proofer' | 'freezer' | 'packaging' | 'other'; - model: string; - serialNumber: string; - location: string; - status: 'operational' | 'maintenance' | 'down' | 'warning'; - installDate: string; - lastMaintenance: string; - nextMaintenance: string; - maintenanceInterval: number; // days - temperature?: number; - targetTemperature?: number; - efficiency: number; - uptime: number; - energyUsage: number; - utilizationToday: number; - alerts: Array<{ - id: string; - type: 'warning' | 'critical' | 'info'; - message: string; - timestamp: string; - acknowledged: boolean; - }>; - maintenanceHistory: Array<{ - id: string; - date: string; - type: 'preventive' | 'corrective' | 'emergency'; - description: string; - technician: string; - cost: number; - downtime: number; // hours - partsUsed: string[]; - }>; - specifications: { - power: number; // kW - capacity: number; - dimensions: { - width: number; - height: number; - depth: number; - }; - weight: number; - }; -} - -export interface EquipmentManagerProps { - className?: string; - equipment?: Equipment[]; - onCreateEquipment?: () => void; - onEditEquipment?: (equipmentId: string) => void; - onScheduleMaintenance?: (equipmentId: string) => void; - onAcknowledgeAlert?: (equipmentId: string, alertId: string) => void; - onViewMaintenanceHistory?: (equipmentId: string) => void; -} - -const MOCK_EQUIPMENT: Equipment[] = [ - { - id: '1', - name: 'Horno Principal #1', - type: 'oven', - model: 'Miwe Condo CO 4.1212', - serialNumber: 'MCO-2021-001', - location: 'rea de Horneado - Zona A', - status: 'operational', - installDate: '2021-03-15', - lastMaintenance: '2024-01-15', - nextMaintenance: '2024-04-15', - maintenanceInterval: 90, - temperature: 220, - targetTemperature: 220, - efficiency: 92, - uptime: 98.5, - energyUsage: 45.2, - utilizationToday: 87, - alerts: [], - maintenanceHistory: [ - { - id: '1', - date: '2024-01-15', - type: 'preventive', - description: 'Limpieza general y calibracin de termostatos', - technician: 'Juan Prez', - cost: 150, - downtime: 2, - partsUsed: ['Filtros de aire', 'Sellos de puerta'] - } - ], - specifications: { - power: 45, - capacity: 24, - dimensions: { width: 200, height: 180, depth: 120 }, - weight: 850 - } - }, - { - id: '2', - name: 'Batidora Industrial #2', - type: 'mixer', - model: 'Hobart HL800', - serialNumber: 'HHL-2020-002', - location: 'rea de Preparacin - Zona B', - status: 'warning', - installDate: '2020-08-10', - lastMaintenance: '2024-01-20', - nextMaintenance: '2024-02-20', - maintenanceInterval: 30, - efficiency: 88, - uptime: 94.2, - energyUsage: 12.8, - utilizationToday: 76, - alerts: [ - { - id: '1', - type: 'warning', - message: 'Vibracin inusual detectada en el motor', - timestamp: '2024-01-23T10:30:00Z', - acknowledged: false - }, - { - id: '2', - type: 'info', - message: 'Mantenimiento programado en 5 das', - timestamp: '2024-01-23T08:00:00Z', - acknowledged: true - } - ], - maintenanceHistory: [ - { - id: '1', - date: '2024-01-20', - type: 'corrective', - description: 'Reemplazo de correas de transmisin', - technician: 'Mara Gonzlez', - cost: 85, - downtime: 4, - partsUsed: ['Correa tipo V', 'Rodamientos'] - } - ], - specifications: { - power: 15, - capacity: 80, - dimensions: { width: 120, height: 150, depth: 80 }, - weight: 320 - } - }, - { - id: '3', - name: 'Cmara de Fermentacin #1', - type: 'proofer', - model: 'Bongard EUROPA 16.18', - serialNumber: 'BEU-2022-001', - location: 'rea de Fermentacin', - status: 'maintenance', - installDate: '2022-06-20', - lastMaintenance: '2024-01-23', - nextMaintenance: '2024-01-24', - maintenanceInterval: 60, - temperature: 32, - targetTemperature: 35, - efficiency: 0, - uptime: 85.1, - energyUsage: 0, - utilizationToday: 0, - alerts: [ - { - id: '1', - type: 'info', - message: 'En mantenimiento programado', - timestamp: '2024-01-23T06:00:00Z', - acknowledged: true - } - ], - maintenanceHistory: [ - { - id: '1', - date: '2024-01-23', - type: 'preventive', - description: 'Mantenimiento programado - sistema de humidificacin', - technician: 'Carlos Rodrguez', - cost: 200, - downtime: 8, - partsUsed: ['Sensor de humedad', 'Vlvulas'] - } - ], - specifications: { - power: 8, - capacity: 16, - dimensions: { width: 180, height: 200, depth: 100 }, - weight: 450 - } - } -]; - -const EquipmentManager: React.FC = ({ - className, - equipment = MOCK_EQUIPMENT, - onCreateEquipment, - onEditEquipment, - onScheduleMaintenance, - onAcknowledgeAlert, - onViewMaintenanceHistory -}) => { - const { t } = useTranslation(); - const [activeTab, setActiveTab] = useState('overview'); - const [searchQuery, setSearchQuery] = useState(''); - const [statusFilter, setStatusFilter] = useState('all'); - const [selectedEquipment, setSelectedEquipment] = useState(null); - const [showEquipmentModal, setShowEquipmentModal] = useState(false); - - const filteredEquipment = useMemo(() => { - return equipment.filter(eq => { - const matchesSearch = !searchQuery || - eq.name.toLowerCase().includes(searchQuery.toLowerCase()) || - eq.location.toLowerCase().includes(searchQuery.toLowerCase()) || - eq.type.toLowerCase().includes(searchQuery.toLowerCase()); - - const matchesStatus = statusFilter === 'all' || eq.status === statusFilter; - - return matchesSearch && matchesStatus; - }); - }, [equipment, searchQuery, statusFilter]); - - const equipmentStats = useMemo(() => { - const total = equipment.length; - const operational = equipment.filter(e => e.status === 'operational').length; - const warning = equipment.filter(e => e.status === 'warning').length; - const maintenance = equipment.filter(e => e.status === 'maintenance').length; - const down = equipment.filter(e => e.status === 'down').length; - const avgEfficiency = equipment.reduce((sum, e) => sum + e.efficiency, 0) / total; - const avgUptime = equipment.reduce((sum, e) => sum + e.uptime, 0) / total; - const totalAlerts = equipment.reduce((sum, e) => sum + e.alerts.filter(a => !a.acknowledged).length, 0); - - return { - total, - operational, - warning, - maintenance, - down, - avgEfficiency, - avgUptime, - totalAlerts - }; - }, [equipment]); - - const getStatusConfig = (status: Equipment['status']) => { - const configs = { - operational: { color: 'success' as const, icon: CheckCircle, label: t('equipment.status.operational', 'Operational') }, - warning: { color: 'warning' as const, icon: AlertTriangle, label: t('equipment.status.warning', 'Warning') }, - maintenance: { color: 'info' as const, icon: Wrench, label: t('equipment.status.maintenance', 'Maintenance') }, - down: { color: 'error' as const, icon: AlertTriangle, label: t('equipment.status.down', 'Down') } - }; - return configs[status]; - }; - - const getTypeIcon = (type: Equipment['type']) => { - const icons = { - oven: Thermometer, - mixer: Activity, - proofer: Settings, - freezer: Zap, - packaging: Settings, - other: Settings - }; - return icons[type]; - }; - - const formatDateTime = (dateString: string) => { - return new Date(dateString).toLocaleDateString('es-ES', { - day: '2-digit', - month: '2-digit', - year: 'numeric' - }); - }; - - const stats = [ - { - title: t('equipment.stats.total', 'Total Equipment'), - value: equipmentStats.total, - icon: Settings, - variant: 'default' as const - }, - { - title: t('equipment.stats.operational', 'Operational'), - value: equipmentStats.operational, - icon: CheckCircle, - variant: 'success' as const, - subtitle: `${((equipmentStats.operational / equipmentStats.total) * 100).toFixed(1)}%` - }, - { - title: t('equipment.stats.avg_efficiency', 'Avg Efficiency'), - value: `${equipmentStats.avgEfficiency.toFixed(1)}%`, - icon: TrendingUp, - variant: equipmentStats.avgEfficiency >= 90 ? 'success' as const : 'warning' as const - }, - { - title: t('equipment.stats.alerts', 'Active Alerts'), - value: equipmentStats.totalAlerts, - icon: Bell, - variant: equipmentStats.totalAlerts === 0 ? 'success' as const : 'error' as const - } - ]; - - return ( - - -
-
-

- {t('equipment.manager.title', 'Equipment Management')} -

-

- {t('equipment.manager.subtitle', 'Monitor and manage production equipment')} -

-
-
- - -
-
-
- - - {/* Stats */} - - - {/* Filters */} -
-
-
- - setSearchQuery(e.target.value)} - className="pl-10" - /> -
-
-
- - -
-
- - {/* Equipment List */} - - - - {t('equipment.tabs.overview', 'Overview')} - - - {t('equipment.tabs.maintenance', 'Maintenance')} - - - {t('equipment.tabs.alerts', 'Alerts')} - - - - -
- {filteredEquipment.map((eq) => { - const statusConfig = getStatusConfig(eq.status); - const TypeIcon = getTypeIcon(eq.type); - const StatusIcon = statusConfig.icon; - - return ( -
{ - setSelectedEquipment(eq); - setShowEquipmentModal(true); - }} - > -
-
- -

{eq.name}

-
- - {statusConfig.label} - -
- -
-
- {t('equipment.efficiency', 'Efficiency')}: - {eq.efficiency}% -
-
- {t('equipment.uptime', 'Uptime')}: - {eq.uptime.toFixed(1)}% -
-
- {t('equipment.location', 'Location')}: - {eq.location} -
- {eq.temperature && ( -
- {t('equipment.temperature', 'Temperature')}: - {eq.temperature}C -
- )} -
- - {eq.alerts.filter(a => !a.acknowledged).length > 0 && ( -
-
- - - {eq.alerts.filter(a => !a.acknowledged).length} {t('equipment.unread_alerts', 'unread alerts')} - -
-
- )} - -
- - -
-
- ); - })} -
-
- - - {equipment.map((eq) => ( -
-
-

{eq.name}

- - {new Date(eq.nextMaintenance) <= new Date() ? t('equipment.maintenance.overdue', 'Overdue') : t('equipment.maintenance.scheduled', 'Scheduled')} - -
-
-
- {t('equipment.maintenance.last', 'Last')}: -
{formatDateTime(eq.lastMaintenance)}
-
-
- {t('equipment.maintenance.next', 'Next')}: -
{formatDateTime(eq.nextMaintenance)}
-
-
- {t('equipment.maintenance.interval', 'Interval')}: -
{eq.maintenanceInterval} {t('common.days', 'days')}
-
-
- {t('equipment.maintenance.history', 'History')}: -
{eq.maintenanceHistory.length} {t('equipment.maintenance.records', 'records')}
-
-
-
- ))} -
- - - {equipment.flatMap(eq => - eq.alerts.map(alert => ( -
-
-
- -

{eq.name}

- - {alert.acknowledged ? t('equipment.alerts.acknowledged', 'Acknowledged') : t('equipment.alerts.new', 'New')} - -
- - {new Date(alert.timestamp).toLocaleString('es-ES')} - -
-

{alert.message}

- {!alert.acknowledged && ( - - )} -
- )) - )} -
-
- - {/* Equipment Details Modal */} - {selectedEquipment && ( - { - setShowEquipmentModal(false); - setSelectedEquipment(null); - }} - title={selectedEquipment.name} - size="lg" - > -
- {/* Basic Info */} -
-
- -

{selectedEquipment.model}

-
-
- -

{selectedEquipment.serialNumber}

-
-
- -

{selectedEquipment.location}

-
-
- -

{formatDateTime(selectedEquipment.installDate)}

-
-
- - {/* Current Status */} -
-
-
{selectedEquipment.efficiency}%
-
{t('equipment.efficiency', 'Efficiency')}
-
-
-
{selectedEquipment.uptime.toFixed(1)}%
-
{t('equipment.uptime', 'Uptime')}
-
-
-
{selectedEquipment.energyUsage} kW
-
{t('equipment.energy_usage', 'Energy Usage')}
-
-
- - {/* Actions */} -
- - - -
-
-
- )} -
-
- ); -}; - -export default EquipmentManager; \ No newline at end of file diff --git a/frontend/src/components/domain/production/ProcessStageTracker.tsx b/frontend/src/components/domain/production/ProcessStageTracker.tsx deleted file mode 100644 index 0ec3b9a3..00000000 --- a/frontend/src/components/domain/production/ProcessStageTracker.tsx +++ /dev/null @@ -1,377 +0,0 @@ -import React from 'react'; -import { - CheckCircle, - Clock, - AlertTriangle, - Play, - Pause, - ArrowRight, - Settings, - Thermometer, - Scale, - Eye -} from 'lucide-react'; -import { Button, Badge, Card, ProgressBar } from '../../ui'; -import { ProcessStage } from '../../../api/types/qualityTemplates'; -import type { ProductionBatchResponse } from '../../../api/types/production'; - -interface ProcessStageTrackerProps { - batch: ProductionBatchResponse; - onStageAdvance?: (stage: ProcessStage) => void; - onQualityCheck?: (stage: ProcessStage) => void; - onStageStart?: (stage: ProcessStage) => void; - onStagePause?: (stage: ProcessStage) => void; - className?: string; -} - -const PROCESS_STAGES_ORDER: ProcessStage[] = [ - ProcessStage.MIXING, - ProcessStage.PROOFING, - ProcessStage.SHAPING, - ProcessStage.BAKING, - ProcessStage.COOLING, - ProcessStage.PACKAGING, - ProcessStage.FINISHING -]; - -const STAGE_CONFIG = { - [ProcessStage.MIXING]: { - label: 'Mezclado', - icon: Settings, - color: 'bg-blue-500', - description: 'Preparación y mezclado de ingredientes' - }, - [ProcessStage.PROOFING]: { - label: 'Fermentación', - icon: Clock, - color: 'bg-yellow-500', - description: 'Proceso de fermentación y crecimiento' - }, - [ProcessStage.SHAPING]: { - label: 'Formado', - icon: Settings, - color: 'bg-purple-500', - description: 'Dar forma al producto' - }, - [ProcessStage.BAKING]: { - label: 'Horneado', - icon: Thermometer, - color: 'bg-red-500', - description: 'Cocción en horno' - }, - [ProcessStage.COOLING]: { - label: 'Enfriado', - icon: Settings, - color: 'bg-cyan-500', - description: 'Enfriamiento del producto' - }, - [ProcessStage.PACKAGING]: { - label: 'Empaquetado', - icon: Settings, - color: 'bg-green-500', - description: 'Empaque del producto terminado' - }, - [ProcessStage.FINISHING]: { - label: 'Acabado', - icon: CheckCircle, - color: 'bg-indigo-500', - description: 'Toques finales y control final' - } -}; - -export const ProcessStageTracker: React.FC = ({ - batch, - onStageAdvance, - onQualityCheck, - onStageStart, - onStagePause, - className = '' -}) => { - const currentStage = batch.current_process_stage; - const stageHistory = batch.process_stage_history || {}; - const pendingQualityChecks = batch.pending_quality_checks || {}; - const completedQualityChecks = batch.completed_quality_checks || {}; - - const getCurrentStageIndex = () => { - if (!currentStage) return -1; - return PROCESS_STAGES_ORDER.indexOf(currentStage); - }; - - const isStageCompleted = (stage: ProcessStage): boolean => { - const stageIndex = PROCESS_STAGES_ORDER.indexOf(stage); - const currentIndex = getCurrentStageIndex(); - return stageIndex < currentIndex || (stageIndex === currentIndex && batch.status === 'COMPLETED'); - }; - - const isStageActive = (stage: ProcessStage): boolean => { - return currentStage === stage && batch.status === 'IN_PROGRESS'; - }; - - const isStageUpcoming = (stage: ProcessStage): boolean => { - const stageIndex = PROCESS_STAGES_ORDER.indexOf(stage); - const currentIndex = getCurrentStageIndex(); - return stageIndex > currentIndex; - }; - - const hasQualityChecksRequired = (stage: ProcessStage): boolean => { - return pendingQualityChecks[stage]?.length > 0; - }; - - const hasQualityChecksCompleted = (stage: ProcessStage): boolean => { - return completedQualityChecks[stage]?.length > 0; - }; - - const getStageProgress = (stage: ProcessStage): number => { - if (isStageCompleted(stage)) return 100; - if (isStageActive(stage)) { - // Calculate progress based on time elapsed vs estimated duration - const stageStartTime = stageHistory[stage]?.start_time; - if (stageStartTime) { - const elapsed = Date.now() - new Date(stageStartTime).getTime(); - const estimated = 30 * 60 * 1000; // Default 30 minutes per stage - return Math.min(100, (elapsed / estimated) * 100); - } - } - return 0; - }; - - const getStageStatus = (stage: ProcessStage) => { - if (isStageCompleted(stage)) { - return hasQualityChecksCompleted(stage) - ? { status: 'completed', icon: CheckCircle, color: 'text-green-500' } - : { status: 'completed-no-quality', icon: CheckCircle, color: 'text-yellow-500' }; - } - - if (isStageActive(stage)) { - return hasQualityChecksRequired(stage) - ? { status: 'active-quality-pending', icon: AlertTriangle, color: 'text-orange-500' } - : { status: 'active', icon: Play, color: 'text-blue-500' }; - } - - if (isStageUpcoming(stage)) { - return { status: 'upcoming', icon: Clock, color: 'text-gray-400' }; - } - - return { status: 'pending', icon: Clock, color: 'text-gray-400' }; - }; - - const getOverallProgress = (): number => { - const currentIndex = getCurrentStageIndex(); - if (currentIndex === -1) return 0; - - const stageProgress = getStageProgress(currentStage!); - return ((currentIndex + stageProgress / 100) / PROCESS_STAGES_ORDER.length) * 100; - }; - - return ( - -
- {/* Header */} -
-
-

- Etapas del Proceso -

-

- {batch.product_name} • Lote #{batch.batch_number} -

-
-
-
- {Math.round(getOverallProgress())}% -
-
- Completado -
-
-
- - {/* Overall Progress Bar */} - - - {/* Stage Timeline */} -
- {PROCESS_STAGES_ORDER.map((stage, index) => { - const config = STAGE_CONFIG[stage]; - const status = getStageStatus(stage); - const progress = getStageProgress(stage); - const StageIcon = config.icon; - const StatusIcon = status.icon; - - return ( -
- {/* Connection Line */} - {index < PROCESS_STAGES_ORDER.length - 1 && ( -
- )} - -
- {/* Stage Icon */} -
- - - {/* Status Indicator */} -
- -
-
- - {/* Stage Content */} -
-
-
-

- {config.label} -

-

- {config.description} -

-
- - {/* Stage Actions */} -
- {/* Quality Check Indicators */} - {hasQualityChecksRequired(stage) && ( - - Control pendiente - - )} - - {hasQualityChecksCompleted(stage) && ( - - ✓ Calidad - - )} - - {/* Action Buttons */} - {isStageActive(stage) && ( -
- {hasQualityChecksRequired(stage) && ( - - )} - - -
- )} - - {getCurrentStageIndex() === index - 1 && ( - - )} -
-
- - {/* Stage Progress */} - {isStageActive(stage) && progress > 0 && ( -
- -

- Progreso de la etapa: {Math.round(progress)}% -

-
- )} - - {/* Stage Details */} - {(isStageActive(stage) || isStageCompleted(stage)) && ( -
- {stageHistory[stage]?.start_time && ( -
- Inicio: {new Date(stageHistory[stage].start_time).toLocaleTimeString('es-ES')} -
- )} - {stageHistory[stage]?.end_time && ( -
- Fin: {new Date(stageHistory[stage].end_time).toLocaleTimeString('es-ES')} -
- )} - {completedQualityChecks[stage]?.length > 0 && ( -
- Controles completados: {completedQualityChecks[stage].length} -
- )} -
- )} -
-
-
- ); - })} -
- - {/* Stage Summary */} -
-
-
-
- {PROCESS_STAGES_ORDER.filter(s => isStageCompleted(s)).length} -
-
- Completadas -
-
-
-
- {currentStage ? 1 : 0} -
-
- En Proceso -
-
-
-
- {PROCESS_STAGES_ORDER.filter(s => isStageUpcoming(s)).length} -
-
- Pendientes -
-
-
-
-
- - ); -}; - -export default ProcessStageTracker; \ No newline at end of file diff --git a/frontend/src/components/domain/production/QualityControl.tsx b/frontend/src/components/domain/production/QualityControl.tsx deleted file mode 100644 index ac908ae4..00000000 --- a/frontend/src/components/domain/production/QualityControl.tsx +++ /dev/null @@ -1,882 +0,0 @@ -import React, { useState, useCallback, useRef } from 'react'; -import { Card, Button, Badge, Input, Modal, Table, Select, DatePicker } from '../../ui'; -import { productionService, type QualityCheckResponse, QualityCheckStatus } from '../../../api'; -import type { QualityCheck, QualityCheckCriteria, ProductionBatch, QualityCheckType } from '../../../types/production.types'; - -interface QualityControlProps { - className?: string; - batchId?: string; - checkType?: QualityCheckType; - onQualityCheckCompleted?: (result: QualityCheck) => void; - onCorrectiveActionRequired?: (check: QualityCheck, actions: string[]) => void; -} - -interface QualityCheckTemplate { - id: string; - name: string; - spanishName: string; - productTypes: string[]; - criteria: QualityChecklistItem[]; - requiresPhotos: boolean; - passThreshold: number; - criticalPoints: string[]; -} - -interface QualityChecklistItem { - id: string; - category: string; - description: string; - spanishDescription: string; - type: 'boolean' | 'numeric' | 'scale' | 'text'; - required: boolean; - minValue?: number; - maxValue?: number; - unit?: string; - acceptableCriteria?: string; - weight: number; - isCritical: boolean; -} - -interface QualityInspectionResult { - checklistId: string; - value: string | number | boolean; - notes?: string; - photo?: File; - timestamp: string; - inspector: string; -} - -const QUALITY_CHECK_TEMPLATES: Record = { - visual_inspection: { - id: 'visual_inspection', - name: 'Visual Inspection', - spanishName: 'Inspección Visual', - productTypes: ['pan', 'bolleria', 'reposteria'], - requiresPhotos: true, - passThreshold: 80, - criticalPoints: ['color_defects', 'structural_defects'], - criteria: [ - { - id: 'color_uniformity', - category: 'appearance', - description: 'Color uniformity', - spanishDescription: 'Uniformidad del color', - type: 'scale', - required: true, - minValue: 1, - maxValue: 5, - acceptableCriteria: 'Score 3 or higher', - weight: 20, - isCritical: false, - }, - { - id: 'surface_texture', - category: 'appearance', - description: 'Surface texture quality', - spanishDescription: 'Calidad de la textura superficial', - type: 'scale', - required: true, - minValue: 1, - maxValue: 5, - acceptableCriteria: 'Score 3 or higher', - weight: 15, - isCritical: false, - }, - { - id: 'shape_integrity', - category: 'structure', - description: 'Shape and form integrity', - spanishDescription: 'Integridad de forma y estructura', - type: 'boolean', - required: true, - acceptableCriteria: 'Must be true', - weight: 25, - isCritical: true, - }, - { - id: 'size_consistency', - category: 'dimensions', - description: 'Size consistency within batch', - spanishDescription: 'Consistencia de tamaño en el lote', - type: 'scale', - required: true, - minValue: 1, - maxValue: 5, - acceptableCriteria: 'Score 3 or higher', - weight: 20, - isCritical: false, - }, - { - id: 'defects_presence', - category: 'defects', - description: 'Visible defects or imperfections', - spanishDescription: 'Defectos visibles o imperfecciones', - type: 'boolean', - required: true, - acceptableCriteria: 'Must be false', - weight: 20, - isCritical: true, - }, - ], - }, - weight_check: { - id: 'weight_check', - name: 'Weight Check', - spanishName: 'Control de Peso', - productTypes: ['pan', 'bolleria', 'reposteria'], - requiresPhotos: false, - passThreshold: 95, - criticalPoints: ['weight_variance'], - criteria: [ - { - id: 'individual_weight', - category: 'weight', - description: 'Individual piece weight', - spanishDescription: 'Peso individual de la pieza', - type: 'numeric', - required: true, - minValue: 0, - unit: 'g', - acceptableCriteria: 'Within ±5% of target', - weight: 40, - isCritical: true, - }, - { - id: 'batch_average_weight', - category: 'weight', - description: 'Batch average weight', - spanishDescription: 'Peso promedio del lote', - type: 'numeric', - required: true, - minValue: 0, - unit: 'g', - acceptableCriteria: 'Within ±3% of target', - weight: 30, - isCritical: true, - }, - { - id: 'weight_variance', - category: 'consistency', - description: 'Weight variance within batch', - spanishDescription: 'Variación de peso dentro del lote', - type: 'numeric', - required: true, - minValue: 0, - unit: '%', - acceptableCriteria: 'Less than 5%', - weight: 30, - isCritical: true, - }, - ], - }, - temperature_check: { - id: 'temperature_check', - name: 'Temperature Check', - spanishName: 'Control de Temperatura', - productTypes: ['pan', 'bolleria'], - requiresPhotos: false, - passThreshold: 90, - criticalPoints: ['core_temperature'], - criteria: [ - { - id: 'core_temperature', - category: 'temperature', - description: 'Core temperature', - spanishDescription: 'Temperatura del núcleo', - type: 'numeric', - required: true, - minValue: 85, - maxValue: 98, - unit: '°C', - acceptableCriteria: '88-95°C', - weight: 60, - isCritical: true, - }, - { - id: 'cooling_temperature', - category: 'temperature', - description: 'Cooling temperature', - spanishDescription: 'Temperatura de enfriado', - type: 'numeric', - required: false, - minValue: 18, - maxValue: 25, - unit: '°C', - acceptableCriteria: '20-23°C', - weight: 40, - isCritical: false, - }, - ], - }, - packaging_quality: { - id: 'packaging_quality', - name: 'Packaging Quality', - spanishName: 'Calidad del Empaquetado', - productTypes: ['pan', 'bolleria', 'reposteria'], - requiresPhotos: true, - passThreshold: 85, - criticalPoints: ['seal_integrity', 'labeling_accuracy'], - criteria: [ - { - id: 'seal_integrity', - category: 'packaging', - description: 'Package seal integrity', - spanishDescription: 'Integridad del sellado del envase', - type: 'boolean', - required: true, - acceptableCriteria: 'Must be true', - weight: 30, - isCritical: true, - }, - { - id: 'labeling_accuracy', - category: 'labeling', - description: 'Label accuracy and placement', - spanishDescription: 'Precisión y colocación de etiquetas', - type: 'scale', - required: true, - minValue: 1, - maxValue: 5, - acceptableCriteria: 'Score 4 or higher', - weight: 25, - isCritical: true, - }, - { - id: 'package_appearance', - category: 'appearance', - description: 'Overall package appearance', - spanishDescription: 'Apariencia general del envase', - type: 'scale', - required: true, - minValue: 1, - maxValue: 5, - acceptableCriteria: 'Score 3 or higher', - weight: 20, - isCritical: false, - }, - { - id: 'barcode_readability', - category: 'labeling', - description: 'Barcode readability', - spanishDescription: 'Legibilidad del código de barras', - type: 'boolean', - required: true, - acceptableCriteria: 'Must be true', - weight: 25, - isCritical: false, - }, - ], - }, -}; - -const STATUS_COLORS = { - [QualityCheckStatus.SCHEDULED]: 'bg-[var(--color-info)]/10 text-[var(--color-info)]', - [QualityCheckStatus.IN_PROGRESS]: 'bg-yellow-100 text-yellow-800', - [QualityCheckStatus.PASSED]: 'bg-[var(--color-success)]/10 text-[var(--color-success)]', - [QualityCheckStatus.FAILED]: 'bg-[var(--color-error)]/10 text-[var(--color-error)]', - [QualityCheckStatus.REQUIRES_REVIEW]: 'bg-[var(--color-primary)]/10 text-[var(--color-primary)]', - [QualityCheckStatus.CANCELLED]: 'bg-[var(--bg-tertiary)] text-[var(--text-primary)]', -}; - -export const QualityControl: React.FC = ({ - className = '', - batchId, - checkType, - onQualityCheckCompleted, - onCorrectiveActionRequired, -}) => { - const [qualityChecks, setQualityChecks] = useState([]); - const [selectedTemplate, setSelectedTemplate] = useState(null); - const [activeCheck, setActiveCheck] = useState(null); - const [inspectionResults, setInspectionResults] = useState>({}); - const [loading, setLoading] = useState(false); - const [isInspectionModalOpen, setIsInspectionModalOpen] = useState(false); - const [uploadedPhotos, setUploadedPhotos] = useState>({}); - const [currentInspector, setCurrentInspector] = useState('inspector-1'); - const fileInputRef = useRef(null); - - const loadQualityChecks = useCallback(async () => { - setLoading(true); - try { - const params: any = {}; - if (batchId) params.batch_id = batchId; - if (checkType) params.check_type = checkType; - - const response = await productionService.getQualityChecks(params); - - if (response.success && response.data) { - setQualityChecks(response.data.items || []); - } - } catch (error) { - console.error('Error loading quality checks:', error); - } finally { - setLoading(false); - } - }, [batchId, checkType]); - - const startQualityCheck = (template: QualityCheckTemplate, batch?: any) => { - setSelectedTemplate(template); - setInspectionResults({}); - setUploadedPhotos({}); - setIsInspectionModalOpen(true); - - // Initialize empty results for all criteria - template.criteria.forEach(criterion => { - setInspectionResults(prev => ({ - ...prev, - [criterion.id]: { - checklistId: criterion.id, - value: criterion.type === 'boolean' ? false : criterion.type === 'numeric' ? 0 : '', - timestamp: new Date().toISOString(), - inspector: currentInspector, - } - })); - }); - }; - - const updateInspectionResult = (criterionId: string, value: string | number | boolean, notes?: string) => { - setInspectionResults(prev => ({ - ...prev, - [criterionId]: { - ...prev[criterionId], - value, - notes, - timestamp: new Date().toISOString(), - } - })); - }; - - const handlePhotoUpload = (criterionId: string, file: File) => { - setUploadedPhotos(prev => ({ - ...prev, - [criterionId]: file, - })); - - // Update inspection result with photo reference - setInspectionResults(prev => ({ - ...prev, - [criterionId]: { - ...prev[criterionId], - photo: file, - } - })); - }; - - const calculateOverallScore = (): number => { - if (!selectedTemplate) return 0; - - let totalScore = 0; - let totalWeight = 0; - - selectedTemplate.criteria.forEach(criterion => { - const result = inspectionResults[criterion.id]; - if (result) { - let score = 0; - - if (criterion.type === 'boolean') { - score = result.value ? 100 : 0; - } else if (criterion.type === 'scale') { - const numValue = Number(result.value); - score = (numValue / (criterion.maxValue || 5)) * 100; - } else if (criterion.type === 'numeric') { - // For numeric values, assume pass/fail based on acceptable range - score = 100; // Simplified - would need more complex logic - } - - totalScore += score * criterion.weight; - totalWeight += criterion.weight; - } - }); - - return totalWeight > 0 ? totalScore / totalWeight : 0; - }; - - const checkCriticalFailures = (): string[] => { - if (!selectedTemplate) return []; - - const failures: string[] = []; - - selectedTemplate.criteria.forEach(criterion => { - if (criterion.isCritical) { - const result = inspectionResults[criterion.id]; - if (result) { - if (criterion.type === 'boolean' && !result.value) { - failures.push(criterion.spanishDescription); - } else if (criterion.type === 'scale' && Number(result.value) < 3) { - failures.push(criterion.spanishDescription); - } - } - } - }); - - return failures; - }; - - const completeQualityCheck = async () => { - if (!selectedTemplate) return; - - const overallScore = calculateOverallScore(); - const criticalFailures = checkCriticalFailures(); - const passed = overallScore >= selectedTemplate.passThreshold && criticalFailures.length === 0; - - const checkData = { - status: passed ? QualityCheckStatus.PASSED : QualityCheckStatus.FAILED, - results: { - overallScore, - criticalFailures, - individualResults: inspectionResults, - photos: Object.keys(uploadedPhotos), - }, - notes: `Inspección completada. Puntuación: ${overallScore.toFixed(1)}%`, - corrective_actions: criticalFailures.length > 0 ? [ - `Fallas críticas encontradas: ${criticalFailures.join(', ')}`, - 'Revisar proceso de producción', - 'Re-entrenar personal si es necesario' - ] : undefined, - }; - - try { - // This would be the actual check ID from the created quality check - const mockCheckId = 'check-id'; - const response = await productionService.completeQualityCheck(mockCheckId, checkData); - - if (response.success) { - if (onQualityCheckCompleted) { - onQualityCheckCompleted(response.data as unknown as QualityCheck); - } - - if (!passed && checkData.corrective_actions && onCorrectiveActionRequired) { - onCorrectiveActionRequired(response.data as unknown as QualityCheck, checkData.corrective_actions); - } - - setIsInspectionModalOpen(false); - setSelectedTemplate(null); - await loadQualityChecks(); - } - } catch (error) { - console.error('Error completing quality check:', error); - } - }; - - const renderInspectionForm = () => { - if (!selectedTemplate) return null; - - return ( -
-
-

{selectedTemplate.spanishName}

-

- Umbral de aprobación: {selectedTemplate.passThreshold}% -

- {selectedTemplate.criticalPoints.length > 0 && ( -

- Puntos críticos: {selectedTemplate.criticalPoints.length} -

- )} -
- -
- {selectedTemplate.criteria.map((criterion) => ( - -
-
-

{criterion.spanishDescription}

-

{criterion.acceptableCriteria}

- {criterion.isCritical && ( - - Punto Crítico - - )} -
- Peso: {criterion.weight}% -
- -
- {criterion.type === 'boolean' && ( -
- - -
- )} - - {criterion.type === 'scale' && ( -
- 1 - updateInspectionResult(criterion.id, parseInt(e.target.value))} - className="flex-1" - /> - {criterion.maxValue || 5} - - {inspectionResults[criterion.id]?.value || criterion.minValue || 1} - -
- )} - - {criterion.type === 'numeric' && ( -
- updateInspectionResult(criterion.id, parseFloat(e.target.value) || 0)} - className="w-24" - /> - {criterion.unit && {criterion.unit}} -
- )} - - { - const currentResult = inspectionResults[criterion.id]; - if (currentResult) { - updateInspectionResult(criterion.id, currentResult.value, e.target.value); - } - }} - /> - - {selectedTemplate.requiresPhotos && ( -
- { - const file = e.target.files?.[0]; - if (file) { - handlePhotoUpload(criterion.id, file); - } - }} - className="hidden" - /> - - {uploadedPhotos[criterion.id] && ( -

- ✓ Foto capturada: {uploadedPhotos[criterion.id].name} -

- )} -
- )} -
-
- ))} -
- - -
- Puntuación general - - {calculateOverallScore().toFixed(1)}% - -
- -
-
= selectedTemplate.passThreshold - ? 'bg-green-500' - : 'bg-red-500' - }`} - style={{ width: `${calculateOverallScore()}%` }} - /> -
- -
- Umbral: {selectedTemplate.passThreshold}% - = selectedTemplate.passThreshold - ? 'text-[var(--color-success)] font-medium' - : 'text-[var(--color-error)] font-medium' - }> - {calculateOverallScore() >= selectedTemplate.passThreshold ? '✓ APROBADO' : '✗ REPROBADO'} - -
- - {checkCriticalFailures().length > 0 && ( -
-

Fallas críticas detectadas:

-
    - {checkCriticalFailures().map((failure, index) => ( -
  • • {failure}
  • - ))} -
-
- )} - -
- ); - }; - - const renderQualityChecksTable = () => { - const columns = [ - { key: 'batch', label: 'Lote', sortable: true }, - { key: 'type', label: 'Tipo de control', sortable: true }, - { key: 'status', label: 'Estado', sortable: true }, - { key: 'inspector', label: 'Inspector', sortable: false }, - { key: 'scheduled_date', label: 'Fecha programada', sortable: true }, - { key: 'completed_date', label: 'Fecha completada', sortable: true }, - { key: 'actions', label: 'Acciones', sortable: false }, - ]; - - const data = qualityChecks.map(check => ({ - id: check.id, - batch: `#${check.batch?.batch_number || 'N/A'}`, - type: QUALITY_CHECK_TEMPLATES[check.check_type]?.spanishName || check.check_type, - status: ( - - {check.status === QualityCheckStatus.SCHEDULED && 'Programado'} - {check.status === QualityCheckStatus.IN_PROGRESS && 'En progreso'} - {check.status === QualityCheckStatus.PASSED && 'Aprobado'} - {check.status === QualityCheckStatus.FAILED && 'Reprobado'} - {check.status === QualityCheckStatus.REQUIRES_REVIEW && 'Requiere revisión'} - - ), - inspector: check.inspector || 'No asignado', - scheduled_date: check.scheduled_date - ? new Date(check.scheduled_date).toLocaleDateString('es-ES') - : '-', - completed_date: check.completed_date - ? new Date(check.completed_date).toLocaleDateString('es-ES') - : '-', - actions: ( -
- {check.status === QualityCheckStatus.SCHEDULED && ( - - )} - -
- ), - })); - - return ; - }; - - return ( -
-
-
-

Control de Calidad

-

Gestiona las inspecciones de calidad y cumplimiento

-
- -
- - - -
-
- - {/* Quick Start Templates */} -
- {Object.values(QUALITY_CHECK_TEMPLATES).map((template) => ( - startQualityCheck(template)} - > -
-

{template.spanishName}

- {template.requiresPhotos && ( - - 📸 Fotos - - )} -
- -

- {template.criteria.length} criterios • Umbral: {template.passThreshold}% -

- -
- {template.productTypes.map((type) => ( - - {type === 'pan' && 'Pan'} - {type === 'bolleria' && 'Bollería'} - {type === 'reposteria' && 'Repostería'} - - ))} -
- - {template.criticalPoints.length > 0 && ( -

- {template.criticalPoints.length} puntos críticos -

- )} -
- ))} -
- - {/* Quality Checks Table */} - -
-

Controles de calidad recientes

- -
- - - -
-
- - {loading ? ( -
-
-
- ) : ( - renderQualityChecksTable() - )} -
- - {/* Quality Check Modal */} - { - setIsInspectionModalOpen(false); - setSelectedTemplate(null); - }} - title={`Control de Calidad: ${selectedTemplate?.spanishName}`} - size="lg" - > - {renderInspectionForm()} - -
- - - -
-
- - {/* Stats Cards */} -
- -
-
-

Tasa de aprobación

-

94.2%

-
- -
-
- - -
-
-

Controles pendientes

-

7

-
- -
-
- - -
-
-

Fallas críticas

-

2

-
- 🚨 -
-
- - -
-
-

Controles hoy

-

23

-
- 📋 -
-
-
-
- ); -}; - -export default QualityControl; \ No newline at end of file diff --git a/frontend/src/components/domain/production/QualityDashboard.tsx b/frontend/src/components/domain/production/QualityDashboard.tsx deleted file mode 100644 index 7e9164fb..00000000 --- a/frontend/src/components/domain/production/QualityDashboard.tsx +++ /dev/null @@ -1,467 +0,0 @@ -import React, { useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Card, CardHeader, CardBody } from '../../ui/Card'; -import { StatsGrid } from '../../ui/Stats'; -import { Badge } from '../../ui/Badge'; -import { Button } from '../../ui/Button'; -import { Tabs, TabsList, TabsTrigger, TabsContent } from '../../ui/Tabs'; -import { - CheckCircle, - AlertTriangle, - TrendingUp, - TrendingDown, - Target, - Activity, - BarChart3, - Camera, - FileCheck, - Clock, - Users, - Package -} from 'lucide-react'; -import { useCurrentTenant } from '../../../stores/tenant.store'; -import { useProductionDashboard } from '../../../api'; - -export interface QualityMetrics { - totalChecks: number; - passedChecks: number; - failedChecks: number; - averageScore: number; - passRate: number; - trendsData: Array<{ - date: string; - score: number; - passRate: number; - checks: number; - }>; - byProduct: Array<{ - productName: string; - checks: number; - averageScore: number; - passRate: number; - topDefects: string[]; - }>; - byCategory: Array<{ - category: string; - checks: number; - averageScore: number; - issues: number; - }>; - recentChecks: Array<{ - id: string; - batchId: string; - productName: string; - checkType: string; - score: number; - status: 'passed' | 'failed' | 'warning'; - timestamp: string; - inspector: string; - defects: string[]; - }>; -} - -export interface QualityDashboardProps { - className?: string; - data?: QualityMetrics; - dateRange?: { - startDate: string; - endDate: string; - }; - onCreateCheck?: () => void; - onViewCheck?: (checkId: string) => void; - onViewTrends?: () => void; -} - -const QualityDashboard: React.FC = ({ - className, - data, - dateRange, - onCreateCheck, - onViewCheck, - onViewTrends -}) => { - const { t } = useTranslation(); - const [activeTab, setActiveTab] = useState('overview'); - const currentTenant = useCurrentTenant(); - const tenantId = currentTenant?.id || ''; - - const { - data: dashboardData, - isLoading, - error - } = useProductionDashboard(tenantId); - - const qualityData = useMemo((): QualityMetrics => { - if (data) return data; - - // Mock data for demonstration - return { - totalChecks: 156, - passedChecks: 142, - failedChecks: 14, - averageScore: 8.3, - passRate: 91.0, - trendsData: [ - { date: '2024-01-17', score: 8.1, passRate: 89, checks: 22 }, - { date: '2024-01-18', score: 8.4, passRate: 92, checks: 25 }, - { date: '2024-01-19', score: 8.2, passRate: 90, checks: 24 }, - { date: '2024-01-20', score: 8.5, passRate: 94, checks: 23 }, - { date: '2024-01-21', score: 8.3, passRate: 91, checks: 26 }, - { date: '2024-01-22', score: 8.6, passRate: 95, checks: 21 }, - { date: '2024-01-23', score: 8.3, passRate: 91, checks: 15 } - ], - byProduct: [ - { - productName: 'Pan de Molde Integral', - checks: 35, - averageScore: 8.7, - passRate: 94.3, - topDefects: ['Forma irregular', 'Color desigual'] - }, - { - productName: 'Croissants de Mantequilla', - checks: 28, - averageScore: 8.1, - passRate: 89.3, - topDefects: ['Textura dura', 'Tamao inconsistente'] - }, - { - productName: 'Baguettes Tradicionales', - checks: 22, - averageScore: 8.5, - passRate: 95.5, - topDefects: ['Corteza muy oscura'] - } - ], - byCategory: [ - { category: 'Apariencia Visual', checks: 156, averageScore: 8.4, issues: 12 }, - { category: 'Textura', checks: 142, averageScore: 8.2, issues: 15 }, - { category: 'Sabor', checks: 98, averageScore: 8.6, issues: 8 }, - { category: 'Dimensiones', checks: 156, averageScore: 8.1, issues: 18 }, - { category: 'Peso', checks: 156, averageScore: 8.9, issues: 4 } - ], - recentChecks: [ - { - id: '1', - batchId: 'PROD-2024-0123-001', - productName: 'Pan de Molde Integral', - checkType: 'Inspeccin Visual', - score: 8.5, - status: 'passed', - timestamp: '2024-01-23T14:30:00Z', - inspector: 'Mara Gonzlez', - defects: [] - }, - { - id: '2', - batchId: 'PROD-2024-0123-002', - productName: 'Croissants de Mantequilla', - checkType: 'Control de Calidad', - score: 7.2, - status: 'warning', - timestamp: '2024-01-23T13:45:00Z', - inspector: 'Carlos Rodrguez', - defects: ['Textura ligeramente dura'] - }, - { - id: '3', - batchId: 'PROD-2024-0123-003', - productName: 'Baguettes Tradicionales', - checkType: 'Inspeccin Final', - score: 6.8, - status: 'failed', - timestamp: '2024-01-23T12:15:00Z', - inspector: 'Ana Martn', - defects: ['Corteza muy oscura', 'Forma irregular'] - } - ] - }; - }, [data]); - - const getStatusColor = (status: 'passed' | 'failed' | 'warning') => { - const colors = { - passed: 'success', - failed: 'error', - warning: 'warning' - }; - return colors[status] as 'success' | 'error' | 'warning'; - }; - - const getStatusIcon = (status: 'passed' | 'failed' | 'warning') => { - const icons = { - passed: CheckCircle, - failed: AlertTriangle, - warning: AlertTriangle - }; - return icons[status]; - }; - - const formatDateTime = (timestamp: string) => { - return new Date(timestamp).toLocaleString('es-ES', { - day: '2-digit', - month: '2-digit', - hour: '2-digit', - minute: '2-digit' - }); - }; - - if (isLoading) { - return ( - - -

- {t('quality.dashboard.title', 'Quality Dashboard')} -

-
- -
-
-
-
-
-
-
-
-
-
-
- ); - } - - if (error) { - return ( - - - -

- {t('quality.dashboard.error', 'Error loading quality data')} -

-
-
- ); - } - - const qualityStats = [ - { - title: t('quality.stats.total_checks', 'Total Checks'), - value: qualityData.totalChecks, - icon: FileCheck, - variant: 'default' as const, - subtitle: t('quality.stats.last_7_days', 'Last 7 days') - }, - { - title: t('quality.stats.pass_rate', 'Pass Rate'), - value: `${qualityData.passRate.toFixed(1)}%`, - icon: CheckCircle, - variant: qualityData.passRate >= 95 ? 'success' as const : - qualityData.passRate >= 90 ? 'warning' as const : 'error' as const, - trend: { - value: 2.3, - direction: 'up' as const, - label: t('quality.trends.vs_last_week', 'vs last week') - }, - subtitle: `${qualityData.passedChecks}/${qualityData.totalChecks} ${t('quality.stats.passed', 'passed')}` - }, - { - title: t('quality.stats.average_score', 'Average Score'), - value: qualityData.averageScore.toFixed(1), - icon: Target, - variant: qualityData.averageScore >= 8.5 ? 'success' as const : - qualityData.averageScore >= 7.5 ? 'warning' as const : 'error' as const, - subtitle: t('quality.stats.out_of_10', 'out of 10') - }, - { - title: t('quality.stats.failed_checks', 'Failed Checks'), - value: qualityData.failedChecks, - icon: AlertTriangle, - variant: qualityData.failedChecks === 0 ? 'success' as const : - qualityData.failedChecks <= 5 ? 'warning' as const : 'error' as const, - subtitle: t('quality.stats.requiring_action', 'Requiring action') - } - ]; - - return ( - - -
-
-

- {t('quality.dashboard.title', 'Quality Dashboard')} -

-

- {t('quality.dashboard.subtitle', 'Monitor quality metrics and trends')} -

-
- -
-
- - - {/* Key Metrics */} - - - {/* Detailed Analysis Tabs */} - - - - {t('quality.tabs.overview', 'Overview')} - - - {t('quality.tabs.by_product', 'By Product')} - - - {t('quality.tabs.categories', 'Categories')} - - - {t('quality.tabs.recent', 'Recent')} - - - - - {/* Trends Chart Placeholder */} -
-
-

- {t('quality.charts.weekly_trends', 'Weekly Quality Trends')} -

- -
-
-

- {t('quality.charts.placeholder', 'Quality trends chart will be displayed here')} -

-
-
-
- - -
- {qualityData.byProduct.map((product, index) => ( -
-
-

- {product.productName} -

- = 95 ? 'success' : product.passRate >= 90 ? 'warning' : 'error'}> - {product.passRate.toFixed(1)}% {t('quality.stats.pass_rate', 'pass rate')} - -
-
-
- {t('quality.stats.checks', 'Checks')}: - {product.checks} -
-
- {t('quality.stats.avg_score', 'Avg Score')}: - {product.averageScore.toFixed(1)} -
-
- {t('quality.stats.top_defects', 'Top Defects')}: - {product.topDefects.join(', ')} -
-
-
- ))} -
-
- - -
- {qualityData.byCategory.map((category, index) => ( -
-
-

- {category.category} -

-
- {category.checks} {t('quality.stats.checks', 'checks')} - {category.averageScore.toFixed(1)} {t('quality.stats.avg_score', 'avg score')} -
-
-
-
- {category.issues} -
-
- {t('quality.stats.issues', 'issues')} -
-
-
- ))} -
-
- - -
- {qualityData.recentChecks.map((check) => { - const StatusIcon = getStatusIcon(check.status); - return ( -
onViewCheck?.(check.id)} - > -
-
- -
-

- {check.productName} -

-

- {check.checkType} " {check.batchId} -

-
-
-
-
- {check.score.toFixed(1)} -
-
- {formatDateTime(check.timestamp)} -
-
-
-
-
- - {check.inspector} -
- {check.defects.length > 0 && ( -
- - - {check.defects.length} {t('quality.stats.defects', 'defects')} - -
- )} -
-
- ); - })} -
-
-
-
-
- ); -}; - -export default QualityDashboard; \ No newline at end of file diff --git a/frontend/src/components/domain/production/QualityInspection.tsx b/frontend/src/components/domain/production/QualityInspection.tsx deleted file mode 100644 index a73e7ca1..00000000 --- a/frontend/src/components/domain/production/QualityInspection.tsx +++ /dev/null @@ -1,585 +0,0 @@ -import React, { useState, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Card, CardHeader, CardBody } from '../../ui/Card'; -import { Button } from '../../ui/Button'; -import { Input } from '../../ui/Input'; -import { Textarea } from '../../ui'; -import { Badge } from '../../ui/Badge'; -import { Modal } from '../../ui/Modal'; -import { Tabs, TabsList, TabsTrigger, TabsContent } from '../../ui/Tabs'; -import { - Camera, - CheckCircle, - XCircle, - AlertTriangle, - Upload, - Star, - Target, - FileText, - Clock, - User, - Package -} from 'lucide-react'; -import { useCurrentTenant } from '../../../stores/tenant.store'; - -export interface InspectionCriteria { - id: string; - category: string; - name: string; - description: string; - type: 'visual' | 'measurement' | 'taste' | 'texture' | 'temperature'; - required: boolean; - weight: number; - acceptableCriteria: string; - minValue?: number; - maxValue?: number; - unit?: string; -} - -export interface InspectionResult { - criteriaId: string; - value: number | string | boolean; - score: number; - notes?: string; - photos?: File[]; - pass: boolean; - timestamp: string; -} - -export interface QualityInspectionData { - batchId: string; - productName: string; - inspectionType: string; - inspector: string; - startTime: string; - criteria: InspectionCriteria[]; - results: InspectionResult[]; - overallScore: number; - overallPass: boolean; - finalNotes: string; - photos: File[]; - correctiveActions: string[]; -} - -export interface QualityInspectionProps { - className?: string; - batchId?: string; - productName?: string; - inspectionType?: string; - criteria?: InspectionCriteria[]; - onComplete?: (data: QualityInspectionData) => void; - onCancel?: () => void; - onSaveDraft?: (data: Partial) => void; -} - -const DEFAULT_CRITERIA: InspectionCriteria[] = [ - { - id: 'color_uniformity', - category: 'Visual', - name: 'Color Uniformity', - description: 'Evaluate the consistency of color across the product', - type: 'visual', - required: true, - weight: 15, - acceptableCriteria: 'Score 7 or higher', - minValue: 1, - maxValue: 10 - }, - { - id: 'shape_integrity', - category: 'Visual', - name: 'Shape Integrity', - description: 'Check if the product maintains its intended shape', - type: 'visual', - required: true, - weight: 20, - acceptableCriteria: 'Score 7 or higher', - minValue: 1, - maxValue: 10 - }, - { - id: 'surface_texture', - category: 'Texture', - name: 'Surface Texture', - description: 'Evaluate surface texture quality', - type: 'texture', - required: true, - weight: 15, - acceptableCriteria: 'Score 7 or higher', - minValue: 1, - maxValue: 10 - }, - { - id: 'weight_accuracy', - category: 'Measurement', - name: 'Weight Accuracy', - description: 'Measure actual weight vs target weight', - type: 'measurement', - required: true, - weight: 20, - acceptableCriteria: 'Within �5% of target', - unit: 'g' - }, - { - id: 'internal_texture', - category: 'Texture', - name: 'Internal Texture', - description: 'Evaluate crumb structure and texture', - type: 'texture', - required: false, - weight: 15, - acceptableCriteria: 'Score 7 or higher', - minValue: 1, - maxValue: 10 - }, - { - id: 'taste_quality', - category: 'Taste', - name: 'Taste Quality', - description: 'Overall flavor and taste assessment', - type: 'taste', - required: false, - weight: 15, - acceptableCriteria: 'Score 7 or higher', - minValue: 1, - maxValue: 10 - } -]; - -const QualityInspection: React.FC = ({ - className, - batchId = 'PROD-2024-0123-001', - productName = 'Pan de Molde Integral', - inspectionType = 'Final Quality Check', - criteria = DEFAULT_CRITERIA, - onComplete, - onCancel, - onSaveDraft -}) => { - const { t } = useTranslation(); - const currentTenant = useCurrentTenant(); - const [activeTab, setActiveTab] = useState('inspection'); - - const [inspectionData, setInspectionData] = useState>({ - batchId, - productName, - inspectionType, - inspector: currentTenant?.name || 'Inspector', - startTime: new Date().toISOString(), - criteria, - results: [], - finalNotes: '', - photos: [], - correctiveActions: [] - }); - - const [currentCriteriaIndex, setCurrentCriteriaIndex] = useState(0); - const [showPhotoModal, setShowPhotoModal] = useState(false); - const [tempPhotos, setTempPhotos] = useState([]); - - const updateResult = useCallback((criteriaId: string, updates: Partial) => { - setInspectionData(prev => { - const existingResults = prev.results || []; - const existingIndex = existingResults.findIndex(r => r.criteriaId === criteriaId); - - let newResults; - if (existingIndex >= 0) { - newResults = [...existingResults]; - newResults[existingIndex] = { ...newResults[existingIndex], ...updates }; - } else { - newResults = [...existingResults, { - criteriaId, - value: '', - score: 0, - pass: false, - timestamp: new Date().toISOString(), - ...updates - }]; - } - - return { ...prev, results: newResults }; - }); - }, []); - - const getCriteriaResult = useCallback((criteriaId: string): InspectionResult | undefined => { - return inspectionData.results?.find(r => r.criteriaId === criteriaId); - }, [inspectionData.results]); - - const calculateOverallScore = useCallback((): number => { - if (!inspectionData.results || inspectionData.results.length === 0) return 0; - - const totalWeight = criteria.reduce((sum, c) => sum + c.weight, 0); - const weightedScore = inspectionData.results.reduce((sum, result) => { - const criterion = criteria.find(c => c.id === result.criteriaId); - return sum + (result.score * (criterion?.weight || 0)); - }, 0); - - return totalWeight > 0 ? weightedScore / totalWeight : 0; - }, [inspectionData.results, criteria]); - - const isInspectionComplete = useCallback((): boolean => { - const requiredCriteria = criteria.filter(c => c.required); - const completedRequired = requiredCriteria.filter(c => - inspectionData.results?.some(r => r.criteriaId === c.id) - ); - return completedRequired.length === requiredCriteria.length; - }, [criteria, inspectionData.results]); - - const handlePhotoUpload = useCallback((event: React.ChangeEvent) => { - const files = Array.from(event.target.files || []); - setTempPhotos(prev => [...prev, ...files]); - }, []); - - const handleComplete = useCallback(() => { - const overallScore = calculateOverallScore(); - const overallPass = overallScore >= 7.0; // Configurable threshold - - const completedData: QualityInspectionData = { - ...inspectionData as QualityInspectionData, - overallScore, - overallPass, - photos: tempPhotos - }; - - onComplete?.(completedData); - }, [inspectionData, calculateOverallScore, tempPhotos, onComplete]); - - const currentCriteria = criteria[currentCriteriaIndex]; - const currentResult = currentCriteria ? getCriteriaResult(currentCriteria.id) : undefined; - const overallScore = calculateOverallScore(); - const isComplete = isInspectionComplete(); - - const renderCriteriaInput = (criterion: InspectionCriteria) => { - const result = getCriteriaResult(criterion.id); - - if (criterion.type === 'measurement') { - return ( -
- { - const value = parseFloat(e.target.value); - const score = !isNaN(value) ? Math.min(10, Math.max(1, 8)) : 0; // Simplified scoring - updateResult(criterion.id, { - value: e.target.value, - score, - pass: score >= 7 - }); - }} - /> - {criterion.unit && ( -

- Unit: {criterion.unit} -

- )} -
- ); - } - - // For visual, texture, taste types - use 1-10 scale - return ( -
-
- {[...Array(10)].map((_, i) => { - const score = i + 1; - const isSelected = result?.score === score; - return ( - - ); - })} -
- -
-

1-3: Poor | 4-6: Fair | 7-8: Good | 9-10: Excellent

-

Acceptable: {criterion.acceptableCriteria}

-
-
- ); - }; - - return ( - - -
-
-

- {t('quality.inspection.title', 'Quality Inspection')} -

-

- {productName} " {batchId} " {inspectionType} -

-
-
- - {isComplete ? t('quality.status.complete', 'Complete') : t('quality.status.in_progress', 'In Progress')} - - {overallScore > 0 && ( - = 7 ? 'success' : overallScore >= 5 ? 'warning' : 'error'}> - {overallScore.toFixed(1)}/10 - - )} -
-
-
- - - - - - {t('quality.tabs.inspection', 'Inspection')} - - - {t('quality.tabs.photos', 'Photos')} - - - {t('quality.tabs.summary', 'Summary')} - - - - - {/* Progress Indicator */} -
- {criteria.map((criterion, index) => { - const result = getCriteriaResult(criterion.id); - const isCompleted = !!result; - const isCurrent = index === currentCriteriaIndex; - - return ( - - ); - })} -
- - {/* Current Criteria */} - {currentCriteria && ( -
-
-
-

- {currentCriteria.name} -

- - {currentCriteria.required ? t('quality.required', 'Required') : t('quality.optional', 'Optional')} - -
-

- {currentCriteria.description} -

-
- Weight: {currentCriteria.weight}% - Category: {currentCriteria.category} - Type: {currentCriteria.type} -
-
- - {/* Input Section */} - {renderCriteriaInput(currentCriteria)} - - {/* Notes Section */} -