diff --git a/frontend/src/components/domain/production/ProductionStatusCard.tsx b/frontend/src/components/domain/production/ProductionStatusCard.tsx new file mode 100644 index 00000000..66120062 --- /dev/null +++ b/frontend/src/components/domain/production/ProductionStatusCard.tsx @@ -0,0 +1,345 @@ +import React from 'react'; +import { Clock, Timer, CheckCircle, AlertCircle, Package, Play, Pause, X, Edit, Eye, History, Settings } from 'lucide-react'; +import { StatusCard, StatusIndicatorConfig } from '../../ui/StatusCard/StatusCard'; +import { statusColors } from '../../../styles/colors'; +import { ProductionBatchResponse, ProductionStatus, ProductionPriority } from '../../../api/types/production'; +import { useProductionEnums } from '../../../utils/enumHelpers'; + +export interface ProductionStatusCardProps { + batch: ProductionBatchResponse; + onView?: (batch: ProductionBatchResponse) => void; + onEdit?: (batch: ProductionBatchResponse) => void; + onStart?: (batch: ProductionBatchResponse) => void; + onPause?: (batch: ProductionBatchResponse) => void; + onComplete?: (batch: ProductionBatchResponse) => void; + onCancel?: (batch: ProductionBatchResponse) => void; + onQualityCheck?: (batch: ProductionBatchResponse) => void; + onViewHistory?: (batch: ProductionBatchResponse) => void; + showDetailedProgress?: boolean; + compact?: boolean; +} + +export const ProductionStatusCard: React.FC = ({ + batch, + onView, + onEdit, + onStart, + onPause, + onComplete, + onCancel, + onQualityCheck, + onViewHistory, + showDetailedProgress = true, + compact = false +}) => { + const productionEnums = useProductionEnums(); + + const getProductionStatusConfig = (status: ProductionStatus, priority: ProductionPriority): StatusIndicatorConfig => { + const statusConfig = { + [ProductionStatus.PENDING]: { icon: Clock }, + [ProductionStatus.IN_PROGRESS]: { icon: Timer }, + [ProductionStatus.COMPLETED]: { icon: CheckCircle }, + [ProductionStatus.CANCELLED]: { icon: X }, + [ProductionStatus.ON_HOLD]: { icon: Pause }, + [ProductionStatus.QUALITY_CHECK]: { icon: Package }, + [ProductionStatus.FAILED]: { icon: AlertCircle }, + }; + + const config = statusConfig[status] || { icon: AlertCircle }; + const Icon = config.icon; + const isUrgent = priority === ProductionPriority.URGENT; + const isCritical = status === ProductionStatus.FAILED || + status === ProductionStatus.CANCELLED || + (status === ProductionStatus.PENDING && isUrgent); + const isHighlight = isUrgent && status !== ProductionStatus.FAILED && status !== ProductionStatus.CANCELLED; + + const getStatusColorForProduction = (status: ProductionStatus) => { + const normalizedStatus = status.toLowerCase(); + switch (normalizedStatus) { + case 'pending': + return statusColors.pending.primary; + case 'in_progress': + return statusColors.inProgress.primary; + case 'completed': + return statusColors.completed.primary; + case 'cancelled': + case 'failed': + return statusColors.cancelled.primary; + case 'on_hold': + return statusColors.pending.primary; + case 'quality_check': + return statusColors.inProgress.primary; + default: + return statusColors.other.primary; + } + }; + + return { + color: getStatusColorForProduction(status), + text: productionEnums.getProductionStatusLabel(status), + icon: Icon, + isCritical, + isHighlight + }; + }; + + const calculateDetailedProgress = (batch: ProductionBatchResponse): number => { + if (batch.status === ProductionStatus.COMPLETED) return 100; + if (batch.status === ProductionStatus.PENDING) return 0; + if (batch.status === ProductionStatus.CANCELLED || batch.status === ProductionStatus.FAILED) return 0; + + if (batch.actual_start_time && batch.planned_end_time) { + const now = new Date(); + const startTime = new Date(batch.actual_start_time); + const endTime = new Date(batch.planned_end_time); + const totalDuration = endTime.getTime() - startTime.getTime(); + const elapsed = now.getTime() - startTime.getTime(); + + if (totalDuration > 0) { + return Math.min(90, Math.max(10, Math.round((elapsed / totalDuration) * 100))); + } + } + + if (batch.status === ProductionStatus.QUALITY_CHECK) return 85; + if (batch.status === ProductionStatus.ON_HOLD) return 50; + + return 50; + }; + + const getProgressLabel = (batch: ProductionBatchResponse): string => { + switch (batch.status) { + case ProductionStatus.PENDING: + return 'Programado'; + case ProductionStatus.IN_PROGRESS: + return 'En producción'; + case ProductionStatus.QUALITY_CHECK: + return 'Control de calidad'; + case ProductionStatus.ON_HOLD: + return 'En pausa'; + case ProductionStatus.COMPLETED: + return 'Completado'; + case ProductionStatus.CANCELLED: + return 'Cancelado'; + case ProductionStatus.FAILED: + return 'Fallido'; + default: + return 'Estado desconocido'; + } + }; + + const getQualityStatusInfo = (batch: ProductionBatchResponse): string | undefined => { + if (batch.status === ProductionStatus.QUALITY_CHECK) { + return 'Control de calidad pendiente'; + } + + if (batch.quality_score !== undefined && batch.quality_score !== null) { + if (batch.quality_score >= 8) { + return `Calidad excelente (${batch.quality_score}/10)`; + } else if (batch.quality_score >= 6) { + return `Calidad aceptable (${batch.quality_score}/10)`; + } else { + return `Calidad baja (${batch.quality_score}/10)`; + } + } + + if (batch.status === ProductionStatus.COMPLETED) { + return 'Control de calidad final pendiente'; + } + + return undefined; + }; + + const getProductionActions = () => { + const actions = []; + + // Debug logging to see what status we're getting + console.log('ProductionStatusCard - Batch:', batch.batch_number, 'Status:', batch.status, 'Type:', typeof batch.status); + + if (onView) { + actions.push({ + label: 'Ver Detalles', + icon: Eye, + priority: 'primary' as const, + onClick: () => onView(batch) + }); + } + + if (batch.status === ProductionStatus.PENDING && onStart) { + actions.push({ + label: 'Iniciar Producción', + icon: Play, + priority: 'primary' as const, + onClick: () => onStart(batch) + }); + } + + if (batch.status === ProductionStatus.IN_PROGRESS) { + if (onPause) { + actions.push({ + label: 'Pausar', + icon: Pause, + priority: 'secondary' as const, + onClick: () => onPause(batch) + }); + } + + if (onComplete) { + actions.push({ + label: 'Completar', + icon: CheckCircle, + priority: 'primary' as const, + onClick: () => onComplete(batch) + }); + } + } + + if (batch.status === ProductionStatus.ON_HOLD && onStart) { + actions.push({ + label: 'Reanudar', + icon: Play, + priority: 'primary' as const, + onClick: () => onStart(batch) + }); + } + + if (batch.status === ProductionStatus.QUALITY_CHECK && onQualityCheck) { + console.log('ProductionStatusCard - Adding quality check button for batch:', batch.batch_number); + actions.push({ + label: 'Control Calidad', + icon: Package, + priority: 'primary' as const, + onClick: () => onQualityCheck(batch) + }); + } else { + console.log('ProductionStatusCard - Quality check condition not met:', { + batchNumber: batch.batch_number, + status: batch.status, + expectedStatus: ProductionStatus.QUALITY_CHECK, + hasOnQualityCheck: !!onQualityCheck, + statusMatch: batch.status === ProductionStatus.QUALITY_CHECK + }); + } + + if (onEdit && (batch.status === ProductionStatus.PENDING || batch.status === ProductionStatus.ON_HOLD)) { + actions.push({ + label: 'Editar', + icon: Edit, + priority: 'secondary' as const, + onClick: () => onEdit(batch) + }); + } + + if (onCancel && batch.status !== ProductionStatus.COMPLETED && batch.status !== ProductionStatus.CANCELLED) { + actions.push({ + label: 'Cancelar', + icon: X, + priority: 'tertiary' as const, + destructive: true, + onClick: () => onCancel(batch) + }); + } + + if (onViewHistory) { + actions.push({ + label: 'Historial', + icon: History, + priority: 'tertiary' as const, + onClick: () => onViewHistory(batch) + }); + } + + // Debug logging to see final actions array + console.log('ProductionStatusCard - Final actions array for batch', batch.batch_number, ':', actions); + + return actions; + }; + + const getMetadata = (): string[] => { + const metadata = []; + + if (batch.planned_start_time) { + const startTime = new Date(batch.planned_start_time); + metadata.push(`Inicio: ${startTime.toLocaleDateString()} ${startTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`); + } + + if (batch.planned_end_time) { + const endTime = new Date(batch.planned_end_time); + metadata.push(`Fin: ${endTime.toLocaleDateString()} ${endTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`); + } + + if (batch.staff_assigned && batch.staff_assigned.length > 0) { + metadata.push(`Personal: ${batch.staff_assigned.join(', ')}`); + } + + if (batch.equipment_used && batch.equipment_used.length > 0) { + metadata.push(`Equipos: ${batch.equipment_used.join(', ')}`); + } + + const qualityInfo = getQualityStatusInfo(batch); + if (qualityInfo) { + metadata.push(qualityInfo); + } + + if (batch.priority === ProductionPriority.URGENT) { + metadata.push('⚡ Orden urgente'); + } + + if (batch.is_rush_order) { + metadata.push('🏃 Orden express'); + } + + if (batch.is_special_recipe) { + metadata.push('🌟 Receta especial'); + } + + return metadata; + }; + + const getSecondaryInfo = () => { + if (batch.actual_quantity && batch.planned_quantity) { + const completionPercent = Math.round((batch.actual_quantity / batch.planned_quantity) * 100); + return { + label: 'Progreso Cantidad', + value: `${batch.actual_quantity}/${batch.planned_quantity} (${completionPercent}%)` + }; + } + + if (batch.staff_assigned && batch.staff_assigned.length > 0) { + return { + label: 'Personal Asignado', + value: batch.staff_assigned.join(', ') + }; + } + + return { + label: 'Prioridad', + value: productionEnums.getProductionPriorityLabel(batch.priority) + }; + }; + + const statusConfig = getProductionStatusConfig(batch.status, batch.priority); + const progress = showDetailedProgress ? calculateDetailedProgress(batch) : undefined; + + return ( + onView(batch) : undefined} + compact={compact} + /> + ); +}; + +export default ProductionStatusCard; \ No newline at end of file diff --git a/frontend/src/components/domain/production/QualityCheckModal.tsx b/frontend/src/components/domain/production/QualityCheckModal.tsx new file mode 100644 index 00000000..67f008b5 --- /dev/null +++ b/frontend/src/components/domain/production/QualityCheckModal.tsx @@ -0,0 +1,677 @@ +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 = ({ + isOpen, + onClose, + batch, + onComplete +}) => { + const currentTenant = useCurrentTenant(); + const [activeTab, setActiveTab] = useState('inspection'); + const [selectedTemplate, setSelectedTemplate] = useState('visual_inspection'); + const [currentCriteriaIndex, setCurrentCriteriaIndex] = useState(0); + const [results, setResults] = useState>({}); + const [finalNotes, setFinalNotes] = useState(''); + const [photos, setPhotos] = useState([]); + const [loading, setLoading] = useState(false); + const fileInputRef = useRef(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) => { + 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) => { + 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 ( +
+
+ + +
+
+ ); + } + + if (criterion.type === 'measurement' || criterion.type === 'weight' || criterion.type === 'temperature') { + return ( +
+
+ { + 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 && {criterion.unit}} +
+
+ ); + } + + // Visual type - 1-10 star rating + return ( +
+
+ {[...Array(10)].map((_, i) => { + const score = i + 1; + const isSelected = result?.score === score; + return ( + + ); + })} +
+
+

1-3: Malo | 4-6: Regular | 7-8: Bueno | 9-10: Excelente

+
+
+ ); + }; + + 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 ( + +
+ {/* Template Selection */} +
+ {Object.entries(QUALITY_CHECK_TEMPLATES).map(([key, tmpl]) => { + const Icon = tmpl.icon; + return ( + + ); + })} +
+ + + + Inspección + Fotos + Resumen + + + + {template && ( + <> + {/* Progress Indicator */} +
+ {template.criteria.map((criterion, index) => { + const result = results[criterion.id]; + const isCompleted = !!result; + const isCurrent = index === currentCriteriaIndex; + + return ( + + ); + })} +
+ + {/* Current Criteria */} + {currentCriteria && ( +
+ +
+
+

{currentCriteria.spanishName}

+

{currentCriteria.description}

+

{currentCriteria.acceptableCriteria}

+
+
+ {currentCriteria.isCritical && ( + + Crítico + + )} + {currentCriteria.required && ( + + Requerido + + )} +
+
+ + {renderCriteriaInput(currentCriteria)} + +