Files
bakery-ia/frontend/src/components/domain/production/BatchTracker.tsx.backup
2025-08-28 10:41:04 +02:00

680 lines
23 KiB
Plaintext

import React, { useState, useCallback, useEffect } from 'react';
import { Card, Button, Badge, Input, Modal, Table, Select, DatePicker } from '../../ui';
import { productionService, type ProductionBatchResponse, ProductionBatchStatus, ProductionPriority } from '../../../services/api/production.service';
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<string, ProductionStage> = {
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-blue-100 text-blue-800 border-blue-200',
[ProductionBatchStatus.IN_PROGRESS]: 'bg-yellow-100 text-yellow-800 border-yellow-200',
[ProductionBatchStatus.COMPLETED]: 'bg-green-100 text-green-800 border-green-200',
[ProductionBatchStatus.CANCELLED]: 'bg-red-100 text-red-800 border-red-200',
[ProductionBatchStatus.ON_HOLD]: 'bg-gray-100 text-gray-800 border-gray-200',
};
const PRIORITY_COLORS = {
[ProductionPriority.LOW]: 'bg-gray-100 text-gray-800',
[ProductionPriority.NORMAL]: 'bg-blue-100 text-blue-800',
[ProductionPriority.HIGH]: 'bg-orange-100 text-orange-800',
[ProductionPriority.URGENT]: 'bg-red-100 text-red-800',
};
export const BatchTracker: React.FC<BatchTrackerProps> = ({
className = '',
batchId,
onStageUpdate,
onQualityCheckRequired,
}) => {
const [batches, setBatches] = useState<ProductionBatchResponse[]>([]);
const [selectedBatch, setSelectedBatch] = useState<ProductionBatchResponse | null>(null);
const [loading, setLoading] = useState(false);
const [currentStage, setCurrentStage] = useState<string>('mixing');
const [stageNotes, setStageNotes] = useState<Record<string, string>>({});
const [isStageModalOpen, setIsStageModalOpen] = useState(false);
const [selectedStageForUpdate, setSelectedStageForUpdate] = useState<ProductionStage | null>(null);
const [alerts, setAlerts] = useState<Array<{
id: string;
batchId: string;
stage: string;
type: 'overdue' | 'temperature' | 'quality' | 'equipment';
message: string;
severity: 'low' | 'medium' | 'high';
timestamp: string;
}>>([]);
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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-lg">Progreso del lote</h3>
<Badge className={STATUS_COLORS[batch.status]}>
{batch.status === ProductionBatchStatus.IN_PROGRESS && 'En progreso'}
{batch.status === ProductionBatchStatus.PLANNED && 'Planificado'}
{batch.status === ProductionBatchStatus.COMPLETED && 'Completado'}
</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{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 (
<Card
key={stage.id}
className={`p-4 border-2 transition-all cursor-pointer hover:shadow-md ${
isActive
? 'border-blue-500 bg-blue-50'
: isCompleted
? 'border-green-500 bg-green-50'
: 'border-gray-200 bg-white'
} ${isOverdue ? 'border-red-500 bg-red-50' : ''}`}
onClick={() => {
setSelectedStageForUpdate(stage);
setIsStageModalOpen(true);
}}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-xl">{stage.icon}</span>
<span className="font-medium text-sm">{stage.spanishName}</span>
</div>
{stage.criticalControlPoint && (
<Badge className="bg-orange-100 text-orange-800 text-xs">
PCC
</Badge>
)}
</div>
{isActive && (
<div className="space-y-2">
<div className="flex justify-between text-xs text-gray-600">
<span>Progreso</span>
<span>{Math.round(progress)}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full transition-all duration-500"
style={{ width: `${progress}%` }}
/>
</div>
<p className="text-xs text-gray-600">
{getTimeRemaining(batch, stage)}
</p>
</div>
)}
{isCompleted && (
<div className="flex items-center text-green-600 text-sm">
<span className="mr-1">✓</span>
Completado
</div>
)}
{!isActive && !isCompleted && (
<p className="text-xs text-gray-500">
~{stage.estimatedMinutes}min
</p>
)}
{stage.temperature && isActive && (
<p className="text-xs text-gray-600 mt-1">
🌡️ {stage.temperature.min}-{stage.temperature.max}{stage.temperature.unit}
</p>
)}
</Card>
);
})}
</div>
</div>
);
};
const renderBatchDetails = (batch: ProductionBatchResponse) => (
<Card className="p-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<h4 className="font-semibold text-gray-900">{batch.recipe?.name || 'Producto'}</h4>
<p className="text-sm text-gray-600">Lote #{batch.batch_number}</p>
<Badge className={PRIORITY_COLORS[batch.priority]} size="sm">
{batch.priority === ProductionPriority.LOW && 'Baja'}
{batch.priority === ProductionPriority.NORMAL && 'Normal'}
{batch.priority === ProductionPriority.HIGH && 'Alta'}
{batch.priority === ProductionPriority.URGENT && 'Urgente'}
</Badge>
</div>
<div>
<p className="text-sm text-gray-600">Cantidad planificada</p>
<p className="font-semibold">{batch.planned_quantity} unidades</p>
{batch.actual_quantity && (
<>
<p className="text-sm text-gray-600 mt-1">Cantidad real</p>
<p className="font-semibold">{batch.actual_quantity} unidades</p>
</>
)}
</div>
<div>
<p className="text-sm text-gray-600">Inicio planificado</p>
<p className="font-semibold">
{new Date(batch.planned_start_date).toLocaleString('es-ES')}
</p>
{batch.actual_start_date && (
<>
<p className="text-sm text-gray-600 mt-1">Inicio real</p>
<p className="font-semibold">
{new Date(batch.actual_start_date).toLocaleString('es-ES')}
</p>
</>
)}
</div>
</div>
{batch.notes && (
<div className="mt-4 p-3 bg-gray-50 rounded-lg">
<p className="text-sm text-gray-700">{batch.notes}</p>
</div>
)}
</Card>
);
const renderAlerts = () => (
<Card className="p-4">
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
🚨 Alertas activas
</h3>
{alerts.length === 0 ? (
<p className="text-gray-500 text-center py-4">No hay alertas activas</p>
) : (
<div className="space-y-3">
{alerts.map((alert) => (
<div
key={alert.id}
className={`p-3 rounded-lg border-l-4 ${
alert.severity === 'high'
? 'bg-red-50 border-red-500'
: alert.severity === 'medium'
? 'bg-yellow-50 border-yellow-500'
: 'bg-blue-50 border-blue-500'
}`}
>
<div className="flex justify-between items-start">
<div>
<p className="font-medium text-sm">
Lote #{batches.find(b => b.id === alert.batchId)?.batch_number} - {PRODUCTION_STAGES[alert.stage]?.spanishName}
</p>
<p className="text-sm text-gray-700 mt-1">{alert.message}</p>
</div>
<Badge
className={
alert.severity === 'high'
? 'bg-red-100 text-red-800'
: alert.severity === 'medium'
? 'bg-yellow-100 text-yellow-800'
: 'bg-blue-100 text-blue-800'
}
>
{alert.severity === 'high' && 'Alta'}
{alert.severity === 'medium' && 'Media'}
{alert.severity === 'low' && 'Baja'}
</Badge>
</div>
<p className="text-xs text-gray-500 mt-2">
{new Date(alert.timestamp).toLocaleTimeString('es-ES')}
</p>
</div>
))}
</div>
)}
</Card>
);
return (
<div className={`space-y-6 ${className}`}>
<div className="flex justify-between items-center">
<div>
<h2 className="text-2xl font-bold text-gray-900">Seguimiento de Lotes</h2>
<p className="text-gray-600">Rastrea el progreso de los lotes a través de las etapas de producción</p>
</div>
{selectedBatch && (
<div className="flex gap-2">
<Select
value={selectedBatch.id}
onChange={(e) => {
const batch = batches.find(b => b.id === e.target.value);
if (batch) setSelectedBatch(batch);
}}
className="w-64"
>
{batches.map((batch) => (
<option key={batch.id} value={batch.id}>
Lote #{batch.batch_number} - {batch.recipe?.name}
</option>
))}
</Select>
<Button
variant="outline"
onClick={loadBatches}
disabled={loading}
>
{loading ? 'Actualizando...' : 'Actualizar'}
</Button>
</div>
)}
</div>
{loading ? (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : (
<>
{selectedBatch ? (
<div className="space-y-6">
{renderBatchDetails(selectedBatch)}
{renderStageProgress(selectedBatch)}
{renderAlerts()}
</div>
) : (
<Card className="p-8 text-center">
<p className="text-gray-500">No hay lotes en producción actualmente</p>
<Button variant="primary" className="mt-4">
Ver todos los lotes
</Button>
</Card>
)}
</>
)}
{/* Stage Update Modal */}
<Modal
isOpen={isStageModalOpen}
onClose={() => {
setIsStageModalOpen(false);
setSelectedStageForUpdate(null);
}}
title={`${selectedStageForUpdate?.spanishName} - Lote #${selectedBatch?.batch_number}`}
>
{selectedStageForUpdate && selectedBatch && (
<div className="space-y-4">
<div className="bg-gray-50 p-4 rounded-lg">
<h4 className="font-medium mb-2 flex items-center gap-2">
<span className="text-xl">{selectedStageForUpdate.icon}</span>
{selectedStageForUpdate.spanishName}
</h4>
<p className="text-sm text-gray-600 mb-2">
Duración estimada: {selectedStageForUpdate.estimatedMinutes} minutos
</p>
{selectedStageForUpdate.temperature && (
<p className="text-sm text-gray-600">
🌡️ Temperatura: {selectedStageForUpdate.temperature.min}-{selectedStageForUpdate.temperature.max}{selectedStageForUpdate.temperature.unit}
</p>
)}
{selectedStageForUpdate.criticalControlPoint && (
<Badge className="bg-orange-100 text-orange-800 mt-2">
Punto Crítico de Control (PCC)
</Badge>
)}
</div>
{selectedStageForUpdate.requiresQualityCheck && (
<div className="bg-yellow-50 border border-yellow-200 p-3 rounded-lg">
<p className="text-sm text-yellow-800">
⚠️ Esta etapa requiere control de calidad antes de continuar
</p>
<Button
variant="outline"
size="sm"
className="mt-2"
onClick={() => handleQualityCheck(selectedBatch, selectedStageForUpdate)}
>
Realizar control de calidad
</Button>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Notas de la etapa
</label>
<Input
as="textarea"
rows={3}
placeholder="Añadir observaciones o notas sobre esta etapa..."
value={stageNotes[`${selectedBatch.id}-${selectedStageForUpdate.id}`] || ''}
onChange={(e) => setStageNotes(prev => ({
...prev,
[`${selectedBatch.id}-${selectedStageForUpdate.id}`]: e.target.value,
}))}
/>
</div>
<div className="flex justify-end gap-2 pt-4">
<Button
variant="outline"
onClick={() => setIsStageModalOpen(false)}
>
Cerrar
</Button>
<Button
variant="primary"
onClick={() => {
const notes = stageNotes[`${selectedBatch.id}-${selectedStageForUpdate.id}`];
updateStage(selectedBatch.id, selectedStageForUpdate.id, notes);
setIsStageModalOpen(false);
}}
>
Marcar como completado
</Button>
</div>
</div>
)}
</Modal>
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Lotes activos</p>
<p className="text-2xl font-bold text-blue-600">{batches.length}</p>
</div>
<span className="text-2xl">📊</span>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Alertas activas</p>
<p className="text-2xl font-bold text-red-600">{alerts.length}</p>
</div>
<span className="text-2xl">🚨</span>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">En horneado</p>
<p className="text-2xl font-bold text-orange-600">3</p>
</div>
<span className="text-2xl">🔥</span>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Completados hoy</p>
<p className="text-2xl font-bold text-green-600">12</p>
</div>
<span className="text-2xl">✅</span>
</div>
</Card>
</div>
</div>
);
};
export default BatchTracker;