Clean frontend
This commit is contained in:
@@ -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, 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;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from '../BatchTracker';
|
||||
@@ -1,579 +0,0 @@
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { Card, Button, Badge, Select, DatePicker, Input, Modal, Table } from '../../ui';
|
||||
import { productionService, type ProductionScheduleEntry, ProductionBatchStatus } from '../../../services/api/production.service';
|
||||
import type { ProductionSchedule as ProductionScheduleType, ProductionBatch, EquipmentReservation, StaffAssignment } from '../../../types/production.types';
|
||||
|
||||
interface ProductionScheduleProps {
|
||||
className?: string;
|
||||
onBatchSelected?: (batch: ProductionBatch) => void;
|
||||
onScheduleUpdate?: (schedule: ProductionScheduleEntry) => void;
|
||||
}
|
||||
|
||||
interface TimeSlot {
|
||||
time: string;
|
||||
capacity: number;
|
||||
utilized: number;
|
||||
available: number;
|
||||
batches: ProductionScheduleEntry[];
|
||||
}
|
||||
|
||||
interface CapacityResource {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'oven' | 'mixer' | 'staff';
|
||||
capacity: number;
|
||||
slots: TimeSlot[];
|
||||
}
|
||||
|
||||
const PRODUCT_TYPE_COLORS = {
|
||||
pan: 'bg-[var(--color-warning)]/10 border-[var(--color-warning)]/20 text-[var(--color-warning)]',
|
||||
bolleria: 'bg-[var(--color-primary)]/10 border-[var(--color-primary)]/20 text-[var(--color-primary)]',
|
||||
reposteria: 'bg-[var(--color-error)]/10 border-[var(--color-error)]/20 text-[var(--color-error)]',
|
||||
especial: 'bg-[var(--color-info)]/10 border-[var(--color-info)]/20 text-[var(--color-info)]',
|
||||
};
|
||||
|
||||
const STATUS_COLORS = {
|
||||
[ProductionBatchStatus.PLANNED]: 'bg-[var(--color-info)]/10 text-[var(--color-info)]',
|
||||
[ProductionBatchStatus.IN_PROGRESS]: 'bg-[var(--color-warning)]/10 text-[var(--color-warning)]',
|
||||
[ProductionBatchStatus.COMPLETED]: 'bg-[var(--color-success)]/10 text-[var(--color-success)]',
|
||||
[ProductionBatchStatus.CANCELLED]: 'bg-[var(--color-error)]/10 text-[var(--color-error)]',
|
||||
[ProductionBatchStatus.ON_HOLD]: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)]',
|
||||
};
|
||||
|
||||
export const ProductionSchedule: React.FC<ProductionScheduleProps> = ({
|
||||
className = '',
|
||||
onBatchSelected,
|
||||
onScheduleUpdate,
|
||||
}) => {
|
||||
const [scheduleEntries, setScheduleEntries] = useState<ProductionScheduleEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||
const [viewMode, setViewMode] = useState<'calendar' | 'timeline' | 'capacity'>('timeline');
|
||||
const [filterStatus, setFilterStatus] = useState<ProductionBatchStatus | 'all'>('all');
|
||||
const [filterProduct, setFilterProduct] = useState<string>('all');
|
||||
const [draggedBatch, setDraggedBatch] = useState<ProductionScheduleEntry | null>(null);
|
||||
const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false);
|
||||
const [selectedBatchForScheduling, setSelectedBatchForScheduling] = useState<ProductionBatch | null>(null);
|
||||
|
||||
// Mock data for demonstration
|
||||
const mockCapacityResources: CapacityResource[] = [
|
||||
{
|
||||
id: 'horno-1',
|
||||
name: 'Horno Principal',
|
||||
type: 'oven',
|
||||
capacity: 4,
|
||||
slots: generateTimeSlots('06:00', '20:00', 2),
|
||||
},
|
||||
{
|
||||
id: 'horno-2',
|
||||
name: 'Horno Secundario',
|
||||
type: 'oven',
|
||||
capacity: 2,
|
||||
slots: generateTimeSlots('06:00', '18:00', 2),
|
||||
},
|
||||
{
|
||||
id: 'amasadora-1',
|
||||
name: 'Amasadora Industrial',
|
||||
type: 'mixer',
|
||||
capacity: 6,
|
||||
slots: generateTimeSlots('05:00', '21:00', 1),
|
||||
},
|
||||
{
|
||||
id: 'panadero-1',
|
||||
name: 'Equipo Panadería',
|
||||
type: 'staff',
|
||||
capacity: 8,
|
||||
slots: generateTimeSlots('06:00', '14:00', 1),
|
||||
},
|
||||
];
|
||||
|
||||
function generateTimeSlots(startTime: string, endTime: string, intervalHours: number): TimeSlot[] {
|
||||
const slots: TimeSlot[] = [];
|
||||
const start = new Date(`2024-01-01 ${startTime}`);
|
||||
const end = new Date(`2024-01-01 ${endTime}`);
|
||||
|
||||
while (start < end) {
|
||||
const timeStr = start.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' });
|
||||
slots.push({
|
||||
time: timeStr,
|
||||
capacity: 100,
|
||||
utilized: Math.floor(Math.random() * 80),
|
||||
available: 100 - Math.floor(Math.random() * 80),
|
||||
batches: [],
|
||||
});
|
||||
start.setHours(start.getHours() + intervalHours);
|
||||
}
|
||||
|
||||
return slots;
|
||||
}
|
||||
|
||||
const loadSchedule = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await productionService.getProductionSchedule({
|
||||
start_date: selectedDate.toISOString().split('T')[0],
|
||||
end_date: selectedDate.toISOString().split('T')[0],
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
setScheduleEntries(response.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading schedule:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedDate]);
|
||||
|
||||
const filteredEntries = useMemo(() => {
|
||||
return scheduleEntries.filter(entry => {
|
||||
if (filterStatus !== 'all' && entry.batch?.status !== filterStatus) {
|
||||
return false;
|
||||
}
|
||||
if (filterProduct !== 'all' && entry.batch?.recipe?.category !== filterProduct) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [scheduleEntries, filterStatus, filterProduct]);
|
||||
|
||||
const handleDragStart = (e: React.DragEvent, entry: ProductionScheduleEntry) => {
|
||||
setDraggedBatch(entry);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
};
|
||||
|
||||
const handleDrop = async (e: React.DragEvent, newTime: string) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!draggedBatch) return;
|
||||
|
||||
try {
|
||||
const response = await productionService.updateScheduleEntry(draggedBatch.id, {
|
||||
scheduled_start_time: newTime,
|
||||
scheduled_end_time: calculateEndTime(newTime, draggedBatch.estimated_duration_minutes),
|
||||
});
|
||||
|
||||
if (response.success && onScheduleUpdate) {
|
||||
onScheduleUpdate(response.data);
|
||||
await loadSchedule();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating schedule:', error);
|
||||
} finally {
|
||||
setDraggedBatch(null);
|
||||
}
|
||||
};
|
||||
|
||||
const calculateEndTime = (startTime: string, durationMinutes: number): string => {
|
||||
const start = new Date(`2024-01-01 ${startTime}`);
|
||||
start.setMinutes(start.getMinutes() + durationMinutes);
|
||||
return start.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
const handleScheduleBatch = async (scheduleData: {
|
||||
batch_id: string;
|
||||
scheduled_date: string;
|
||||
scheduled_start_time: string;
|
||||
scheduled_end_time: string;
|
||||
equipment_reservations?: string[];
|
||||
staff_assignments?: string[];
|
||||
}) => {
|
||||
try {
|
||||
const response = await productionService.scheduleProductionBatch(scheduleData);
|
||||
|
||||
if (response.success && onScheduleUpdate) {
|
||||
onScheduleUpdate(response.data);
|
||||
await loadSchedule();
|
||||
setIsScheduleModalOpen(false);
|
||||
setSelectedBatchForScheduling(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error scheduling batch:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getProductTypeColor = (category?: string) => {
|
||||
return PRODUCT_TYPE_COLORS[category as keyof typeof PRODUCT_TYPE_COLORS] || 'bg-[var(--bg-tertiary)] border-[var(--border-secondary)] text-[var(--text-secondary)]';
|
||||
};
|
||||
|
||||
const getStatusColor = (status?: ProductionBatchStatus) => {
|
||||
return STATUS_COLORS[status as keyof typeof STATUS_COLORS] || 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)]';
|
||||
};
|
||||
|
||||
const renderTimelineView = () => (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-4 items-center mb-6">
|
||||
<DatePicker
|
||||
selected={selectedDate}
|
||||
onChange={(date) => setSelectedDate(date || new Date())}
|
||||
dateFormat="dd/MM/yyyy"
|
||||
className="w-40"
|
||||
/>
|
||||
<Select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value as ProductionBatchStatus | 'all')}
|
||||
className="w-40"
|
||||
>
|
||||
<option value="all">Todos los estados</option>
|
||||
<option value={ProductionBatchStatus.PLANNED}>Planificado</option>
|
||||
<option value={ProductionBatchStatus.IN_PROGRESS}>En progreso</option>
|
||||
<option value={ProductionBatchStatus.COMPLETED}>Completado</option>
|
||||
</Select>
|
||||
<Select
|
||||
value={filterProduct}
|
||||
onChange={(e) => setFilterProduct(e.target.value)}
|
||||
className="w-40"
|
||||
>
|
||||
<option value="all">Todos los productos</option>
|
||||
<option value="pan">Pan</option>
|
||||
<option value="bolleria">Bollería</option>
|
||||
<option value="reposteria">Repostería</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border">
|
||||
<div className="grid grid-cols-25 gap-1 p-4 bg-[var(--bg-secondary)] text-xs font-medium text-[var(--text-tertiary)] border-b">
|
||||
<div className="col-span-3">Producto</div>
|
||||
{Array.from({ length: 22 }, (_, i) => (
|
||||
<div key={i} className="text-center">
|
||||
{String(i + 3).padStart(2, '0')}:00
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="divide-y">
|
||||
{filteredEntries.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="grid grid-cols-25 gap-1 p-2 hover:bg-[var(--bg-secondary)]"
|
||||
>
|
||||
<div className="col-span-3 flex items-center space-x-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-sm">{entry.batch?.recipe?.name}</span>
|
||||
<Badge className={`text-xs ${getStatusColor(entry.batch?.status)}`}>
|
||||
{entry.batch?.status === ProductionBatchStatus.PLANNED && 'Planificado'}
|
||||
{entry.batch?.status === ProductionBatchStatus.IN_PROGRESS && 'En progreso'}
|
||||
{entry.batch?.status === ProductionBatchStatus.COMPLETED && 'Completado'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{Array.from({ length: 22 }, (_, hourIndex) => {
|
||||
const hour = hourIndex + 3;
|
||||
const hourStr = `${String(hour).padStart(2, '0')}:00`;
|
||||
const startHour = parseInt(entry.scheduled_start_time.split(':')[0]);
|
||||
const endHour = parseInt(entry.scheduled_end_time.split(':')[0]);
|
||||
|
||||
const isInRange = hour >= startHour && hour < endHour;
|
||||
const isStart = hour === startHour;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={hourIndex}
|
||||
className={`h-12 border border-[var(--border-primary)] ${isInRange ? getProductTypeColor(entry.batch?.recipe?.category) : ''}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(e, hourStr)}
|
||||
>
|
||||
{isStart && (
|
||||
<div
|
||||
className={`h-full w-full rounded px-1 py-1 cursor-move ${getProductTypeColor(entry.batch?.recipe?.category)} border-l-4 border-blue-500`}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, entry)}
|
||||
onClick={() => onBatchSelected?.(entry.batch!)}
|
||||
>
|
||||
<div className="text-xs font-medium truncate">
|
||||
{entry.batch?.recipe?.name}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
{entry.batch?.planned_quantity} uds
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderCapacityView = () => (
|
||||
<div className="space-y-6">
|
||||
<div className="flex gap-4 items-center mb-6">
|
||||
<DatePicker
|
||||
selected={selectedDate}
|
||||
onChange={(date) => setSelectedDate(date || new Date())}
|
||||
dateFormat="dd/MM/yyyy"
|
||||
className="w-40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{mockCapacityResources.map((resource) => (
|
||||
<Card key={resource.id} className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">{resource.name}</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{resource.type === 'oven' && 'Horno'}
|
||||
{resource.type === 'mixer' && 'Amasadora'}
|
||||
{resource.type === 'staff' && 'Personal'}
|
||||
- Capacidad: {resource.capacity}
|
||||
</p>
|
||||
</div>
|
||||
<Badge className="bg-[var(--color-info)]/10 text-[var(--color-info)]">
|
||||
{Math.round(resource.slots.reduce((acc, slot) => acc + slot.utilized, 0) / resource.slots.length)}% utilizado
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{resource.slots.slice(0, 8).map((slot, index) => (
|
||||
<div key={index} className="flex items-center gap-3">
|
||||
<span className="text-sm font-mono w-16">{slot.time}</span>
|
||||
<div className="flex-1 h-6 bg-[var(--bg-quaternary)] rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${
|
||||
slot.utilized > 80
|
||||
? 'bg-[var(--color-error)]'
|
||||
: slot.utilized > 60
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-[var(--color-success)]'
|
||||
}`}
|
||||
style={{ width: `${slot.utilized}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-[var(--text-secondary)] w-12">
|
||||
{slot.utilized}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-4 w-full"
|
||||
onClick={() => {
|
||||
// Open detailed capacity view
|
||||
console.log('Open detailed view for', resource.name);
|
||||
}}
|
||||
>
|
||||
Ver detalles completos
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderCalendarView = () => (
|
||||
<div className="bg-white rounded-lg border p-4">
|
||||
<div className="grid grid-cols-7 gap-4 mb-4">
|
||||
{['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'].map((day) => (
|
||||
<div key={day} className="text-center font-semibold text-[var(--text-secondary)] py-2">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-4">
|
||||
{Array.from({ length: 35 }, (_, i) => {
|
||||
const date = new Date(selectedDate);
|
||||
date.setDate(date.getDate() - date.getDay() + i);
|
||||
const dayEntries = filteredEntries.filter(entry =>
|
||||
new Date(entry.scheduled_date).toDateString() === date.toDateString()
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`min-h-24 p-2 border rounded-lg ${
|
||||
date.toDateString() === selectedDate.toDateString()
|
||||
? 'bg-[var(--color-info)]/5 border-[var(--color-info)]/20'
|
||||
: 'bg-[var(--bg-secondary)] border-[var(--border-primary)]'
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-medium mb-1">{date.getDate()}</div>
|
||||
<div className="space-y-1">
|
||||
{dayEntries.slice(0, 3).map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={`text-xs p-1 rounded cursor-pointer ${getProductTypeColor(entry.batch?.recipe?.category)}`}
|
||||
onClick={() => onBatchSelected?.(entry.batch!)}
|
||||
>
|
||||
{entry.batch?.recipe?.name}
|
||||
</div>
|
||||
))}
|
||||
{dayEntries.length > 3 && (
|
||||
<div className="text-xs text-[var(--text-tertiary)]">
|
||||
+{dayEntries.length - 3} más
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`space-y-6 ${className}`}>
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-[var(--text-primary)]">Programación de Producción</h2>
|
||||
<p className="text-[var(--text-secondary)]">Gestiona y programa la producción diaria de la panadería</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex bg-gray-100 rounded-lg p-1">
|
||||
<Button
|
||||
variant={viewMode === 'calendar' ? 'primary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('calendar')}
|
||||
className="px-3"
|
||||
>
|
||||
Calendario
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'timeline' ? 'primary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('timeline')}
|
||||
className="px-3"
|
||||
>
|
||||
Línea de tiempo
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'capacity' ? 'primary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('capacity')}
|
||||
className="px-3"
|
||||
>
|
||||
Capacidad
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => setIsScheduleModalOpen(true)}
|
||||
>
|
||||
Nueva programación
|
||||
</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>
|
||||
) : (
|
||||
<>
|
||||
{viewMode === 'calendar' && renderCalendarView()}
|
||||
{viewMode === 'timeline' && renderTimelineView()}
|
||||
{viewMode === 'capacity' && renderCapacityView()}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Schedule Creation Modal */}
|
||||
<Modal
|
||||
isOpen={isScheduleModalOpen}
|
||||
onClose={() => {
|
||||
setIsScheduleModalOpen(false);
|
||||
setSelectedBatchForScheduling(null);
|
||||
}}
|
||||
title="Programar Lote de Producción"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Select className="w-full">
|
||||
<option value="">Seleccionar lote...</option>
|
||||
<option value="batch-1">Lote #001 - Pan de Molde (100 uds)</option>
|
||||
<option value="batch-2">Lote #002 - Croissants (50 uds)</option>
|
||||
<option value="batch-3">Lote #003 - Tarta de Chocolate (5 uds)</option>
|
||||
</Select>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<DatePicker
|
||||
selected={selectedDate}
|
||||
onChange={(date) => setSelectedDate(date || new Date())}
|
||||
dateFormat="dd/MM/yyyy"
|
||||
/>
|
||||
<Input
|
||||
type="time"
|
||||
placeholder="Hora de inicio"
|
||||
defaultValue="06:00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Duración (horas)"
|
||||
defaultValue="4"
|
||||
/>
|
||||
<Select>
|
||||
<option value="">Asignar horno...</option>
|
||||
<option value="horno-1">Horno Principal</option>
|
||||
<option value="horno-2">Horno Secundario</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Select>
|
||||
<option value="">Asignar personal...</option>
|
||||
<option value="panadero-1">Equipo Panadería - Turno Mañana</option>
|
||||
<option value="panadero-2">Equipo Panadería - Turno Tarde</option>
|
||||
</Select>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsScheduleModalOpen(false)}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
// Handle schedule creation
|
||||
setIsScheduleModalOpen(false);
|
||||
}}
|
||||
>
|
||||
Programar lote
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Legend */}
|
||||
<Card className="p-4">
|
||||
<h3 className="font-semibold mb-3">Leyenda de productos</h3>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-[var(--color-warning)]/10 border border-[var(--color-warning)]/20 rounded"></div>
|
||||
<span className="text-sm">Pan</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-[var(--color-primary)]/10 border border-[var(--color-primary)]/20 rounded"></div>
|
||||
<span className="text-sm">Bollería</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-pink-100 border border-pink-300 rounded"></div>
|
||||
<span className="text-sm">Repostería</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-purple-100 border border-purple-300 rounded"></div>
|
||||
<span className="text-sm">Especial</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductionSchedule;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from '../ProductionSchedule';
|
||||
@@ -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 '../../../services/api/production.service';
|
||||
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<string, QualityCheckTemplate> = {
|
||||
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-blue-100 text-blue-800',
|
||||
[QualityCheckStatus.IN_PROGRESS]: 'bg-yellow-100 text-yellow-800',
|
||||
[QualityCheckStatus.PASSED]: 'bg-green-100 text-green-800',
|
||||
[QualityCheckStatus.FAILED]: 'bg-red-100 text-red-800',
|
||||
[QualityCheckStatus.REQUIRES_REVIEW]: 'bg-orange-100 text-orange-800',
|
||||
[QualityCheckStatus.CANCELLED]: 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
|
||||
export const QualityControl: React.FC<QualityControlProps> = ({
|
||||
className = '',
|
||||
batchId,
|
||||
checkType,
|
||||
onQualityCheckCompleted,
|
||||
onCorrectiveActionRequired,
|
||||
}) => {
|
||||
const [qualityChecks, setQualityChecks] = useState<QualityCheckResponse[]>([]);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<QualityCheckTemplate | null>(null);
|
||||
const [activeCheck, setActiveCheck] = useState<QualityCheck | null>(null);
|
||||
const [inspectionResults, setInspectionResults] = useState<Record<string, QualityInspectionResult>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isInspectionModalOpen, setIsInspectionModalOpen] = useState(false);
|
||||
const [uploadedPhotos, setUploadedPhotos] = useState<Record<string, File>>({});
|
||||
const [currentInspector, setCurrentInspector] = useState<string>('inspector-1');
|
||||
const fileInputRef = useRef<HTMLInputElement>(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 (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-blue-50 border border-blue-200 p-4 rounded-lg">
|
||||
<h3 className="font-semibold text-blue-900 mb-2">{selectedTemplate.spanishName}</h3>
|
||||
<p className="text-sm text-blue-700">
|
||||
Umbral de aprobación: {selectedTemplate.passThreshold}%
|
||||
</p>
|
||||
{selectedTemplate.criticalPoints.length > 0 && (
|
||||
<p className="text-sm text-blue-700 mt-1">
|
||||
Puntos críticos: {selectedTemplate.criticalPoints.length}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{selectedTemplate.criteria.map((criterion) => (
|
||||
<Card key={criterion.id} className={`p-4 ${criterion.isCritical ? 'border-orange-200 bg-orange-50' : ''}`}>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-gray-900">{criterion.spanishDescription}</h4>
|
||||
<p className="text-sm text-gray-600 mt-1">{criterion.acceptableCriteria}</p>
|
||||
{criterion.isCritical && (
|
||||
<Badge className="bg-orange-100 text-orange-800 text-xs mt-1">
|
||||
Punto Crítico
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">Peso: {criterion.weight}%</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{criterion.type === 'boolean' && (
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name={criterion.id}
|
||||
value="true"
|
||||
checked={inspectionResults[criterion.id]?.value === true}
|
||||
onChange={() => updateInspectionResult(criterion.id, true)}
|
||||
className="mr-2"
|
||||
/>
|
||||
Sí / Pasa
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name={criterion.id}
|
||||
value="false"
|
||||
checked={inspectionResults[criterion.id]?.value === false}
|
||||
onChange={() => updateInspectionResult(criterion.id, false)}
|
||||
className="mr-2"
|
||||
/>
|
||||
No / Falla
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{criterion.type === 'scale' && (
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm">1</span>
|
||||
<input
|
||||
type="range"
|
||||
min={criterion.minValue || 1}
|
||||
max={criterion.maxValue || 5}
|
||||
step="1"
|
||||
value={inspectionResults[criterion.id]?.value || criterion.minValue || 1}
|
||||
onChange={(e) => updateInspectionResult(criterion.id, parseInt(e.target.value))}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-sm">{criterion.maxValue || 5}</span>
|
||||
<span className="min-w-8 text-center font-medium">
|
||||
{inspectionResults[criterion.id]?.value || criterion.minValue || 1}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{criterion.type === 'numeric' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={criterion.minValue}
|
||||
max={criterion.maxValue}
|
||||
step="0.1"
|
||||
value={inspectionResults[criterion.id]?.value || ''}
|
||||
onChange={(e) => updateInspectionResult(criterion.id, parseFloat(e.target.value) || 0)}
|
||||
className="w-24"
|
||||
/>
|
||||
{criterion.unit && <span className="text-sm text-gray-600">{criterion.unit}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
placeholder="Notas adicionales (opcional)"
|
||||
value={inspectionResults[criterion.id]?.notes || ''}
|
||||
onChange={(e) => {
|
||||
const currentResult = inspectionResults[criterion.id];
|
||||
if (currentResult) {
|
||||
updateInspectionResult(criterion.id, currentResult.value, e.target.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{selectedTemplate.requiresPhotos && (
|
||||
<div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
handlePhotoUpload(criterion.id, file);
|
||||
}
|
||||
}}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
📸 {uploadedPhotos[criterion.id] ? 'Cambiar foto' : 'Tomar foto'}
|
||||
</Button>
|
||||
{uploadedPhotos[criterion.id] && (
|
||||
<p className="text-sm text-green-600 mt-1">
|
||||
✓ Foto capturada: {uploadedPhotos[criterion.id].name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card className="p-4 bg-gray-50">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="font-medium">Puntuación general</span>
|
||||
<span className="text-2xl font-bold text-blue-600">
|
||||
{calculateOverallScore().toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-gray-200 rounded-full h-3 mb-2">
|
||||
<div
|
||||
className={`h-3 rounded-full transition-all duration-500 ${
|
||||
calculateOverallScore() >= selectedTemplate.passThreshold
|
||||
? 'bg-green-500'
|
||||
: 'bg-red-500'
|
||||
}`}
|
||||
style={{ width: `${calculateOverallScore()}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-sm text-gray-600">
|
||||
<span>Umbral: {selectedTemplate.passThreshold}%</span>
|
||||
<span className={
|
||||
calculateOverallScore() >= selectedTemplate.passThreshold
|
||||
? 'text-green-600 font-medium'
|
||||
: 'text-red-600 font-medium'
|
||||
}>
|
||||
{calculateOverallScore() >= selectedTemplate.passThreshold ? '✓ APROBADO' : '✗ REPROBADO'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{checkCriticalFailures().length > 0 && (
|
||||
<div className="mt-3 p-2 bg-red-100 border border-red-200 rounded">
|
||||
<p className="text-sm font-medium text-red-800">Fallas críticas detectadas:</p>
|
||||
<ul className="text-sm text-red-700 mt-1">
|
||||
{checkCriticalFailures().map((failure, index) => (
|
||||
<li key={index}>• {failure}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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: (
|
||||
<Badge className={STATUS_COLORS[check.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'}
|
||||
</Badge>
|
||||
),
|
||||
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: (
|
||||
<div className="flex gap-2">
|
||||
{check.status === QualityCheckStatus.SCHEDULED && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const template = QUALITY_CHECK_TEMPLATES[check.check_type];
|
||||
if (template) {
|
||||
startQualityCheck(template);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Iniciar
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm">
|
||||
Ver detalles
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
return <Table columns={columns} data={data} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-6 ${className}`}>
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">Control de Calidad</h2>
|
||||
<p className="text-gray-600">Gestiona las inspecciones de calidad y cumplimiento</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={currentInspector}
|
||||
onChange={(e) => setCurrentInspector(e.target.value)}
|
||||
className="w-48"
|
||||
>
|
||||
<option value="inspector-1">María García (Inspector)</option>
|
||||
<option value="inspector-2">Juan López (Supervisor)</option>
|
||||
<option value="inspector-3">Ana Martín (Jefe Calidad)</option>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
// Show modal to select quality check type
|
||||
const template = QUALITY_CHECK_TEMPLATES.visual_inspection;
|
||||
startQualityCheck(template);
|
||||
}}
|
||||
>
|
||||
Nueva inspección
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Start Templates */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{Object.values(QUALITY_CHECK_TEMPLATES).map((template) => (
|
||||
<Card
|
||||
key={template.id}
|
||||
className="p-4 cursor-pointer hover:shadow-md transition-shadow border-2 border-transparent hover:border-blue-200"
|
||||
onClick={() => startQualityCheck(template)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-semibold text-gray-900">{template.spanishName}</h3>
|
||||
{template.requiresPhotos && (
|
||||
<span className="text-sm bg-blue-100 text-blue-800 px-2 py-1 rounded">
|
||||
📸 Fotos
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
{template.criteria.length} criterios • Umbral: {template.passThreshold}%
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{template.productTypes.map((type) => (
|
||||
<Badge key={type} className="text-xs bg-gray-100 text-gray-700">
|
||||
{type === 'pan' && 'Pan'}
|
||||
{type === 'bolleria' && 'Bollería'}
|
||||
{type === 'reposteria' && 'Repostería'}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{template.criticalPoints.length > 0 && (
|
||||
<p className="text-xs text-orange-600 mt-2">
|
||||
{template.criticalPoints.length} puntos críticos
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Quality Checks Table */}
|
||||
<Card className="p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-semibold">Controles de calidad recientes</h3>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Select className="w-40">
|
||||
<option value="all">Todos los estados</option>
|
||||
<option value={QualityCheckStatus.SCHEDULED}>Programados</option>
|
||||
<option value={QualityCheckStatus.IN_PROGRESS}>En progreso</option>
|
||||
<option value={QualityCheckStatus.PASSED}>Aprobados</option>
|
||||
<option value={QualityCheckStatus.FAILED}>Reprobados</option>
|
||||
</Select>
|
||||
|
||||
<Button variant="outline" onClick={loadQualityChecks}>
|
||||
Actualizar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : (
|
||||
renderQualityChecksTable()
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Quality Check Modal */}
|
||||
<Modal
|
||||
isOpen={isInspectionModalOpen}
|
||||
onClose={() => {
|
||||
setIsInspectionModalOpen(false);
|
||||
setSelectedTemplate(null);
|
||||
}}
|
||||
title={`Control de Calidad: ${selectedTemplate?.spanishName}`}
|
||||
size="lg"
|
||||
>
|
||||
{renderInspectionForm()}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-6 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsInspectionModalOpen(false);
|
||||
setSelectedTemplate(null);
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={completeQualityCheck}
|
||||
disabled={!selectedTemplate || Object.keys(inspectionResults).length === 0}
|
||||
>
|
||||
Completar inspección
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<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">Tasa de aprobación</p>
|
||||
<p className="text-2xl font-bold text-green-600">94.2%</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">Controles pendientes</p>
|
||||
<p className="text-2xl font-bold text-orange-600">7</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">Fallas críticas</p>
|
||||
<p className="text-2xl font-bold text-red-600">2</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">Controles hoy</p>
|
||||
<p className="text-2xl font-bold text-blue-600">23</p>
|
||||
</div>
|
||||
<span className="text-2xl">📋</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QualityControl;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from '../QualityControl';
|
||||
@@ -1,794 +0,0 @@
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { Card, Button, Badge, Input, Modal, Table, Select } from '../../ui';
|
||||
import type { Recipe, RecipeIngredient, RecipeInstruction, NutritionalInfo, DifficultyLevel } from '../../../types/production.types';
|
||||
|
||||
interface RecipeDisplayProps {
|
||||
className?: string;
|
||||
recipe: Recipe;
|
||||
editable?: boolean;
|
||||
showNutrition?: boolean;
|
||||
showCosting?: boolean;
|
||||
onScaleChange?: (scaleFactor: number, scaledRecipe: Recipe) => void;
|
||||
onVersionUpdate?: (newVersion: Recipe) => void;
|
||||
onCostCalculation?: (totalCost: number, costPerUnit: number) => void;
|
||||
}
|
||||
|
||||
interface ScaledIngredient extends RecipeIngredient {
|
||||
scaledQuantity: number;
|
||||
scaledCost?: number;
|
||||
}
|
||||
|
||||
interface ScaledInstruction extends RecipeInstruction {
|
||||
scaledDuration?: number;
|
||||
}
|
||||
|
||||
interface ScaledRecipe extends Omit<Recipe, 'ingredients' | 'instructions'> {
|
||||
scaledYieldQuantity: number;
|
||||
scaledTotalTime: number;
|
||||
ingredients: ScaledIngredient[];
|
||||
instructions: ScaledInstruction[];
|
||||
scaleFactor: number;
|
||||
estimatedTotalCost?: number;
|
||||
costPerScaledUnit?: number;
|
||||
}
|
||||
|
||||
interface TimerState {
|
||||
instructionId: string;
|
||||
duration: number;
|
||||
remaining: number;
|
||||
isActive: boolean;
|
||||
isComplete: boolean;
|
||||
}
|
||||
|
||||
const DIFFICULTY_COLORS = {
|
||||
[DifficultyLevel.BEGINNER]: 'bg-green-100 text-green-800',
|
||||
[DifficultyLevel.INTERMEDIATE]: 'bg-yellow-100 text-yellow-800',
|
||||
[DifficultyLevel.ADVANCED]: 'bg-orange-100 text-orange-800',
|
||||
[DifficultyLevel.EXPERT]: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
const DIFFICULTY_LABELS = {
|
||||
[DifficultyLevel.BEGINNER]: 'Principiante',
|
||||
[DifficultyLevel.INTERMEDIATE]: 'Intermedio',
|
||||
[DifficultyLevel.ADVANCED]: 'Avanzado',
|
||||
[DifficultyLevel.EXPERT]: 'Experto',
|
||||
};
|
||||
|
||||
const ALLERGEN_ICONS: Record<string, string> = {
|
||||
gluten: '🌾',
|
||||
milk: '🥛',
|
||||
eggs: '🥚',
|
||||
nuts: '🥜',
|
||||
soy: '🫘',
|
||||
sesame: '🌰',
|
||||
fish: '🐟',
|
||||
shellfish: '🦐',
|
||||
};
|
||||
|
||||
const EQUIPMENT_ICONS: Record<string, string> = {
|
||||
oven: '🔥',
|
||||
mixer: '🥄',
|
||||
scale: '⚖️',
|
||||
bowl: '🥣',
|
||||
whisk: '🔄',
|
||||
spatula: '🍴',
|
||||
thermometer: '🌡️',
|
||||
timer: '⏰',
|
||||
};
|
||||
|
||||
export const RecipeDisplay: React.FC<RecipeDisplayProps> = ({
|
||||
className = '',
|
||||
recipe,
|
||||
editable = false,
|
||||
showNutrition = true,
|
||||
showCosting = false,
|
||||
onScaleChange,
|
||||
onVersionUpdate,
|
||||
onCostCalculation,
|
||||
}) => {
|
||||
const [scaleFactor, setScaleFactor] = useState<number>(1);
|
||||
const [activeTimers, setActiveTimers] = useState<Record<string, TimerState>>({});
|
||||
const [isNutritionModalOpen, setIsNutritionModalOpen] = useState(false);
|
||||
const [isCostingModalOpen, setIsCostingModalOpen] = useState(false);
|
||||
const [isEquipmentModalOpen, setIsEquipmentModalOpen] = useState(false);
|
||||
const [selectedInstruction, setSelectedInstruction] = useState<RecipeInstruction | null>(null);
|
||||
const [showAllergensDetail, setShowAllergensDetail] = useState(false);
|
||||
|
||||
// Mock ingredient costs for demonstration
|
||||
const ingredientCosts: Record<string, number> = {
|
||||
flour: 1.2, // €/kg
|
||||
water: 0.001, // €/l
|
||||
salt: 0.8, // €/kg
|
||||
yeast: 8.5, // €/kg
|
||||
sugar: 1.5, // €/kg
|
||||
butter: 6.2, // €/kg
|
||||
milk: 1.3, // €/l
|
||||
eggs: 0.25, // €/unit
|
||||
};
|
||||
|
||||
const scaledRecipe = useMemo((): ScaledRecipe => {
|
||||
const scaledIngredients: ScaledIngredient[] = recipe.ingredients.map(ingredient => ({
|
||||
...ingredient,
|
||||
scaledQuantity: ingredient.quantity * scaleFactor,
|
||||
scaledCost: ingredientCosts[ingredient.ingredient_id]
|
||||
? ingredientCosts[ingredient.ingredient_id] * ingredient.quantity * scaleFactor
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
const scaledInstructions: ScaledInstruction[] = recipe.instructions.map(instruction => ({
|
||||
...instruction,
|
||||
scaledDuration: instruction.duration_minutes ? Math.ceil(instruction.duration_minutes * scaleFactor) : undefined,
|
||||
}));
|
||||
|
||||
const estimatedTotalCost = scaledIngredients.reduce((total, ingredient) =>
|
||||
total + (ingredient.scaledCost || 0), 0
|
||||
);
|
||||
|
||||
const scaledYieldQuantity = recipe.yield_quantity * scaleFactor;
|
||||
const costPerScaledUnit = scaledYieldQuantity > 0 ? estimatedTotalCost / scaledYieldQuantity : 0;
|
||||
|
||||
return {
|
||||
...recipe,
|
||||
scaledYieldQuantity,
|
||||
scaledTotalTime: Math.ceil(recipe.total_time_minutes * scaleFactor),
|
||||
ingredients: scaledIngredients,
|
||||
instructions: scaledInstructions,
|
||||
scaleFactor,
|
||||
estimatedTotalCost,
|
||||
costPerScaledUnit,
|
||||
};
|
||||
}, [recipe, scaleFactor, ingredientCosts]);
|
||||
|
||||
const handleScaleChange = useCallback((newScaleFactor: number) => {
|
||||
setScaleFactor(newScaleFactor);
|
||||
if (onScaleChange) {
|
||||
onScaleChange(newScaleFactor, scaledRecipe as unknown as Recipe);
|
||||
}
|
||||
}, [scaledRecipe, onScaleChange]);
|
||||
|
||||
const startTimer = (instruction: RecipeInstruction) => {
|
||||
if (!instruction.duration_minutes) return;
|
||||
|
||||
const timerState: TimerState = {
|
||||
instructionId: instruction.step_number.toString(),
|
||||
duration: instruction.duration_minutes * 60, // Convert to seconds
|
||||
remaining: instruction.duration_minutes * 60,
|
||||
isActive: true,
|
||||
isComplete: false,
|
||||
};
|
||||
|
||||
setActiveTimers(prev => ({
|
||||
...prev,
|
||||
[instruction.step_number.toString()]: timerState,
|
||||
}));
|
||||
|
||||
// Start countdown
|
||||
const interval = setInterval(() => {
|
||||
setActiveTimers(prev => {
|
||||
const current = prev[instruction.step_number.toString()];
|
||||
if (!current || current.remaining <= 0) {
|
||||
clearInterval(interval);
|
||||
return {
|
||||
...prev,
|
||||
[instruction.step_number.toString()]: {
|
||||
...current,
|
||||
remaining: 0,
|
||||
isActive: false,
|
||||
isComplete: true,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[instruction.step_number.toString()]: {
|
||||
...current,
|
||||
remaining: current.remaining - 1,
|
||||
}
|
||||
};
|
||||
});
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const stopTimer = (instructionId: string) => {
|
||||
setActiveTimers(prev => ({
|
||||
...prev,
|
||||
[instructionId]: {
|
||||
...prev[instructionId],
|
||||
isActive: false,
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
||||
}
|
||||
return `${minutes}:${String(secs).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const renderScalingControls = () => (
|
||||
<Card className="p-4">
|
||||
<h3 className="font-semibold text-lg mb-4">Escalado de receta</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Factor de escala
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0.1"
|
||||
max="10"
|
||||
step="0.1"
|
||||
value={scaleFactor}
|
||||
onChange={(e) => handleScaleChange(parseFloat(e.target.value) || 1)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Cantidad original
|
||||
</label>
|
||||
<p className="text-lg font-semibold text-gray-900">
|
||||
{recipe.yield_quantity} {recipe.yield_unit}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Cantidad escalada
|
||||
</label>
|
||||
<p className="text-lg font-semibold text-blue-600">
|
||||
{scaledRecipe.scaledYieldQuantity} {recipe.yield_unit}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleScaleChange(0.5)}
|
||||
className={scaleFactor === 0.5 ? 'bg-blue-50 border-blue-300' : ''}
|
||||
>
|
||||
1/2
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleScaleChange(1)}
|
||||
className={scaleFactor === 1 ? 'bg-blue-50 border-blue-300' : ''}
|
||||
>
|
||||
1x
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleScaleChange(2)}
|
||||
className={scaleFactor === 2 ? 'bg-blue-50 border-blue-300' : ''}
|
||||
>
|
||||
2x
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleScaleChange(3)}
|
||||
className={scaleFactor === 3 ? 'bg-blue-50 border-blue-300' : ''}
|
||||
>
|
||||
3x
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleScaleChange(5)}
|
||||
className={scaleFactor === 5 ? 'bg-blue-50 border-blue-300' : ''}
|
||||
>
|
||||
5x
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{scaleFactor !== 1 && (
|
||||
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<p className="text-sm text-blue-800">
|
||||
<span className="font-medium">Tiempo total escalado:</span> {Math.ceil(scaledRecipe.scaledTotalTime / 60)}h {scaledRecipe.scaledTotalTime % 60}m
|
||||
</p>
|
||||
{showCosting && scaledRecipe.estimatedTotalCost && (
|
||||
<p className="text-sm text-blue-800 mt-1">
|
||||
<span className="font-medium">Costo estimado:</span> €{scaledRecipe.estimatedTotalCost.toFixed(2)}
|
||||
(€{scaledRecipe.costPerScaledUnit?.toFixed(3)}/{recipe.yield_unit})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
const renderRecipeHeader = () => (
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col lg:flex-row gap-6">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">{recipe.name}</h1>
|
||||
<p className="text-gray-600 mt-1">{recipe.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Badge className={DIFFICULTY_COLORS[recipe.difficulty_level]}>
|
||||
{DIFFICULTY_LABELS[recipe.difficulty_level]}
|
||||
</Badge>
|
||||
<Badge className="bg-blue-100 text-blue-800">
|
||||
v{recipe.version}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Preparación</p>
|
||||
<p className="font-semibold">{recipe.prep_time_minutes} min</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Cocción</p>
|
||||
<p className="font-semibold">{recipe.cook_time_minutes} min</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Total</p>
|
||||
<p className="font-semibold">{Math.ceil(recipe.total_time_minutes / 60)}h {recipe.total_time_minutes % 60}m</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Rendimiento</p>
|
||||
<p className="font-semibold">{scaledRecipe.scaledYieldQuantity} {recipe.yield_unit}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{recipe.allergen_warnings.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-700">Alérgenos:</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowAllergensDetail(!showAllergensDetail)}
|
||||
>
|
||||
{showAllergensDetail ? 'Ocultar' : 'Ver detalles'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{recipe.allergen_warnings.map((allergen) => (
|
||||
<Badge
|
||||
key={allergen}
|
||||
className="bg-red-100 text-red-800 border border-red-200"
|
||||
>
|
||||
{ALLERGEN_ICONS[allergen]} {allergen}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showAllergensDetail && (
|
||||
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-sm text-red-800">
|
||||
⚠️ Este producto contiene los siguientes alérgenos. Revisar cuidadosamente
|
||||
antes del consumo si existe alguna alergia o intolerancia alimentaria.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recipe.storage_instructions && (
|
||||
<div className="mt-4 p-3 bg-gray-50 border border-gray-200 rounded-lg">
|
||||
<p className="text-sm text-gray-700">
|
||||
<span className="font-medium">Conservación:</span> {recipe.storage_instructions}
|
||||
{recipe.shelf_life_hours && (
|
||||
<span className="ml-2">
|
||||
• Vida útil: {Math.ceil(recipe.shelf_life_hours / 24)} días
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 lg:w-48">
|
||||
{showNutrition && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsNutritionModalOpen(true)}
|
||||
className="w-full"
|
||||
>
|
||||
📊 Información nutricional
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showCosting && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsCostingModalOpen(true)}
|
||||
className="w-full"
|
||||
>
|
||||
💰 Análisis de costos
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsEquipmentModalOpen(true)}
|
||||
className="w-full"
|
||||
>
|
||||
🔧 Equipo necesario
|
||||
</Button>
|
||||
|
||||
{editable && onVersionUpdate && (
|
||||
<Button variant="primary" className="w-full">
|
||||
✏️ Editar receta
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const renderIngredients = () => (
|
||||
<Card className="p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Ingredientes</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
{scaledRecipe.ingredients.map((ingredient, index) => (
|
||||
<div
|
||||
key={`${ingredient.ingredient_id}-${index}`}
|
||||
className="flex items-center justify-between py-2 border-b border-gray-100 last:border-b-0"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-900">{ingredient.ingredient_name}</p>
|
||||
{ingredient.preparation_notes && (
|
||||
<p className="text-sm text-gray-600 italic">{ingredient.preparation_notes}</p>
|
||||
)}
|
||||
{ingredient.is_optional && (
|
||||
<Badge className="bg-gray-100 text-gray-600 text-xs mt-1">
|
||||
Opcional
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-right ml-4">
|
||||
<p className="font-semibold text-lg">
|
||||
{ingredient.scaledQuantity.toFixed(ingredient.scaledQuantity < 1 ? 2 : 0)} {ingredient.unit}
|
||||
</p>
|
||||
{scaleFactor !== 1 && (
|
||||
<p className="text-sm text-gray-500">
|
||||
(original: {ingredient.quantity} {ingredient.unit})
|
||||
</p>
|
||||
)}
|
||||
{showCosting && ingredient.scaledCost && (
|
||||
<p className="text-sm text-green-600">
|
||||
€{ingredient.scaledCost.toFixed(2)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showCosting && scaledRecipe.estimatedTotalCost && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-semibold">Costo total estimado:</span>
|
||||
<span className="text-xl font-bold text-green-600">
|
||||
€{scaledRecipe.estimatedTotalCost.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 text-right">
|
||||
€{scaledRecipe.costPerScaledUnit?.toFixed(3)} por {recipe.yield_unit}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
const renderInstructions = () => (
|
||||
<Card className="p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Instrucciones</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{scaledRecipe.instructions.map((instruction, index) => {
|
||||
const timer = activeTimers[instruction.step_number.toString()];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={instruction.step_number}
|
||||
className={`p-4 border rounded-lg ${
|
||||
instruction.critical_control_point
|
||||
? 'border-orange-300 bg-orange-50'
|
||||
: 'border-gray-200 bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-8 h-8 bg-blue-600 text-white rounded-full flex items-center justify-center font-semibold">
|
||||
{instruction.step_number}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{instruction.duration_minutes && (
|
||||
<Badge className="bg-blue-100 text-blue-800">
|
||||
⏱️ {instruction.scaledDuration || instruction.duration_minutes} min
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{instruction.temperature && (
|
||||
<Badge className="bg-red-100 text-red-800">
|
||||
🌡️ {instruction.temperature}°C
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{instruction.critical_control_point && (
|
||||
<Badge className="bg-orange-100 text-orange-800">
|
||||
🚨 PCC
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{instruction.duration_minutes && (
|
||||
<div className="flex gap-2">
|
||||
{!timer?.isActive && !timer?.isComplete && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => startTimer(instruction)}
|
||||
>
|
||||
▶️ Iniciar timer
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{timer?.isActive && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-mono font-bold text-blue-600">
|
||||
{formatTime(timer.remaining)}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => stopTimer(instruction.step_number.toString())}
|
||||
>
|
||||
⏸️
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{timer?.isComplete && (
|
||||
<Badge className="bg-green-100 text-green-800 animate-pulse">
|
||||
✅ Completado
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-gray-800 leading-relaxed mb-2">
|
||||
{instruction.instruction}
|
||||
</p>
|
||||
|
||||
{instruction.equipment && instruction.equipment.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<p className="text-sm text-gray-600 mb-1">Equipo necesario:</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{instruction.equipment.map((equipment, idx) => (
|
||||
<Badge key={idx} className="bg-gray-100 text-gray-700 text-xs">
|
||||
{EQUIPMENT_ICONS[equipment.toLowerCase()] || '🔧'} {equipment}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{instruction.tips && (
|
||||
<div className="mt-3 p-2 bg-yellow-50 border border-yellow-200 rounded">
|
||||
<p className="text-sm text-yellow-800">
|
||||
💡 <span className="font-medium">Tip:</span> {instruction.tips}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const renderNutritionModal = () => (
|
||||
<Modal
|
||||
isOpen={isNutritionModalOpen}
|
||||
onClose={() => setIsNutritionModalOpen(false)}
|
||||
title="Información Nutricional"
|
||||
>
|
||||
{recipe.nutritional_info ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<p className="text-sm text-gray-600">Calorías por porción</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{recipe.nutritional_info.calories_per_serving || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<p className="text-sm text-gray-600">Tamaño de porción</p>
|
||||
<p className="text-lg font-semibold text-gray-900">
|
||||
{recipe.nutritional_info.serving_size || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Proteínas</p>
|
||||
<p className="text-lg font-semibold">{recipe.nutritional_info.protein_g || 0}g</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Carbohidratos</p>
|
||||
<p className="text-lg font-semibold">{recipe.nutritional_info.carbohydrates_g || 0}g</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Grasas</p>
|
||||
<p className="text-lg font-semibold">{recipe.nutritional_info.fat_g || 0}g</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Fibra</p>
|
||||
<p className="text-lg font-semibold">{recipe.nutritional_info.fiber_g || 0}g</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Azúcares</p>
|
||||
<p className="text-lg font-semibold">{recipe.nutritional_info.sugar_g || 0}g</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Sodio</p>
|
||||
<p className="text-lg font-semibold">{recipe.nutritional_info.sodium_mg || 0}mg</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<p className="text-sm text-blue-800">
|
||||
<span className="font-medium">Porciones por lote escalado:</span> {
|
||||
recipe.nutritional_info.servings_per_batch
|
||||
? Math.ceil(recipe.nutritional_info.servings_per_batch * scaleFactor)
|
||||
: 'N/A'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-8">
|
||||
No hay información nutricional disponible para esta receta.
|
||||
</p>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
|
||||
const renderCostingModal = () => (
|
||||
<Modal
|
||||
isOpen={isCostingModalOpen}
|
||||
onClose={() => setIsCostingModalOpen(false)}
|
||||
title="Análisis de Costos"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="bg-green-50 border border-green-200 p-4 rounded-lg">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="font-semibold text-green-900">Costo total</span>
|
||||
<span className="text-2xl font-bold text-green-600">
|
||||
€{scaledRecipe.estimatedTotalCost?.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-green-700">
|
||||
€{scaledRecipe.costPerScaledUnit?.toFixed(3)} por {recipe.yield_unit}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold mb-3">Desglose por ingrediente</h4>
|
||||
<div className="space-y-2">
|
||||
{scaledRecipe.ingredients.map((ingredient, index) => (
|
||||
<div key={index} className="flex justify-between items-center py-2 border-b border-gray-100">
|
||||
<div>
|
||||
<p className="font-medium">{ingredient.ingredient_name}</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
{ingredient.scaledQuantity.toFixed(2)} {ingredient.unit}
|
||||
</p>
|
||||
</div>
|
||||
<p className="font-semibold text-green-600">
|
||||
€{ingredient.scaledCost?.toFixed(2) || '0.00'}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<h4 className="font-semibold text-blue-900 mb-2">Análisis de rentabilidad</h4>
|
||||
<div className="space-y-1 text-sm text-blue-800">
|
||||
<p>• Costo de ingredientes: €{scaledRecipe.estimatedTotalCost?.toFixed(2)}</p>
|
||||
<p>• Margen sugerido (300%): €{((scaledRecipe.estimatedTotalCost || 0) * 3).toFixed(2)}</p>
|
||||
<p>• Precio de venta sugerido por {recipe.yield_unit}: €{((scaledRecipe.costPerScaledUnit || 0) * 4).toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
const renderEquipmentModal = () => (
|
||||
<Modal
|
||||
isOpen={isEquipmentModalOpen}
|
||||
onClose={() => setIsEquipmentModalOpen(false)}
|
||||
title="Equipo Necesario"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-semibold mb-3">Equipo general de la receta</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{recipe.equipment_needed.map((equipment, index) => (
|
||||
<div key={index} className="flex items-center gap-2 p-2 bg-gray-50 rounded">
|
||||
<span className="text-xl">
|
||||
{EQUIPMENT_ICONS[equipment.toLowerCase()] || '🔧'}
|
||||
</span>
|
||||
<span className="text-sm">{equipment}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold mb-3">Equipo por instrucción</h4>
|
||||
<div className="space-y-2">
|
||||
{recipe.instructions
|
||||
.filter(instruction => instruction.equipment && instruction.equipment.length > 0)
|
||||
.map((instruction) => (
|
||||
<div key={instruction.step_number} className="border border-gray-200 p-3 rounded">
|
||||
<p className="font-medium text-sm mb-2">
|
||||
Paso {instruction.step_number}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{instruction.equipment?.map((equipment, idx) => (
|
||||
<Badge key={idx} className="bg-blue-100 text-blue-800 text-xs">
|
||||
{EQUIPMENT_ICONS[equipment.toLowerCase()] || '🔧'} {equipment}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`space-y-6 ${className}`}>
|
||||
{renderRecipeHeader()}
|
||||
{renderScalingControls()}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{renderIngredients()}
|
||||
<div className="space-y-6">
|
||||
{renderInstructions()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderNutritionModal()}
|
||||
{renderCostingModal()}
|
||||
{renderEquipmentModal()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecipeDisplay;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from '../RecipeDisplay';
|
||||
Reference in New Issue
Block a user