Add improved production UI 3

This commit is contained in:
Urtzi Alfaro
2025-09-23 19:24:22 +02:00
parent 7f871fc933
commit 7892c5a739
47 changed files with 6211 additions and 267 deletions

View File

@@ -15,9 +15,43 @@ import {
ChevronRight,
Timer,
Package,
Flame
Flame,
ChefHat,
Eye,
Scale,
FlaskRound,
CircleDot,
ArrowRight,
CheckSquare,
XSquare,
Zap,
Snowflake,
Box
} from 'lucide-react';
export interface QualityCheckRequirement {
id: string;
name: string;
stage: ProcessStage;
isRequired: boolean;
isCritical: boolean;
status: 'pending' | 'completed' | 'failed' | 'skipped';
checkType: 'visual' | 'measurement' | 'temperature' | 'weight' | 'boolean';
}
export interface ProcessStageInfo {
current: ProcessStage;
history: Array<{
stage: ProcessStage;
timestamp: string;
duration?: number;
}>;
pendingQualityChecks: QualityCheckRequirement[];
completedQualityChecks: QualityCheckRequirement[];
}
export type ProcessStage = 'mixing' | 'proofing' | 'shaping' | 'baking' | 'cooling' | 'packaging' | 'finishing';
export interface ProductionOrder {
id: string;
product: string;
@@ -39,6 +73,7 @@ export interface ProductionOrder {
unit: string;
available: boolean;
}>;
processStage?: ProcessStageInfo;
}
export interface ProductionPlansProps {
@@ -50,6 +85,56 @@ export interface ProductionPlansProps {
onViewAllPlans?: () => void;
}
const getProcessStageIcon = (stage: ProcessStage) => {
switch (stage) {
case 'mixing': return ChefHat;
case 'proofing': return Timer;
case 'shaping': return Package;
case 'baking': return Flame;
case 'cooling': return Snowflake;
case 'packaging': return Box;
case 'finishing': return CheckCircle;
default: return CircleDot;
}
};
const getProcessStageColor = (stage: ProcessStage) => {
switch (stage) {
case 'mixing': return 'var(--color-info)';
case 'proofing': return 'var(--color-warning)';
case 'shaping': return 'var(--color-primary)';
case 'baking': return 'var(--color-error)';
case 'cooling': return 'var(--color-info)';
case 'packaging': return 'var(--color-success)';
case 'finishing': return 'var(--color-success)';
default: return 'var(--color-gray)';
}
};
const getProcessStageLabel = (stage: ProcessStage) => {
switch (stage) {
case 'mixing': return 'Mezclado';
case 'proofing': return 'Fermentado';
case 'shaping': return 'Formado';
case 'baking': return 'Horneado';
case 'cooling': return 'Enfriado';
case 'packaging': return 'Empaquetado';
case 'finishing': return 'Acabado';
default: return 'Sin etapa';
}
};
const getQualityCheckIcon = (checkType: string) => {
switch (checkType) {
case 'visual': return Eye;
case 'measurement': return Scale;
case 'temperature': return Thermometer;
case 'weight': return Scale;
case 'boolean': return CheckSquare;
default: return FlaskRound;
}
};
const ProductionPlansToday: React.FC<ProductionPlansProps> = ({
className,
orders = [],
@@ -78,7 +163,38 @@ const ProductionPlansToday: React.FC<ProductionPlansProps> = ({
{ name: 'Levadura', quantity: 0.5, unit: 'kg', available: true },
{ name: 'Sal', quantity: 0.2, unit: 'kg', available: true },
{ name: 'Agua', quantity: 3, unit: 'L', available: true }
]
],
processStage: {
current: 'baking',
history: [
{ stage: 'mixing', timestamp: '06:00', duration: 30 },
{ stage: 'proofing', timestamp: '06:30', duration: 90 },
{ stage: 'shaping', timestamp: '08:00', duration: 15 },
{ stage: 'baking', timestamp: '08:15' }
],
pendingQualityChecks: [
{
id: 'qc1',
name: 'Control de temperatura interna',
stage: 'baking',
isRequired: true,
isCritical: true,
status: 'pending',
checkType: 'temperature'
}
],
completedQualityChecks: [
{
id: 'qc2',
name: 'Inspección visual de masa',
stage: 'mixing',
isRequired: true,
isCritical: false,
status: 'completed',
checkType: 'visual'
}
]
}
},
{
id: '2',
@@ -99,7 +215,34 @@ const ProductionPlansToday: React.FC<ProductionPlansProps> = ({
{ name: 'Masa de croissant', quantity: 3, unit: 'kg', available: true },
{ name: 'Mantequilla', quantity: 1, unit: 'kg', available: false },
{ name: 'Huevo', quantity: 6, unit: 'unidades', available: true }
]
],
processStage: {
current: 'shaping',
history: [
{ stage: 'proofing', timestamp: '07:30', duration: 120 }
],
pendingQualityChecks: [
{
id: 'qc3',
name: 'Verificar formado de hojaldre',
stage: 'shaping',
isRequired: true,
isCritical: false,
status: 'pending',
checkType: 'visual'
},
{
id: 'qc4',
name: 'Control de peso individual',
stage: 'shaping',
isRequired: false,
isCritical: false,
status: 'pending',
checkType: 'weight'
}
],
completedQualityChecks: []
}
},
{
id: '3',
@@ -120,7 +263,39 @@ const ProductionPlansToday: React.FC<ProductionPlansProps> = ({
{ name: 'Levadura', quantity: 0.3, unit: 'kg', available: true },
{ name: 'Sal', quantity: 0.15, unit: 'kg', available: true },
{ name: 'Agua', quantity: 2.5, unit: 'L', available: true }
]
],
processStage: {
current: 'finishing',
history: [
{ stage: 'mixing', timestamp: '05:00', duration: 20 },
{ stage: 'proofing', timestamp: '05:20', duration: 120 },
{ stage: 'shaping', timestamp: '07:20', duration: 30 },
{ stage: 'baking', timestamp: '07:50', duration: 45 },
{ stage: 'cooling', timestamp: '08:35', duration: 30 },
{ stage: 'finishing', timestamp: '09:05' }
],
pendingQualityChecks: [],
completedQualityChecks: [
{
id: 'qc5',
name: 'Inspección visual final',
stage: 'finishing',
isRequired: true,
isCritical: false,
status: 'completed',
checkType: 'visual'
},
{
id: 'qc6',
name: 'Control de temperatura de cocción',
stage: 'baking',
isRequired: true,
isCritical: true,
status: 'completed',
checkType: 'temperature'
}
]
}
},
{
id: '4',
@@ -143,7 +318,23 @@ const ProductionPlansToday: React.FC<ProductionPlansProps> = ({
{ name: 'Huevos', quantity: 24, unit: 'unidades', available: true },
{ name: 'Mantequilla', quantity: 1, unit: 'kg', available: false },
{ name: 'Vainilla', quantity: 50, unit: 'ml', available: true }
]
],
processStage: {
current: 'mixing',
history: [],
pendingQualityChecks: [
{
id: 'qc7',
name: 'Verificar consistencia de masa',
stage: 'mixing',
isRequired: true,
isCritical: false,
status: 'pending',
checkType: 'visual'
}
],
completedQualityChecks: []
}
}
];
@@ -214,6 +405,63 @@ const ProductionPlansToday: React.FC<ProductionPlansProps> = ({
const completedOrders = displayOrders.filter(order => order.status === 'completed').length;
const delayedOrders = displayOrders.filter(order => order.status === 'delayed').length;
// Cross-batch quality overview calculations
const totalPendingQualityChecks = displayOrders.reduce((total, order) =>
total + (order.processStage?.pendingQualityChecks.length || 0), 0);
const criticalPendingQualityChecks = displayOrders.reduce((total, order) =>
total + (order.processStage?.pendingQualityChecks.filter(qc => qc.isCritical).length || 0), 0);
const ordersBlockedByQuality = displayOrders.filter(order =>
order.processStage?.pendingQualityChecks.some(qc => qc.isCritical && qc.isRequired) || false).length;
// Helper function to create enhanced metadata with process stage info
const createEnhancedMetadata = (order: ProductionOrder) => {
const baseMetadata = [
`⏰ Inicio: ${order.startTime}`,
`⏱️ Duración: ${formatDuration(order.estimatedDuration)}`,
...(order.ovenNumber ? [`🔥 Horno ${order.ovenNumber} - ${order.temperature}°C`] : [])
];
if (order.processStage) {
const { current, pendingQualityChecks, completedQualityChecks } = order.processStage;
const currentStageIcon = getProcessStageIcon(current);
const currentStageLabel = getProcessStageLabel(current);
// Add current stage info
baseMetadata.push(`🔄 Etapa: ${currentStageLabel}`);
// Add quality check info
if (pendingQualityChecks.length > 0) {
const criticalPending = pendingQualityChecks.filter(qc => qc.isCritical).length;
const requiredPending = pendingQualityChecks.filter(qc => qc.isRequired).length;
if (criticalPending > 0) {
baseMetadata.push(`🚨 ${criticalPending} controles críticos pendientes`);
} else if (requiredPending > 0) {
baseMetadata.push(`${requiredPending} controles requeridos pendientes`);
} else {
baseMetadata.push(`📋 ${pendingQualityChecks.length} controles opcionales pendientes`);
}
}
if (completedQualityChecks.length > 0) {
baseMetadata.push(`${completedQualityChecks.length} controles completados`);
}
}
// Add ingredients info
const availableIngredients = order.ingredients.filter(ing => ing.available).length;
const totalIngredients = order.ingredients.length;
const ingredientsReady = availableIngredients === totalIngredients;
baseMetadata.push(`📋 Ingredientes: ${availableIngredients}/${totalIngredients} ${ingredientsReady ? '✅' : '❌'}`);
// Add notes if any
if (order.notes) {
baseMetadata.push(`📝 ${order.notes}`);
}
return baseMetadata;
};
return (
<Card className={className} variant="elevated" padding="none">
<CardHeader padding="lg" divider>
@@ -236,6 +484,21 @@ const ProductionPlansToday: React.FC<ProductionPlansProps> = ({
</div>
<div className="flex items-center gap-2">
{ordersBlockedByQuality > 0 && (
<Badge variant="error" size="sm">
🚨 {ordersBlockedByQuality} bloqueadas por calidad
</Badge>
)}
{criticalPendingQualityChecks > 0 && ordersBlockedByQuality === 0 && (
<Badge variant="warning" size="sm">
🔍 {criticalPendingQualityChecks} controles críticos
</Badge>
)}
{totalPendingQualityChecks > 0 && criticalPendingQualityChecks === 0 && (
<Badge variant="info" size="sm">
📋 {totalPendingQualityChecks} controles pendientes
</Badge>
)}
{delayedOrders > 0 && (
<Badge variant="error" size="sm">
{delayedOrders} retrasadas
@@ -273,23 +536,44 @@ const ProductionPlansToday: React.FC<ProductionPlansProps> = ({
<div className="space-y-3 p-4">
{displayOrders.map((order) => {
const statusConfig = getOrderStatusConfig(order);
const availableIngredients = order.ingredients.filter(ing => ing.available).length;
const totalIngredients = order.ingredients.length;
const ingredientsReady = availableIngredients === totalIngredients;
const enhancedMetadata = createEnhancedMetadata(order);
// Enhanced secondary info that includes stage information
const getSecondaryInfo = () => {
if (order.processStage) {
const currentStageLabel = getProcessStageLabel(order.processStage.current);
return {
label: 'Etapa actual',
value: `${currentStageLabel}${order.assignedBaker}`
};
}
return {
label: 'Panadero asignado',
value: order.assignedBaker
};
};
// Enhanced status indicator with process stage color for active orders
const getEnhancedStatusConfig = () => {
if (order.processStage && order.status === 'in_progress') {
return {
...statusConfig,
color: getProcessStageColor(order.processStage.current)
};
}
return statusConfig;
};
return (
<StatusCard
key={order.id}
id={order.id}
statusIndicator={statusConfig}
statusIndicator={getEnhancedStatusConfig()}
title={order.product}
subtitle={`${order.recipe}${order.quantity} ${order.unit}`}
primaryValue={`${order.progress}%`}
primaryValueLabel="PROGRESO"
secondaryInfo={{
label: 'Panadero asignado',
value: order.assignedBaker
}}
secondaryInfo={getSecondaryInfo()}
progress={order.status !== 'pending' ? {
label: `Progreso de producción`,
percentage: order.progress,
@@ -297,13 +581,7 @@ const ProductionPlansToday: React.FC<ProductionPlansProps> = ({
order.progress > 70 ? 'var(--color-info)' :
order.progress > 30 ? 'var(--color-warning)' : 'var(--color-error)'
} : undefined}
metadata={[
`⏰ Inicio: ${order.startTime}`,
`⏱️ Duración: ${formatDuration(order.estimatedDuration)}`,
...(order.ovenNumber ? [`🔥 Horno ${order.ovenNumber} - ${order.temperature}°C`] : []),
`📋 Ingredientes: ${availableIngredients}/${totalIngredients} ${ingredientsReady ? '✅' : '❌'}`,
...(order.notes ? [`📝 ${order.notes}`] : [])
]}
metadata={enhancedMetadata}
actions={[
...(order.status === 'pending' ? [{
label: 'Iniciar',
@@ -320,6 +598,22 @@ const ProductionPlansToday: React.FC<ProductionPlansProps> = ({
priority: 'primary' as const,
destructive: true
}] : []),
// Add quality check action if there are pending quality checks
...(order.processStage?.pendingQualityChecks.length > 0 ? [{
label: `${order.processStage.pendingQualityChecks.filter(qc => qc.isCritical).length > 0 ? '🚨 ' : ''}Controles Calidad`,
icon: FlaskRound,
variant: order.processStage.pendingQualityChecks.filter(qc => qc.isCritical).length > 0 ? 'primary' as const : 'outline' as const,
onClick: () => onViewDetails?.(order.id), // This would open the quality check modal
priority: order.processStage.pendingQualityChecks.filter(qc => qc.isCritical).length > 0 ? 'primary' as const : 'secondary' as const
}] : []),
// Add next stage action for orders that can progress
...(order.status === 'in_progress' && order.processStage && order.processStage.pendingQualityChecks.length === 0 ? [{
label: 'Siguiente Etapa',
icon: ArrowRight,
variant: 'primary' as const,
onClick: () => console.log(`Advancing stage for order ${order.id}`),
priority: 'primary' as const
}] : []),
{
label: 'Ver Detalles',
icon: ChevronRight,

View File

@@ -0,0 +1,303 @@
import React from 'react';
import { Badge } from '../../ui/Badge';
import { Button } from '../../ui/Button';
import {
ChefHat,
Timer,
Package,
Flame,
Snowflake,
Box,
CheckCircle,
CircleDot,
Eye,
Scale,
Thermometer,
FlaskRound,
CheckSquare,
ArrowRight,
Clock,
AlertTriangle
} from 'lucide-react';
export type ProcessStage = 'mixing' | 'proofing' | 'shaping' | 'baking' | 'cooling' | 'packaging' | 'finishing';
export interface QualityCheckRequirement {
id: string;
name: string;
stage: ProcessStage;
isRequired: boolean;
isCritical: boolean;
status: 'pending' | 'completed' | 'failed' | 'skipped';
checkType: 'visual' | 'measurement' | 'temperature' | 'weight' | 'boolean';
}
export interface ProcessStageInfo {
current: ProcessStage;
history: Array<{
stage: ProcessStage;
timestamp: string;
duration?: number;
}>;
pendingQualityChecks: QualityCheckRequirement[];
completedQualityChecks: QualityCheckRequirement[];
}
export interface CompactProcessStageTrackerProps {
processStage: ProcessStageInfo;
onAdvanceStage?: (currentStage: ProcessStage) => void;
onQualityCheck?: (checkId: string) => void;
className?: string;
}
const getProcessStageIcon = (stage: ProcessStage) => {
switch (stage) {
case 'mixing': return ChefHat;
case 'proofing': return Timer;
case 'shaping': return Package;
case 'baking': return Flame;
case 'cooling': return Snowflake;
case 'packaging': return Box;
case 'finishing': return CheckCircle;
default: return CircleDot;
}
};
const getProcessStageColor = (stage: ProcessStage) => {
switch (stage) {
case 'mixing': return 'var(--color-info)';
case 'proofing': return 'var(--color-warning)';
case 'shaping': return 'var(--color-primary)';
case 'baking': return 'var(--color-error)';
case 'cooling': return 'var(--color-info)';
case 'packaging': return 'var(--color-success)';
case 'finishing': return 'var(--color-success)';
default: return 'var(--color-gray)';
}
};
const getProcessStageLabel = (stage: ProcessStage) => {
switch (stage) {
case 'mixing': return 'Mezclado';
case 'proofing': return 'Fermentado';
case 'shaping': return 'Formado';
case 'baking': return 'Horneado';
case 'cooling': return 'Enfriado';
case 'packaging': return 'Empaquetado';
case 'finishing': return 'Acabado';
default: return 'Sin etapa';
}
};
const getQualityCheckIcon = (checkType: string) => {
switch (checkType) {
case 'visual': return Eye;
case 'measurement': return Scale;
case 'temperature': return Thermometer;
case 'weight': return Scale;
case 'boolean': return CheckSquare;
default: return FlaskRound;
}
};
const formatTime = (timestamp: string) => {
return new Date(timestamp).toLocaleTimeString('es-ES', {
hour: '2-digit',
minute: '2-digit'
});
};
const formatDuration = (minutes?: number) => {
if (!minutes) return '';
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hours > 0) {
return `${hours}h ${mins}m`;
}
return `${mins}m`;
};
const CompactProcessStageTracker: React.FC<CompactProcessStageTrackerProps> = ({
processStage,
onAdvanceStage,
onQualityCheck,
className = ''
}) => {
const allStages: ProcessStage[] = ['mixing', 'proofing', 'shaping', 'baking', 'cooling', 'packaging', 'finishing'];
const currentStageIndex = allStages.indexOf(processStage.current);
const completedStages = processStage.history.map(h => h.stage);
const criticalPendingChecks = processStage.pendingQualityChecks.filter(qc => qc.isCritical);
const canAdvanceStage = processStage.pendingQualityChecks.length === 0 && currentStageIndex < allStages.length - 1;
return (
<div className={`space-y-4 ${className}`}>
{/* Current Stage Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div
className="p-2 rounded-lg"
style={{
backgroundColor: `${getProcessStageColor(processStage.current)}20`,
color: getProcessStageColor(processStage.current)
}}
>
{React.createElement(getProcessStageIcon(processStage.current), { className: 'w-4 h-4' })}
</div>
<div>
<h4 className="font-medium text-[var(--text-primary)]">
{getProcessStageLabel(processStage.current)}
</h4>
<p className="text-sm text-[var(--text-secondary)]">
Etapa actual
</p>
</div>
</div>
{canAdvanceStage && (
<Button
size="sm"
variant="primary"
onClick={() => onAdvanceStage?.(processStage.current)}
className="flex items-center gap-1"
>
<ArrowRight className="w-4 h-4" />
Siguiente
</Button>
)}
</div>
{/* Process Timeline */}
<div className="relative">
<div className="flex items-center justify-between">
{allStages.map((stage, index) => {
const StageIcon = getProcessStageIcon(stage);
const isCompleted = completedStages.includes(stage);
const isCurrent = stage === processStage.current;
const stageHistory = processStage.history.find(h => h.stage === stage);
return (
<div key={stage} className="flex flex-col items-center relative">
{/* Connection Line */}
{index < allStages.length - 1 && (
<div
className="absolute left-full top-4 w-full h-0.5 -translate-y-1/2 z-0"
style={{
backgroundColor: isCompleted || isCurrent ? getProcessStageColor(stage) : 'var(--border-primary)',
opacity: isCompleted || isCurrent ? 1 : 0.3
}}
/>
)}
{/* Stage Icon */}
<div
className="w-8 h-8 rounded-full flex items-center justify-center relative z-10 border-2"
style={{
backgroundColor: isCompleted || isCurrent ? getProcessStageColor(stage) : 'var(--bg-primary)',
borderColor: isCompleted || isCurrent ? getProcessStageColor(stage) : 'var(--border-primary)',
color: isCompleted || isCurrent ? 'white' : 'var(--text-tertiary)'
}}
>
<StageIcon className="w-4 h-4" />
</div>
{/* Stage Label */}
<div className="text-xs mt-1 text-center max-w-12">
<div className={`font-medium ${isCurrent ? 'text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'}`}>
{getProcessStageLabel(stage).split(' ')[0]}
</div>
{stageHistory && (
<div className="text-[var(--text-tertiary)]">
{formatTime(stageHistory.timestamp)}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
{/* Quality Checks Section */}
{processStage.pendingQualityChecks.length > 0 && (
<div className="bg-[var(--bg-secondary)] rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<FlaskRound className="w-4 h-4 text-[var(--text-secondary)]" />
<h5 className="font-medium text-[var(--text-primary)]">
Controles de Calidad Pendientes
</h5>
{criticalPendingChecks.length > 0 && (
<Badge variant="error" size="xs">
{criticalPendingChecks.length} críticos
</Badge>
)}
</div>
<div className="space-y-2">
{processStage.pendingQualityChecks.map((check) => {
const CheckIcon = getQualityCheckIcon(check.checkType);
return (
<div
key={check.id}
className="flex items-center justify-between bg-[var(--bg-primary)] rounded-md p-2"
>
<div className="flex items-center gap-2">
<CheckIcon className="w-4 h-4 text-[var(--text-secondary)]" />
<div>
<div className="text-sm font-medium text-[var(--text-primary)]">
{check.name}
</div>
<div className="text-xs text-[var(--text-secondary)] flex items-center gap-2">
{check.isCritical && <AlertTriangle className="w-3 h-3 text-[var(--color-error)]" />}
{check.isRequired ? 'Obligatorio' : 'Opcional'}
</div>
</div>
</div>
<Button
size="xs"
variant={check.isCritical ? 'primary' : 'outline'}
onClick={() => onQualityCheck?.(check.id)}
>
{check.isCritical ? 'Realizar' : 'Verificar'}
</Button>
</div>
);
})}
</div>
</div>
)}
{/* Completed Quality Checks Summary */}
{processStage.completedQualityChecks.length > 0 && (
<div className="text-sm text-[var(--text-secondary)]">
<div className="flex items-center gap-1">
<CheckCircle className="w-4 h-4 text-[var(--color-success)]" />
<span>{processStage.completedQualityChecks.length} controles de calidad completados</span>
</div>
</div>
)}
{/* Stage History Summary */}
{processStage.history.length > 0 && (
<div className="text-xs text-[var(--text-tertiary)] bg-[var(--bg-secondary)] rounded-md p-2">
<div className="font-medium mb-1">Historial de etapas:</div>
<div className="space-y-1">
{processStage.history.map((historyItem, index) => (
<div key={index} className="flex justify-between">
<span>{getProcessStageLabel(historyItem.stage)}</span>
<span>
{formatTime(historyItem.timestamp)}
{historyItem.duration && ` (${formatDuration(historyItem.duration)})`}
</span>
</div>
))}
</div>
</div>
)}
</div>
);
};
export default CompactProcessStageTracker;

View File

@@ -0,0 +1,406 @@
import React, { useState } from 'react';
import { useForm, Controller } from 'react-hook-form';
import {
Modal,
Button,
Input,
Textarea,
Select,
Badge,
Card
} from '../../ui';
import {
QualityCheckType,
ProcessStage,
type QualityCheckTemplateCreate
} from '../../../api/types/qualityTemplates';
import { useCurrentTenant } from '../../../stores/tenant.store';
interface CreateQualityTemplateModalProps {
isOpen: boolean;
onClose: () => void;
onCreateTemplate: (templateData: QualityCheckTemplateCreate) => Promise<void>;
isLoading?: boolean;
}
const QUALITY_CHECK_TYPE_OPTIONS = [
{ value: QualityCheckType.VISUAL, label: 'Visual - Inspección visual' },
{ value: QualityCheckType.MEASUREMENT, label: 'Medición - Medidas precisas' },
{ value: QualityCheckType.TEMPERATURE, label: 'Temperatura - Control térmico' },
{ value: QualityCheckType.WEIGHT, label: 'Peso - Control de peso' },
{ value: QualityCheckType.BOOLEAN, label: 'Sí/No - Verificación binaria' },
{ value: QualityCheckType.TIMING, label: 'Tiempo - Control temporal' }
];
const PROCESS_STAGE_OPTIONS = [
{ value: ProcessStage.MIXING, label: 'Mezclado' },
{ value: ProcessStage.PROOFING, label: 'Fermentación' },
{ value: ProcessStage.SHAPING, label: 'Formado' },
{ value: ProcessStage.BAKING, label: 'Horneado' },
{ value: ProcessStage.COOLING, label: 'Enfriado' },
{ value: ProcessStage.PACKAGING, label: 'Empaquetado' },
{ value: ProcessStage.FINISHING, label: 'Acabado' }
];
const CATEGORY_OPTIONS = [
{ value: 'appearance', label: 'Apariencia' },
{ value: 'structure', label: 'Estructura' },
{ value: 'texture', label: 'Textura' },
{ value: 'flavor', label: 'Sabor' },
{ value: 'safety', label: 'Seguridad' },
{ value: 'packaging', label: 'Empaque' },
{ value: 'temperature', label: 'Temperatura' },
{ value: 'weight', label: 'Peso' },
{ value: 'dimensions', label: 'Dimensiones' }
];
export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProps> = ({
isOpen,
onClose,
onCreateTemplate,
isLoading = false
}) => {
const currentTenant = useCurrentTenant();
const [selectedStages, setSelectedStages] = useState<ProcessStage[]>([]);
const {
register,
control,
handleSubmit,
watch,
reset,
formState: { errors, isValid }
} = useForm<QualityCheckTemplateCreate>({
defaultValues: {
name: '',
template_code: '',
check_type: QualityCheckType.VISUAL,
category: '',
description: '',
instructions: '',
is_active: true,
is_required: false,
is_critical: false,
weight: 1.0,
applicable_stages: [],
created_by: currentTenant?.id || ''
}
});
const checkType = watch('check_type');
const showMeasurementFields = [
QualityCheckType.MEASUREMENT,
QualityCheckType.TEMPERATURE,
QualityCheckType.WEIGHT
].includes(checkType);
const handleStageToggle = (stage: ProcessStage) => {
const newStages = selectedStages.includes(stage)
? selectedStages.filter(s => s !== stage)
: [...selectedStages, stage];
setSelectedStages(newStages);
};
const onSubmit = async (data: QualityCheckTemplateCreate) => {
try {
await onCreateTemplate({
...data,
applicable_stages: selectedStages.length > 0 ? selectedStages : undefined,
created_by: currentTenant?.id || ''
});
// Reset form
reset();
setSelectedStages([]);
} catch (error) {
console.error('Error creating template:', error);
}
};
const handleClose = () => {
reset();
setSelectedStages([]);
onClose();
};
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
title="Nueva Plantilla de Control de Calidad"
size="xl"
>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Basic Information */}
<Card className="p-4">
<h4 className="font-medium text-[var(--text-primary)] mb-4">
Información Básica
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Nombre *
</label>
<Input
{...register('name', { required: 'El nombre es requerido' })}
placeholder="Ej: Control Visual de Pan"
error={errors.name?.message}
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Código de Plantilla
</label>
<Input
{...register('template_code')}
placeholder="Ej: CV_PAN_01"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Tipo de Control *
</label>
<Controller
name="check_type"
control={control}
render={({ field }) => (
<Select {...field} options={QUALITY_CHECK_TYPE_OPTIONS.map(opt => ({ value: opt.value, label: opt.label }))}>
{QUALITY_CHECK_TYPE_OPTIONS.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
)}
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Categoría
</label>
<Controller
name="category"
control={control}
render={({ field }) => (
<Select {...field} options={[{ value: '', label: 'Seleccionar categoría' }, ...CATEGORY_OPTIONS]}>
<option value="">Seleccionar categoría</option>
{CATEGORY_OPTIONS.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
)}
/>
</div>
</div>
<div className="mt-4">
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Descripción
</label>
<Textarea
{...register('description')}
placeholder="Describe qué evalúa esta plantilla de calidad"
rows={2}
/>
</div>
<div className="mt-4">
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Instrucciones para el Personal
</label>
<Textarea
{...register('instructions')}
placeholder="Instrucciones detalladas para realizar este control de calidad"
rows={3}
/>
</div>
</Card>
{/* Measurement Configuration */}
{showMeasurementFields && (
<Card className="p-4">
<h4 className="font-medium text-[var(--text-primary)] mb-4">
Configuración de Medición
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Valor Mínimo
</label>
<Input
type="number"
step="0.01"
{...register('min_value', { valueAsNumber: true })}
placeholder="0"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Valor Máximo
</label>
<Input
type="number"
step="0.01"
{...register('max_value', { valueAsNumber: true })}
placeholder="100"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Valor Objetivo
</label>
<Input
type="number"
step="0.01"
{...register('target_value', { valueAsNumber: true })}
placeholder="50"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Unidad
</label>
<Input
{...register('unit')}
placeholder={
checkType === QualityCheckType.TEMPERATURE ? '°C' :
checkType === QualityCheckType.WEIGHT ? 'g' : 'unidad'
}
/>
</div>
</div>
<div className="mt-4">
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Tolerancia (%)
</label>
<Input
type="number"
step="0.1"
{...register('tolerance_percentage', { valueAsNumber: true })}
placeholder="5"
className="w-32"
/>
</div>
</Card>
)}
{/* Process Stages */}
<Card className="p-4">
<h4 className="font-medium text-[var(--text-primary)] mb-4">
Etapas del Proceso Aplicables
</h4>
<p className="text-sm text-[var(--text-secondary)] mb-4">
Selecciona las etapas donde se debe aplicar este control. Si no seleccionas ninguna, se aplicará a todas las etapas.
</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{PROCESS_STAGE_OPTIONS.map(stage => (
<label
key={stage.value}
className="flex items-center space-x-2 p-2 border rounded cursor-pointer hover:bg-[var(--bg-secondary)]"
>
<input
type="checkbox"
checked={selectedStages.includes(stage.value)}
onChange={() => handleStageToggle(stage.value)}
className="rounded border-[var(--border-primary)]"
/>
<span className="text-sm">{stage.label}</span>
</label>
))}
</div>
</Card>
{/* Settings */}
<Card className="p-4">
<h4 className="font-medium text-[var(--text-primary)] mb-4">
Configuración
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Peso en Puntuación General
</label>
<Input
type="number"
step="0.1"
min="0"
max="10"
{...register('weight', { valueAsNumber: true })}
placeholder="1.0"
/>
<p className="text-xs text-[var(--text-tertiary)] mt-1">
Mayor peso = mayor importancia en la puntuación final
</p>
</div>
<div className="space-y-4">
<label className="flex items-center space-x-2">
<input
type="checkbox"
{...register('is_active')}
className="rounded border-[var(--border-primary)]"
/>
<span className="text-sm">Plantilla activa</span>
</label>
<label className="flex items-center space-x-2">
<input
type="checkbox"
{...register('is_required')}
className="rounded border-[var(--border-primary)]"
/>
<span className="text-sm">Control requerido</span>
</label>
<label className="flex items-center space-x-2">
<input
type="checkbox"
{...register('is_critical')}
className="rounded border-[var(--border-primary)]"
/>
<span className="text-sm">Control crítico</span>
<Badge variant="error" size="sm">
Bloquea producción si falla
</Badge>
</label>
</div>
</div>
</Card>
{/* Actions */}
<div className="flex justify-end space-x-3 pt-4 border-t border-[var(--border-primary)]">
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={isLoading}
>
Cancelar
</Button>
<Button
type="submit"
variant="primary"
disabled={!isValid || isLoading}
isLoading={isLoading}
>
Crear Plantilla
</Button>
</div>
</form>
</Modal>
);
};

View File

@@ -0,0 +1,463 @@
import React, { useState, useEffect } from 'react';
import { useForm, Controller } from 'react-hook-form';
import {
Modal,
Button,
Input,
Textarea,
Select,
Badge,
Card
} from '../../ui';
import {
QualityCheckType,
ProcessStage,
type QualityCheckTemplate,
type QualityCheckTemplateUpdate
} from '../../../api/types/qualityTemplates';
interface EditQualityTemplateModalProps {
isOpen: boolean;
onClose: () => void;
template: QualityCheckTemplate;
onUpdateTemplate: (templateData: QualityCheckTemplateUpdate) => Promise<void>;
isLoading?: boolean;
}
const QUALITY_CHECK_TYPE_OPTIONS = [
{ value: QualityCheckType.VISUAL, label: 'Visual - Inspección visual' },
{ value: QualityCheckType.MEASUREMENT, label: 'Medición - Medidas precisas' },
{ value: QualityCheckType.TEMPERATURE, label: 'Temperatura - Control térmico' },
{ value: QualityCheckType.WEIGHT, label: 'Peso - Control de peso' },
{ value: QualityCheckType.BOOLEAN, label: 'Sí/No - Verificación binaria' },
{ value: QualityCheckType.TIMING, label: 'Tiempo - Control temporal' }
];
const PROCESS_STAGE_OPTIONS = [
{ value: ProcessStage.MIXING, label: 'Mezclado' },
{ value: ProcessStage.PROOFING, label: 'Fermentación' },
{ value: ProcessStage.SHAPING, label: 'Formado' },
{ value: ProcessStage.BAKING, label: 'Horneado' },
{ value: ProcessStage.COOLING, label: 'Enfriado' },
{ value: ProcessStage.PACKAGING, label: 'Empaquetado' },
{ value: ProcessStage.FINISHING, label: 'Acabado' }
];
const CATEGORY_OPTIONS = [
{ value: 'appearance', label: 'Apariencia' },
{ value: 'structure', label: 'Estructura' },
{ value: 'texture', label: 'Textura' },
{ value: 'flavor', label: 'Sabor' },
{ value: 'safety', label: 'Seguridad' },
{ value: 'packaging', label: 'Empaque' },
{ value: 'temperature', label: 'Temperatura' },
{ value: 'weight', label: 'Peso' },
{ value: 'dimensions', label: 'Dimensiones' }
];
export const EditQualityTemplateModal: React.FC<EditQualityTemplateModalProps> = ({
isOpen,
onClose,
template,
onUpdateTemplate,
isLoading = false
}) => {
const [selectedStages, setSelectedStages] = useState<ProcessStage[]>(
template.applicable_stages || []
);
const {
register,
control,
handleSubmit,
watch,
reset,
formState: { errors, isDirty }
} = useForm<QualityCheckTemplateUpdate>({
defaultValues: {
name: template.name,
template_code: template.template_code || '',
check_type: template.check_type,
category: template.category || '',
description: template.description || '',
instructions: template.instructions || '',
is_active: template.is_active,
is_required: template.is_required,
is_critical: template.is_critical,
weight: template.weight,
min_value: template.min_value,
max_value: template.max_value,
target_value: template.target_value,
unit: template.unit || '',
tolerance_percentage: template.tolerance_percentage
}
});
const checkType = watch('check_type');
const showMeasurementFields = [
QualityCheckType.MEASUREMENT,
QualityCheckType.TEMPERATURE,
QualityCheckType.WEIGHT
].includes(checkType || template.check_type);
// Update form when template changes
useEffect(() => {
if (template) {
reset({
name: template.name,
template_code: template.template_code || '',
check_type: template.check_type,
category: template.category || '',
description: template.description || '',
instructions: template.instructions || '',
is_active: template.is_active,
is_required: template.is_required,
is_critical: template.is_critical,
weight: template.weight,
min_value: template.min_value,
max_value: template.max_value,
target_value: template.target_value,
unit: template.unit || '',
tolerance_percentage: template.tolerance_percentage
});
setSelectedStages(template.applicable_stages || []);
}
}, [template, reset]);
const handleStageToggle = (stage: ProcessStage) => {
const newStages = selectedStages.includes(stage)
? selectedStages.filter(s => s !== stage)
: [...selectedStages, stage];
setSelectedStages(newStages);
};
const onSubmit = async (data: QualityCheckTemplateUpdate) => {
try {
// Only include changed fields
const updates: QualityCheckTemplateUpdate = {};
Object.entries(data).forEach(([key, value]) => {
const originalValue = (template as any)[key];
if (value !== originalValue) {
(updates as any)[key] = value;
}
});
// Handle applicable stages
const stagesChanged = JSON.stringify(selectedStages.sort()) !==
JSON.stringify((template.applicable_stages || []).sort());
if (stagesChanged) {
updates.applicable_stages = selectedStages.length > 0 ? selectedStages : undefined;
}
// Only submit if there are actual changes
if (Object.keys(updates).length > 0) {
await onUpdateTemplate(updates);
} else {
onClose();
}
} catch (error) {
console.error('Error updating template:', error);
}
};
const handleClose = () => {
reset();
setSelectedStages(template.applicable_stages || []);
onClose();
};
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
title={`Editar Plantilla: ${template.name}`}
size="xl"
>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Basic Information */}
<Card className="p-4">
<h4 className="font-medium text-[var(--text-primary)] mb-4">
Información Básica
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Nombre *
</label>
<Input
{...register('name', { required: 'El nombre es requerido' })}
placeholder="Ej: Control Visual de Pan"
error={errors.name?.message}
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Código de Plantilla
</label>
<Input
{...register('template_code')}
placeholder="Ej: CV_PAN_01"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Tipo de Control *
</label>
<Controller
name="check_type"
control={control}
render={({ field }) => (
<Select {...field} options={QUALITY_CHECK_TYPE_OPTIONS.map(opt => ({ value: opt.value, label: opt.label }))}>
{QUALITY_CHECK_TYPE_OPTIONS.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
)}
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Categoría
</label>
<Controller
name="category"
control={control}
render={({ field }) => (
<Select {...field} options={[{ value: '', label: 'Seleccionar categoría' }, ...CATEGORY_OPTIONS]}>
<option value="">Seleccionar categoría</option>
{CATEGORY_OPTIONS.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
)}
/>
</div>
</div>
<div className="mt-4">
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Descripción
</label>
<Textarea
{...register('description')}
placeholder="Describe qué evalúa esta plantilla de calidad"
rows={2}
/>
</div>
<div className="mt-4">
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Instrucciones para el Personal
</label>
<Textarea
{...register('instructions')}
placeholder="Instrucciones detalladas para realizar este control de calidad"
rows={3}
/>
</div>
</Card>
{/* Measurement Configuration */}
{showMeasurementFields && (
<Card className="p-4">
<h4 className="font-medium text-[var(--text-primary)] mb-4">
Configuración de Medición
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Valor Mínimo
</label>
<Input
type="number"
step="0.01"
{...register('min_value', { valueAsNumber: true })}
placeholder="0"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Valor Máximo
</label>
<Input
type="number"
step="0.01"
{...register('max_value', { valueAsNumber: true })}
placeholder="100"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Valor Objetivo
</label>
<Input
type="number"
step="0.01"
{...register('target_value', { valueAsNumber: true })}
placeholder="50"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Unidad
</label>
<Input
{...register('unit')}
placeholder={
checkType === QualityCheckType.TEMPERATURE ? '°C' :
checkType === QualityCheckType.WEIGHT ? 'g' : 'unidad'
}
/>
</div>
</div>
<div className="mt-4">
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Tolerancia (%)
</label>
<Input
type="number"
step="0.1"
{...register('tolerance_percentage', { valueAsNumber: true })}
placeholder="5"
className="w-32"
/>
</div>
</Card>
)}
{/* Process Stages */}
<Card className="p-4">
<h4 className="font-medium text-[var(--text-primary)] mb-4">
Etapas del Proceso Aplicables
</h4>
<p className="text-sm text-[var(--text-secondary)] mb-4">
Selecciona las etapas donde se debe aplicar este control. Si no seleccionas ninguna, se aplicará a todas las etapas.
</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{PROCESS_STAGE_OPTIONS.map(stage => (
<label
key={stage.value}
className="flex items-center space-x-2 p-2 border rounded cursor-pointer hover:bg-[var(--bg-secondary)]"
>
<input
type="checkbox"
checked={selectedStages.includes(stage.value)}
onChange={() => handleStageToggle(stage.value)}
className="rounded border-[var(--border-primary)]"
/>
<span className="text-sm">{stage.label}</span>
</label>
))}
</div>
</Card>
{/* Settings */}
<Card className="p-4">
<h4 className="font-medium text-[var(--text-primary)] mb-4">
Configuración
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Peso en Puntuación General
</label>
<Input
type="number"
step="0.1"
min="0"
max="10"
{...register('weight', { valueAsNumber: true })}
placeholder="1.0"
/>
<p className="text-xs text-[var(--text-tertiary)] mt-1">
Mayor peso = mayor importancia en la puntuación final
</p>
</div>
<div className="space-y-4">
<label className="flex items-center space-x-2">
<input
type="checkbox"
{...register('is_active')}
className="rounded border-[var(--border-primary)]"
/>
<span className="text-sm">Plantilla activa</span>
</label>
<label className="flex items-center space-x-2">
<input
type="checkbox"
{...register('is_required')}
className="rounded border-[var(--border-primary)]"
/>
<span className="text-sm">Control requerido</span>
</label>
<label className="flex items-center space-x-2">
<input
type="checkbox"
{...register('is_critical')}
className="rounded border-[var(--border-primary)]"
/>
<span className="text-sm">Control crítico</span>
<Badge variant="error" size="sm">
Bloquea producción si falla
</Badge>
</label>
</div>
</div>
</Card>
{/* Template Info */}
<Card className="p-4 bg-[var(--bg-secondary)]">
<h4 className="font-medium text-[var(--text-primary)] mb-2">
Información de la Plantilla
</h4>
<div className="text-sm text-[var(--text-secondary)] space-y-1">
<p>ID: {template.id}</p>
<p>Creado: {new Date(template.created_at).toLocaleDateString('es-ES')}</p>
<p>Última actualización: {new Date(template.updated_at).toLocaleDateString('es-ES')}</p>
</div>
</Card>
{/* Actions */}
<div className="flex justify-end space-x-3 pt-4 border-t border-[var(--border-primary)]">
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={isLoading}
>
Cancelar
</Button>
<Button
type="submit"
variant="primary"
disabled={!isDirty || isLoading}
isLoading={isLoading}
>
Guardar Cambios
</Button>
</div>
</form>
</Modal>
);
};

View File

@@ -0,0 +1,377 @@
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

@@ -19,11 +19,14 @@ import { statusColors } from '../../../styles/colors';
import { productionService, type QualityCheckResponse, QualityCheckStatus } from '../../../api';
import { ProductionBatchResponse } from '../../../api/types/production';
import { useCurrentTenant } from '../../../stores/tenant.store';
import { useQualityTemplatesForStage, useExecuteQualityCheck } from '../../../api/hooks/qualityTemplates';
import { ProcessStage, type QualityCheckTemplate, type QualityCheckExecutionRequest } from '../../../api/types/qualityTemplates';
export interface QualityCheckModalProps {
isOpen: boolean;
onClose: () => void;
batch: ProductionBatchResponse;
processStage?: ProcessStage;
onComplete?: (result: QualityCheckResult) => void;
}
@@ -220,54 +223,72 @@ export const QualityCheckModal: React.FC<QualityCheckModalProps> = ({
isOpen,
onClose,
batch,
processStage,
onComplete
}) => {
const currentTenant = useCurrentTenant();
const tenantId = currentTenant?.id || '';
const [activeTab, setActiveTab] = useState('inspection');
const [selectedTemplate, setSelectedTemplate] = useState<string>('visual_inspection');
const [selectedTemplate, setSelectedTemplate] = useState<QualityCheckTemplate | null>(null);
const [currentCriteriaIndex, setCurrentCriteriaIndex] = useState(0);
const [results, setResults] = useState<Record<string, QualityCheckResult>>({});
const [results, setResults] = useState<Record<string, any>>({});
const [finalNotes, setFinalNotes] = useState('');
const [photos, setPhotos] = useState<File[]>([]);
const [loading, setLoading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const template = QUALITY_CHECK_TEMPLATES[selectedTemplate as keyof typeof QUALITY_CHECK_TEMPLATES];
const currentCriteria = template?.criteria[currentCriteriaIndex];
const currentResult = currentCriteria ? results[currentCriteria.id] : undefined;
// Get available templates for the current stage
const currentStage = processStage || batch.current_process_stage;
const {
data: templatesData,
isLoading: templatesLoading,
error: templatesError
} = useQualityTemplatesForStage(tenantId, currentStage!, true, {
enabled: !!currentStage
});
const updateResult = useCallback((criterionId: string, updates: Partial<QualityCheckResult>) => {
// Execute quality check mutation
const executeQualityCheckMutation = useExecuteQualityCheck(tenantId);
// Initialize selected template
React.useEffect(() => {
if (templatesData?.templates && templatesData.templates.length > 0 && !selectedTemplate) {
setSelectedTemplate(templatesData.templates[0]);
}
}, [templatesData?.templates, selectedTemplate]);
const availableTemplates = templatesData?.templates || [];
const updateResult = useCallback((field: string, value: any, score?: number) => {
setResults(prev => ({
...prev,
[criterionId]: {
criterionId,
value: '',
score: 0,
pass: false,
timestamp: new Date().toISOString(),
...prev[criterionId],
...updates
[field]: {
value,
score: score !== undefined ? score : (typeof value === 'boolean' ? (value ? 10 : 0) : value),
pass_check: score !== undefined ? score >= 7 : (typeof value === 'boolean' ? value : value >= 7),
timestamp: new Date().toISOString()
}
}));
}, []);
const calculateOverallScore = useCallback((): number => {
if (!template || Object.keys(results).length === 0) return 0;
if (!selectedTemplate || Object.keys(results).length === 0) return 0;
const totalWeight = template.criteria.reduce((sum, c) => sum + c.weight, 0);
const weightedScore = Object.values(results).reduce((sum, result) => {
const criterion = template.criteria.find(c => c.id === result.criterionId);
return sum + (result.score * (criterion?.weight || 0));
}, 0);
const templateWeight = selectedTemplate.weight || 1;
const resultValues = Object.values(results);
return totalWeight > 0 ? weightedScore / totalWeight : 0;
}, [template, results]);
if (resultValues.length === 0) return 0;
const averageScore = resultValues.reduce((sum: number, result: any) => sum + (result.score || 0), 0) / resultValues.length;
return Math.min(10, averageScore * templateWeight);
}, [selectedTemplate, results]);
const getCriticalFailures = useCallback((): string[] => {
if (!template) return [];
if (!selectedTemplate) return [];
const failures: string[] = [];
template.criteria.forEach(criterion => {
selectedTemplate.criteria.forEach(criterion => {
if (criterion.isCritical && results[criterion.id]) {
const result = results[criterion.id];
if (criterion.type === 'boolean' && !result.value) {
@@ -279,7 +300,7 @@ export const QualityCheckModal: React.FC<QualityCheckModalProps> = ({
});
return failures;
}, [template, results]);
}, [selectedTemplate, results]);
const handlePhotoUpload = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
@@ -287,7 +308,7 @@ export const QualityCheckModal: React.FC<QualityCheckModalProps> = ({
}, []);
const handleComplete = async () => {
if (!template) return;
if (!selectedTemplate) return;
setLoading(true);
try {
@@ -297,7 +318,7 @@ export const QualityCheckModal: React.FC<QualityCheckModalProps> = ({
const qualityData: QualityCheckData = {
batchId: batch.id,
checkType: template.id,
checkType: selectedTemplate.id,
inspector: currentTenant?.name || 'Inspector',
startTime: new Date().toISOString(),
results: Object.values(results),
@@ -316,7 +337,7 @@ export const QualityCheckModal: React.FC<QualityCheckModalProps> = ({
// Create quality check via API
const checkData = {
batch_id: batch.id,
check_type: template.id,
check_type: selectedTemplate.id,
check_time: new Date().toISOString(),
quality_score: overallScore / 10, // Convert to 0-1 scale
pass_fail: passed,
@@ -434,7 +455,7 @@ export const QualityCheckModal: React.FC<QualityCheckModalProps> = ({
const overallScore = calculateOverallScore();
const criticalFailures = getCriticalFailures();
const isComplete = template?.criteria.filter(c => c.required).every(c => results[c.id]) || false;
const isComplete = selectedTemplate?.criteria.filter(c => c.required).every(c => results[c.id]) || false;
const statusIndicator: StatusIndicatorConfig = {
color: statusColors.inProgress.primary,
@@ -484,11 +505,11 @@ export const QualityCheckModal: React.FC<QualityCheckModalProps> = ({
</TabsList>
<TabsContent value="inspection" className="space-y-6">
{template && (
{selectedTemplate && (
<>
{/* Progress Indicator */}
<div className="grid grid-cols-6 gap-2">
{template.criteria.map((criterion, index) => {
{selectedTemplate.criteria.map((criterion, index) => {
const result = results[criterion.id];
const isCompleted = !!result;
const isCurrent = index === currentCriteriaIndex;
@@ -559,8 +580,8 @@ export const QualityCheckModal: React.FC<QualityCheckModalProps> = ({
</Button>
<Button
variant="primary"
onClick={() => setCurrentCriteriaIndex(Math.min(template.criteria.length - 1, currentCriteriaIndex + 1))}
disabled={currentCriteriaIndex === template.criteria.length - 1}
onClick={() => setCurrentCriteriaIndex(Math.min(selectedTemplate.criteria.length - 1, currentCriteriaIndex + 1))}
disabled={currentCriteriaIndex === selectedTemplate.criteria.length - 1}
>
Siguiente
</Button>

View File

@@ -0,0 +1,467 @@
import React, { useState, useMemo } from 'react';
import {
Plus,
Search,
Filter,
Edit,
Copy,
Trash2,
Eye,
CheckCircle,
AlertTriangle,
Settings,
Tag,
Thermometer,
Scale,
Timer,
FileCheck
} from 'lucide-react';
import {
Button,
Input,
Card,
Badge,
Select,
StatusCard,
Modal,
StatsGrid
} from '../../ui';
import { LoadingSpinner } from '../../shared';
import { PageHeader } from '../../layout';
import { useCurrentTenant } from '../../../stores/tenant.store';
import {
useQualityTemplates,
useCreateQualityTemplate,
useUpdateQualityTemplate,
useDeleteQualityTemplate,
useDuplicateQualityTemplate
} from '../../../api/hooks/qualityTemplates';
import {
QualityCheckType,
ProcessStage,
type QualityCheckTemplate,
type QualityCheckTemplateCreate,
type QualityCheckTemplateUpdate
} from '../../../api/types/qualityTemplates';
import { CreateQualityTemplateModal } from './CreateQualityTemplateModal';
import { EditQualityTemplateModal } from './EditQualityTemplateModal';
interface QualityTemplateManagerProps {
className?: string;
}
const QUALITY_CHECK_TYPE_CONFIG = {
[QualityCheckType.VISUAL]: {
icon: Eye,
label: 'Visual',
color: 'bg-blue-500',
description: 'Inspección visual'
},
[QualityCheckType.MEASUREMENT]: {
icon: Settings,
label: 'Medición',
color: 'bg-green-500',
description: 'Mediciones precisas'
},
[QualityCheckType.TEMPERATURE]: {
icon: Thermometer,
label: 'Temperatura',
color: 'bg-red-500',
description: 'Control de temperatura'
},
[QualityCheckType.WEIGHT]: {
icon: Scale,
label: 'Peso',
color: 'bg-purple-500',
description: 'Control de peso'
},
[QualityCheckType.BOOLEAN]: {
icon: CheckCircle,
label: 'Sí/No',
color: 'bg-gray-500',
description: 'Verificación binaria'
},
[QualityCheckType.TIMING]: {
icon: Timer,
label: 'Tiempo',
color: 'bg-orange-500',
description: 'Control de tiempo'
}
};
const PROCESS_STAGE_LABELS = {
[ProcessStage.MIXING]: 'Mezclado',
[ProcessStage.PROOFING]: 'Fermentación',
[ProcessStage.SHAPING]: 'Formado',
[ProcessStage.BAKING]: 'Horneado',
[ProcessStage.COOLING]: 'Enfriado',
[ProcessStage.PACKAGING]: 'Empaquetado',
[ProcessStage.FINISHING]: 'Acabado'
};
export const QualityTemplateManager: React.FC<QualityTemplateManagerProps> = ({
className = ''
}) => {
const [searchTerm, setSearchTerm] = useState('');
const [selectedCheckType, setSelectedCheckType] = useState<QualityCheckType | ''>('');
const [selectedStage, setSelectedStage] = useState<ProcessStage | ''>('');
const [showActiveOnly, setShowActiveOnly] = useState(true);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState<QualityCheckTemplate | null>(null);
const currentTenant = useCurrentTenant();
const tenantId = currentTenant?.id || '';
// API hooks
const {
data: templatesData,
isLoading,
error
} = useQualityTemplates(tenantId, {
check_type: selectedCheckType || undefined,
stage: selectedStage || undefined,
is_active: showActiveOnly,
search: searchTerm || undefined
});
const createTemplateMutation = useCreateQualityTemplate(tenantId);
const updateTemplateMutation = useUpdateQualityTemplate(tenantId);
const deleteTemplateMutation = useDeleteQualityTemplate(tenantId);
const duplicateTemplateMutation = useDuplicateQualityTemplate(tenantId);
// Filtered templates
const filteredTemplates = useMemo(() => {
if (!templatesData?.templates) return [];
return templatesData.templates.filter(template => {
const matchesSearch = !searchTerm ||
template.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
template.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
template.category?.toLowerCase().includes(searchTerm.toLowerCase());
return matchesSearch;
});
}, [templatesData?.templates, searchTerm]);
// Statistics
const templateStats = useMemo(() => {
const templates = templatesData?.templates || [];
return {
total: templates.length,
active: templates.filter(t => t.is_active).length,
critical: templates.filter(t => t.is_critical).length,
required: templates.filter(t => t.is_required).length,
byType: Object.values(QualityCheckType).map(type => ({
type,
count: templates.filter(t => t.check_type === type).length
}))
};
}, [templatesData?.templates]);
// Event handlers
const handleCreateTemplate = async (templateData: QualityCheckTemplateCreate) => {
try {
await createTemplateMutation.mutateAsync(templateData);
setShowCreateModal(false);
} catch (error) {
console.error('Error creating template:', error);
}
};
const handleUpdateTemplate = async (templateData: QualityCheckTemplateUpdate) => {
if (!selectedTemplate) return;
try {
await updateTemplateMutation.mutateAsync({
templateId: selectedTemplate.id,
templateData
});
setShowEditModal(false);
setSelectedTemplate(null);
} catch (error) {
console.error('Error updating template:', error);
}
};
const handleDeleteTemplate = async (templateId: string) => {
if (!confirm('¿Estás seguro de que quieres eliminar esta plantilla?')) return;
try {
await deleteTemplateMutation.mutateAsync(templateId);
} catch (error) {
console.error('Error deleting template:', error);
}
};
const handleDuplicateTemplate = async (templateId: string) => {
try {
await duplicateTemplateMutation.mutateAsync(templateId);
} catch (error) {
console.error('Error duplicating template:', error);
}
};
const getTemplateStatusConfig = (template: QualityCheckTemplate) => {
const typeConfig = QUALITY_CHECK_TYPE_CONFIG[template.check_type];
return {
color: template.is_active ? typeConfig.color : '#6b7280',
text: typeConfig.label,
icon: typeConfig.icon
};
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-64">
<LoadingSpinner text="Cargando plantillas de calidad..." />
</div>
);
}
if (error) {
return (
<div className="text-center py-12">
<AlertTriangle className="mx-auto h-12 w-12 text-red-500 mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
Error al cargar las plantillas
</h3>
<p className="text-[var(--text-secondary)] mb-4">
{error?.message || 'Ha ocurrido un error inesperado'}
</p>
<Button onClick={() => window.location.reload()}>
Reintentar
</Button>
</div>
);
}
return (
<div className={`space-y-6 ${className}`}>
<PageHeader
title="Plantillas de Control de Calidad"
description="Gestiona las plantillas de control de calidad para tus procesos de producción"
actions={[
{
id: "new",
label: "Nueva Plantilla",
variant: "primary" as const,
icon: Plus,
onClick: () => setShowCreateModal(true)
}
]}
/>
{/* Statistics */}
<StatsGrid
stats={[
{
title: 'Total Plantillas',
value: templateStats.total,
variant: 'default',
icon: FileCheck
},
{
title: 'Activas',
value: templateStats.active,
variant: 'success',
icon: CheckCircle
},
{
title: 'Críticas',
value: templateStats.critical,
variant: 'error',
icon: AlertTriangle
},
{
title: 'Requeridas',
value: templateStats.required,
variant: 'warning',
icon: Tag
}
]}
columns={4}
/>
{/* Filters */}
<Card className="p-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-[var(--text-tertiary)]" />
<Input
placeholder="Buscar plantillas..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
<Select
value={selectedCheckType}
onChange={(e) => setSelectedCheckType(e.target.value as QualityCheckType | '')}
placeholder="Tipo de control"
>
<option value="">Todos los tipos</option>
{Object.entries(QUALITY_CHECK_TYPE_CONFIG).map(([type, config]) => (
<option key={type} value={type}>
{config.label}
</option>
))}
</Select>
<Select
value={selectedStage}
onChange={(e) => setSelectedStage(e.target.value as ProcessStage | '')}
placeholder="Etapa del proceso"
>
<option value="">Todas las etapas</option>
{Object.entries(PROCESS_STAGE_LABELS).map(([stage, label]) => (
<option key={stage} value={stage}>
{label}
</option>
))}
</Select>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="activeOnly"
checked={showActiveOnly}
onChange={(e) => setShowActiveOnly(e.target.checked)}
className="rounded border-[var(--border-primary)]"
/>
<label htmlFor="activeOnly" className="text-sm text-[var(--text-secondary)]">
Solo activas
</label>
</div>
</div>
</Card>
{/* Templates Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredTemplates.map((template) => {
const statusConfig = getTemplateStatusConfig(template);
const stageLabels = template.applicable_stages?.map(stage =>
PROCESS_STAGE_LABELS[stage]
) || ['Todas las etapas'];
return (
<StatusCard
key={template.id}
id={template.id}
statusIndicator={statusConfig}
title={template.name}
subtitle={template.category || 'Sin categoría'}
primaryValue={template.weight}
primaryValueLabel="peso"
secondaryInfo={{
label: 'Etapas',
value: stageLabels.length > 2
? `${stageLabels.slice(0, 2).join(', ')}...`
: stageLabels.join(', ')
}}
metadata={[
template.description || 'Sin descripción',
`${template.is_required ? 'Requerido' : 'Opcional'}`,
`${template.is_critical ? 'Crítico' : 'Normal'}`
]}
actions={[
{
label: 'Ver',
icon: Eye,
variant: 'primary',
priority: 'primary',
onClick: () => {
setSelectedTemplate(template);
// Could open a view modal here
}
},
{
label: 'Editar',
icon: Edit,
priority: 'secondary',
onClick: () => {
setSelectedTemplate(template);
setShowEditModal(true);
}
},
{
label: 'Duplicar',
icon: Copy,
priority: 'secondary',
onClick: () => handleDuplicateTemplate(template.id)
},
{
label: 'Eliminar',
icon: Trash2,
variant: 'danger',
priority: 'secondary',
onClick: () => handleDeleteTemplate(template.id)
}
]}
>
{/* Additional badges */}
<div className="flex flex-wrap gap-1 mt-2">
{template.is_active && (
<Badge variant="success" size="sm">Activa</Badge>
)}
{template.is_required && (
<Badge variant="warning" size="sm">Requerida</Badge>
)}
{template.is_critical && (
<Badge variant="error" size="sm">Crítica</Badge>
)}
</div>
</StatusCard>
);
})}
</div>
{/* Empty State */}
{filteredTemplates.length === 0 && (
<div className="text-center py-12">
<FileCheck className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
No se encontraron plantillas
</h3>
<p className="text-[var(--text-secondary)] mb-4">
{templatesData?.templates?.length === 0
? 'No hay plantillas de calidad creadas. Crea la primera plantilla para comenzar.'
: 'Intenta ajustar los filtros de búsqueda o crear una nueva plantilla'
}
</p>
<Button onClick={() => setShowCreateModal(true)}>
<Plus className="w-4 h-4 mr-2" />
Nueva Plantilla
</Button>
</div>
)}
{/* Create Template Modal */}
<CreateQualityTemplateModal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
onCreateTemplate={handleCreateTemplate}
isLoading={createTemplateMutation.isPending}
/>
{/* Edit Template Modal */}
{selectedTemplate && (
<EditQualityTemplateModal
isOpen={showEditModal}
onClose={() => {
setShowEditModal(false);
setSelectedTemplate(null);
}}
template={selectedTemplate}
onUpdateTemplate={handleUpdateTemplate}
isLoading={updateTemplateMutation.isPending}
/>
)}
</div>
);
};
export default QualityTemplateManager;

View File

@@ -6,6 +6,11 @@ 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';
export { EditQualityTemplateModal } from './EditQualityTemplateModal';
// Export component props types
export type { ProductionScheduleProps } from './ProductionSchedule';

View File

@@ -0,0 +1,372 @@
import React, { useState, useEffect } from 'react';
import {
CheckCircle,
AlertTriangle,
Settings,
Plus,
Trash2,
Save
} from 'lucide-react';
import {
Modal,
Button,
Card,
Badge,
Select
} from '../../ui';
import { LoadingSpinner } from '../../shared';
import { useCurrentTenant } from '../../../stores/tenant.store';
import { useQualityTemplatesForRecipe } from '../../../api/hooks/qualityTemplates';
import {
ProcessStage,
type QualityCheckTemplate,
type RecipeQualityConfiguration,
type ProcessStageQualityConfig
} from '../../../api/types/qualityTemplates';
import type { RecipeResponse } from '../../../api/types/recipes';
interface QualityCheckConfigurationModalProps {
isOpen: boolean;
onClose: () => void;
recipe: RecipeResponse;
onSaveConfiguration: (config: RecipeQualityConfiguration) => Promise<void>;
isLoading?: boolean;
}
const PROCESS_STAGE_LABELS = {
[ProcessStage.MIXING]: 'Mezclado',
[ProcessStage.PROOFING]: 'Fermentación',
[ProcessStage.SHAPING]: 'Formado',
[ProcessStage.BAKING]: 'Horneado',
[ProcessStage.COOLING]: 'Enfriado',
[ProcessStage.PACKAGING]: 'Empaquetado',
[ProcessStage.FINISHING]: 'Acabado'
};
export const QualityCheckConfigurationModal: React.FC<QualityCheckConfigurationModalProps> = ({
isOpen,
onClose,
recipe,
onSaveConfiguration,
isLoading = false
}) => {
const currentTenant = useCurrentTenant();
const tenantId = currentTenant?.id || '';
// Initialize configuration from recipe or create default
const [configuration, setConfiguration] = useState<RecipeQualityConfiguration>(() => {
const existing = recipe.quality_check_configuration as RecipeQualityConfiguration;
return existing || {
stages: {},
global_parameters: {},
default_templates: []
};
});
// Fetch available templates
const {
data: templatesByStage,
isLoading: templatesLoading,
error: templatesError
} = useQualityTemplatesForRecipe(tenantId, recipe.id);
// Reset configuration when recipe changes
useEffect(() => {
const existing = recipe.quality_check_configuration as RecipeQualityConfiguration;
setConfiguration(existing || {
stages: {},
global_parameters: {},
default_templates: []
});
}, [recipe]);
const handleStageToggle = (stage: ProcessStage) => {
setConfiguration(prev => {
const newStages = { ...prev.stages };
if (newStages[stage]) {
// Remove stage configuration
delete newStages[stage];
} else {
// Add default stage configuration
newStages[stage] = {
stage,
template_ids: [],
custom_parameters: {},
is_required: true,
blocking: true
};
}
return {
...prev,
stages: newStages
};
});
};
const handleTemplateToggle = (stage: ProcessStage, templateId: string) => {
setConfiguration(prev => {
const stageConfig = prev.stages[stage];
if (!stageConfig) return prev;
const templateIds = stageConfig.template_ids.includes(templateId)
? stageConfig.template_ids.filter(id => id !== templateId)
: [...stageConfig.template_ids, templateId];
return {
...prev,
stages: {
...prev.stages,
[stage]: {
...stageConfig,
template_ids: templateIds
}
}
};
});
};
const handleStageSettingChange = (
stage: ProcessStage,
setting: keyof ProcessStageQualityConfig,
value: boolean
) => {
setConfiguration(prev => {
const stageConfig = prev.stages[stage];
if (!stageConfig) return prev;
return {
...prev,
stages: {
...prev.stages,
[stage]: {
...stageConfig,
[setting]: value
}
}
};
});
};
const handleSave = async () => {
try {
await onSaveConfiguration(configuration);
onClose();
} catch (error) {
console.error('Error saving quality configuration:', error);
}
};
const getConfiguredStagesCount = () => {
return Object.keys(configuration.stages).length;
};
const getTotalTemplatesCount = () => {
return Object.values(configuration.stages).reduce(
(total, stage) => total + stage.template_ids.length,
0
);
};
if (templatesLoading) {
return (
<Modal isOpen={isOpen} onClose={onClose} title="Configuración de Control de Calidad" size="xl">
<div className="flex items-center justify-center py-12">
<LoadingSpinner text="Cargando plantillas de calidad..." />
</div>
</Modal>
);
}
if (templatesError) {
return (
<Modal isOpen={isOpen} onClose={onClose} title="Configuración de Control de Calidad" size="xl">
<div className="text-center py-12">
<AlertTriangle className="mx-auto h-12 w-12 text-red-500 mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
Error al cargar plantillas
</h3>
<p className="text-[var(--text-secondary)]">
No se pudieron cargar las plantillas de control de calidad
</p>
</div>
</Modal>
);
}
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={`Control de Calidad - ${recipe.name}`}
size="xl"
>
<div className="space-y-6">
{/* Summary */}
<Card className="p-4 bg-[var(--bg-secondary)]">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium text-[var(--text-primary)]">
Resumen de Configuración
</h4>
<p className="text-sm text-[var(--text-secondary)] mt-1">
{getConfiguredStagesCount()} etapas configuradas {getTotalTemplatesCount()} controles asignados
</p>
</div>
<div className="flex gap-2">
<Badge variant="info">
{recipe.category || 'Sin categoría'}
</Badge>
<Badge variant={recipe.status === 'active' ? 'success' : 'warning'}>
{recipe.status}
</Badge>
</div>
</div>
</Card>
{/* Process Stages Configuration */}
<div className="space-y-4">
<h4 className="font-medium text-[var(--text-primary)] flex items-center gap-2">
<Settings className="w-5 h-5" />
Configuración por Etapas del Proceso
</h4>
{Object.values(ProcessStage).map(stage => {
const stageConfig = configuration.stages[stage];
const isConfigured = !!stageConfig;
const availableTemplates = templatesByStage?.[stage] || [];
return (
<Card key={stage} className="p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={isConfigured}
onChange={() => handleStageToggle(stage)}
className="rounded border-[var(--border-primary)]"
/>
<span className="font-medium">
{PROCESS_STAGE_LABELS[stage]}
</span>
</label>
{isConfigured && (
<Badge variant="success" size="sm">
{stageConfig.template_ids.length} controles
</Badge>
)}
</div>
{isConfigured && (
<div className="flex gap-4 text-sm">
<label className="flex items-center gap-1">
<input
type="checkbox"
checked={stageConfig.is_required}
onChange={(e) => handleStageSettingChange(
stage,
'is_required',
e.target.checked
)}
className="rounded border-[var(--border-primary)]"
/>
Requerido
</label>
<label className="flex items-center gap-1">
<input
type="checkbox"
checked={stageConfig.blocking}
onChange={(e) => handleStageSettingChange(
stage,
'blocking',
e.target.checked
)}
className="rounded border-[var(--border-primary)]"
/>
Bloqueante
</label>
</div>
)}
</div>
{isConfigured && (
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
Plantillas de Control de Calidad
</label>
{availableTemplates.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{availableTemplates.map(template => (
<label
key={template.id}
className="flex items-center gap-2 p-2 border rounded cursor-pointer hover:bg-[var(--bg-secondary)]"
>
<input
type="checkbox"
checked={stageConfig.template_ids.includes(template.id)}
onChange={() => handleTemplateToggle(stage, template.id)}
className="rounded border-[var(--border-primary)]"
/>
<div className="flex-1">
<div className="font-medium text-sm">
{template.name}
</div>
<div className="text-xs text-[var(--text-secondary)]">
{template.check_type} {template.category || 'Sin categoría'}
</div>
</div>
{template.is_critical && (
<Badge variant="error" size="sm">Crítico</Badge>
)}
{template.is_required && (
<Badge variant="warning" size="sm">Req.</Badge>
)}
</label>
))}
</div>
) : (
<p className="text-sm text-[var(--text-secondary)] py-4 text-center border border-dashed rounded">
No hay plantillas disponibles para esta etapa.
<br />
<a
href="/app/operations/production/quality-templates"
className="text-[var(--color-primary)] hover:underline"
>
Crear nueva plantilla
</a>
</p>
)}
</div>
</div>
)}
</Card>
);
})}
</div>
{/* Actions */}
<div className="flex justify-end gap-3 pt-4 border-t border-[var(--border-primary)]">
<Button
variant="outline"
onClick={onClose}
disabled={isLoading}
>
Cancelar
</Button>
<Button
variant="primary"
onClick={handleSave}
disabled={isLoading}
isLoading={isLoading}
>
<Save className="w-4 h-4 mr-2" />
Guardar Configuración
</Button>
</div>
</div>
</Modal>
);
};

View File

@@ -1 +1,2 @@
export { CreateRecipeModal } from './CreateRecipeModal';
export { CreateRecipeModal } from './CreateRecipeModal';
export { QualityCheckConfigurationModal } from './QualityCheckConfigurationModal';

View File

@@ -156,6 +156,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
'/app/operations': 'navigation.operations',
'/app/operations/procurement': 'navigation.procurement',
'/app/operations/production': 'navigation.production',
'/app/operations/maquinaria': 'navigation.equipment',
'/app/operations/pos': 'navigation.pos',
'/app/bakery': 'navigation.bakery',
'/app/bakery/recipes': 'navigation.recipes',

View File

@@ -25,11 +25,12 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
...props
}, ref) => {
const baseClasses = [
'inline-flex items-center justify-center font-medium',
'inline-flex items-center justify-center font-semibold tracking-wide',
'transition-all duration-200 ease-in-out',
'focus:outline-none focus:ring-2 focus:ring-offset-2',
'disabled:opacity-50 disabled:cursor-not-allowed',
'border rounded-lg'
'border rounded-xl shadow-sm',
'hover:shadow-md active:shadow-sm'
];
const variantClasses = {
@@ -78,11 +79,11 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
};
const sizeClasses = {
xs: 'px-2 py-1 text-xs gap-1 min-h-6',
sm: 'px-3 py-1.5 text-sm gap-1.5 min-h-8 sm:min-h-10', // Better touch target on mobile
md: 'px-4 py-2 text-sm gap-2 min-h-10 sm:min-h-12',
lg: 'px-6 py-2.5 text-base gap-2 min-h-12 sm:min-h-14',
xl: 'px-8 py-3 text-lg gap-3 min-h-14 sm:min-h-16'
xs: 'px-3 py-1.5 text-xs gap-1.5 min-h-7',
sm: 'px-4 py-2 text-sm gap-2 min-h-9 sm:min-h-10',
md: 'px-6 py-3 text-sm gap-2.5 min-h-11 sm:min-h-12',
lg: 'px-8 py-3.5 text-base gap-3 min-h-13 sm:min-h-14',
xl: 'px-10 py-4 text-lg gap-3.5 min-h-15 sm:min-h-16'
};
const loadingSpinner = (
@@ -128,13 +129,13 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
>
{isLoading && loadingSpinner}
{!isLoading && leftIcon && (
<span className="flex-shrink-0">{leftIcon}</span>
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center">{leftIcon}</span>
)}
<span>
<span className="relative">
{isLoading && loadingText ? loadingText : children}
</span>
{!isLoading && rightIcon && (
<span className="flex-shrink-0">{rightIcon}</span>
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center">{rightIcon}</span>
)}
</button>
);

View File

@@ -0,0 +1,121 @@
import React from 'react';
export interface ToggleProps {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
size?: 'sm' | 'md' | 'lg';
label?: string;
description?: string;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
className?: string;
}
const Toggle: React.FC<ToggleProps> = ({
checked,
onChange,
disabled = false,
size = 'md',
label,
description,
leftIcon,
rightIcon,
className = ''
}) => {
const sizeClasses = {
sm: 'w-8 h-4',
md: 'w-11 h-6',
lg: 'w-14 h-8'
};
const thumbSizeClasses = {
sm: 'h-3 w-3',
md: 'h-5 w-5',
lg: 'h-7 w-7'
};
const thumbPositionClasses = {
sm: 'after:top-[2px] after:left-[2px] peer-checked:after:translate-x-4',
md: 'after:top-[2px] after:left-[2px] peer-checked:after:translate-x-full',
lg: 'after:top-[2px] after:left-[2px] peer-checked:after:translate-x-6'
};
const handleToggle = () => {
if (!disabled) {
onChange(!checked);
}
};
return (
<div className={`flex items-center ${className}`}>
{leftIcon && (
<div className="mr-3 flex-shrink-0">
{leftIcon}
</div>
)}
<div className="flex items-center flex-1">
<div className="flex items-center">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={handleToggle}
disabled={disabled}
className="sr-only peer"
/>
<div
className={`
${sizeClasses[size]}
bg-[var(--bg-tertiary)]
peer-focus:outline-none
rounded-full
peer
${thumbPositionClasses[size]}
peer-checked:after:border-white
after:content-['']
after:absolute
${thumbSizeClasses[size]}
after:bg-white
after:rounded-full
after:transition-all
peer-checked:bg-[var(--color-primary)]
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
`}
/>
</label>
{(label || description) && (
<div className="ml-3">
{label && (
<span className={`
text-sm font-medium text-[var(--text-primary)]
${disabled ? 'opacity-50' : ''}
`}>
{label}
</span>
)}
{description && (
<p className={`
text-xs text-[var(--text-secondary)]
${disabled ? 'opacity-50' : ''}
`}>
{description}
</p>
)}
</div>
)}
</div>
</div>
{rightIcon && (
<div className="ml-3 flex-shrink-0">
{rightIcon}
</div>
)}
</div>
);
};
export default Toggle;

View File

@@ -0,0 +1,2 @@
export { default as Toggle } from './Toggle';
export type { ToggleProps } from './Toggle';

View File

@@ -12,6 +12,7 @@ export { default as Select } from './Select';
export { default as DatePicker } from './DatePicker';
export { Tabs } from './Tabs';
export { ThemeToggle } from './ThemeToggle';
export { Toggle } from './Toggle';
export { ProgressBar } from './ProgressBar';
export { StatusIndicator } from './StatusIndicator';
export { ListItem } from './ListItem';
@@ -34,6 +35,7 @@ export type { SelectProps, SelectOption } from './Select';
export type { DatePickerProps } from './DatePicker';
export type { TabsProps, TabItem } from './Tabs';
export type { ThemeToggleProps } from './ThemeToggle';
export type { ToggleProps } from './Toggle';
export type { ProgressBarProps } from './ProgressBar';
export type { StatusIndicatorProps } from './StatusIndicator';
export type { ListItemProps } from './ListItem';