Files
bakery-ia/frontend/src/components/domain/production/QualityCheckModal.tsx
2025-10-27 16:33:26 +01:00

700 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useCallback, useRef } from 'react';
import {
Camera,
CheckCircle,
XCircle,
Star,
Upload,
FileText,
Package,
Thermometer,
Scale,
Eye,
AlertTriangle
} from 'lucide-react';
import { Modal, Button, Input, Textarea, Badge, Tabs, Card } from '../../ui';
import { TabsList, TabsTrigger, TabsContent } from '../../ui/Tabs';
import { StatusIndicatorConfig } from '../../ui/StatusCard/StatusCard';
import { statusColors } from '../../../styles/colors';
import { productionService, type QualityCheckResponse, QualityCheckStatus } from '../../../api';
import { ProductionBatchResponse } from '../../../api/types/production';
import { useCurrentTenant } from '../../../stores/tenant.store';
import { useQualityTemplatesForStage, useExecuteQualityCheck } from '../../../api/hooks/qualityTemplates';
import { ProcessStage, type QualityCheckTemplate, type QualityCheckExecutionRequest } from '../../../api/types/qualityTemplates';
import { useTranslation } from 'react-i18next';
export interface QualityCheckModalProps {
isOpen: boolean;
onClose: () => void;
batch: ProductionBatchResponse;
processStage?: ProcessStage;
onComplete?: (result: QualityCheckResult) => void;
}
export interface QualityCheckCriterion {
id: string;
category: string;
name: string;
spanishName: string;
description: string;
type: 'visual' | 'measurement' | 'temperature' | 'weight' | 'boolean';
required: boolean;
weight: number;
acceptableCriteria: string;
minValue?: number;
maxValue?: number;
unit?: string;
isCritical: boolean;
}
export interface QualityCheckResult {
criterionId: string;
value: number | string | boolean;
score: number;
notes?: string;
photos?: File[];
pass: boolean;
timestamp: string;
}
export interface QualityCheckData {
batchId: string;
checkType: string;
inspector: string;
startTime: string;
results: QualityCheckResult[];
overallScore: number;
overallPass: boolean;
finalNotes: string;
photos: File[];
criticalFailures: string[];
correctiveActions: string[];
}
const QUALITY_CHECK_TEMPLATES = {
visual_inspection: {
id: 'visual_inspection',
name: 'Visual Inspection',
spanishName: 'Inspección Visual',
icon: Eye,
criteria: [
{
id: 'color_uniformity',
category: 'Apariencia',
name: 'Color Uniformity',
spanishName: 'Uniformidad del Color',
description: 'Evalúa la consistencia del color en todo el producto',
type: 'visual' as const,
required: true,
weight: 20,
acceptableCriteria: 'Puntuación 7 o superior',
minValue: 1,
maxValue: 10,
isCritical: false,
},
{
id: 'shape_integrity',
category: 'Estructura',
name: 'Shape Integrity',
spanishName: 'Integridad de la Forma',
description: 'Verifica que el producto mantenga su forma prevista',
type: 'boolean' as const,
required: true,
weight: 25,
acceptableCriteria: 'Debe ser verdadero',
isCritical: true,
},
{
id: 'surface_texture',
category: 'Textura',
name: 'Surface Texture',
spanishName: 'Textura Superficial',
description: 'Evalúa la calidad de la textura de la superficie',
type: 'visual' as const,
required: true,
weight: 15,
acceptableCriteria: 'Puntuación 7 o superior',
minValue: 1,
maxValue: 10,
isCritical: false,
},
{
id: 'defects_presence',
category: 'Defectos',
name: 'Defects Presence',
spanishName: 'Presencia de Defectos',
description: 'Detecta defectos visibles o imperfecciones',
type: 'boolean' as const,
required: true,
weight: 20,
acceptableCriteria: 'Debe ser falso (sin defectos)',
isCritical: true,
},
{
id: 'size_consistency',
category: 'Dimensiones',
name: 'Size Consistency',
spanishName: 'Consistencia de Tamaño',
description: 'Evalúa la consistencia del tamaño dentro del lote',
type: 'visual' as const,
required: true,
weight: 20,
acceptableCriteria: 'Puntuación 7 o superior',
minValue: 1,
maxValue: 10,
isCritical: false,
}
]
},
weight_check: {
id: 'weight_check',
name: 'Weight Check',
spanishName: 'Control de Peso',
icon: Scale,
criteria: [
{
id: 'individual_weight',
category: 'Peso',
name: 'Individual Weight',
spanishName: 'Peso Individual',
description: 'Peso individual de cada pieza',
type: 'weight' as const,
required: true,
weight: 50,
acceptableCriteria: 'Dentro del ±5% del objetivo',
unit: 'g',
isCritical: true,
},
{
id: 'weight_variance',
category: 'Consistencia',
name: 'Weight Variance',
spanishName: 'Variación de Peso',
description: 'Variación de peso dentro del lote',
type: 'measurement' as const,
required: true,
weight: 50,
acceptableCriteria: 'Menos del 5%',
unit: '%',
isCritical: true,
}
]
},
temperature_check: {
id: 'temperature_check',
name: 'Temperature Check',
spanishName: 'Control de Temperatura',
icon: Thermometer,
criteria: [
{
id: 'core_temperature',
category: 'Temperatura',
name: 'Core Temperature',
spanishName: 'Temperatura del Núcleo',
description: 'Temperatura interna del producto',
type: 'temperature' as const,
required: true,
weight: 70,
acceptableCriteria: '88-95°C',
minValue: 85,
maxValue: 98,
unit: '°C',
isCritical: true,
},
{
id: 'cooling_temperature',
category: 'Temperatura',
name: 'Cooling Temperature',
spanishName: 'Temperatura de Enfriado',
description: 'Temperatura durante el enfriado',
type: 'temperature' as const,
required: false,
weight: 30,
acceptableCriteria: '20-23°C',
minValue: 18,
maxValue: 25,
unit: '°C',
isCritical: false,
}
]
}
};
export const QualityCheckModal: React.FC<QualityCheckModalProps> = ({
isOpen,
onClose,
batch,
processStage,
onComplete
}) => {
const currentTenant = useCurrentTenant();
const tenantId = currentTenant?.id || '';
const [activeTab, setActiveTab] = useState('inspection');
const [selectedTemplate, setSelectedTemplate] = useState<QualityCheckTemplate | null>(null);
const [currentCriteriaIndex, setCurrentCriteriaIndex] = useState(0);
const [results, setResults] = useState<Record<string, any>>({});
const [finalNotes, setFinalNotes] = useState('');
const [photos, setPhotos] = useState<File[]>([]);
const [loading, setLoading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Get available templates for the current stage
const currentStage = processStage || batch.current_process_stage;
const {
data: templatesData,
isLoading: templatesLoading,
error: templatesError
} = useQualityTemplatesForStage(tenantId, currentStage!, true, {
enabled: !!currentStage
});
// Execute quality check mutation
const executeQualityCheckMutation = useExecuteQualityCheck(tenantId);
// Initialize selected template
React.useEffect(() => {
if (templatesData?.templates && templatesData.templates.length > 0 && !selectedTemplate) {
setSelectedTemplate(templatesData.templates[0]);
}
}, [templatesData?.templates, selectedTemplate]);
const availableTemplates = templatesData?.templates || [];
const updateResult = useCallback((field: string, value: any, score?: number) => {
setResults(prev => ({
...prev,
[field]: {
value,
score: score !== undefined ? score : (typeof value === 'boolean' ? (value ? 10 : 0) : value),
pass_check: score !== undefined ? score >= 7 : (typeof value === 'boolean' ? value : value >= 7),
timestamp: new Date().toISOString()
}
}));
}, []);
const calculateOverallScore = useCallback((): number => {
if (!selectedTemplate || Object.keys(results).length === 0) return 0;
const templateWeight = selectedTemplate.weight || 1;
const resultValues = Object.values(results);
if (resultValues.length === 0) return 0;
const averageScore = resultValues.reduce((sum: number, result: any) => sum + (result.score || 0), 0) / resultValues.length;
return Math.min(10, averageScore * templateWeight);
}, [selectedTemplate, results]);
const getCriticalFailures = useCallback((): string[] => {
if (!selectedTemplate) return [];
const failures: string[] = [];
selectedTemplate.criteria.forEach(criterion => {
if (criterion.isCritical && results[criterion.id]) {
const result = results[criterion.id];
if (criterion.type === 'boolean' && !result.value) {
failures.push(criterion.spanishName);
} else if (result.score < 7) {
failures.push(criterion.spanishName);
}
}
});
return failures;
}, [selectedTemplate, results]);
const handlePhotoUpload = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
setPhotos(prev => [...prev, ...files]);
}, []);
const handleComplete = async () => {
if (!selectedTemplate) return;
setLoading(true);
try {
const overallScore = calculateOverallScore();
const criticalFailures = getCriticalFailures();
const passed = overallScore >= 7.0 && criticalFailures.length === 0;
const qualityData: QualityCheckData = {
batchId: batch.id,
checkType: selectedTemplate.id,
inspector: currentTenant?.name || 'Inspector',
startTime: new Date().toISOString(),
results: Object.values(results),
overallScore,
overallPass: passed,
finalNotes,
photos,
criticalFailures,
correctiveActions: criticalFailures.length > 0 ? [
`Fallas críticas encontradas: ${criticalFailures.join(', ')}`,
'Revisar proceso de producción',
'Re-entrenar personal si es necesario'
] : []
};
// Create quality check via API
const checkData = {
batch_id: batch.id,
check_type: selectedTemplate.id,
check_time: new Date().toISOString(),
quality_score: overallScore / 10, // Convert to 0-1 scale
pass_fail: passed,
defect_count: criticalFailures.length,
defect_types: criticalFailures,
check_notes: finalNotes,
corrective_actions: qualityData.correctiveActions
};
await productionService.createQualityCheck(currentTenant?.id || '', checkData);
onComplete?.(qualityData as any);
onClose();
} catch (error) {
console.error('Error completing quality check:', error);
} finally {
setLoading(false);
}
};
const renderCriteriaInput = (criterion: QualityCheckCriterion) => {
const result = results[criterion.id];
if (criterion.type === 'boolean') {
return (
<div className="space-y-4">
<div className="flex gap-4">
<label className="flex items-center">
<input
type="radio"
name={criterion.id}
checked={result?.value === true}
onChange={() => updateResult(criterion.id, { value: true, score: 10, pass: true })}
className="mr-2"
/>
/ Pasa
</label>
<label className="flex items-center">
<input
type="radio"
name={criterion.id}
checked={result?.value === false}
onChange={() => updateResult(criterion.id, { value: false, score: 0, pass: false })}
className="mr-2"
/>
No / Falla
</label>
</div>
</div>
);
}
if (criterion.type === 'measurement' || criterion.type === 'weight' || criterion.type === 'temperature') {
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Input
type="number"
placeholder={`Ingrese ${criterion.spanishName.toLowerCase()}`}
value={result?.value as string || ''}
onChange={(e) => {
const value = parseFloat(e.target.value);
const score = !isNaN(value) ? 8 : 0; // Simplified scoring
updateResult(criterion.id, {
value: e.target.value,
score,
pass: score >= 7
});
}}
className="w-32"
/>
{criterion.unit && <span className="text-sm text-[var(--text-secondary)]">{criterion.unit}</span>}
</div>
</div>
);
}
// Visual type - 1-10 star rating
return (
<div className="space-y-4">
<div className="grid grid-cols-5 gap-2">
{[...Array(10)].map((_, i) => {
const score = i + 1;
const isSelected = result?.score === score;
return (
<button
key={score}
className={`p-3 rounded-lg border-2 transition-all ${
isSelected
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/10'
: 'border-[var(--border-primary)] hover:border-[var(--color-primary)]/50'
}`}
onClick={() => {
updateResult(criterion.id, {
value: score,
score,
pass: score >= 7
});
}}
>
<div className="text-center">
<div className="font-bold text-[var(--text-primary)]">{score}</div>
<Star className={`w-4 h-4 mx-auto ${isSelected ? 'text-[var(--color-primary)] fill-current' : 'text-[var(--text-tertiary)]'}`} />
</div>
</button>
);
})}
</div>
<div className="text-sm text-[var(--text-secondary)]">
<p>1-3: Malo | 4-6: Regular | 7-8: Bueno | 9-10: Excelente</p>
</div>
</div>
);
};
const overallScore = calculateOverallScore();
const criticalFailures = getCriticalFailures();
const isComplete = selectedTemplate?.criteria.filter(c => c.required).every(c => results[c.id]) || false;
const statusIndicator: StatusIndicatorConfig = {
color: statusColors.inProgress.primary,
text: 'Control de Calidad',
icon: Package
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={`Control de Calidad - ${batch.product_name}`}
subtitle={`Lote: ${batch.batch_number}`}
size="xl"
>
<div className="space-y-6">
{/* Template Selection */}
<div className="grid grid-cols-3 gap-3">
{Object.entries(QUALITY_CHECK_TEMPLATES).map(([key, tmpl]) => {
const Icon = tmpl.icon;
return (
<button
key={key}
className={`p-3 rounded-lg border-2 transition-all ${
selectedTemplate === key
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/10'
: 'border-[var(--border-primary)] hover:border-[var(--color-primary)]/50'
}`}
onClick={() => {
setSelectedTemplate(key);
setCurrentCriteriaIndex(0);
setResults({});
}}
>
<Icon className="w-6 h-6 mx-auto mb-2 text-[var(--color-primary)]" />
<div className="font-medium text-sm">{tmpl.spanishName}</div>
</button>
);
})}
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="inspection">Inspección</TabsTrigger>
<TabsTrigger value="photos">Fotos</TabsTrigger>
<TabsTrigger value="summary">Resumen</TabsTrigger>
</TabsList>
<TabsContent value="inspection" className="space-y-6">
{selectedTemplate && (
<>
{/* Progress Indicator */}
<div className="grid grid-cols-6 gap-2">
{selectedTemplate.criteria.map((criterion, index) => {
const result = results[criterion.id];
const isCompleted = !!result;
const isCurrent = index === currentCriteriaIndex;
return (
<button
key={criterion.id}
className={`p-2 rounded text-xs transition-all ${
isCurrent
? 'bg-[var(--color-primary)] text-white'
: isCompleted
? 'bg-green-100 text-green-800'
: 'bg-[var(--bg-secondary)] text-[var(--text-secondary)]'
}`}
onClick={() => setCurrentCriteriaIndex(index)}
>
{index + 1}
</button>
);
})}
</div>
{/* Current Criteria */}
{currentCriteria && (
<div className="space-y-6">
<Card className={`p-4 ${currentCriteria.isCritical ? 'border-orange-200 bg-orange-50' : ''}`}>
<div className="flex justify-between items-start mb-3">
<div>
<h4 className="font-semibold text-[var(--text-primary)]">{currentCriteria.spanishName}</h4>
<p className="text-sm text-[var(--text-secondary)] mt-1">{currentCriteria.description}</p>
<p className="text-sm text-[var(--color-info)] mt-1">{currentCriteria.acceptableCriteria}</p>
</div>
<div className="flex gap-2">
{currentCriteria.isCritical && (
<Badge className="bg-[var(--color-error)]/10 text-[var(--color-error)]">
Crítico
</Badge>
)}
{currentCriteria.required && (
<Badge className="bg-[var(--color-warning)]/10 text-[var(--color-warning)]">
Requerido
</Badge>
)}
</div>
</div>
{renderCriteriaInput(currentCriteria)}
<Textarea
placeholder="Notas adicionales (opcional)..."
value={currentResult?.notes || ''}
onChange={(e) => {
updateResult(currentCriteria.id, { notes: e.target.value });
}}
rows={2}
className="mt-4"
/>
</Card>
{/* Navigation */}
<div className="flex justify-between">
<Button
variant="outline"
onClick={() => setCurrentCriteriaIndex(Math.max(0, currentCriteriaIndex - 1))}
disabled={currentCriteriaIndex === 0}
>
Anterior
</Button>
<Button
variant="primary"
onClick={() => setCurrentCriteriaIndex(Math.min(selectedTemplate.criteria.length - 1, currentCriteriaIndex + 1))}
disabled={currentCriteriaIndex === selectedTemplate.criteria.length - 1}
>
Siguiente
</Button>
</div>
</div>
)}
</>
)}
</TabsContent>
<TabsContent value="photos" className="space-y-4">
<div className="text-center py-8">
<Camera className="w-12 h-12 text-[var(--text-tertiary)] mx-auto mb-4" />
<p className="text-[var(--text-secondary)] mb-4">
Añade fotos para documentar problemas de calidad o evidencia
</p>
<Button
variant="outline"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="w-4 h-4 mr-2" />
Subir Fotos
</Button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
onChange={handlePhotoUpload}
className="hidden"
/>
</div>
{photos.length > 0 && (
<div className="grid grid-cols-3 gap-4">
{photos.map((photo, index) => (
<div key={index} className="relative">
<img
src={URL.createObjectURL(photo)}
alt={`Foto de calidad ${index + 1}`}
className="w-full h-32 object-cover rounded-lg"
/>
<button
className="absolute top-2 right-2 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center"
onClick={() => setPhotos(prev => prev.filter((_, i) => i !== index))}
>
×
</button>
</div>
))}
</div>
)}
</TabsContent>
<TabsContent value="summary" className="space-y-6">
{/* Overall Score */}
<Card className="p-6 text-center">
<div className="text-4xl font-bold text-[var(--text-primary)] mb-2">
{overallScore.toFixed(1)}/10
</div>
<div className="text-lg text-[var(--text-secondary)] mb-4">
Puntuación General de Calidad
</div>
<Badge
variant={overallScore >= 7 ? 'success' : overallScore >= 5 ? 'warning' : 'error'}
className="text-lg px-4 py-2"
>
{overallScore >= 7 ? 'APROBADO' : 'REPROBADO'}
</Badge>
{criticalFailures.length > 0 && (
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-center gap-2 text-red-800 font-medium">
<AlertTriangle className="w-5 h-5" />
Fallas Críticas Detectadas
</div>
<ul className="mt-2 text-red-700 text-sm">
{criticalFailures.map((failure, index) => (
<li key={index}> {failure}</li>
))}
</ul>
</div>
)}
</Card>
{/* Final Notes */}
<Textarea
placeholder="Notas finales y recomendaciones..."
value={finalNotes}
onChange={(e) => setFinalNotes(e.target.value)}
rows={4}
/>
</TabsContent>
</Tabs>
{/* Action Buttons */}
<div className="flex justify-between pt-6 border-t border-[var(--border-primary)]">
<Button variant="outline" onClick={onClose}>
Cancelar
</Button>
<Button
variant="primary"
onClick={handleComplete}
disabled={!isComplete || loading}
>
{loading ? 'Completando...' : 'Completar Inspección'}
</Button>
</div>
</div>
</Modal>
);
};
export default QualityCheckModal;