Add improved production UI 3
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1 +1,2 @@
|
||||
export { CreateRecipeModal } from './CreateRecipeModal';
|
||||
export { CreateRecipeModal } from './CreateRecipeModal';
|
||||
export { QualityCheckConfigurationModal } from './QualityCheckConfigurationModal';
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
121
frontend/src/components/ui/Toggle/Toggle.tsx
Normal file
121
frontend/src/components/ui/Toggle/Toggle.tsx
Normal 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;
|
||||
2
frontend/src/components/ui/Toggle/index.ts
Normal file
2
frontend/src/components/ui/Toggle/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as Toggle } from './Toggle';
|
||||
export type { ToggleProps } from './Toggle';
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user