Improve frontend 3

This commit is contained in:
Urtzi Alfaro
2025-11-19 22:12:51 +01:00
parent 938df0866e
commit 29e6ddcea9
17 changed files with 2215 additions and 268 deletions

View File

@@ -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 && (

View File

@@ -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 */}

View File

@@ -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);