Improve frontend 3
This commit is contained in:
@@ -3,8 +3,9 @@ import { Plus, ShoppingCart, Euro, Calendar, CheckCircle, AlertCircle, Package,
|
||||
import { Button, Card, StatsGrid, StatusCard, getStatusColor, EditViewModal, SearchAndFilter, Input, type FilterConfig, EmptyState } from '../../../../components/ui';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { CreatePurchaseOrderModal } from '../../../../components/domain/procurement/CreatePurchaseOrderModal';
|
||||
import { UnifiedPurchaseOrderModal } from '../../../../components/domain/procurement/UnifiedPurchaseOrderModal';
|
||||
import { UnifiedAddWizard } from '../../../../components/domain/unified-wizard';
|
||||
import type { ItemType } from '../../../../components/domain/unified-wizard';
|
||||
import {
|
||||
usePurchaseOrders,
|
||||
usePurchaseOrder,
|
||||
@@ -24,7 +25,7 @@ const ProcurementPage: React.FC = () => {
|
||||
const [statusFilter, setStatusFilter] = useState<PurchaseOrderStatus | ''>('');
|
||||
const [priorityFilter, setPriorityFilter] = useState<PurchaseOrderPriority | ''>('');
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [showCreatePOModal, setShowCreatePOModal] = useState(false);
|
||||
const [isWizardOpen, setIsWizardOpen] = useState(false);
|
||||
const [showDetailsModal, setShowDetailsModal] = useState(false);
|
||||
const [selectedPOId, setSelectedPOId] = useState<string | null>(null);
|
||||
const [showApprovalModal, setShowApprovalModal] = useState(false);
|
||||
@@ -395,7 +396,7 @@ const ProcurementPage: React.FC = () => {
|
||||
id: 'create-po',
|
||||
label: 'Nueva Orden',
|
||||
icon: Plus,
|
||||
onClick: () => setShowCreatePOModal(true),
|
||||
onClick: () => setIsWizardOpen(true),
|
||||
variant: 'primary',
|
||||
size: 'md'
|
||||
}
|
||||
@@ -471,7 +472,7 @@ const ProcurementPage: React.FC = () => {
|
||||
description="Comienza creando una nueva orden de compra"
|
||||
actionLabel="Nueva Orden"
|
||||
actionIcon={Plus}
|
||||
onAction={() => setShowCreatePOModal(true)}
|
||||
onAction={() => setIsWizardOpen(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
@@ -514,19 +515,16 @@ const ProcurementPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create PO Modal */}
|
||||
{showCreatePOModal && (
|
||||
<CreatePurchaseOrderModal
|
||||
isOpen={showCreatePOModal}
|
||||
onClose={() => setShowCreatePOModal(false)}
|
||||
requirements={[]}
|
||||
onSuccess={() => {
|
||||
setShowCreatePOModal(false);
|
||||
refetchPOs();
|
||||
showToast.success('Orden de compra creada exitosamente');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Unified Add Wizard for Purchase Orders */}
|
||||
<UnifiedAddWizard
|
||||
isOpen={isWizardOpen}
|
||||
onClose={() => setIsWizardOpen(false)}
|
||||
onComplete={(itemType: ItemType, data?: any) => {
|
||||
console.log('Purchase order created:', data);
|
||||
refetchPOs();
|
||||
}}
|
||||
initialItemType="purchase-order"
|
||||
/>
|
||||
|
||||
{/* PO Details Modal - Using Unified Component */}
|
||||
{showDetailsModal && selectedPOId && (
|
||||
|
||||
@@ -5,7 +5,9 @@ import { statusColors } from '../../../../styles/colors';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
import { LoadingSpinner } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { ProductionSchedule, CreateProductionBatchModal, ProductionStatusCard, QualityCheckModal, ProcessStageTracker } from '../../../../components/domain/production';
|
||||
import { ProductionSchedule, ProductionStatusCard, QualityCheckModal, ProcessStageTracker } from '../../../../components/domain/production';
|
||||
import { UnifiedAddWizard } from '../../../../components/domain/unified-wizard';
|
||||
import type { ItemType } from '../../../../components/domain/unified-wizard';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import {
|
||||
useProductionDashboard,
|
||||
@@ -34,7 +36,7 @@ const ProductionPage: React.FC = () => {
|
||||
const [priorityFilter, setPriorityFilter] = useState('');
|
||||
const [selectedBatch, setSelectedBatch] = useState<ProductionBatchResponse | null>(null);
|
||||
const [showBatchModal, setShowBatchModal] = useState(false);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [isWizardOpen, setIsWizardOpen] = useState(false);
|
||||
const [showQualityModal, setShowQualityModal] = useState(false);
|
||||
const [modalMode, setModalMode] = useState<'view' | 'edit'>('view');
|
||||
|
||||
@@ -324,7 +326,7 @@ const ProductionPage: React.FC = () => {
|
||||
id: 'create-batch',
|
||||
label: 'Nueva Orden de Producción',
|
||||
icon: PlusCircle,
|
||||
onClick: () => setShowCreateModal(true),
|
||||
onClick: () => setIsWizardOpen(true),
|
||||
variant: 'primary',
|
||||
size: 'md'
|
||||
}
|
||||
@@ -420,11 +422,6 @@ const ProductionPage: React.FC = () => {
|
||||
setModalMode('view');
|
||||
setShowBatchModal(true);
|
||||
}}
|
||||
onEdit={(batch) => {
|
||||
setSelectedBatch(batch);
|
||||
setModalMode('edit');
|
||||
setShowBatchModal(true);
|
||||
}}
|
||||
onStart={async (batch) => {
|
||||
try {
|
||||
await updateBatchStatusMutation.mutateAsync({
|
||||
@@ -469,11 +466,6 @@ const ProductionPage: React.FC = () => {
|
||||
setSelectedBatch(batch);
|
||||
setShowQualityModal(true);
|
||||
}}
|
||||
onViewHistory={(batch) => {
|
||||
setSelectedBatch(batch);
|
||||
setModalMode('view');
|
||||
setShowBatchModal(true);
|
||||
}}
|
||||
showDetailedProgress={true}
|
||||
/>
|
||||
))}
|
||||
@@ -491,14 +483,14 @@ const ProductionPage: React.FC = () => {
|
||||
}
|
||||
actionLabel="Nueva Orden de Producción"
|
||||
actionIcon={Plus}
|
||||
onAction={() => setShowCreateModal(true)}
|
||||
onAction={() => setIsWizardOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
|
||||
|
||||
{/* Production Batch Modal */}
|
||||
{/* Production Batch Detail Modal */}
|
||||
{showBatchModal && selectedBatch && (
|
||||
<EditViewModal
|
||||
isOpen={showBatchModal}
|
||||
@@ -516,35 +508,34 @@ const ProductionPage: React.FC = () => {
|
||||
text: t(`production:status.${selectedBatch.status.toLowerCase()}`),
|
||||
icon: Package
|
||||
}}
|
||||
size="lg"
|
||||
size="xl"
|
||||
sections={[
|
||||
{
|
||||
title: 'Información General',
|
||||
icon: Package,
|
||||
fields: [
|
||||
{
|
||||
label: 'Producto',
|
||||
value: selectedBatch.product_name,
|
||||
highlight: true
|
||||
},
|
||||
{
|
||||
label: 'Número de Lote',
|
||||
value: selectedBatch.batch_number
|
||||
},
|
||||
{
|
||||
label: 'Cantidad Planificada',
|
||||
value: `${selectedBatch.planned_quantity} unidades`,
|
||||
highlight: true
|
||||
},
|
||||
{
|
||||
label: 'Cantidad Real',
|
||||
label: 'Cantidad Producida',
|
||||
value: selectedBatch.actual_quantity
|
||||
? `${selectedBatch.actual_quantity} unidades`
|
||||
: 'Pendiente',
|
||||
editable: modalMode === 'edit',
|
||||
type: 'number'
|
||||
},
|
||||
{
|
||||
label: 'Prioridad',
|
||||
value: selectedBatch.priority,
|
||||
type: 'select',
|
||||
editable: modalMode === 'edit',
|
||||
options: Object.values(ProductionPriorityEnum).map(value => ({
|
||||
value,
|
||||
label: t(`production:priority.${value.toLowerCase()}`)
|
||||
}))
|
||||
},
|
||||
{
|
||||
label: 'Estado',
|
||||
value: selectedBatch.status,
|
||||
@@ -555,11 +546,25 @@ const ProductionPage: React.FC = () => {
|
||||
label: t(`production:status.${value.toLowerCase()}`)
|
||||
}))
|
||||
},
|
||||
{
|
||||
label: 'Prioridad',
|
||||
value: selectedBatch.priority,
|
||||
type: 'select',
|
||||
editable: modalMode === 'edit',
|
||||
options: Object.values(ProductionPriorityEnum).map(value => ({
|
||||
value,
|
||||
label: t(`production:priority.${value.toLowerCase()}`)
|
||||
}))
|
||||
},
|
||||
{
|
||||
label: 'Personal Asignado',
|
||||
value: selectedBatch.staff_assigned?.join(', ') || 'No asignado',
|
||||
editable: modalMode === 'edit',
|
||||
type: 'text'
|
||||
},
|
||||
{
|
||||
label: 'Equipos Utilizados',
|
||||
value: selectedBatch.equipment_used?.join(', ') || 'No especificado'
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -577,6 +582,12 @@ const ProductionPage: React.FC = () => {
|
||||
value: selectedBatch.planned_end_time,
|
||||
type: 'datetime'
|
||||
},
|
||||
{
|
||||
label: 'Duración Planificada',
|
||||
value: selectedBatch.planned_duration_minutes
|
||||
? `${selectedBatch.planned_duration_minutes} minutos`
|
||||
: 'No especificada'
|
||||
},
|
||||
{
|
||||
label: 'Inicio Real',
|
||||
value: selectedBatch.actual_start_time || 'Pendiente',
|
||||
@@ -586,11 +597,17 @@ const ProductionPage: React.FC = () => {
|
||||
label: 'Fin Real',
|
||||
value: selectedBatch.actual_end_time || 'Pendiente',
|
||||
type: 'datetime'
|
||||
},
|
||||
{
|
||||
label: 'Duración Real',
|
||||
value: selectedBatch.actual_duration_minutes
|
||||
? `${selectedBatch.actual_duration_minutes} minutos`
|
||||
: 'Pendiente'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Seguimiento de Proceso',
|
||||
title: 'Fases del Proceso de Producción',
|
||||
icon: Timer,
|
||||
fields: [
|
||||
{
|
||||
@@ -657,7 +674,8 @@ const ProductionPage: React.FC = () => {
|
||||
label: 'Puntuación de Calidad',
|
||||
value: selectedBatch.quality_score
|
||||
? `${selectedBatch.quality_score}/10`
|
||||
: 'Pendiente'
|
||||
: 'Pendiente',
|
||||
highlight: selectedBatch.quality_score ? selectedBatch.quality_score >= 8 : false
|
||||
},
|
||||
{
|
||||
label: 'Rendimiento',
|
||||
@@ -674,6 +692,19 @@ const ProductionPage: React.FC = () => {
|
||||
label: 'Costo Real',
|
||||
value: selectedBatch.actual_cost || 0,
|
||||
type: 'currency'
|
||||
},
|
||||
{
|
||||
label: 'Notas de Producción',
|
||||
value: selectedBatch.production_notes || 'Sin notas',
|
||||
type: 'textarea',
|
||||
editable: modalMode === 'edit',
|
||||
span: 2
|
||||
},
|
||||
{
|
||||
label: 'Notas de Calidad',
|
||||
value: selectedBatch.quality_notes || 'Sin notas de calidad',
|
||||
type: 'textarea',
|
||||
span: 2
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -696,39 +727,33 @@ const ProductionPage: React.FC = () => {
|
||||
onFieldChange={(sectionIndex, fieldIndex, value) => {
|
||||
if (!selectedBatch) return;
|
||||
|
||||
const sections = [
|
||||
['planned_quantity', 'actual_quantity', 'priority', 'status', 'staff_assigned'],
|
||||
['planned_start_time', 'planned_end_time', 'actual_start_time', 'actual_end_time'],
|
||||
['quality_score', 'yield_percentage', 'estimated_cost', 'actual_cost']
|
||||
];
|
||||
|
||||
// Get the field names from modal sections
|
||||
const sectionFields = [
|
||||
{ fields: ['planned_quantity', 'actual_quantity', 'priority', 'status', 'staff_assigned'] },
|
||||
{ fields: ['planned_start_time', 'planned_end_time', 'actual_start_time', 'actual_end_time'] },
|
||||
{ fields: ['quality_score', 'yield_percentage', 'estimated_cost', 'actual_cost'] }
|
||||
];
|
||||
|
||||
const fieldMapping: Record<string, string> = {
|
||||
'Cantidad Real': 'actual_quantity',
|
||||
'Prioridad': 'priority',
|
||||
// General Information
|
||||
'Cantidad Producida': 'actual_quantity',
|
||||
'Estado': 'status',
|
||||
'Personal Asignado': 'staff_assigned'
|
||||
'Prioridad': 'priority',
|
||||
'Personal Asignado': 'staff_assigned',
|
||||
// Schedule - most fields are read-only datetime
|
||||
// Quality and Costs
|
||||
'Notas de Producción': 'production_notes',
|
||||
'Notas de Calidad': 'quality_notes'
|
||||
};
|
||||
|
||||
// Get section labels to map back to field names
|
||||
const sectionLabels = [
|
||||
['Cantidad Planificada', 'Cantidad Real', 'Prioridad', 'Estado', 'Personal Asignado'],
|
||||
['Inicio Planificado', 'Fin Planificado', 'Inicio Real', 'Fin Real'],
|
||||
['Puntuación de Calidad', 'Rendimiento', 'Costo Estimado', 'Costo Real']
|
||||
['Producto', 'Número de Lote', 'Cantidad Planificada', 'Cantidad Producida', 'Estado', 'Prioridad', 'Personal Asignado', 'Equipos Utilizados'],
|
||||
['Inicio Planificado', 'Fin Planificado', 'Duración Planificada', 'Inicio Real', 'Fin Real', 'Duración Real'],
|
||||
[], // Process Stage Tracker section - no editable fields
|
||||
['Puntuación de Calidad', 'Rendimiento', 'Costo Estimado', 'Costo Real', 'Notas de Producción', 'Notas de Calidad']
|
||||
];
|
||||
|
||||
const fieldLabel = sectionLabels[sectionIndex]?.[fieldIndex];
|
||||
const propertyName = fieldMapping[fieldLabel] || sectionFields[sectionIndex]?.fields[fieldIndex];
|
||||
const propertyName = fieldMapping[fieldLabel];
|
||||
|
||||
if (propertyName) {
|
||||
let processedValue: any = value;
|
||||
|
||||
// Process specific field types
|
||||
if (propertyName === 'staff_assigned' && typeof value === 'string') {
|
||||
processedValue = value.split(',').map(s => s.trim()).filter(s => s.length > 0);
|
||||
} else if (propertyName === 'actual_quantity') {
|
||||
@@ -744,11 +769,15 @@ const ProductionPage: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Create Production Batch Modal */}
|
||||
<CreateProductionBatchModal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onCreateBatch={handleCreateBatch}
|
||||
{/* Unified Add Wizard for Production Batches */}
|
||||
<UnifiedAddWizard
|
||||
isOpen={isWizardOpen}
|
||||
onClose={() => setIsWizardOpen(false)}
|
||||
onComplete={(itemType: ItemType, data?: any) => {
|
||||
console.log('Production batch created:', data);
|
||||
refetchBatches();
|
||||
}}
|
||||
initialItemType="production-batch"
|
||||
/>
|
||||
|
||||
{/* Quality Check Modal */}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Plus, Star, Clock, Euro, Package, Eye, Edit, ChefHat, Timer, CheckCircle, Trash2, Settings, FileText } from 'lucide-react';
|
||||
import { Button, StatsGrid, StatusCard, getStatusColor, EditViewModal, SearchAndFilter, type FilterConfig, EmptyState } from '../../../../components/ui';
|
||||
import { LoadingSpinner } from '../../../../components/ui';
|
||||
import { Plus, Star, Clock, Euro, Package, Eye, ChefHat, Timer, CheckCircle, Trash2, Settings, FileText } from 'lucide-react';
|
||||
import { StatsGrid, StatusCard, getStatusColor, EditViewModal, SearchAndFilter, type FilterConfig, EmptyState } from '../../../../components/ui';
|
||||
import { QualityPromptDialog } from '../../../../components/ui/QualityPromptDialog';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { useRecipes, useRecipeStatistics, useCreateRecipe, useUpdateRecipe, useDeleteRecipe, useArchiveRecipe } from '../../../../api/hooks/recipes';
|
||||
import { useRecipes, useCreateRecipe, useUpdateRecipe, useDeleteRecipe, useArchiveRecipe } from '../../../../api/hooks/recipes';
|
||||
import { recipesService } from '../../../../api/services/recipes';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import type { RecipeResponse, RecipeCreate } from '../../../../api/types/recipes';
|
||||
import { MeasurementUnit } from '../../../../api/types/recipes';
|
||||
import { useQualityTemplatesForRecipe } from '../../../../api/hooks/qualityTemplates';
|
||||
import { useIngredients } from '../../../../api/hooks/inventory';
|
||||
import { ProcessStage, type RecipeQualityConfiguration } from '../../../../api/types/qualityTemplates';
|
||||
import { CreateRecipeModal, DeleteRecipeModal } from '../../../../components/domain/recipes';
|
||||
@@ -50,6 +48,15 @@ const IngredientsEditComponent: React.FC<{
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
const [expandedIngredients, setExpandedIngredients] = React.useState<Record<number, boolean>>({});
|
||||
|
||||
const toggleExpanded = (index: number) => {
|
||||
setExpandedIngredients(prev => ({
|
||||
...prev,
|
||||
[index]: !prev[index]
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -60,81 +67,189 @@ const IngredientsEditComponent: React.FC<{
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-[var(--color-primary)] text-white rounded-md hover:bg-[var(--color-primary)]/90 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Agregar
|
||||
Agregar Ingrediente
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{ingredientsArray.map((ingredient, index) => (
|
||||
<div key={ingredient.id || index} className="p-3 border border-[var(--border-secondary)] rounded-lg bg-[var(--bg-secondary)]/50 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">Ingrediente #{index + 1}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeIngredient(index)}
|
||||
className="p-1 text-red-500 hover:text-red-700 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Ingrediente</label>
|
||||
<select
|
||||
value={ingredient.ingredient_id}
|
||||
onChange={(e) => updateIngredient(index, 'ingredient_id', e.target.value)}
|
||||
className="w-full px-2 py-1.5 border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
|
||||
>
|
||||
<option value="">Seleccionar...</option>
|
||||
{availableIngredients.map(ing => (
|
||||
<option key={ing.value} value={ing.value}>{ing.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Cantidad</label>
|
||||
<input
|
||||
type="number"
|
||||
value={ingredient.quantity}
|
||||
onChange={(e) => updateIngredient(index, 'quantity', parseFloat(e.target.value) || 0)}
|
||||
className="w-full px-2 py-1.5 border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
|
||||
min="0"
|
||||
step="0.1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Unidad</label>
|
||||
<select
|
||||
value={ingredient.unit}
|
||||
onChange={(e) => updateIngredient(index, 'unit', e.target.value as MeasurementUnit)}
|
||||
className="w-full px-2 py-1.5 border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
|
||||
>
|
||||
{unitOptions.map(unit => (
|
||||
<option key={unit.value} value={unit.value}>{unit.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1 flex items-center gap-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={ingredient.is_optional}
|
||||
onChange={(e) => updateIngredient(index, 'is_optional', e.target.checked)}
|
||||
className="rounded border-[var(--border-secondary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
|
||||
/>
|
||||
Opcional
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 max-h-[600px] overflow-y-auto pr-2">
|
||||
{ingredientsArray.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--text-secondary)] bg-[var(--bg-secondary)]/30 rounded-lg border-2 border-dashed border-[var(--border-secondary)]">
|
||||
<Package className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No hay ingredientes. Haz clic en "Agregar Ingrediente" para comenzar.</p>
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
ingredientsArray.map((ingredient, index) => {
|
||||
const isExpanded = expandedIngredients[index];
|
||||
|
||||
return (
|
||||
<div key={ingredient.id || index} className="p-4 border-2 border-[var(--border-secondary)] rounded-lg bg-[var(--bg-secondary)]/50 hover:border-[var(--color-primary)]/30 transition-all space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-full bg-[var(--color-primary)]/10 flex items-center justify-center text-sm font-semibold text-[var(--color-primary)]">
|
||||
{index + 1}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{availableIngredients.find(i => i.value === ingredient.ingredient_id)?.label || 'Ingrediente sin seleccionar'}
|
||||
</span>
|
||||
{ingredient.is_optional && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-500/10 text-amber-700 dark:text-amber-400 border border-amber-500/20">
|
||||
opcional
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeIngredient(index)}
|
||||
className="p-1.5 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950 rounded transition-colors"
|
||||
title="Eliminar ingrediente"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main fields */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3">
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Ingrediente *</label>
|
||||
<select
|
||||
value={ingredient.ingredient_id}
|
||||
onChange={(e) => updateIngredient(index, 'ingredient_id', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
|
||||
>
|
||||
<option value="">Seleccionar ingrediente...</option>
|
||||
{availableIngredients.map(ing => (
|
||||
<option key={ing.value} value={ing.value}>{ing.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Cantidad *</label>
|
||||
<input
|
||||
type="number"
|
||||
value={ingredient.quantity}
|
||||
onChange={(e) => updateIngredient(index, 'quantity', parseFloat(e.target.value) || 0)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
|
||||
min="0"
|
||||
step="0.1"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Unidad *</label>
|
||||
<select
|
||||
value={ingredient.unit}
|
||||
onChange={(e) => updateIngredient(index, 'unit', e.target.value as MeasurementUnit)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
|
||||
>
|
||||
{unitOptions.map(unit => (
|
||||
<option key={unit.value} value={unit.value}>{unit.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Optional checkbox and expand button */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-[var(--border-secondary)]">
|
||||
<label className="flex items-center gap-2 text-xs font-medium text-[var(--text-secondary)] cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={ingredient.is_optional}
|
||||
onChange={(e) => updateIngredient(index, 'is_optional', e.target.checked)}
|
||||
className="rounded border-[var(--border-secondary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
|
||||
/>
|
||||
Ingrediente opcional
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpanded(index)}
|
||||
className="flex items-center gap-1 text-xs text-[var(--color-primary)] hover:text-[var(--color-primary)]/80 transition-colors"
|
||||
>
|
||||
{isExpanded ? 'Ocultar detalles' : 'Agregar detalles'}
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expanded details section */}
|
||||
{isExpanded && (
|
||||
<div className="space-y-3 pt-3 border-t border-[var(--border-secondary)] bg-[var(--bg-primary)]/50 -mx-4 -mb-3 px-4 pb-3 rounded-b-lg">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
||||
Método de preparación
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={ingredient.preparation_method || ''}
|
||||
onChange={(e) => updateIngredient(index, 'preparation_method', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
|
||||
placeholder="ej: tamizada, a temperatura ambiente, derretida..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
||||
Notas del ingrediente
|
||||
</label>
|
||||
<textarea
|
||||
value={ingredient.ingredient_notes || ''}
|
||||
onChange={(e) => updateIngredient(index, 'ingredient_notes', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
|
||||
placeholder="Notas adicionales sobre este ingrediente..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
||||
Opciones de sustitución
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={typeof ingredient.substitution_options === 'string' ? ingredient.substitution_options : ''}
|
||||
onChange={(e) => updateIngredient(index, 'substitution_options', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
|
||||
placeholder="ej: Aceite de oliva, mantequilla..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
||||
Ratio de sustitución
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={typeof ingredient.substitution_ratio === 'string' ? ingredient.substitution_ratio : (typeof ingredient.substitution_ratio === 'number' ? String(ingredient.substitution_ratio) : '')}
|
||||
onChange={(e) => updateIngredient(index, 'substitution_ratio', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
|
||||
placeholder="ej: 1:1, 1:1.2..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{ingredientsArray.length > 0 && (
|
||||
<div className="pt-3 border-t border-[var(--border-secondary)]">
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Total: {ingredientsArray.length} ingrediente{ingredientsArray.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -169,20 +284,12 @@ const RecipesPage: React.FC = () => {
|
||||
// API Data
|
||||
const {
|
||||
data: recipes = [],
|
||||
isLoading: recipesLoading,
|
||||
error: recipesError,
|
||||
isRefetching: isRefetchingRecipes
|
||||
} = useRecipes(tenantId, { search_term: searchTerm || undefined });
|
||||
|
||||
const {
|
||||
data: statisticsData,
|
||||
isLoading: statisticsLoading
|
||||
} = useRecipeStatistics(tenantId);
|
||||
|
||||
// Fetch inventory items for ingredient name lookup
|
||||
const {
|
||||
data: inventoryItems = [],
|
||||
isLoading: inventoryLoading
|
||||
data: inventoryItems = []
|
||||
} = useIngredients(tenantId, {});
|
||||
|
||||
// Create ingredient lookup map (UUID -> name)
|
||||
@@ -254,10 +361,23 @@ const RecipesPage: React.FC = () => {
|
||||
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
|
||||
};
|
||||
|
||||
// Helper to translate category values to labels
|
||||
const getCategoryLabel = (category: string | null | undefined): string => {
|
||||
if (!category) return 'Sin categoría';
|
||||
const categoryLabels: Record<string, string> = {
|
||||
'bread': 'Panes',
|
||||
'pastry': 'Bollería',
|
||||
'cake': 'Tartas',
|
||||
'cookie': 'Galletas',
|
||||
'other': 'Otro'
|
||||
};
|
||||
return categoryLabels[category] || category;
|
||||
};
|
||||
|
||||
const getQualityConfigSummary = (config: any) => {
|
||||
if (!config || !config.stages) return 'No configurado';
|
||||
|
||||
const stageLabels = {
|
||||
const stageLabels: Record<string, string> = {
|
||||
[ProcessStage.MIXING]: 'Mezclado',
|
||||
[ProcessStage.PROOFING]: 'Fermentación',
|
||||
[ProcessStage.SHAPING]: 'Formado',
|
||||
@@ -269,7 +389,7 @@ const RecipesPage: React.FC = () => {
|
||||
|
||||
const configuredStages = Object.keys(config.stages)
|
||||
.filter(stage => config.stages[stage]?.template_ids?.length > 0)
|
||||
.map(stage => stageLabels[stage as ProcessStage] || stage);
|
||||
.map(stage => stageLabels[stage] || stage);
|
||||
|
||||
if (configuredStages.length === 0) return 'No configurado';
|
||||
if (configuredStages.length <= 2) return `Configurado para: ${configuredStages.join(', ')}`;
|
||||
@@ -509,7 +629,7 @@ const RecipesPage: React.FC = () => {
|
||||
const handleRecipeSaveComplete = async () => {
|
||||
if (!tenantId) return;
|
||||
// Invalidate recipes query to trigger refetch
|
||||
await queryClient.invalidateQueries(['recipes', tenantId]);
|
||||
await queryClient.invalidateQueries({ queryKey: ['recipes', tenantId] });
|
||||
};
|
||||
|
||||
// Handle saving edited recipe
|
||||
@@ -520,18 +640,24 @@ const RecipesPage: React.FC = () => {
|
||||
const updateData: any = {
|
||||
...editedRecipe,
|
||||
// Convert time fields from formatted strings back to numbers if needed
|
||||
prep_time_minutes: typeof editedRecipe.prep_time_minutes === 'string'
|
||||
? parseInt(editedRecipe.prep_time_minutes.toString())
|
||||
: editedRecipe.prep_time_minutes,
|
||||
cook_time_minutes: typeof editedRecipe.cook_time_minutes === 'string'
|
||||
? parseInt(editedRecipe.cook_time_minutes.toString())
|
||||
: editedRecipe.cook_time_minutes,
|
||||
prep_time_minutes: editedRecipe.prep_time_minutes !== undefined
|
||||
? (typeof editedRecipe.prep_time_minutes === 'string'
|
||||
? parseInt(String(editedRecipe.prep_time_minutes))
|
||||
: editedRecipe.prep_time_minutes)
|
||||
: undefined,
|
||||
cook_time_minutes: editedRecipe.cook_time_minutes !== undefined
|
||||
? (typeof editedRecipe.cook_time_minutes === 'string'
|
||||
? parseInt(String(editedRecipe.cook_time_minutes))
|
||||
: editedRecipe.cook_time_minutes)
|
||||
: undefined,
|
||||
// Ensure yield_unit is properly typed
|
||||
yield_unit: editedRecipe.yield_unit ? editedRecipe.yield_unit as MeasurementUnit : undefined,
|
||||
// Convert difficulty level to number if needed
|
||||
difficulty_level: typeof editedRecipe.difficulty_level === 'string'
|
||||
? parseInt(editedRecipe.difficulty_level.toString())
|
||||
: editedRecipe.difficulty_level,
|
||||
difficulty_level: editedRecipe.difficulty_level !== undefined
|
||||
? (typeof editedRecipe.difficulty_level === 'string'
|
||||
? parseInt(String(editedRecipe.difficulty_level))
|
||||
: editedRecipe.difficulty_level)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
// Include ingredient updates if they were edited
|
||||
@@ -625,13 +751,33 @@ const RecipesPage: React.FC = () => {
|
||||
const formatJsonField = (jsonData: any): string => {
|
||||
if (!jsonData) return 'No especificado';
|
||||
if (typeof jsonData === 'string') return jsonData;
|
||||
|
||||
// Handle arrays
|
||||
if (Array.isArray(jsonData)) {
|
||||
// Check if it's an array of objects with step/description pattern
|
||||
if (jsonData.length > 0 && typeof jsonData[0] === 'object') {
|
||||
return jsonData.map((item, index) => {
|
||||
if (item.step && item.description) {
|
||||
return `${index + 1}. ${item.step}: ${item.description}`;
|
||||
} else if (item.description) {
|
||||
return `${index + 1}. ${item.description}`;
|
||||
} else if (item.step) {
|
||||
return `${index + 1}. ${item.step}`;
|
||||
}
|
||||
return `${index + 1}. ${JSON.stringify(item)}`;
|
||||
}).join('\n');
|
||||
}
|
||||
// Simple array of strings
|
||||
return jsonData.join(', ');
|
||||
}
|
||||
|
||||
if (typeof jsonData === 'object') {
|
||||
// Extract common patterns
|
||||
if (jsonData.steps) return jsonData.steps;
|
||||
if (jsonData.checkpoints) return jsonData.checkpoints;
|
||||
if (jsonData.issues) return jsonData.issues;
|
||||
if (jsonData.allergens) return jsonData.allergens.join(', ');
|
||||
if (jsonData.tags) return jsonData.tags.join(', ');
|
||||
if (jsonData.allergens) return Array.isArray(jsonData.allergens) ? jsonData.allergens.join(', ') : jsonData.allergens;
|
||||
if (jsonData.tags) return Array.isArray(jsonData.tags) ? jsonData.tags.join(', ') : jsonData.tags;
|
||||
if (jsonData.info) return jsonData.info;
|
||||
return JSON.stringify(jsonData, null, 2);
|
||||
}
|
||||
@@ -675,13 +821,15 @@ const RecipesPage: React.FC = () => {
|
||||
},
|
||||
{
|
||||
label: 'Categoría',
|
||||
value: getFieldValue(selectedRecipe.category || 'Sin categoría', 'category'),
|
||||
value: modalMode === 'edit'
|
||||
? getFieldValue(selectedRecipe.category, 'category')
|
||||
: getCategoryLabel(getFieldValue(selectedRecipe.category, 'category') as string),
|
||||
type: modalMode === 'edit' ? 'select' : 'status',
|
||||
options: modalMode === 'edit' ? [
|
||||
{ value: 'bread', label: 'Pan' },
|
||||
{ value: 'bread', label: 'Panes' },
|
||||
{ value: 'pastry', label: 'Bollería' },
|
||||
{ value: 'cake', label: 'Tarta' },
|
||||
{ value: 'cookie', label: 'Galleta' },
|
||||
{ value: 'cake', label: 'Tartas' },
|
||||
{ value: 'cookie', label: 'Galletas' },
|
||||
{ value: 'other', label: 'Otro' }
|
||||
] : undefined,
|
||||
editable: true
|
||||
@@ -956,32 +1104,137 @@ const RecipesPage: React.FC = () => {
|
||||
icon: Package,
|
||||
fields: [
|
||||
{
|
||||
label: 'Lista de ingredientes',
|
||||
label: '',
|
||||
value: modalMode === 'edit'
|
||||
? (() => {
|
||||
const val = editedIngredients.length > 0 ? editedIngredients : selectedRecipe.ingredients || [];
|
||||
console.log('[RecipesPage] Edit mode - Ingredients value:', val, 'editedIngredients.length:', editedIngredients.length);
|
||||
return val;
|
||||
})()
|
||||
? (editedIngredients.length > 0 ? editedIngredients : selectedRecipe.ingredients || [])
|
||||
: (selectedRecipe.ingredients
|
||||
?.sort((a, b) => a.ingredient_order - b.ingredient_order)
|
||||
?.map(ing => {
|
||||
const ingredientName = ingredientLookup[ing.ingredient_id] || ing.ingredient_id;
|
||||
const optional = ing.is_optional ? ' (opcional)' : '';
|
||||
const prep = ing.preparation_method ? ` - ${ing.preparation_method}` : '';
|
||||
const notes = ing.ingredient_notes ? ` [${ing.ingredient_notes}]` : '';
|
||||
return `${ing.quantity} ${ing.unit} de ${ingredientName}${optional}${prep}${notes}`;
|
||||
const ingredientName = ingredientLookup[ing.ingredient_id] || 'Ingrediente desconocido';
|
||||
const parts = [];
|
||||
|
||||
// Main ingredient line with quantity
|
||||
parts.push(`${ing.quantity} ${ing.unit}`);
|
||||
parts.push(ingredientName);
|
||||
|
||||
// Add optional indicator
|
||||
if (ing.is_optional) {
|
||||
parts.push('(opcional)');
|
||||
}
|
||||
|
||||
// Add preparation method on new line if exists
|
||||
if (ing.preparation_method) {
|
||||
parts.push(`\n → ${ing.preparation_method}`);
|
||||
}
|
||||
|
||||
// Add notes on new line if exists
|
||||
if (ing.ingredient_notes) {
|
||||
parts.push(`\n 💡 ${ing.ingredient_notes}`);
|
||||
}
|
||||
|
||||
// Add substitution info if exists
|
||||
if (ing.substitution_options) {
|
||||
parts.push(`\n 🔄 Sustituto: ${ing.substitution_options}`);
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}) || ['No especificados']),
|
||||
type: modalMode === 'edit' ? 'component' as const : 'list' as const,
|
||||
type: modalMode === 'edit' ? 'component' as const : 'custom' as const,
|
||||
component: modalMode === 'edit' ? IngredientsEditComponent : undefined,
|
||||
componentProps: modalMode === 'edit' ? {
|
||||
availableIngredients,
|
||||
unitOptions,
|
||||
onChange: (newIngredients: RecipeIngredientResponse[]) => {
|
||||
console.log('[RecipesPage] Ingredients onChange called with:', newIngredients);
|
||||
setEditedIngredients(newIngredients);
|
||||
}
|
||||
} : undefined,
|
||||
customRenderer: modalMode === 'view' ? () => {
|
||||
const ingredients = selectedRecipe.ingredients
|
||||
?.sort((a, b) => a.ingredient_order - b.ingredient_order) || [];
|
||||
|
||||
if (ingredients.length === 0) {
|
||||
return (
|
||||
<div className="text-sm text-[var(--text-secondary)] italic p-4 bg-[var(--bg-secondary)]/30 rounded-md">
|
||||
No hay ingredientes especificados
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{ingredients.map((ing, idx) => {
|
||||
const ingredientName = ingredientLookup[ing.ingredient_id] || 'Ingrediente desconocido';
|
||||
const hasDetails = ing.preparation_method || ing.ingredient_notes || ing.substitution_options;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ing.id || idx}
|
||||
className="p-3 rounded-lg bg-[var(--bg-secondary)]/50 border border-[var(--border-secondary)] hover:border-[var(--color-primary)]/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-[var(--color-primary)]/10 flex items-center justify-center text-sm font-semibold text-[var(--color-primary)]">
|
||||
{idx + 1}
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-baseline gap-2 flex-wrap">
|
||||
<span className="font-bold text-[var(--color-primary)]">
|
||||
{ing.quantity} {ing.unit}
|
||||
</span>
|
||||
<span className="text-[var(--text-primary)] font-medium">
|
||||
{ingredientName}
|
||||
</span>
|
||||
{ing.is_optional && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-500/10 text-amber-700 dark:text-amber-400 border border-amber-500/20">
|
||||
opcional
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasDetails && (
|
||||
<div className="space-y-1.5 text-sm">
|
||||
{ing.preparation_method && (
|
||||
<div className="flex items-start gap-2 text-[var(--text-secondary)]">
|
||||
<span className="text-blue-500 flex-shrink-0">→</span>
|
||||
<span className="italic">{ing.preparation_method}</span>
|
||||
</div>
|
||||
)}
|
||||
{ing.ingredient_notes && (
|
||||
<div className="flex items-start gap-2 text-[var(--text-secondary)]">
|
||||
<span className="flex-shrink-0">💡</span>
|
||||
<span>{ing.ingredient_notes}</span>
|
||||
</div>
|
||||
)}
|
||||
{ing.substitution_options && (
|
||||
<div className="flex items-start gap-2 text-[var(--text-secondary)]">
|
||||
<span className="flex-shrink-0">🔄</span>
|
||||
<span>
|
||||
<strong>Sustituto:</strong> {ing.substitution_options}
|
||||
{ing.substitution_ratio && ` (ratio: ${ing.substitution_ratio})`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Summary footer */}
|
||||
<div className="pt-2 mt-2 border-t border-[var(--border-secondary)]">
|
||||
<div className="text-sm text-[var(--text-secondary)] flex items-center justify-between">
|
||||
<span>Total: {ingredients.length} ingrediente{ingredients.length !== 1 ? 's' : ''}</span>
|
||||
{ingredients.some(ing => ing.is_optional) && (
|
||||
<span className="text-xs text-amber-600 dark:text-amber-400">
|
||||
⚠️ Incluye ingredientes opcionales
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} : undefined,
|
||||
span: 2,
|
||||
editable: modalMode === 'edit'
|
||||
}
|
||||
@@ -996,13 +1249,128 @@ const RecipesPage: React.FC = () => {
|
||||
value: selectedRecipe.quality_check_configuration
|
||||
? getQualityConfigSummary(selectedRecipe.quality_check_configuration)
|
||||
: 'No configurado',
|
||||
type: modalMode === 'edit' ? 'button' : 'text',
|
||||
type: modalMode === 'view' ? 'custom' : 'button',
|
||||
span: 2,
|
||||
buttonText: modalMode === 'edit' ? 'Configurar Controles de Calidad' : undefined,
|
||||
onButtonClick: modalMode === 'edit' ? () => {
|
||||
// Open quality check configuration modal
|
||||
setShowQualityConfigModal(true);
|
||||
} : undefined,
|
||||
customRenderer: modalMode === 'view' ? () => {
|
||||
const config = selectedRecipe.quality_check_configuration as any;
|
||||
|
||||
if (!config || !config.stages || Object.keys(config.stages).length === 0) {
|
||||
return (
|
||||
<div className="text-sm text-[var(--text-secondary)] italic p-4 bg-[var(--bg-secondary)]/30 rounded-md">
|
||||
No hay controles de calidad configurados para esta receta
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const stageLabels = {
|
||||
[ProcessStage.MIXING]: 'Mezclado',
|
||||
[ProcessStage.PROOFING]: 'Fermentación',
|
||||
[ProcessStage.SHAPING]: 'Formado',
|
||||
[ProcessStage.BAKING]: 'Horneado',
|
||||
[ProcessStage.COOLING]: 'Enfriado',
|
||||
[ProcessStage.PACKAGING]: 'Empaquetado',
|
||||
[ProcessStage.FINISHING]: 'Acabado'
|
||||
};
|
||||
|
||||
const configuredStages = Object.entries(config.stages).filter(
|
||||
([_, stageConfig]: [string, any]) => stageConfig?.template_ids?.length > 0
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div className="p-3 rounded-lg bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800">
|
||||
<div className="text-xs text-blue-600 dark:text-blue-400 font-medium mb-1">Etapas Configuradas</div>
|
||||
<div className="text-2xl font-bold text-blue-700 dark:text-blue-300">{configuredStages.length}</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800">
|
||||
<div className="text-xs text-green-600 dark:text-green-400 font-medium mb-1">Total Controles</div>
|
||||
<div className="text-2xl font-bold text-green-700 dark:text-green-300">
|
||||
{Object.values(config.stages).reduce((sum: number, stage: any) => sum + (stage?.template_ids?.length || 0), 0)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800">
|
||||
<div className="text-xs text-amber-600 dark:text-amber-400 font-medium mb-1">Umbral de Calidad</div>
|
||||
<div className="text-2xl font-bold text-amber-700 dark:text-amber-300">
|
||||
{config.overall_quality_threshold ? `${config.overall_quality_threshold}/10` : 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configured Stages */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-[var(--text-primary)] flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
Etapas Configuradas
|
||||
</h4>
|
||||
{configuredStages.map(([stage, stageConfig]: [string, any]) => (
|
||||
<div
|
||||
key={stage}
|
||||
className="p-4 rounded-lg bg-[var(--bg-secondary)]/50 border border-[var(--border-secondary)] hover:border-[var(--color-primary)]/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-[var(--color-primary)]/10 flex items-center justify-center">
|
||||
<CheckCircle className="w-4 h-4 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="text-sm font-semibold text-[var(--text-primary)]">
|
||||
{stageLabels[stage as ProcessStage] || stage}
|
||||
</h5>
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
{stageConfig.template_ids.length} control{stageConfig.template_ids.length !== 1 ? 'es' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{stageConfig.is_required && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-red-500/10 text-red-700 dark:text-red-400 border border-red-500/20">
|
||||
Obligatorio
|
||||
</span>
|
||||
)}
|
||||
{stageConfig.blocking && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-500/10 text-amber-700 dark:text-amber-400 border border-amber-500/20">
|
||||
Bloqueante
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Global Settings */}
|
||||
{(config.auto_create_quality_checks || config.quality_manager_approval_required || config.critical_stage_blocking) && (
|
||||
<div className="pt-3 border-t border-[var(--border-secondary)]">
|
||||
<h4 className="text-sm font-semibold text-[var(--text-primary)] mb-2">Configuración Global</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{config.auto_create_quality_checks && (
|
||||
<span className="text-xs px-2 py-1 rounded-md bg-blue-500/10 text-blue-700 dark:text-blue-400 border border-blue-500/20">
|
||||
✓ Creación automática de controles
|
||||
</span>
|
||||
)}
|
||||
{config.quality_manager_approval_required && (
|
||||
<span className="text-xs px-2 py-1 rounded-md bg-purple-500/10 text-purple-700 dark:text-purple-400 border border-purple-500/20">
|
||||
✓ Requiere aprobación del responsable
|
||||
</span>
|
||||
)}
|
||||
{config.critical_stage_blocking && (
|
||||
<span className="text-xs px-2 py-1 rounded-md bg-red-500/10 text-red-700 dark:text-red-400 border border-red-500/20">
|
||||
✓ Etapas críticas bloqueantes
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} : undefined,
|
||||
readonly: modalMode !== 'edit'
|
||||
}
|
||||
]
|
||||
@@ -1124,7 +1492,7 @@ const RecipesPage: React.FC = () => {
|
||||
{
|
||||
label: 'Eliminar',
|
||||
icon: Trash2,
|
||||
variant: 'danger',
|
||||
variant: 'outline',
|
||||
priority: 'secondary',
|
||||
onClick: () => {
|
||||
setRecipeToDelete(recipe);
|
||||
|
||||
Reference in New Issue
Block a user