Add a new analitycs page for production

This commit is contained in:
Urtzi Alfaro
2025-09-24 11:43:25 +02:00
parent 87310ced5f
commit 6cf8d5b935
30 changed files with 6096 additions and 4730 deletions

View File

@@ -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, ProductionPriorityEnum } from '../../../api';
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-[var(--color-info)]/10 text-[var(--color-info)] border-[var(--color-info)]/20',
[ProductionBatchStatus.IN_PROGRESS]: 'bg-yellow-100 text-yellow-800 border-yellow-200',
[ProductionBatchStatus.COMPLETED]: 'bg-[var(--color-success)]/10 text-[var(--color-success)] border-green-200',
[ProductionBatchStatus.CANCELLED]: 'bg-[var(--color-error)]/10 text-[var(--color-error)] border-red-200',
[ProductionBatchStatus.ON_HOLD]: 'bg-[var(--bg-tertiary)] text-[var(--text-primary)] border-[var(--border-primary)]',
};
const PRIORITY_COLORS = {
[ProductionPriorityEnum.LOW]: 'bg-[var(--bg-tertiary)] text-[var(--text-primary)]',
[ProductionPriorityEnum.NORMAL]: 'bg-[var(--color-info)]/10 text-[var(--color-info)]',
[ProductionPriorityEnum.HIGH]: 'bg-[var(--color-primary)]/10 text-[var(--color-primary)]',
[ProductionPriorityEnum.URGENT]: 'bg-[var(--color-error)]/10 text-[var(--color-error)]',
};
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-[var(--color-info)]/5'
: isCompleted
? 'border-green-500 bg-green-50'
: 'border-[var(--border-primary)] 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-[var(--color-primary)]/10 text-[var(--color-primary)] text-xs">
PCC
</Badge>
)}
</div>
{isActive && (
<div className="space-y-2">
<div className="flex justify-between text-xs text-[var(--text-secondary)]">
<span>Progreso</span>
<span>{Math.round(progress)}%</span>
</div>
<div className="w-full bg-[var(--bg-quaternary)] rounded-full h-2">
<div
className="bg-[var(--color-info)]/50 h-2 rounded-full transition-all duration-500"
style={{ width: `${progress}%` }}
/>
</div>
<p className="text-xs text-[var(--text-secondary)]">
{getTimeRemaining(batch, stage)}
</p>
</div>
)}
{isCompleted && (
<div className="flex items-center text-[var(--color-success)] text-sm">
<span className="mr-1"></span>
Completado
</div>
)}
{!isActive && !isCompleted && (
<p className="text-xs text-[var(--text-tertiary)]">
~{stage.estimatedMinutes}min
</p>
)}
{stage.temperature && isActive && (
<p className="text-xs text-[var(--text-secondary)] 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-[var(--text-primary)]">{batch.recipe?.name || 'Producto'}</h4>
<p className="text-sm text-[var(--text-secondary)]">Lote #{batch.batch_number}</p>
<Badge className={PRIORITY_COLORS[batch.priority]} size="sm">
{batch.priority === ProductionPriorityEnum.LOW && 'Baja'}
{batch.priority === ProductionPriorityEnum.NORMAL && 'Normal'}
{batch.priority === ProductionPriorityEnum.HIGH && 'Alta'}
{batch.priority === ProductionPriorityEnum.URGENT && 'Urgente'}
</Badge>
</div>
<div>
<p className="text-sm text-[var(--text-secondary)]">Cantidad planificada</p>
<p className="font-semibold">{batch.planned_quantity} unidades</p>
{batch.actual_quantity && (
<>
<p className="text-sm text-[var(--text-secondary)] mt-1">Cantidad real</p>
<p className="font-semibold">{batch.actual_quantity} unidades</p>
</>
)}
</div>
<div>
<p className="text-sm text-[var(--text-secondary)]">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-[var(--text-secondary)] 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-[var(--bg-secondary)] rounded-lg">
<p className="text-sm text-[var(--text-secondary)]">{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-[var(--text-tertiary)] 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-[var(--color-info)]/5 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-[var(--text-secondary)] mt-1">{alert.message}</p>
</div>
<Badge
className={
alert.severity === 'high'
? 'bg-[var(--color-error)]/10 text-[var(--color-error)]'
: alert.severity === 'medium'
? 'bg-yellow-100 text-yellow-800'
: 'bg-[var(--color-info)]/10 text-[var(--color-info)]'
}
>
{alert.severity === 'high' && 'Alta'}
{alert.severity === 'medium' && 'Media'}
{alert.severity === 'low' && 'Baja'}
</Badge>
</div>
<p className="text-xs text-[var(--text-tertiary)] 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-[var(--text-primary)]">Seguimiento de Lotes</h2>
<p className="text-[var(--text-secondary)]">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-[var(--text-tertiary)]">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-[var(--bg-secondary)] 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-[var(--text-secondary)] mb-2">
Duración estimada: {selectedStageForUpdate.estimatedMinutes} minutos
</p>
{selectedStageForUpdate.temperature && (
<p className="text-sm text-[var(--text-secondary)]">
🌡 Temperatura: {selectedStageForUpdate.temperature.min}-{selectedStageForUpdate.temperature.max}{selectedStageForUpdate.temperature.unit}
</p>
)}
{selectedStageForUpdate.criticalControlPoint && (
<Badge className="bg-[var(--color-primary)]/10 text-[var(--color-primary)] 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-[var(--text-secondary)] 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-[var(--text-secondary)]">Lotes activos</p>
<p className="text-2xl font-bold text-[var(--color-info)]">{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-[var(--text-secondary)]">Alertas activas</p>
<p className="text-2xl font-bold text-[var(--color-error)]">{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-[var(--text-secondary)]">En horneado</p>
<p className="text-2xl font-bold text-[var(--color-primary)]">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-[var(--text-secondary)]">Completados hoy</p>
<p className="text-2xl font-bold text-[var(--color-success)]">12</p>
</div>
<span className="text-2xl"></span>
</div>
</Card>
</div>
</div>
);
};
export default BatchTracker;

View File

@@ -1,632 +0,0 @@
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardHeader, CardBody } from '../../ui/Card';
import { Button } from '../../ui/Button';
import { Input } from '../../ui/Input';
import { Badge } from '../../ui/Badge';
import { Modal } from '../../ui/Modal';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../../ui/Tabs';
import { StatsGrid } from '../../ui/Stats';
import {
Settings,
AlertTriangle,
CheckCircle,
Wrench,
Calendar,
Clock,
Thermometer,
Activity,
Zap,
TrendingUp,
Search,
Plus,
Filter,
Download,
BarChart3,
Bell,
MapPin,
User
} from 'lucide-react';
import { useCurrentTenant } from '../../../stores/tenant.store';
export interface Equipment {
id: string;
name: string;
type: 'oven' | 'mixer' | 'proofer' | 'freezer' | 'packaging' | 'other';
model: string;
serialNumber: string;
location: string;
status: 'operational' | 'maintenance' | 'down' | 'warning';
installDate: string;
lastMaintenance: string;
nextMaintenance: string;
maintenanceInterval: number; // days
temperature?: number;
targetTemperature?: number;
efficiency: number;
uptime: number;
energyUsage: number;
utilizationToday: number;
alerts: Array<{
id: string;
type: 'warning' | 'critical' | 'info';
message: string;
timestamp: string;
acknowledged: boolean;
}>;
maintenanceHistory: Array<{
id: string;
date: string;
type: 'preventive' | 'corrective' | 'emergency';
description: string;
technician: string;
cost: number;
downtime: number; // hours
partsUsed: string[];
}>;
specifications: {
power: number; // kW
capacity: number;
dimensions: {
width: number;
height: number;
depth: number;
};
weight: number;
};
}
export interface EquipmentManagerProps {
className?: string;
equipment?: Equipment[];
onCreateEquipment?: () => void;
onEditEquipment?: (equipmentId: string) => void;
onScheduleMaintenance?: (equipmentId: string) => void;
onAcknowledgeAlert?: (equipmentId: string, alertId: string) => void;
onViewMaintenanceHistory?: (equipmentId: string) => void;
}
const MOCK_EQUIPMENT: Equipment[] = [
{
id: '1',
name: 'Horno Principal #1',
type: 'oven',
model: 'Miwe Condo CO 4.1212',
serialNumber: 'MCO-2021-001',
location: '<27>rea de Horneado - Zona A',
status: 'operational',
installDate: '2021-03-15',
lastMaintenance: '2024-01-15',
nextMaintenance: '2024-04-15',
maintenanceInterval: 90,
temperature: 220,
targetTemperature: 220,
efficiency: 92,
uptime: 98.5,
energyUsage: 45.2,
utilizationToday: 87,
alerts: [],
maintenanceHistory: [
{
id: '1',
date: '2024-01-15',
type: 'preventive',
description: 'Limpieza general y calibraci<63>n de termostatos',
technician: 'Juan P<>rez',
cost: 150,
downtime: 2,
partsUsed: ['Filtros de aire', 'Sellos de puerta']
}
],
specifications: {
power: 45,
capacity: 24,
dimensions: { width: 200, height: 180, depth: 120 },
weight: 850
}
},
{
id: '2',
name: 'Batidora Industrial #2',
type: 'mixer',
model: 'Hobart HL800',
serialNumber: 'HHL-2020-002',
location: '<27>rea de Preparaci<63>n - Zona B',
status: 'warning',
installDate: '2020-08-10',
lastMaintenance: '2024-01-20',
nextMaintenance: '2024-02-20',
maintenanceInterval: 30,
efficiency: 88,
uptime: 94.2,
energyUsage: 12.8,
utilizationToday: 76,
alerts: [
{
id: '1',
type: 'warning',
message: 'Vibraci<63>n inusual detectada en el motor',
timestamp: '2024-01-23T10:30:00Z',
acknowledged: false
},
{
id: '2',
type: 'info',
message: 'Mantenimiento programado en 5 d<>as',
timestamp: '2024-01-23T08:00:00Z',
acknowledged: true
}
],
maintenanceHistory: [
{
id: '1',
date: '2024-01-20',
type: 'corrective',
description: 'Reemplazo de correas de transmisi<73>n',
technician: 'Mar<61>a Gonz<6E>lez',
cost: 85,
downtime: 4,
partsUsed: ['Correa tipo V', 'Rodamientos']
}
],
specifications: {
power: 15,
capacity: 80,
dimensions: { width: 120, height: 150, depth: 80 },
weight: 320
}
},
{
id: '3',
name: 'C<>mara de Fermentaci<63>n #1',
type: 'proofer',
model: 'Bongard EUROPA 16.18',
serialNumber: 'BEU-2022-001',
location: '<27>rea de Fermentaci<63>n',
status: 'maintenance',
installDate: '2022-06-20',
lastMaintenance: '2024-01-23',
nextMaintenance: '2024-01-24',
maintenanceInterval: 60,
temperature: 32,
targetTemperature: 35,
efficiency: 0,
uptime: 85.1,
energyUsage: 0,
utilizationToday: 0,
alerts: [
{
id: '1',
type: 'info',
message: 'En mantenimiento programado',
timestamp: '2024-01-23T06:00:00Z',
acknowledged: true
}
],
maintenanceHistory: [
{
id: '1',
date: '2024-01-23',
type: 'preventive',
description: 'Mantenimiento programado - sistema de humidificaci<63>n',
technician: 'Carlos Rodr<64>guez',
cost: 200,
downtime: 8,
partsUsed: ['Sensor de humedad', 'V<>lvulas']
}
],
specifications: {
power: 8,
capacity: 16,
dimensions: { width: 180, height: 200, depth: 100 },
weight: 450
}
}
];
const EquipmentManager: React.FC<EquipmentManagerProps> = ({
className,
equipment = MOCK_EQUIPMENT,
onCreateEquipment,
onEditEquipment,
onScheduleMaintenance,
onAcknowledgeAlert,
onViewMaintenanceHistory
}) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState('overview');
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<Equipment['status'] | 'all'>('all');
const [selectedEquipment, setSelectedEquipment] = useState<Equipment | null>(null);
const [showEquipmentModal, setShowEquipmentModal] = useState(false);
const filteredEquipment = useMemo(() => {
return equipment.filter(eq => {
const matchesSearch = !searchQuery ||
eq.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
eq.location.toLowerCase().includes(searchQuery.toLowerCase()) ||
eq.type.toLowerCase().includes(searchQuery.toLowerCase());
const matchesStatus = statusFilter === 'all' || eq.status === statusFilter;
return matchesSearch && matchesStatus;
});
}, [equipment, searchQuery, statusFilter]);
const equipmentStats = useMemo(() => {
const total = equipment.length;
const operational = equipment.filter(e => e.status === 'operational').length;
const warning = equipment.filter(e => e.status === 'warning').length;
const maintenance = equipment.filter(e => e.status === 'maintenance').length;
const down = equipment.filter(e => e.status === 'down').length;
const avgEfficiency = equipment.reduce((sum, e) => sum + e.efficiency, 0) / total;
const avgUptime = equipment.reduce((sum, e) => sum + e.uptime, 0) / total;
const totalAlerts = equipment.reduce((sum, e) => sum + e.alerts.filter(a => !a.acknowledged).length, 0);
return {
total,
operational,
warning,
maintenance,
down,
avgEfficiency,
avgUptime,
totalAlerts
};
}, [equipment]);
const getStatusConfig = (status: Equipment['status']) => {
const configs = {
operational: { color: 'success' as const, icon: CheckCircle, label: t('equipment.status.operational', 'Operational') },
warning: { color: 'warning' as const, icon: AlertTriangle, label: t('equipment.status.warning', 'Warning') },
maintenance: { color: 'info' as const, icon: Wrench, label: t('equipment.status.maintenance', 'Maintenance') },
down: { color: 'error' as const, icon: AlertTriangle, label: t('equipment.status.down', 'Down') }
};
return configs[status];
};
const getTypeIcon = (type: Equipment['type']) => {
const icons = {
oven: Thermometer,
mixer: Activity,
proofer: Settings,
freezer: Zap,
packaging: Settings,
other: Settings
};
return icons[type];
};
const formatDateTime = (dateString: string) => {
return new Date(dateString).toLocaleDateString('es-ES', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
};
const stats = [
{
title: t('equipment.stats.total', 'Total Equipment'),
value: equipmentStats.total,
icon: Settings,
variant: 'default' as const
},
{
title: t('equipment.stats.operational', 'Operational'),
value: equipmentStats.operational,
icon: CheckCircle,
variant: 'success' as const,
subtitle: `${((equipmentStats.operational / equipmentStats.total) * 100).toFixed(1)}%`
},
{
title: t('equipment.stats.avg_efficiency', 'Avg Efficiency'),
value: `${equipmentStats.avgEfficiency.toFixed(1)}%`,
icon: TrendingUp,
variant: equipmentStats.avgEfficiency >= 90 ? 'success' as const : 'warning' as const
},
{
title: t('equipment.stats.alerts', 'Active Alerts'),
value: equipmentStats.totalAlerts,
icon: Bell,
variant: equipmentStats.totalAlerts === 0 ? 'success' as const : 'error' as const
}
];
return (
<Card className={className}>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
{t('equipment.manager.title', 'Equipment Management')}
</h3>
<p className="text-sm text-[var(--text-secondary)]">
{t('equipment.manager.subtitle', 'Monitor and manage production equipment')}
</p>
</div>
<div className="flex space-x-2">
<Button variant="outline" size="sm">
<Download className="w-4 h-4 mr-2" />
{t('equipment.actions.export', 'Export')}
</Button>
<Button variant="primary" size="sm" onClick={onCreateEquipment}>
<Plus className="w-4 h-4 mr-2" />
{t('equipment.actions.add', 'Add Equipment')}
</Button>
</div>
</div>
</CardHeader>
<CardBody className="space-y-6">
{/* Stats */}
<StatsGrid stats={stats} columns={4} gap="md" />
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-[var(--text-tertiary)]" />
<Input
placeholder={t('equipment.search.placeholder', 'Search equipment...')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
</div>
<div className="flex items-center space-x-2">
<Filter className="w-4 h-4 text-[var(--text-tertiary)]" />
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as Equipment['status'] | 'all')}
className="px-3 py-2 border border-[var(--border-primary)] rounded-md bg-[var(--bg-primary)] text-[var(--text-primary)]"
>
<option value="all">{t('equipment.filter.all', 'All Status')}</option>
<option value="operational">{t('equipment.status.operational', 'Operational')}</option>
<option value="warning">{t('equipment.status.warning', 'Warning')}</option>
<option value="maintenance">{t('equipment.status.maintenance', 'Maintenance')}</option>
<option value="down">{t('equipment.status.down', 'Down')}</option>
</select>
</div>
</div>
{/* Equipment List */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="overview">
{t('equipment.tabs.overview', 'Overview')}
</TabsTrigger>
<TabsTrigger value="maintenance">
{t('equipment.tabs.maintenance', 'Maintenance')}
</TabsTrigger>
<TabsTrigger value="alerts">
{t('equipment.tabs.alerts', 'Alerts')}
</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredEquipment.map((eq) => {
const statusConfig = getStatusConfig(eq.status);
const TypeIcon = getTypeIcon(eq.type);
const StatusIcon = statusConfig.icon;
return (
<div
key={eq.id}
className="p-4 bg-[var(--bg-secondary)] rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors cursor-pointer"
onClick={() => {
setSelectedEquipment(eq);
setShowEquipmentModal(true);
}}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center space-x-2">
<TypeIcon className="w-5 h-5 text-[var(--color-primary)]" />
<h4 className="font-semibold text-[var(--text-primary)]">{eq.name}</h4>
</div>
<Badge variant={statusConfig.color}>
{statusConfig.label}
</Badge>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-[var(--text-secondary)]">{t('equipment.efficiency', 'Efficiency')}:</span>
<span className="font-medium">{eq.efficiency}%</span>
</div>
<div className="flex items-center justify-between">
<span className="text-[var(--text-secondary)]">{t('equipment.uptime', 'Uptime')}:</span>
<span className="font-medium">{eq.uptime.toFixed(1)}%</span>
</div>
<div className="flex items-center justify-between">
<span className="text-[var(--text-secondary)]">{t('equipment.location', 'Location')}:</span>
<span className="font-medium text-xs">{eq.location}</span>
</div>
{eq.temperature && (
<div className="flex items-center justify-between">
<span className="text-[var(--text-secondary)]">{t('equipment.temperature', 'Temperature')}:</span>
<span className="font-medium">{eq.temperature}<EFBFBD>C</span>
</div>
)}
</div>
{eq.alerts.filter(a => !a.acknowledged).length > 0 && (
<div className="mt-3 p-2 bg-orange-50 dark:bg-orange-900/20 rounded border-l-2 border-orange-500">
<div className="flex items-center space-x-2">
<AlertTriangle className="w-4 h-4 text-orange-500" />
<span className="text-sm font-medium text-orange-700 dark:text-orange-300">
{eq.alerts.filter(a => !a.acknowledged).length} {t('equipment.unread_alerts', 'unread alerts')}
</span>
</div>
</div>
)}
<div className="flex justify-between mt-4 pt-3 border-t border-[var(--border-primary)]">
<Button
variant="outline"
size="xs"
onClick={(e) => {
e.stopPropagation();
onEditEquipment?.(eq.id);
}}
>
{t('common.edit', 'Edit')}
</Button>
<Button
variant="primary"
size="xs"
onClick={(e) => {
e.stopPropagation();
onScheduleMaintenance?.(eq.id);
}}
>
{t('equipment.actions.maintenance', 'Maintenance')}
</Button>
</div>
</div>
);
})}
</div>
</TabsContent>
<TabsContent value="maintenance" className="space-y-4">
{equipment.map((eq) => (
<div key={eq.id} className="p-4 bg-[var(--bg-secondary)] rounded-lg">
<div className="flex items-center justify-between mb-3">
<h4 className="font-semibold text-[var(--text-primary)]">{eq.name}</h4>
<Badge variant={new Date(eq.nextMaintenance) <= new Date() ? 'error' : 'success'}>
{new Date(eq.nextMaintenance) <= new Date() ? t('equipment.maintenance.overdue', 'Overdue') : t('equipment.maintenance.scheduled', 'Scheduled')}
</Badge>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span className="text-[var(--text-secondary)]">{t('equipment.maintenance.last', 'Last')}:</span>
<div className="font-medium">{formatDateTime(eq.lastMaintenance)}</div>
</div>
<div>
<span className="text-[var(--text-secondary)]">{t('equipment.maintenance.next', 'Next')}:</span>
<div className="font-medium">{formatDateTime(eq.nextMaintenance)}</div>
</div>
<div>
<span className="text-[var(--text-secondary)]">{t('equipment.maintenance.interval', 'Interval')}:</span>
<div className="font-medium">{eq.maintenanceInterval} {t('common.days', 'days')}</div>
</div>
<div>
<span className="text-[var(--text-secondary)]">{t('equipment.maintenance.history', 'History')}:</span>
<div className="font-medium">{eq.maintenanceHistory.length} {t('equipment.maintenance.records', 'records')}</div>
</div>
</div>
</div>
))}
</TabsContent>
<TabsContent value="alerts" className="space-y-4">
{equipment.flatMap(eq =>
eq.alerts.map(alert => (
<div key={`${eq.id}-${alert.id}`} className={`p-4 rounded-lg border-l-4 ${
alert.type === 'critical' ? 'bg-red-50 border-red-500 dark:bg-red-900/20' :
alert.type === 'warning' ? 'bg-orange-50 border-orange-500 dark:bg-orange-900/20' :
'bg-blue-50 border-blue-500 dark:bg-blue-900/20'
}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-2">
<AlertTriangle className={`w-5 h-5 ${
alert.type === 'critical' ? 'text-red-500' :
alert.type === 'warning' ? 'text-orange-500' : 'text-blue-500'
}`} />
<h4 className="font-semibold text-[var(--text-primary)]">{eq.name}</h4>
<Badge variant={alert.acknowledged ? 'success' : 'warning'}>
{alert.acknowledged ? t('equipment.alerts.acknowledged', 'Acknowledged') : t('equipment.alerts.new', 'New')}
</Badge>
</div>
<span className="text-sm text-[var(--text-secondary)]">
{new Date(alert.timestamp).toLocaleString('es-ES')}
</span>
</div>
<p className="text-[var(--text-secondary)] mb-3">{alert.message}</p>
{!alert.acknowledged && (
<Button
variant="outline"
size="sm"
onClick={() => onAcknowledgeAlert?.(eq.id, alert.id)}
>
{t('equipment.alerts.acknowledge', 'Acknowledge')}
</Button>
)}
</div>
))
)}
</TabsContent>
</Tabs>
{/* Equipment Details Modal */}
{selectedEquipment && (
<Modal
isOpen={showEquipmentModal}
onClose={() => {
setShowEquipmentModal(false);
setSelectedEquipment(null);
}}
title={selectedEquipment.name}
size="lg"
>
<div className="p-6 space-y-6">
{/* Basic Info */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-[var(--text-secondary)]">{t('equipment.model', 'Model')}</label>
<p className="text-[var(--text-primary)]">{selectedEquipment.model}</p>
</div>
<div>
<label className="text-sm font-medium text-[var(--text-secondary)]">{t('equipment.serial', 'Serial Number')}</label>
<p className="text-[var(--text-primary)]">{selectedEquipment.serialNumber}</p>
</div>
<div>
<label className="text-sm font-medium text-[var(--text-secondary)]">{t('equipment.location', 'Location')}</label>
<p className="text-[var(--text-primary)]">{selectedEquipment.location}</p>
</div>
<div>
<label className="text-sm font-medium text-[var(--text-secondary)]">{t('equipment.install_date', 'Install Date')}</label>
<p className="text-[var(--text-primary)]">{formatDateTime(selectedEquipment.installDate)}</p>
</div>
</div>
{/* Current Status */}
<div className="grid grid-cols-3 gap-4 p-4 bg-[var(--bg-secondary)] rounded-lg">
<div className="text-center">
<div className="text-2xl font-bold text-[var(--text-primary)]">{selectedEquipment.efficiency}%</div>
<div className="text-sm text-[var(--text-secondary)]">{t('equipment.efficiency', 'Efficiency')}</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-[var(--text-primary)]">{selectedEquipment.uptime.toFixed(1)}%</div>
<div className="text-sm text-[var(--text-secondary)]">{t('equipment.uptime', 'Uptime')}</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-[var(--text-primary)]">{selectedEquipment.energyUsage} kW</div>
<div className="text-sm text-[var(--text-secondary)]">{t('equipment.energy_usage', 'Energy Usage')}</div>
</div>
</div>
{/* Actions */}
<div className="flex justify-end space-x-3">
<Button variant="outline" onClick={() => onViewMaintenanceHistory?.(selectedEquipment.id)}>
{t('equipment.actions.view_history', 'View History')}
</Button>
<Button variant="secondary" onClick={() => onEditEquipment?.(selectedEquipment.id)}>
{t('common.edit', 'Edit')}
</Button>
<Button variant="primary" onClick={() => onScheduleMaintenance?.(selectedEquipment.id)}>
{t('equipment.actions.schedule_maintenance', 'Schedule Maintenance')}
</Button>
</div>
</div>
</Modal>
)}
</CardBody>
</Card>
);
};
export default EquipmentManager;

View File

@@ -1,377 +0,0 @@
import React from 'react';
import {
CheckCircle,
Clock,
AlertTriangle,
Play,
Pause,
ArrowRight,
Settings,
Thermometer,
Scale,
Eye
} from 'lucide-react';
import { Button, Badge, Card, ProgressBar } from '../../ui';
import { ProcessStage } from '../../../api/types/qualityTemplates';
import type { ProductionBatchResponse } from '../../../api/types/production';
interface ProcessStageTrackerProps {
batch: ProductionBatchResponse;
onStageAdvance?: (stage: ProcessStage) => void;
onQualityCheck?: (stage: ProcessStage) => void;
onStageStart?: (stage: ProcessStage) => void;
onStagePause?: (stage: ProcessStage) => void;
className?: string;
}
const PROCESS_STAGES_ORDER: ProcessStage[] = [
ProcessStage.MIXING,
ProcessStage.PROOFING,
ProcessStage.SHAPING,
ProcessStage.BAKING,
ProcessStage.COOLING,
ProcessStage.PACKAGING,
ProcessStage.FINISHING
];
const STAGE_CONFIG = {
[ProcessStage.MIXING]: {
label: 'Mezclado',
icon: Settings,
color: 'bg-blue-500',
description: 'Preparación y mezclado de ingredientes'
},
[ProcessStage.PROOFING]: {
label: 'Fermentación',
icon: Clock,
color: 'bg-yellow-500',
description: 'Proceso de fermentación y crecimiento'
},
[ProcessStage.SHAPING]: {
label: 'Formado',
icon: Settings,
color: 'bg-purple-500',
description: 'Dar forma al producto'
},
[ProcessStage.BAKING]: {
label: 'Horneado',
icon: Thermometer,
color: 'bg-red-500',
description: 'Cocción en horno'
},
[ProcessStage.COOLING]: {
label: 'Enfriado',
icon: Settings,
color: 'bg-cyan-500',
description: 'Enfriamiento del producto'
},
[ProcessStage.PACKAGING]: {
label: 'Empaquetado',
icon: Settings,
color: 'bg-green-500',
description: 'Empaque del producto terminado'
},
[ProcessStage.FINISHING]: {
label: 'Acabado',
icon: CheckCircle,
color: 'bg-indigo-500',
description: 'Toques finales y control final'
}
};
export const ProcessStageTracker: React.FC<ProcessStageTrackerProps> = ({
batch,
onStageAdvance,
onQualityCheck,
onStageStart,
onStagePause,
className = ''
}) => {
const currentStage = batch.current_process_stage;
const stageHistory = batch.process_stage_history || {};
const pendingQualityChecks = batch.pending_quality_checks || {};
const completedQualityChecks = batch.completed_quality_checks || {};
const getCurrentStageIndex = () => {
if (!currentStage) return -1;
return PROCESS_STAGES_ORDER.indexOf(currentStage);
};
const isStageCompleted = (stage: ProcessStage): boolean => {
const stageIndex = PROCESS_STAGES_ORDER.indexOf(stage);
const currentIndex = getCurrentStageIndex();
return stageIndex < currentIndex || (stageIndex === currentIndex && batch.status === 'COMPLETED');
};
const isStageActive = (stage: ProcessStage): boolean => {
return currentStage === stage && batch.status === 'IN_PROGRESS';
};
const isStageUpcoming = (stage: ProcessStage): boolean => {
const stageIndex = PROCESS_STAGES_ORDER.indexOf(stage);
const currentIndex = getCurrentStageIndex();
return stageIndex > currentIndex;
};
const hasQualityChecksRequired = (stage: ProcessStage): boolean => {
return pendingQualityChecks[stage]?.length > 0;
};
const hasQualityChecksCompleted = (stage: ProcessStage): boolean => {
return completedQualityChecks[stage]?.length > 0;
};
const getStageProgress = (stage: ProcessStage): number => {
if (isStageCompleted(stage)) return 100;
if (isStageActive(stage)) {
// Calculate progress based on time elapsed vs estimated duration
const stageStartTime = stageHistory[stage]?.start_time;
if (stageStartTime) {
const elapsed = Date.now() - new Date(stageStartTime).getTime();
const estimated = 30 * 60 * 1000; // Default 30 minutes per stage
return Math.min(100, (elapsed / estimated) * 100);
}
}
return 0;
};
const getStageStatus = (stage: ProcessStage) => {
if (isStageCompleted(stage)) {
return hasQualityChecksCompleted(stage)
? { status: 'completed', icon: CheckCircle, color: 'text-green-500' }
: { status: 'completed-no-quality', icon: CheckCircle, color: 'text-yellow-500' };
}
if (isStageActive(stage)) {
return hasQualityChecksRequired(stage)
? { status: 'active-quality-pending', icon: AlertTriangle, color: 'text-orange-500' }
: { status: 'active', icon: Play, color: 'text-blue-500' };
}
if (isStageUpcoming(stage)) {
return { status: 'upcoming', icon: Clock, color: 'text-gray-400' };
}
return { status: 'pending', icon: Clock, color: 'text-gray-400' };
};
const getOverallProgress = (): number => {
const currentIndex = getCurrentStageIndex();
if (currentIndex === -1) return 0;
const stageProgress = getStageProgress(currentStage!);
return ((currentIndex + stageProgress / 100) / PROCESS_STAGES_ORDER.length) * 100;
};
return (
<Card className={`p-6 ${className}`}>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
Etapas del Proceso
</h3>
<p className="text-sm text-[var(--text-secondary)] mt-1">
{batch.product_name} Lote #{batch.batch_number}
</p>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-[var(--text-primary)]">
{Math.round(getOverallProgress())}%
</div>
<div className="text-sm text-[var(--text-secondary)]">
Completado
</div>
</div>
</div>
{/* Overall Progress Bar */}
<ProgressBar
percentage={getOverallProgress()}
color="#f59e0b"
height={8}
showLabel={false}
/>
{/* Stage Timeline */}
<div className="space-y-4">
{PROCESS_STAGES_ORDER.map((stage, index) => {
const config = STAGE_CONFIG[stage];
const status = getStageStatus(stage);
const progress = getStageProgress(stage);
const StageIcon = config.icon;
const StatusIcon = status.icon;
return (
<div key={stage} className="relative">
{/* Connection Line */}
{index < PROCESS_STAGES_ORDER.length - 1 && (
<div
className={`absolute left-6 top-12 w-0.5 h-8 ${
isStageCompleted(stage) ? 'bg-green-500' : 'bg-gray-200'
}`}
/>
)}
<div className="flex items-start gap-4">
{/* Stage Icon */}
<div
className={`relative flex items-center justify-center w-12 h-12 rounded-full border-2 ${
isStageCompleted(stage)
? 'bg-green-500 border-green-500 text-white'
: isStageActive(stage)
? 'bg-blue-500 border-blue-500 text-white'
: 'bg-white border-gray-300 text-gray-400'
}`}
>
<StageIcon className="w-5 h-5" />
{/* Status Indicator */}
<div className="absolute -bottom-1 -right-1">
<StatusIcon className={`w-4 h-4 ${status.color}`} />
</div>
</div>
{/* Stage Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium text-[var(--text-primary)]">
{config.label}
</h4>
<p className="text-sm text-[var(--text-secondary)]">
{config.description}
</p>
</div>
{/* Stage Actions */}
<div className="flex items-center gap-2">
{/* Quality Check Indicators */}
{hasQualityChecksRequired(stage) && (
<Badge variant="warning" size="sm">
Control pendiente
</Badge>
)}
{hasQualityChecksCompleted(stage) && (
<Badge variant="success" size="sm">
Calidad
</Badge>
)}
{/* Action Buttons */}
{isStageActive(stage) && (
<div className="flex gap-1">
{hasQualityChecksRequired(stage) && (
<Button
size="sm"
variant="outline"
onClick={() => onQualityCheck?.(stage)}
>
<Eye className="w-4 h-4 mr-1" />
Control
</Button>
)}
<Button
size="sm"
variant="primary"
onClick={() => onStageAdvance?.(stage)}
disabled={hasQualityChecksRequired(stage)}
>
<ArrowRight className="w-4 h-4" />
</Button>
</div>
)}
{getCurrentStageIndex() === index - 1 && (
<Button
size="sm"
variant="outline"
onClick={() => onStageStart?.(stage)}
>
<Play className="w-4 h-4 mr-1" />
Iniciar
</Button>
)}
</div>
</div>
{/* Stage Progress */}
{isStageActive(stage) && progress > 0 && (
<div className="mt-2">
<ProgressBar
percentage={progress}
color="#3b82f6"
height={4}
showLabel={false}
/>
<p className="text-xs text-[var(--text-tertiary)] mt-1">
Progreso de la etapa: {Math.round(progress)}%
</p>
</div>
)}
{/* Stage Details */}
{(isStageActive(stage) || isStageCompleted(stage)) && (
<div className="mt-3 text-xs text-[var(--text-secondary)] space-y-1">
{stageHistory[stage]?.start_time && (
<div>
Inicio: {new Date(stageHistory[stage].start_time).toLocaleTimeString('es-ES')}
</div>
)}
{stageHistory[stage]?.end_time && (
<div>
Fin: {new Date(stageHistory[stage].end_time).toLocaleTimeString('es-ES')}
</div>
)}
{completedQualityChecks[stage]?.length > 0 && (
<div>
Controles completados: {completedQualityChecks[stage].length}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
})}
</div>
{/* Stage Summary */}
<div className="bg-[var(--bg-secondary)] rounded-lg p-4">
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-lg font-semibold text-green-500">
{PROCESS_STAGES_ORDER.filter(s => isStageCompleted(s)).length}
</div>
<div className="text-xs text-[var(--text-secondary)]">
Completadas
</div>
</div>
<div>
<div className="text-lg font-semibold text-blue-500">
{currentStage ? 1 : 0}
</div>
<div className="text-xs text-[var(--text-secondary)]">
En Proceso
</div>
</div>
<div>
<div className="text-lg font-semibold text-gray-400">
{PROCESS_STAGES_ORDER.filter(s => isStageUpcoming(s)).length}
</div>
<div className="text-xs text-[var(--text-secondary)]">
Pendientes
</div>
</div>
</div>
</div>
</div>
</Card>
);
};
export default ProcessStageTracker;

View File

@@ -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 '../../../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"
/>
/ 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;

View File

@@ -1,467 +0,0 @@
import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardHeader, CardBody } from '../../ui/Card';
import { StatsGrid } from '../../ui/Stats';
import { Badge } from '../../ui/Badge';
import { Button } from '../../ui/Button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../../ui/Tabs';
import {
CheckCircle,
AlertTriangle,
TrendingUp,
TrendingDown,
Target,
Activity,
BarChart3,
Camera,
FileCheck,
Clock,
Users,
Package
} from 'lucide-react';
import { useCurrentTenant } from '../../../stores/tenant.store';
import { useProductionDashboard } from '../../../api';
export interface QualityMetrics {
totalChecks: number;
passedChecks: number;
failedChecks: number;
averageScore: number;
passRate: number;
trendsData: Array<{
date: string;
score: number;
passRate: number;
checks: number;
}>;
byProduct: Array<{
productName: string;
checks: number;
averageScore: number;
passRate: number;
topDefects: string[];
}>;
byCategory: Array<{
category: string;
checks: number;
averageScore: number;
issues: number;
}>;
recentChecks: Array<{
id: string;
batchId: string;
productName: string;
checkType: string;
score: number;
status: 'passed' | 'failed' | 'warning';
timestamp: string;
inspector: string;
defects: string[];
}>;
}
export interface QualityDashboardProps {
className?: string;
data?: QualityMetrics;
dateRange?: {
startDate: string;
endDate: string;
};
onCreateCheck?: () => void;
onViewCheck?: (checkId: string) => void;
onViewTrends?: () => void;
}
const QualityDashboard: React.FC<QualityDashboardProps> = ({
className,
data,
dateRange,
onCreateCheck,
onViewCheck,
onViewTrends
}) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState('overview');
const currentTenant = useCurrentTenant();
const tenantId = currentTenant?.id || '';
const {
data: dashboardData,
isLoading,
error
} = useProductionDashboard(tenantId);
const qualityData = useMemo((): QualityMetrics => {
if (data) return data;
// Mock data for demonstration
return {
totalChecks: 156,
passedChecks: 142,
failedChecks: 14,
averageScore: 8.3,
passRate: 91.0,
trendsData: [
{ date: '2024-01-17', score: 8.1, passRate: 89, checks: 22 },
{ date: '2024-01-18', score: 8.4, passRate: 92, checks: 25 },
{ date: '2024-01-19', score: 8.2, passRate: 90, checks: 24 },
{ date: '2024-01-20', score: 8.5, passRate: 94, checks: 23 },
{ date: '2024-01-21', score: 8.3, passRate: 91, checks: 26 },
{ date: '2024-01-22', score: 8.6, passRate: 95, checks: 21 },
{ date: '2024-01-23', score: 8.3, passRate: 91, checks: 15 }
],
byProduct: [
{
productName: 'Pan de Molde Integral',
checks: 35,
averageScore: 8.7,
passRate: 94.3,
topDefects: ['Forma irregular', 'Color desigual']
},
{
productName: 'Croissants de Mantequilla',
checks: 28,
averageScore: 8.1,
passRate: 89.3,
topDefects: ['Textura dura', 'Tama<6D>o inconsistente']
},
{
productName: 'Baguettes Tradicionales',
checks: 22,
averageScore: 8.5,
passRate: 95.5,
topDefects: ['Corteza muy oscura']
}
],
byCategory: [
{ category: 'Apariencia Visual', checks: 156, averageScore: 8.4, issues: 12 },
{ category: 'Textura', checks: 142, averageScore: 8.2, issues: 15 },
{ category: 'Sabor', checks: 98, averageScore: 8.6, issues: 8 },
{ category: 'Dimensiones', checks: 156, averageScore: 8.1, issues: 18 },
{ category: 'Peso', checks: 156, averageScore: 8.9, issues: 4 }
],
recentChecks: [
{
id: '1',
batchId: 'PROD-2024-0123-001',
productName: 'Pan de Molde Integral',
checkType: 'Inspecci<63>n Visual',
score: 8.5,
status: 'passed',
timestamp: '2024-01-23T14:30:00Z',
inspector: 'Mar<61>a Gonz<6E>lez',
defects: []
},
{
id: '2',
batchId: 'PROD-2024-0123-002',
productName: 'Croissants de Mantequilla',
checkType: 'Control de Calidad',
score: 7.2,
status: 'warning',
timestamp: '2024-01-23T13:45:00Z',
inspector: 'Carlos Rodr<64>guez',
defects: ['Textura ligeramente dura']
},
{
id: '3',
batchId: 'PROD-2024-0123-003',
productName: 'Baguettes Tradicionales',
checkType: 'Inspecci<63>n Final',
score: 6.8,
status: 'failed',
timestamp: '2024-01-23T12:15:00Z',
inspector: 'Ana Mart<72>n',
defects: ['Corteza muy oscura', 'Forma irregular']
}
]
};
}, [data]);
const getStatusColor = (status: 'passed' | 'failed' | 'warning') => {
const colors = {
passed: 'success',
failed: 'error',
warning: 'warning'
};
return colors[status] as 'success' | 'error' | 'warning';
};
const getStatusIcon = (status: 'passed' | 'failed' | 'warning') => {
const icons = {
passed: CheckCircle,
failed: AlertTriangle,
warning: AlertTriangle
};
return icons[status];
};
const formatDateTime = (timestamp: string) => {
return new Date(timestamp).toLocaleString('es-ES', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
if (isLoading) {
return (
<Card className={className}>
<CardHeader>
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
{t('quality.dashboard.title', 'Quality Dashboard')}
</h3>
</CardHeader>
<CardBody>
<div className="animate-pulse space-y-4">
<div className="grid grid-cols-4 gap-4">
<div className="h-20 bg-[var(--bg-secondary)] rounded"></div>
<div className="h-20 bg-[var(--bg-secondary)] rounded"></div>
<div className="h-20 bg-[var(--bg-secondary)] rounded"></div>
<div className="h-20 bg-[var(--bg-secondary)] rounded"></div>
</div>
<div className="h-64 bg-[var(--bg-secondary)] rounded"></div>
</div>
</CardBody>
</Card>
);
}
if (error) {
return (
<Card className={className}>
<CardBody className="text-center py-8">
<AlertTriangle className="w-12 h-12 text-red-500 mx-auto mb-4" />
<p className="text-[var(--text-secondary)]">
{t('quality.dashboard.error', 'Error loading quality data')}
</p>
</CardBody>
</Card>
);
}
const qualityStats = [
{
title: t('quality.stats.total_checks', 'Total Checks'),
value: qualityData.totalChecks,
icon: FileCheck,
variant: 'default' as const,
subtitle: t('quality.stats.last_7_days', 'Last 7 days')
},
{
title: t('quality.stats.pass_rate', 'Pass Rate'),
value: `${qualityData.passRate.toFixed(1)}%`,
icon: CheckCircle,
variant: qualityData.passRate >= 95 ? 'success' as const :
qualityData.passRate >= 90 ? 'warning' as const : 'error' as const,
trend: {
value: 2.3,
direction: 'up' as const,
label: t('quality.trends.vs_last_week', 'vs last week')
},
subtitle: `${qualityData.passedChecks}/${qualityData.totalChecks} ${t('quality.stats.passed', 'passed')}`
},
{
title: t('quality.stats.average_score', 'Average Score'),
value: qualityData.averageScore.toFixed(1),
icon: Target,
variant: qualityData.averageScore >= 8.5 ? 'success' as const :
qualityData.averageScore >= 7.5 ? 'warning' as const : 'error' as const,
subtitle: t('quality.stats.out_of_10', 'out of 10')
},
{
title: t('quality.stats.failed_checks', 'Failed Checks'),
value: qualityData.failedChecks,
icon: AlertTriangle,
variant: qualityData.failedChecks === 0 ? 'success' as const :
qualityData.failedChecks <= 5 ? 'warning' as const : 'error' as const,
subtitle: t('quality.stats.requiring_action', 'Requiring action')
}
];
return (
<Card className={className}>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
{t('quality.dashboard.title', 'Quality Dashboard')}
</h3>
<p className="text-sm text-[var(--text-secondary)]">
{t('quality.dashboard.subtitle', 'Monitor quality metrics and trends')}
</p>
</div>
<Button
onClick={onCreateCheck}
variant="primary"
size="sm"
>
<Camera className="w-4 h-4 mr-2" />
{t('quality.actions.new_check', 'New Check')}
</Button>
</div>
</CardHeader>
<CardBody className="space-y-6">
{/* Key Metrics */}
<StatsGrid
stats={qualityStats}
columns={4}
gap="md"
/>
{/* Detailed Analysis Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="overview">
{t('quality.tabs.overview', 'Overview')}
</TabsTrigger>
<TabsTrigger value="products">
{t('quality.tabs.by_product', 'By Product')}
</TabsTrigger>
<TabsTrigger value="categories">
{t('quality.tabs.categories', 'Categories')}
</TabsTrigger>
<TabsTrigger value="recent">
{t('quality.tabs.recent', 'Recent')}
</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
{/* Trends Chart Placeholder */}
<div className="p-6 bg-[var(--bg-secondary)] rounded-lg">
<div className="flex items-center justify-between mb-4">
<h4 className="font-medium text-[var(--text-primary)]">
{t('quality.charts.weekly_trends', 'Weekly Quality Trends')}
</h4>
<Button variant="outline" size="sm" onClick={onViewTrends}>
<BarChart3 className="w-4 h-4 mr-2" />
{t('quality.actions.view_trends', 'View Trends')}
</Button>
</div>
<div className="h-32 flex items-center justify-center border-2 border-dashed border-[var(--border-primary)] rounded">
<p className="text-[var(--text-tertiary)]">
{t('quality.charts.placeholder', 'Quality trends chart will be displayed here')}
</p>
</div>
</div>
</TabsContent>
<TabsContent value="products" className="space-y-4">
<div className="space-y-3">
{qualityData.byProduct.map((product, index) => (
<div key={index} className="p-4 bg-[var(--bg-secondary)] rounded-lg">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium text-[var(--text-primary)]">
{product.productName}
</h4>
<Badge variant={product.passRate >= 95 ? 'success' : product.passRate >= 90 ? 'warning' : 'error'}>
{product.passRate.toFixed(1)}% {t('quality.stats.pass_rate', 'pass rate')}
</Badge>
</div>
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<span className="text-[var(--text-secondary)]">{t('quality.stats.checks', 'Checks')}: </span>
<span className="font-medium">{product.checks}</span>
</div>
<div>
<span className="text-[var(--text-secondary)]">{t('quality.stats.avg_score', 'Avg Score')}: </span>
<span className="font-medium">{product.averageScore.toFixed(1)}</span>
</div>
<div>
<span className="text-[var(--text-secondary)]">{t('quality.stats.top_defects', 'Top Defects')}: </span>
<span className="font-medium">{product.topDefects.join(', ')}</span>
</div>
</div>
</div>
))}
</div>
</TabsContent>
<TabsContent value="categories" className="space-y-4">
<div className="space-y-3">
{qualityData.byCategory.map((category, index) => (
<div key={index} className="flex items-center justify-between p-4 bg-[var(--bg-secondary)] rounded-lg">
<div>
<h4 className="font-medium text-[var(--text-primary)]">
{category.category}
</h4>
<div className="flex items-center space-x-4 text-sm text-[var(--text-secondary)]">
<span>{category.checks} {t('quality.stats.checks', 'checks')}</span>
<span>{category.averageScore.toFixed(1)} {t('quality.stats.avg_score', 'avg score')}</span>
</div>
</div>
<div className="text-right">
<div className={`text-lg font-bold ${category.issues === 0 ? 'text-green-600' : category.issues <= 5 ? 'text-orange-600' : 'text-red-600'}`}>
{category.issues}
</div>
<div className="text-sm text-[var(--text-secondary)]">
{t('quality.stats.issues', 'issues')}
</div>
</div>
</div>
))}
</div>
</TabsContent>
<TabsContent value="recent" className="space-y-4">
<div className="space-y-3">
{qualityData.recentChecks.map((check) => {
const StatusIcon = getStatusIcon(check.status);
return (
<div
key={check.id}
className="p-4 bg-[var(--bg-secondary)] rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors cursor-pointer"
onClick={() => onViewCheck?.(check.id)}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-3">
<StatusIcon className={`w-5 h-5 ${
check.status === 'passed' ? 'text-green-500' :
check.status === 'warning' ? 'text-orange-500' : 'text-red-500'
}`} />
<div>
<h4 className="font-medium text-[var(--text-primary)]">
{check.productName}
</h4>
<p className="text-sm text-[var(--text-secondary)]">
{check.checkType} " {check.batchId}
</p>
</div>
</div>
<div className="text-right">
<div className="text-lg font-bold text-[var(--text-primary)]">
{check.score.toFixed(1)}
</div>
<div className="text-sm text-[var(--text-secondary)]">
{formatDateTime(check.timestamp)}
</div>
</div>
</div>
<div className="flex items-center justify-between text-sm">
<div className="flex items-center space-x-2">
<Users className="w-4 h-4 text-[var(--text-tertiary)]" />
<span className="text-[var(--text-secondary)]">{check.inspector}</span>
</div>
{check.defects.length > 0 && (
<div className="flex items-center space-x-2">
<AlertTriangle className="w-4 h-4 text-orange-500" />
<span className="text-sm text-orange-600">
{check.defects.length} {t('quality.stats.defects', 'defects')}
</span>
</div>
)}
</div>
</div>
);
})}
</div>
</TabsContent>
</Tabs>
</CardBody>
</Card>
);
};
export default QualityDashboard;

View File

@@ -1,585 +0,0 @@
import React, { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardHeader, CardBody } from '../../ui/Card';
import { Button } from '../../ui/Button';
import { Input } from '../../ui/Input';
import { Textarea } from '../../ui';
import { Badge } from '../../ui/Badge';
import { Modal } from '../../ui/Modal';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../../ui/Tabs';
import {
Camera,
CheckCircle,
XCircle,
AlertTriangle,
Upload,
Star,
Target,
FileText,
Clock,
User,
Package
} from 'lucide-react';
import { useCurrentTenant } from '../../../stores/tenant.store';
export interface InspectionCriteria {
id: string;
category: string;
name: string;
description: string;
type: 'visual' | 'measurement' | 'taste' | 'texture' | 'temperature';
required: boolean;
weight: number;
acceptableCriteria: string;
minValue?: number;
maxValue?: number;
unit?: string;
}
export interface InspectionResult {
criteriaId: string;
value: number | string | boolean;
score: number;
notes?: string;
photos?: File[];
pass: boolean;
timestamp: string;
}
export interface QualityInspectionData {
batchId: string;
productName: string;
inspectionType: string;
inspector: string;
startTime: string;
criteria: InspectionCriteria[];
results: InspectionResult[];
overallScore: number;
overallPass: boolean;
finalNotes: string;
photos: File[];
correctiveActions: string[];
}
export interface QualityInspectionProps {
className?: string;
batchId?: string;
productName?: string;
inspectionType?: string;
criteria?: InspectionCriteria[];
onComplete?: (data: QualityInspectionData) => void;
onCancel?: () => void;
onSaveDraft?: (data: Partial<QualityInspectionData>) => void;
}
const DEFAULT_CRITERIA: InspectionCriteria[] = [
{
id: 'color_uniformity',
category: 'Visual',
name: 'Color Uniformity',
description: 'Evaluate the consistency of color across the product',
type: 'visual',
required: true,
weight: 15,
acceptableCriteria: 'Score 7 or higher',
minValue: 1,
maxValue: 10
},
{
id: 'shape_integrity',
category: 'Visual',
name: 'Shape Integrity',
description: 'Check if the product maintains its intended shape',
type: 'visual',
required: true,
weight: 20,
acceptableCriteria: 'Score 7 or higher',
minValue: 1,
maxValue: 10
},
{
id: 'surface_texture',
category: 'Texture',
name: 'Surface Texture',
description: 'Evaluate surface texture quality',
type: 'texture',
required: true,
weight: 15,
acceptableCriteria: 'Score 7 or higher',
minValue: 1,
maxValue: 10
},
{
id: 'weight_accuracy',
category: 'Measurement',
name: 'Weight Accuracy',
description: 'Measure actual weight vs target weight',
type: 'measurement',
required: true,
weight: 20,
acceptableCriteria: 'Within <20>5% of target',
unit: 'g'
},
{
id: 'internal_texture',
category: 'Texture',
name: 'Internal Texture',
description: 'Evaluate crumb structure and texture',
type: 'texture',
required: false,
weight: 15,
acceptableCriteria: 'Score 7 or higher',
minValue: 1,
maxValue: 10
},
{
id: 'taste_quality',
category: 'Taste',
name: 'Taste Quality',
description: 'Overall flavor and taste assessment',
type: 'taste',
required: false,
weight: 15,
acceptableCriteria: 'Score 7 or higher',
minValue: 1,
maxValue: 10
}
];
const QualityInspection: React.FC<QualityInspectionProps> = ({
className,
batchId = 'PROD-2024-0123-001',
productName = 'Pan de Molde Integral',
inspectionType = 'Final Quality Check',
criteria = DEFAULT_CRITERIA,
onComplete,
onCancel,
onSaveDraft
}) => {
const { t } = useTranslation();
const currentTenant = useCurrentTenant();
const [activeTab, setActiveTab] = useState('inspection');
const [inspectionData, setInspectionData] = useState<Partial<QualityInspectionData>>({
batchId,
productName,
inspectionType,
inspector: currentTenant?.name || 'Inspector',
startTime: new Date().toISOString(),
criteria,
results: [],
finalNotes: '',
photos: [],
correctiveActions: []
});
const [currentCriteriaIndex, setCurrentCriteriaIndex] = useState(0);
const [showPhotoModal, setShowPhotoModal] = useState(false);
const [tempPhotos, setTempPhotos] = useState<File[]>([]);
const updateResult = useCallback((criteriaId: string, updates: Partial<InspectionResult>) => {
setInspectionData(prev => {
const existingResults = prev.results || [];
const existingIndex = existingResults.findIndex(r => r.criteriaId === criteriaId);
let newResults;
if (existingIndex >= 0) {
newResults = [...existingResults];
newResults[existingIndex] = { ...newResults[existingIndex], ...updates };
} else {
newResults = [...existingResults, {
criteriaId,
value: '',
score: 0,
pass: false,
timestamp: new Date().toISOString(),
...updates
}];
}
return { ...prev, results: newResults };
});
}, []);
const getCriteriaResult = useCallback((criteriaId: string): InspectionResult | undefined => {
return inspectionData.results?.find(r => r.criteriaId === criteriaId);
}, [inspectionData.results]);
const calculateOverallScore = useCallback((): number => {
if (!inspectionData.results || inspectionData.results.length === 0) return 0;
const totalWeight = criteria.reduce((sum, c) => sum + c.weight, 0);
const weightedScore = inspectionData.results.reduce((sum, result) => {
const criterion = criteria.find(c => c.id === result.criteriaId);
return sum + (result.score * (criterion?.weight || 0));
}, 0);
return totalWeight > 0 ? weightedScore / totalWeight : 0;
}, [inspectionData.results, criteria]);
const isInspectionComplete = useCallback((): boolean => {
const requiredCriteria = criteria.filter(c => c.required);
const completedRequired = requiredCriteria.filter(c =>
inspectionData.results?.some(r => r.criteriaId === c.id)
);
return completedRequired.length === requiredCriteria.length;
}, [criteria, inspectionData.results]);
const handlePhotoUpload = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
setTempPhotos(prev => [...prev, ...files]);
}, []);
const handleComplete = useCallback(() => {
const overallScore = calculateOverallScore();
const overallPass = overallScore >= 7.0; // Configurable threshold
const completedData: QualityInspectionData = {
...inspectionData as QualityInspectionData,
overallScore,
overallPass,
photos: tempPhotos
};
onComplete?.(completedData);
}, [inspectionData, calculateOverallScore, tempPhotos, onComplete]);
const currentCriteria = criteria[currentCriteriaIndex];
const currentResult = currentCriteria ? getCriteriaResult(currentCriteria.id) : undefined;
const overallScore = calculateOverallScore();
const isComplete = isInspectionComplete();
const renderCriteriaInput = (criterion: InspectionCriteria) => {
const result = getCriteriaResult(criterion.id);
if (criterion.type === 'measurement') {
return (
<div className="space-y-4">
<Input
type="number"
placeholder={`Enter ${criterion.name.toLowerCase()}`}
value={result?.value as string || ''}
onChange={(e) => {
const value = parseFloat(e.target.value);
const score = !isNaN(value) ? Math.min(10, Math.max(1, 8)) : 0; // Simplified scoring
updateResult(criterion.id, {
value: e.target.value,
score,
pass: score >= 7
});
}}
/>
{criterion.unit && (
<p className="text-sm text-[var(--text-secondary)]">
Unit: {criterion.unit}
</p>
)}
</div>
);
}
// For visual, texture, taste types - use 1-10 scale
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>
<div className="flex justify-center">
<Star className={`w-4 h-4 ${isSelected ? 'text-[var(--color-primary)]' : 'text-[var(--text-tertiary)]'}`} />
</div>
</div>
</button>
);
})}
</div>
<div className="text-sm text-[var(--text-secondary)]">
<p>1-3: Poor | 4-6: Fair | 7-8: Good | 9-10: Excellent</p>
<p className="font-medium mt-1">Acceptable: {criterion.acceptableCriteria}</p>
</div>
</div>
);
};
return (
<Card className={className}>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
{t('quality.inspection.title', 'Quality Inspection')}
</h3>
<p className="text-sm text-[var(--text-secondary)]">
{productName} " {batchId} " {inspectionType}
</p>
</div>
<div className="flex items-center space-x-2">
<Badge variant={isComplete ? 'success' : 'warning'}>
{isComplete ? t('quality.status.complete', 'Complete') : t('quality.status.in_progress', 'In Progress')}
</Badge>
{overallScore > 0 && (
<Badge variant={overallScore >= 7 ? 'success' : overallScore >= 5 ? 'warning' : 'error'}>
{overallScore.toFixed(1)}/10
</Badge>
)}
</div>
</div>
</CardHeader>
<CardBody className="space-y-6">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="inspection">
{t('quality.tabs.inspection', 'Inspection')}
</TabsTrigger>
<TabsTrigger value="photos">
{t('quality.tabs.photos', 'Photos')}
</TabsTrigger>
<TabsTrigger value="summary">
{t('quality.tabs.summary', 'Summary')}
</TabsTrigger>
</TabsList>
<TabsContent value="inspection" className="space-y-6">
{/* Progress Indicator */}
<div className="grid grid-cols-6 gap-2">
{criteria.map((criterion, index) => {
const result = getCriteriaResult(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 dark:bg-green-900 dark:text-green-200'
: 'bg-[var(--bg-secondary)] text-[var(--text-secondary)]'
}`}
onClick={() => setCurrentCriteriaIndex(index)}
>
{index + 1}
</button>
);
})}
</div>
{/* Current Criteria */}
{currentCriteria && (
<div className="space-y-6">
<div className="bg-[var(--bg-secondary)] p-4 rounded-lg">
<div className="flex items-center justify-between mb-2">
<h4 className="font-semibold text-[var(--text-primary)]">
{currentCriteria.name}
</h4>
<Badge variant={currentCriteria.required ? 'error' : 'default'}>
{currentCriteria.required ? t('quality.required', 'Required') : t('quality.optional', 'Optional')}
</Badge>
</div>
<p className="text-sm text-[var(--text-secondary)] mb-3">
{currentCriteria.description}
</p>
<div className="flex items-center space-x-4 text-xs text-[var(--text-tertiary)]">
<span>Weight: {currentCriteria.weight}%</span>
<span>Category: {currentCriteria.category}</span>
<span>Type: {currentCriteria.type}</span>
</div>
</div>
{/* Input Section */}
{renderCriteriaInput(currentCriteria)}
{/* Notes Section */}
<Textarea
placeholder={t('quality.inspection.notes_placeholder', 'Add notes for this criteria (optional)...')}
value={currentResult?.notes || ''}
onChange={(e) => {
updateResult(currentCriteria.id, { notes: e.target.value });
}}
rows={3}
/>
{/* Navigation */}
<div className="flex justify-between">
<Button
variant="outline"
onClick={() => setCurrentCriteriaIndex(Math.max(0, currentCriteriaIndex - 1))}
disabled={currentCriteriaIndex === 0}
>
{t('common.previous', 'Previous')}
</Button>
<Button
variant="primary"
onClick={() => setCurrentCriteriaIndex(Math.min(criteria.length - 1, currentCriteriaIndex + 1))}
disabled={currentCriteriaIndex === criteria.length - 1}
>
{t('common.next', 'Next')}
</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">
{t('quality.photos.description', 'Add photos to document quality issues or evidence')}
</p>
<Button
variant="outline"
onClick={() => setShowPhotoModal(true)}
>
<Upload className="w-4 h-4 mr-2" />
{t('quality.photos.upload', 'Upload Photos')}
</Button>
</div>
{tempPhotos.length > 0 && (
<div className="grid grid-cols-3 gap-4">
{tempPhotos.map((photo, index) => (
<div key={index} className="relative">
<img
src={URL.createObjectURL(photo)}
alt={`Quality photo ${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={() => setTempPhotos(prev => prev.filter((_, i) => i !== index))}
>
<EFBFBD>
</button>
</div>
))}
</div>
)}
</TabsContent>
<TabsContent value="summary" className="space-y-6">
{/* Overall Score */}
<div className="text-center py-6 bg-[var(--bg-secondary)] rounded-lg">
<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)]">
{t('quality.summary.overall_score', 'Overall Quality Score')}
</div>
<Badge
variant={overallScore >= 7 ? 'success' : overallScore >= 5 ? 'warning' : 'error'}
className="mt-2"
>
{overallScore >= 7 ? t('quality.status.passed', 'PASSED') : t('quality.status.failed', 'FAILED')}
</Badge>
</div>
{/* Results Summary */}
<div className="space-y-3">
<h4 className="font-medium text-[var(--text-primary)]">
{t('quality.summary.results', 'Inspection Results')}
</h4>
{criteria.map((criterion) => {
const result = getCriteriaResult(criterion.id);
if (!result) return null;
const StatusIcon = result.pass ? CheckCircle : XCircle;
return (
<div key={criterion.id} className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded">
<div className="flex items-center space-x-3">
<StatusIcon className={`w-5 h-5 ${result.pass ? 'text-green-500' : 'text-red-500'}`} />
<div>
<div className="font-medium text-[var(--text-primary)]">{criterion.name}</div>
<div className="text-sm text-[var(--text-secondary)]">{criterion.category}</div>
</div>
</div>
<div className="text-right">
<div className="font-bold text-[var(--text-primary)]">{result.score}/10</div>
<div className="text-sm text-[var(--text-secondary)]">
Weight: {criterion.weight}%
</div>
</div>
</div>
);
})}
</div>
{/* Final Notes */}
<Textarea
placeholder={t('quality.summary.final_notes', 'Add final notes and recommendations...')}
value={inspectionData.finalNotes || ''}
onChange={(e) => setInspectionData(prev => ({ ...prev, finalNotes: e.target.value }))}
rows={4}
/>
</TabsContent>
</Tabs>
{/* Action Buttons */}
<div className="flex justify-between pt-6 border-t border-[var(--border-primary)]">
<div className="flex space-x-3">
<Button variant="outline" onClick={onCancel}>
{t('common.cancel', 'Cancel')}
</Button>
<Button variant="secondary" onClick={() => onSaveDraft?.(inspectionData)}>
{t('quality.actions.save_draft', 'Save Draft')}
</Button>
</div>
<Button
variant="primary"
onClick={handleComplete}
disabled={!isComplete}
>
{t('quality.actions.complete_inspection', 'Complete Inspection')}
</Button>
</div>
{/* Photo Upload Modal */}
<Modal
isOpen={showPhotoModal}
onClose={() => setShowPhotoModal(false)}
title={t('quality.photos.upload_title', 'Upload Quality Photos')}
>
<div className="p-6">
<input
type="file"
accept="image/*"
multiple
onChange={handlePhotoUpload}
className="w-full p-4 border-2 border-dashed border-[var(--border-primary)] rounded-lg"
/>
<p className="text-sm text-[var(--text-secondary)] mt-2">
{t('quality.photos.upload_help', 'Select multiple images to upload')}
</p>
<div className="flex justify-end mt-4">
<Button onClick={() => setShowPhotoModal(false)}>
{t('common.done', 'Done')}
</Button>
</div>
</div>
</Modal>
</CardBody>
</Card>
);
};
export default QualityInspection;

View File

@@ -1,12 +1,8 @@
// Production Domain Components
export { default as ProductionSchedule } from './ProductionSchedule';
export { default as BatchTracker } from './BatchTracker';
export { default as QualityDashboard } from './QualityDashboard';
export { default as EquipmentManager } from './EquipmentManager';
export { CreateProductionBatchModal } from './CreateProductionBatchModal';
export { default as ProductionStatusCard } from './ProductionStatusCard';
export { default as QualityCheckModal } from './QualityCheckModal';
export { default as ProcessStageTracker } from './ProcessStageTracker';
export { default as CompactProcessStageTracker } from './CompactProcessStageTracker';
export { default as QualityTemplateManager } from './QualityTemplateManager';
export { CreateQualityTemplateModal } from './CreateQualityTemplateModal';
@@ -14,8 +10,5 @@ export { EditQualityTemplateModal } from './EditQualityTemplateModal';
// Export component props types
export type { ProductionScheduleProps } from './ProductionSchedule';
export type { BatchTrackerProps } from './BatchTracker';
export type { QualityDashboardProps, QualityMetrics } from './QualityDashboard';
export type { EquipmentManagerProps, Equipment } from './EquipmentManager';
export type { ProductionStatusCardProps } from './ProductionStatusCard';
export type { QualityCheckModalProps } from './QualityCheckModal';