677 lines
23 KiB
TypeScript
677 lines
23 KiB
TypeScript
|
|
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';
|
|||
|
|
|
|||
|
|
export interface QualityCheckModalProps {
|
|||
|
|
isOpen: boolean;
|
|||
|
|
onClose: () => void;
|
|||
|
|
batch: ProductionBatchResponse;
|
|||
|
|
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,
|
|||
|
|
onComplete
|
|||
|
|
}) => {
|
|||
|
|
const currentTenant = useCurrentTenant();
|
|||
|
|
const [activeTab, setActiveTab] = useState('inspection');
|
|||
|
|
const [selectedTemplate, setSelectedTemplate] = useState<string>('visual_inspection');
|
|||
|
|
const [currentCriteriaIndex, setCurrentCriteriaIndex] = useState(0);
|
|||
|
|
const [results, setResults] = useState<Record<string, QualityCheckResult>>({});
|
|||
|
|
const [finalNotes, setFinalNotes] = useState('');
|
|||
|
|
const [photos, setPhotos] = useState<File[]>([]);
|
|||
|
|
const [loading, setLoading] = useState(false);
|
|||
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|||
|
|
|
|||
|
|
const template = QUALITY_CHECK_TEMPLATES[selectedTemplate as keyof typeof QUALITY_CHECK_TEMPLATES];
|
|||
|
|
const currentCriteria = template?.criteria[currentCriteriaIndex];
|
|||
|
|
const currentResult = currentCriteria ? results[currentCriteria.id] : undefined;
|
|||
|
|
|
|||
|
|
const updateResult = useCallback((criterionId: string, updates: Partial<QualityCheckResult>) => {
|
|||
|
|
setResults(prev => ({
|
|||
|
|
...prev,
|
|||
|
|
[criterionId]: {
|
|||
|
|
criterionId,
|
|||
|
|
value: '',
|
|||
|
|
score: 0,
|
|||
|
|
pass: false,
|
|||
|
|
timestamp: new Date().toISOString(),
|
|||
|
|
...prev[criterionId],
|
|||
|
|
...updates
|
|||
|
|
}
|
|||
|
|
}));
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
const calculateOverallScore = useCallback((): number => {
|
|||
|
|
if (!template || Object.keys(results).length === 0) return 0;
|
|||
|
|
|
|||
|
|
const totalWeight = template.criteria.reduce((sum, c) => sum + c.weight, 0);
|
|||
|
|
const weightedScore = Object.values(results).reduce((sum, result) => {
|
|||
|
|
const criterion = template.criteria.find(c => c.id === result.criterionId);
|
|||
|
|
return sum + (result.score * (criterion?.weight || 0));
|
|||
|
|
}, 0);
|
|||
|
|
|
|||
|
|
return totalWeight > 0 ? weightedScore / totalWeight : 0;
|
|||
|
|
}, [template, results]);
|
|||
|
|
|
|||
|
|
const getCriticalFailures = useCallback((): string[] => {
|
|||
|
|
if (!template) return [];
|
|||
|
|
|
|||
|
|
const failures: string[] = [];
|
|||
|
|
template.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;
|
|||
|
|
}, [template, results]);
|
|||
|
|
|
|||
|
|
const handlePhotoUpload = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
|||
|
|
const files = Array.from(event.target.files || []);
|
|||
|
|
setPhotos(prev => [...prev, ...files]);
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
const handleComplete = async () => {
|
|||
|
|
if (!template) 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: template.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: template.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"
|
|||
|
|
/>
|
|||
|
|
Sí / 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 = template?.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">
|
|||
|
|
{template && (
|
|||
|
|
<>
|
|||
|
|
{/* Progress Indicator */}
|
|||
|
|
<div className="grid grid-cols-6 gap-2">
|
|||
|
|
{template.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(template.criteria.length - 1, currentCriteriaIndex + 1))}
|
|||
|
|
disabled={currentCriteriaIndex === template.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;
|