882 lines
30 KiB
TypeScript
882 lines
30 KiB
TypeScript
import React, { useState, useCallback, useRef } from 'react';
|
|
import { Card, Button, Badge, Input, Modal, Table, Select, DatePicker } from '../../ui';
|
|
import { productionService, type QualityCheckResponse, QualityCheckStatus } from '../../../api';
|
|
import type { QualityCheck, QualityCheckCriteria, ProductionBatch, QualityCheckType } from '../../../types/production.types';
|
|
|
|
interface QualityControlProps {
|
|
className?: string;
|
|
batchId?: string;
|
|
checkType?: QualityCheckType;
|
|
onQualityCheckCompleted?: (result: QualityCheck) => void;
|
|
onCorrectiveActionRequired?: (check: QualityCheck, actions: string[]) => void;
|
|
}
|
|
|
|
interface QualityCheckTemplate {
|
|
id: string;
|
|
name: string;
|
|
spanishName: string;
|
|
productTypes: string[];
|
|
criteria: QualityChecklistItem[];
|
|
requiresPhotos: boolean;
|
|
passThreshold: number;
|
|
criticalPoints: string[];
|
|
}
|
|
|
|
interface QualityChecklistItem {
|
|
id: string;
|
|
category: string;
|
|
description: string;
|
|
spanishDescription: string;
|
|
type: 'boolean' | 'numeric' | 'scale' | 'text';
|
|
required: boolean;
|
|
minValue?: number;
|
|
maxValue?: number;
|
|
unit?: string;
|
|
acceptableCriteria?: string;
|
|
weight: number;
|
|
isCritical: boolean;
|
|
}
|
|
|
|
interface QualityInspectionResult {
|
|
checklistId: string;
|
|
value: string | number | boolean;
|
|
notes?: string;
|
|
photo?: File;
|
|
timestamp: string;
|
|
inspector: string;
|
|
}
|
|
|
|
const QUALITY_CHECK_TEMPLATES: Record<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-[var(--color-info)]/10 text-[var(--color-info)]',
|
|
[QualityCheckStatus.IN_PROGRESS]: 'bg-yellow-100 text-yellow-800',
|
|
[QualityCheckStatus.PASSED]: 'bg-[var(--color-success)]/10 text-[var(--color-success)]',
|
|
[QualityCheckStatus.FAILED]: 'bg-[var(--color-error)]/10 text-[var(--color-error)]',
|
|
[QualityCheckStatus.REQUIRES_REVIEW]: 'bg-[var(--color-primary)]/10 text-[var(--color-primary)]',
|
|
[QualityCheckStatus.CANCELLED]: 'bg-[var(--bg-tertiary)] text-[var(--text-primary)]',
|
|
};
|
|
|
|
export const QualityControl: React.FC<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-[var(--color-info)]/5 border border-[var(--color-info)]/20 p-4 rounded-lg">
|
|
<h3 className="font-semibold text-blue-900 mb-2">{selectedTemplate.spanishName}</h3>
|
|
<p className="text-sm text-[var(--color-info)]">
|
|
Umbral de aprobación: {selectedTemplate.passThreshold}%
|
|
</p>
|
|
{selectedTemplate.criticalPoints.length > 0 && (
|
|
<p className="text-sm text-[var(--color-info)] 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-[var(--text-primary)]">{criterion.spanishDescription}</h4>
|
|
<p className="text-sm text-[var(--text-secondary)] mt-1">{criterion.acceptableCriteria}</p>
|
|
{criterion.isCritical && (
|
|
<Badge className="bg-[var(--color-primary)]/10 text-[var(--color-primary)] text-xs mt-1">
|
|
Punto Crítico
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<span className="text-sm text-[var(--text-tertiary)]">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-[var(--text-secondary)]">{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-[var(--color-success)] mt-1">
|
|
✓ Foto capturada: {uploadedPhotos[criterion.id].name}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
<Card className="p-4 bg-[var(--bg-secondary)]">
|
|
<div className="flex justify-between items-center mb-2">
|
|
<span className="font-medium">Puntuación general</span>
|
|
<span className="text-2xl font-bold text-[var(--color-info)]">
|
|
{calculateOverallScore().toFixed(1)}%
|
|
</span>
|
|
</div>
|
|
|
|
<div className="w-full bg-[var(--bg-quaternary)] 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-[var(--text-secondary)]">
|
|
<span>Umbral: {selectedTemplate.passThreshold}%</span>
|
|
<span className={
|
|
calculateOverallScore() >= selectedTemplate.passThreshold
|
|
? 'text-[var(--color-success)] font-medium'
|
|
: 'text-[var(--color-error)] font-medium'
|
|
}>
|
|
{calculateOverallScore() >= selectedTemplate.passThreshold ? '✓ APROBADO' : '✗ REPROBADO'}
|
|
</span>
|
|
</div>
|
|
|
|
{checkCriticalFailures().length > 0 && (
|
|
<div className="mt-3 p-2 bg-[var(--color-error)]/10 border border-red-200 rounded">
|
|
<p className="text-sm font-medium text-[var(--color-error)]">Fallas críticas detectadas:</p>
|
|
<ul className="text-sm text-[var(--color-error)] 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-[var(--text-primary)]">Control de Calidad</h2>
|
|
<p className="text-[var(--text-secondary)]">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-[var(--color-info)]/20"
|
|
onClick={() => startQualityCheck(template)}
|
|
>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="font-semibold text-[var(--text-primary)]">{template.spanishName}</h3>
|
|
{template.requiresPhotos && (
|
|
<span className="text-sm bg-[var(--color-info)]/10 text-[var(--color-info)] px-2 py-1 rounded">
|
|
📸 Fotos
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<p className="text-sm text-[var(--text-secondary)] 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-[var(--bg-tertiary)] text-[var(--text-secondary)]">
|
|
{type === 'pan' && 'Pan'}
|
|
{type === 'bolleria' && 'Bollería'}
|
|
{type === 'reposteria' && 'Repostería'}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
|
|
{template.criticalPoints.length > 0 && (
|
|
<p className="text-xs text-[var(--color-primary)] 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-[var(--text-secondary)]">Tasa de aprobación</p>
|
|
<p className="text-2xl font-bold text-[var(--color-success)]">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-[var(--text-secondary)]">Controles pendientes</p>
|
|
<p className="text-2xl font-bold text-[var(--color-primary)]">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-[var(--text-secondary)]">Fallas críticas</p>
|
|
<p className="text-2xl font-bold text-[var(--color-error)]">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-[var(--text-secondary)]">Controles hoy</p>
|
|
<p className="text-2xl font-bold text-[var(--color-info)]">23</p>
|
|
</div>
|
|
<span className="text-2xl">📋</span>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default QualityControl; |