From d573c38621d322bb76d9b5646eb9a5a70b4b9a12 Mon Sep 17 00:00:00 2001 From: Urtzi Alfaro Date: Fri, 26 Sep 2025 07:46:25 +0200 Subject: [PATCH] Refactor components and modals --- frontend/src/App.tsx | 4 +- frontend/src/{ => api}/types/equipment.ts | 0 .../domain/equipment/EquipmentModal.tsx | 6 +- .../domain/forecasting/ModelDetailsModal.tsx | 6 +- .../domain/inventory/AddStockModal.tsx | 18 +- .../domain/inventory/BatchModal.tsx | 4 +- .../inventory/CreateIngredientModal.tsx | 227 ++--- .../domain/inventory/ShowInfoModal.tsx | 4 +- .../domain/inventory/StockHistoryModal.tsx | 4 +- .../domain/orders/OrderFormModal.tsx | 45 +- .../procurement/CreatePurchaseOrderModal.tsx | 938 ++++++----------- .../production/CreateProductionBatchModal.tsx | 373 +++---- .../production/CreateQualityTemplateModal.tsx | 959 +++++------------- .../production/ProductionStatusCard.tsx | 8 +- .../production/QualityTemplateManager.tsx | 2 +- .../production/analytics}/AnalyticsChart.tsx | 0 .../production/analytics}/AnalyticsWidget.tsx | 2 +- .../analytics}/widgets/AIInsightsWidget.tsx | 4 +- .../widgets/CapacityUtilizationWidget.tsx | 6 +- .../analytics}/widgets/CostPerUnitWidget.tsx | 6 +- .../widgets/EquipmentEfficiencyWidget.tsx | 4 +- .../widgets/EquipmentStatusWidget.tsx | 4 +- .../widgets/LiveBatchTrackerWidget.tsx | 8 +- .../widgets/MaintenanceScheduleWidget.tsx | 4 +- .../widgets/OnTimeCompletionWidget.tsx | 6 +- .../widgets/PredictiveMaintenanceWidget.tsx | 4 +- .../widgets/QualityScoreTrendsWidget.tsx | 6 +- .../widgets/TodaysScheduleSummaryWidget.tsx | 8 +- .../widgets/TopDefectTypesWidget.tsx | 6 +- .../widgets/WasteDefectTrackerWidget.tsx | 6 +- .../widgets/YieldPerformanceWidget.tsx | 6 +- .../production/analytics}/widgets/index.ts | 0 .../domain/recipes/CreateRecipeModal.tsx | 634 ++++-------- .../QualityCheckConfigurationModal.tsx | 2 +- .../domain/suppliers/CreateSupplierForm.tsx | 33 +- .../domain/team/AddTeamMemberModal.tsx | 149 +-- .../ErrorBoundary/ErrorBoundary.tsx | 0 .../{shared => layout}/ErrorBoundary/index.ts | 0 .../layout/MinimalSidebar/README.md | 70 -- frontend/src/components/layout/index.ts | 4 +- .../shared/ConfirmDialog/ConfirmDialog.tsx | 297 ------ .../components/shared/ConfirmDialog/index.ts | 2 - .../components/shared/DataTable/DataTable.tsx | 684 ------------- .../src/components/shared/DataTable/index.ts | 9 - frontend/src/components/shared/index.ts | 13 - .../src/components/ui/AddModal/AddModal.tsx | 713 +++++++++++++ frontend/src/components/ui/AddModal/index.ts | 6 + .../components/ui/DialogModal/DialogModal.tsx | 321 ++++++ .../src/components/ui/DialogModal/index.ts | 14 + .../ui/EditViewModal/EditViewModal.tsx | 698 +++++++++++++ .../src/components/ui/EditViewModal/index.ts | 7 + .../{shared => ui}/EmptyState/EmptyState.tsx | 0 .../{shared => ui}/EmptyState/index.ts | 0 .../{shared => ui}/LoadingSpinner.tsx | 0 .../LoadingSpinner/LoadingSpinner.tsx | 0 .../{shared => ui}/LoadingSpinner/index.ts | 0 frontend/src/components/ui/Select/Select.tsx | 40 +- frontend/src/components/ui/index.ts | 12 +- frontend/src/examples/RecipesExample.tsx | 542 ---------- frontend/src/locales/en/common.json | 44 + frontend/src/locales/en/inventory.json | 50 +- frontend/src/locales/en/orders.json | 107 +- frontend/src/locales/en/suppliers.json | 85 +- frontend/src/locales/es/common.json | 44 + frontend/src/locales/eu/common.json | 44 + .../app/analytics/ProductionAnalyticsPage.tsx | 2 +- .../analytics/forecasting/ForecastingPage.tsx | 2 +- .../sales-analytics/SalesAnalyticsPage.tsx | 2 +- .../operations/inventory/InventoryPage.tsx | 2 +- .../operations/maquinaria/MaquinariaPage.tsx | 4 +- .../app/operations/orders/OrdersPage.tsx | 54 +- .../src/pages/app/operations/pos/POSPage.tsx | 2 +- .../procurement/ProcurementPage.tsx | 4 +- .../operations/production/ProductionPage.tsx | 22 +- .../app/operations/recipes/RecipesPage.tsx | 6 +- .../operations/suppliers/SuppliersPage.tsx | 32 +- frontend/src/router/AppRouter.tsx | 2 +- frontend/src/utils/enumHelpers.ts | 353 ------- frontend/src/utils/foodSafetyEnumHelpers.ts | 109 -- frontend/src/utils/inventoryEnumHelpers.ts | 140 --- 80 files changed, 3421 insertions(+), 4617 deletions(-) rename frontend/src/{ => api}/types/equipment.ts (100%) rename frontend/src/components/{analytics/production => domain/production/analytics}/AnalyticsChart.tsx (100%) rename frontend/src/components/{analytics/production => domain/production/analytics}/AnalyticsWidget.tsx (99%) rename frontend/src/components/{analytics/production => domain/production/analytics}/widgets/AIInsightsWidget.tsx (99%) rename frontend/src/components/{analytics/production => domain/production/analytics}/widgets/CapacityUtilizationWidget.tsx (98%) rename frontend/src/components/{analytics/production => domain/production/analytics}/widgets/CostPerUnitWidget.tsx (98%) rename frontend/src/components/{analytics/production => domain/production/analytics}/widgets/EquipmentEfficiencyWidget.tsx (99%) rename frontend/src/components/{analytics/production => domain/production/analytics}/widgets/EquipmentStatusWidget.tsx (98%) rename frontend/src/components/{analytics/production => domain/production/analytics}/widgets/LiveBatchTrackerWidget.tsx (97%) rename frontend/src/components/{analytics/production => domain/production/analytics}/widgets/MaintenanceScheduleWidget.tsx (99%) rename frontend/src/components/{analytics/production => domain/production/analytics}/widgets/OnTimeCompletionWidget.tsx (97%) rename frontend/src/components/{analytics/production => domain/production/analytics}/widgets/PredictiveMaintenanceWidget.tsx (99%) rename frontend/src/components/{analytics/production => domain/production/analytics}/widgets/QualityScoreTrendsWidget.tsx (98%) rename frontend/src/components/{analytics/production => domain/production/analytics}/widgets/TodaysScheduleSummaryWidget.tsx (96%) rename frontend/src/components/{analytics/production => domain/production/analytics}/widgets/TopDefectTypesWidget.tsx (98%) rename frontend/src/components/{analytics/production => domain/production/analytics}/widgets/WasteDefectTrackerWidget.tsx (98%) rename frontend/src/components/{analytics/production => domain/production/analytics}/widgets/YieldPerformanceWidget.tsx (98%) rename frontend/src/components/{analytics/production => domain/production/analytics}/widgets/index.ts (100%) rename frontend/src/components/{shared => layout}/ErrorBoundary/ErrorBoundary.tsx (100%) rename frontend/src/components/{shared => layout}/ErrorBoundary/index.ts (100%) delete mode 100644 frontend/src/components/layout/MinimalSidebar/README.md delete mode 100644 frontend/src/components/shared/ConfirmDialog/ConfirmDialog.tsx delete mode 100644 frontend/src/components/shared/ConfirmDialog/index.ts delete mode 100644 frontend/src/components/shared/DataTable/DataTable.tsx delete mode 100644 frontend/src/components/shared/DataTable/index.ts delete mode 100644 frontend/src/components/shared/index.ts create mode 100644 frontend/src/components/ui/AddModal/AddModal.tsx create mode 100644 frontend/src/components/ui/AddModal/index.ts create mode 100644 frontend/src/components/ui/DialogModal/DialogModal.tsx create mode 100644 frontend/src/components/ui/DialogModal/index.ts create mode 100644 frontend/src/components/ui/EditViewModal/EditViewModal.tsx create mode 100644 frontend/src/components/ui/EditViewModal/index.ts rename frontend/src/components/{shared => ui}/EmptyState/EmptyState.tsx (100%) rename frontend/src/components/{shared => ui}/EmptyState/index.ts (100%) rename frontend/src/components/{shared => ui}/LoadingSpinner.tsx (100%) rename frontend/src/components/{shared => ui}/LoadingSpinner/LoadingSpinner.tsx (100%) rename frontend/src/components/{shared => ui}/LoadingSpinner/index.ts (100%) delete mode 100644 frontend/src/examples/RecipesExample.tsx delete mode 100644 frontend/src/utils/enumHelpers.ts delete mode 100644 frontend/src/utils/foodSafetyEnumHelpers.ts delete mode 100644 frontend/src/utils/inventoryEnumHelpers.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bb5b2eb8..f7dc1a03 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,8 +4,8 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { BrowserRouter } from 'react-router-dom'; import { I18nextProvider } from 'react-i18next'; import { Toaster } from 'react-hot-toast'; -import { ErrorBoundary } from './components/shared/ErrorBoundary'; -import { LoadingSpinner } from './components/shared/LoadingSpinner'; +import { ErrorBoundary } from './components/layout/ErrorBoundary'; +import { LoadingSpinner } from './components/ui/LoadingSpinner'; import { AppRouter } from './router/AppRouter'; import { ThemeProvider } from './contexts/ThemeContext'; import { AuthProvider } from './contexts/AuthContext'; diff --git a/frontend/src/types/equipment.ts b/frontend/src/api/types/equipment.ts similarity index 100% rename from frontend/src/types/equipment.ts rename to frontend/src/api/types/equipment.ts diff --git a/frontend/src/components/domain/equipment/EquipmentModal.tsx b/frontend/src/components/domain/equipment/EquipmentModal.tsx index e754f9b6..12ab117f 100644 --- a/frontend/src/components/domain/equipment/EquipmentModal.tsx +++ b/frontend/src/components/domain/equipment/EquipmentModal.tsx @@ -1,8 +1,8 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Building2, Settings, Wrench, Thermometer, Activity, CheckCircle, AlertTriangle, Eye, Edit } from 'lucide-react'; -import { StatusModal, StatusModalSection } from '../../ui/StatusModal/StatusModal'; -import { Equipment } from '../../../types/equipment'; +import { EditViewModal, StatusModalSection } from '../../ui/EditViewModal/EditViewModal'; +import { Equipment } from '../../../api/types/equipment'; interface EquipmentModalProps { isOpen: boolean; @@ -337,7 +337,7 @@ export const EquipmentModal: React.FC = ({ }; return ( - { onClose(); diff --git a/frontend/src/components/domain/forecasting/ModelDetailsModal.tsx b/frontend/src/components/domain/forecasting/ModelDetailsModal.tsx index a0ec9056..4df67a9b 100644 --- a/frontend/src/components/domain/forecasting/ModelDetailsModal.tsx +++ b/frontend/src/components/domain/forecasting/ModelDetailsModal.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { StatusModal, StatusModalSection } from '@/components/ui/StatusModal/StatusModal'; +import { EditViewModal, StatusModalSection } from '@/components/ui/EditViewModal/EditViewModal'; import { Badge } from '@/components/ui/Badge'; import { Tooltip } from '@/components/ui/Tooltip'; import { TrainedModelResponse, TrainingMetrics } from '@/types/training'; @@ -94,7 +94,7 @@ const ModelDetailsModal: React.FC = ({ // Early return if model is not provided if (!model) { return ( - = ({ } return ( - = ({ }); const suppliers = (suppliersData || []).filter(supplier => supplier.status === 'active'); - // Get inventory enum helpers - const inventoryEnums = useInventoryEnums(); + // Get translations + const { t } = useTranslation(['inventory', 'common']); + + // Get production stage options using direct i18n + const productionStageOptions = Object.values(ProductionStage).map(value => ({ + value, + label: t(`inventory:production_stage.${value}`) + })); // Create supplier options for select const supplierOptions = [ @@ -251,7 +257,7 @@ export const AddStockModal: React.FC = ({ value: formData.production_stage || ProductionStage.RAW_INGREDIENT, type: 'select' as const, editable: true, - options: inventoryEnums.getProductionStageOptions() + options: productionStageOptions } ] }, @@ -382,7 +388,7 @@ export const AddStockModal: React.FC = ({ ]; return ( - = ({ } return ( - = ({ onClose, onCreateIngredient }) => { - const { t } = useTranslation(['inventory']); - const [formData, setFormData] = useState({ - name: '', - description: '', - category: '', - unit_of_measure: 'kg', - low_stock_threshold: 10, - reorder_point: 20, - max_stock_level: 100, - is_seasonal: false, - average_cost: 0, - notes: '' - }); - + const { t } = useTranslation(['inventory', 'common']); const [loading, setLoading] = useState(false); - const [mode, setMode] = useState<'overview' | 'edit'>('edit'); - // Get enum options using helpers - const inventoryEnums = useInventoryEnums(); + // Get enum options using direct i18n implementation + const ingredientCategoryOptions = Object.values(IngredientCategory).map(value => ({ + value, + label: t(`inventory:ingredient_category.${value}`) + })).sort((a, b) => a.label.localeCompare(b.label)); + + const productCategoryOptions = Object.values(ProductCategory).map(value => ({ + value, + label: t(`inventory:product_category.${value}`) + })); - // Combine ingredient and product categories const categoryOptions = [ - ...inventoryEnums.getIngredientCategoryOptions(), - ...inventoryEnums.getProductCategoryOptions() + ...ingredientCategoryOptions, + ...productCategoryOptions ]; - const unitOptions = inventoryEnums.getUnitOfMeasureOptions(); + const unitOptions = Object.values(UnitOfMeasure).map(value => ({ + value, + label: t(`inventory:unit_of_measure.${value}`) + })); - const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number | boolean) => { - // Map field positions to form data fields - const fieldMappings = [ - // Basic Information section - ['name', 'description', 'category', 'unit_of_measure'], - // Cost and Quantities section - ['average_cost', 'low_stock_threshold', 'reorder_point', 'max_stock_level'], - // Additional Information section - ['notes'] - ]; - - const fieldName = fieldMappings[sectionIndex]?.[fieldIndex] as keyof IngredientCreate; - if (fieldName) { - setFormData(prev => ({ - ...prev, - [fieldName]: value - })); - } - }; - - const handleSave = async () => { - // Validation - if (!formData.name?.trim()) { - alert(t('inventory:validation.name_required', 'El nombre es requerido')); - return; - } - - if (!formData.category) { - alert(t('inventory:validation.category_required', 'La categoría es requerida')); - return; - } - - if (!formData.unit_of_measure) { - alert(t('inventory:validation.unit_required', 'La unidad de medida es requerida')); - return; - } - - if (!formData.low_stock_threshold || formData.low_stock_threshold < 0) { - alert(t('inventory:validation.min_greater_than_zero', 'El umbral de stock bajo debe ser un número positivo')); - return; - } - - if (!formData.reorder_point || formData.reorder_point < 0) { - alert(t('inventory:validation.min_greater_than_zero', 'El punto de reorden debe ser un número positivo')); - return; - } - - if (formData.reorder_point <= formData.low_stock_threshold) { - alert(t('inventory:validation.max_greater_than_min', 'El punto de reorden debe ser mayor que el umbral de stock bajo')); - return; - } + const handleSave = async (formData: Record) => { + // Transform form data to IngredientCreate format + const ingredientData: IngredientCreate = { + name: formData.name, + description: formData.description || '', + category: formData.category, + unit_of_measure: formData.unit_of_measure, + low_stock_threshold: Number(formData.low_stock_threshold), + reorder_point: Number(formData.reorder_point), + max_stock_level: Number(formData.max_stock_level), + is_seasonal: false, + average_cost: Number(formData.average_cost) || 0, + notes: formData.notes || '' + }; setLoading(true); try { if (onCreateIngredient) { - await onCreateIngredient(formData); + await onCreateIngredient(ingredientData); } - - // Reset form - setFormData({ - name: '', - description: '', - category: '', - unit_of_measure: 'kg', - low_stock_threshold: 10, - reorder_point: 20, - max_stock_level: 100, - shelf_life_days: undefined, - requires_refrigeration: false, - requires_freezing: false, - is_seasonal: false, - average_cost: 0, - notes: '' - }); - - onClose(); } catch (error) { console.error('Error creating ingredient:', error); - alert('Error al crear el artículo. Por favor, intenta de nuevo.'); + throw error; // Let AddModal handle error display } finally { setLoading(false); } }; - const handleCancel = () => { - // Reset form to initial values - setFormData({ - name: '', - description: '', - category: '', - unit_of_measure: 'kg', - low_stock_threshold: 10, - reorder_point: 20, - max_stock_level: 100, - shelf_life_days: undefined, - requires_refrigeration: false, - requires_freezing: false, - is_seasonal: false, - average_cost: 0, - notes: '' - }); - onClose(); - }; - const statusConfig = { color: statusColors.inProgress.primary, text: t('inventory:actions.add_item', 'Nuevo Artículo'), @@ -168,34 +87,36 @@ export const CreateIngredientModal: React.FC = ({ fields: [ { label: t('inventory:fields.name', 'Nombre'), - value: formData.name, + name: 'name', type: 'text' as const, - editable: true, required: true, - placeholder: 'Ej: Harina de trigo 000' + placeholder: 'Ej: Harina de trigo 000', + validation: (value: string | number) => { + const str = String(value).trim(); + return str.length < 2 ? 'El nombre debe tener al menos 2 caracteres' : null; + } }, { label: t('inventory:fields.description', 'Descripción'), - value: formData.description || '', + name: 'description', type: 'text' as const, - editable: true, placeholder: 'Descripción opcional del artículo' }, { label: 'Categoría', - value: formData.category, + name: 'category', type: 'select' as const, - editable: true, required: true, - options: categoryOptions + options: categoryOptions, + placeholder: 'Seleccionar categoría...' }, { label: 'Unidad de Medida', - value: formData.unit_of_measure, + name: 'unit_of_measure', type: 'select' as const, - editable: true, required: true, - options: unitOptions + options: unitOptions, + defaultValue: 'kg' } ] }, @@ -205,33 +126,49 @@ export const CreateIngredientModal: React.FC = ({ fields: [ { label: 'Costo Promedio', - value: formData.average_cost || 0, + name: 'average_cost', type: 'currency' as const, - editable: true, - placeholder: '0.00' + placeholder: '0.00', + defaultValue: 0, + validation: (value: string | number) => { + const num = Number(value); + return num < 0 ? 'El costo no puede ser negativo' : null; + } }, { label: 'Umbral Stock Bajo', - value: formData.low_stock_threshold, + name: 'low_stock_threshold', type: 'number' as const, - editable: true, required: true, - placeholder: '10' + placeholder: '10', + defaultValue: 10, + validation: (value: string | number) => { + const num = Number(value); + return num < 0 ? 'El umbral debe ser un número positivo' : null; + } }, { label: 'Punto de Reorden', - value: formData.reorder_point, + name: 'reorder_point', type: 'number' as const, - editable: true, required: true, - placeholder: '20' + placeholder: '20', + defaultValue: 20, + validation: (value: string | number) => { + const num = Number(value); + return num < 0 ? 'El punto de reorden debe ser un número positivo' : null; + } }, { label: 'Stock Máximo', - value: formData.max_stock_level || 0, + name: 'max_stock_level', type: 'number' as const, - editable: true, - placeholder: '100' + placeholder: '100', + defaultValue: 100, + validation: (value: string | number) => { + const num = Number(value); + return num < 0 ? 'El stock máximo debe ser un número positivo' : null; + } } ] }, @@ -241,30 +178,26 @@ export const CreateIngredientModal: React.FC = ({ fields: [ { label: 'Notas', - value: formData.notes || '', - type: 'text' as const, - editable: true, - placeholder: 'Notas adicionales' + name: 'notes', + type: 'textarea' as const, + placeholder: 'Notas adicionales', + span: 2 // Full width } ] } ]; return ( - ); }; diff --git a/frontend/src/components/domain/inventory/ShowInfoModal.tsx b/frontend/src/components/domain/inventory/ShowInfoModal.tsx index be8b625b..b2472d27 100644 --- a/frontend/src/components/domain/inventory/ShowInfoModal.tsx +++ b/frontend/src/components/domain/inventory/ShowInfoModal.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { Package, AlertTriangle, CheckCircle, Clock, Euro, Edit, Info, Thermometer, Calendar, Tag, Save, X, TrendingUp } from 'lucide-react'; -import { StatusModal } from '../../ui/StatusModal/StatusModal'; +import { EditViewModal } from '../../ui/EditViewModal/EditViewModal'; import { IngredientResponse } from '../../../api/types/inventory'; import { formatters } from '../../ui/Stats/StatsPresets'; import { statusColors } from '../../../styles/colors'; @@ -284,7 +284,7 @@ export const ShowInfoModal: React.FC = ({ }; return ( - = ({ ]; return ( - = ({ const currentTenant = useCurrentTenant(); const user = useAuthUser(); const tenantId = currentTenant?.id || user?.tenant_id || ''; - const orderEnums = useOrderEnums(); + const { t } = useTranslation(['orders', 'common']); + + // Create enum options using direct i18n + const orderTypeOptions = Object.values(OrderType).map(value => ({ + value, + label: t(`orders:order_types.${value}`) + })); + + const priorityLevelOptions = Object.values(PriorityLevel).map(value => ({ + value, + label: t(`orders:priority_levels.${value}`) + })); + + const deliveryMethodOptions = Object.values(DeliveryMethod).map(value => ({ + value, + label: t(`orders:delivery_methods.${value}`) + })); + + const customerTypeOptions = Object.values(CustomerType).map(value => ({ + value, + label: t(`orders:customer_types.${value}`) + })); // Form state const [selectedCustomer, setSelectedCustomer] = useState(null); @@ -265,21 +286,21 @@ export const OrderFormModal: React.FC = ({ value: orderData.order_type || OrderType.STANDARD, type: 'select', editable: true, - options: orderEnums.getOrderTypeOptions() + options: orderTypeOptions }, { label: 'Prioridad', value: orderData.priority || PriorityLevel.NORMAL, type: 'select', editable: true, - options: orderEnums.getPriorityLevelOptions() + options: priorityLevelOptions }, { label: 'Método de Entrega', value: orderData.delivery_method || DeliveryMethod.PICKUP, type: 'select', editable: true, - options: orderEnums.getDeliveryMethodOptions() + options: deliveryMethodOptions }, { label: 'Fecha de Entrega', @@ -442,7 +463,7 @@ export const OrderFormModal: React.FC = ({ return ( <> - = ({ {/* New Customer Modal - Using StatusModal for consistency */} - setShowCustomerForm(false)} mode="edit" @@ -515,14 +536,14 @@ export const OrderFormModal: React.FC = ({ value: newCustomerData.customer_type || CustomerType.INDIVIDUAL, type: 'select', editable: true, - options: orderEnums.getCustomerTypeOptions() + options: customerTypeOptions }, { label: 'Método de Entrega Preferido', value: newCustomerData.preferred_delivery_method || DeliveryMethod.PICKUP, type: 'select', editable: true, - options: orderEnums.getDeliveryMethodOptions() + options: deliveryMethodOptions } ] } @@ -574,7 +595,7 @@ export const OrderFormModal: React.FC = ({ /> {/* Customer Selector Modal */} - setShowCustomerSelector(false)} mode="view" diff --git a/frontend/src/components/domain/procurement/CreatePurchaseOrderModal.tsx b/frontend/src/components/domain/procurement/CreatePurchaseOrderModal.tsx index de02dd5d..55e9f6c9 100644 --- a/frontend/src/components/domain/procurement/CreatePurchaseOrderModal.tsx +++ b/frontend/src/components/domain/procurement/CreatePurchaseOrderModal.tsx @@ -1,13 +1,14 @@ -import React, { useState, useEffect } from 'react'; -import { Plus, Package, Euro, Calendar, Truck, Building2, X, Save, AlertCircle } from 'lucide-react'; -import { Button } from '../../ui/Button'; -import { Input } from '../../ui/Input'; -import { Card } from '../../ui/Card'; +import React, { useState, useEffect, useMemo } from 'react'; +import { Plus, Package, Calendar, Building2 } from 'lucide-react'; +import { AddModal } from '../../ui/AddModal/AddModal'; import { useSuppliers } from '../../../api/hooks/suppliers'; import { useCreatePurchaseOrder } from '../../../api/hooks/suppliers'; +import { useIngredients } from '../../../api/hooks/inventory'; import { useTenantStore } from '../../../stores/tenant.store'; import type { ProcurementRequirementResponse, PurchaseOrderItem } from '../../../api/types/orders'; import type { SupplierSummary } from '../../../api/types/suppliers'; +import type { IngredientResponse } from '../../../api/types/inventory'; +import { statusColors } from '../../../styles/colors'; interface CreatePurchaseOrderModalProps { isOpen: boolean; @@ -16,6 +17,7 @@ interface CreatePurchaseOrderModalProps { onSuccess?: () => void; } + /** * CreatePurchaseOrderModal - Modal for creating purchase orders from procurement requirements * Allows supplier selection and purchase order creation for ingredients @@ -27,29 +29,8 @@ export const CreatePurchaseOrderModal: React.FC = requirements, onSuccess }) => { - const [selectedSupplierId, setSelectedSupplierId] = useState(''); - const [deliveryDate, setDeliveryDate] = useState(''); - const [notes, setNotes] = useState(''); - const [selectedRequirements, setSelectedRequirements] = useState>({}); - const [quantities, setQuantities] = useState>({}); const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - // For manual creation when no requirements are provided - const [manualItems, setManualItems] = useState>([]); - const [manualItemInputs, setManualItemInputs] = useState({ - product_name: '', - product_sku: '', - unit_of_measure: '', - unit_price: '', - quantity: '' - }); + const [selectedSupplier, setSelectedSupplier] = useState(''); // Get current tenant const { currentTenant } = useTenantStore(); @@ -63,657 +44,312 @@ export const CreatePurchaseOrderModal: React.FC = ); const suppliers = (suppliersData || []).filter(supplier => supplier.status === 'active'); + // Fetch ingredients filtered by selected supplier (only when manually adding products) + const { data: ingredientsData = [] } = useIngredients( + tenantId, + selectedSupplier ? { supplier_id: selectedSupplier } : {}, + { enabled: !!tenantId && isOpen && !requirements?.length && !!selectedSupplier } + ); + // Create purchase order mutation const createPurchaseOrderMutation = useCreatePurchaseOrder(); - // Initialize quantities when requirements change - useEffect(() => { - if (requirements && requirements.length > 0) { - // Initialize from requirements (existing behavior) - const initialQuantities: Record = {}; - requirements.forEach(req => { - initialQuantities[req.id] = req.approved_quantity || req.net_requirement || req.required_quantity; - }); - setQuantities(initialQuantities); + const supplierOptions = useMemo(() => suppliers.map(supplier => ({ + value: supplier.id, + label: `${supplier.name} (${supplier.supplier_code})` + })), [suppliers]); - // Initialize all requirements as selected - const initialSelected: Record = {}; - requirements.forEach(req => { - initialSelected[req.id] = true; - }); - setSelectedRequirements(initialSelected); - - // Clear manual items when using requirements - setManualItems([]); - } else { - // Reset for manual creation - setQuantities({}); - setSelectedRequirements({}); - setManualItems([]); - } - }, [requirements]); + // Create ingredient options from supplier-filtered ingredients + const ingredientOptions = useMemo(() => ingredientsData.map(ingredient => ({ + value: ingredient.id, + label: ingredient.name, + data: ingredient // Store full ingredient data for later use + })), [ingredientsData]); - // Group requirements by supplier (only when requirements exist) - const groupedRequirements = requirements && requirements.length > 0 ? - requirements.reduce((acc, req) => { - const supplierId = req.preferred_supplier_id || 'unassigned'; - if (!acc[supplierId]) { - acc[supplierId] = []; - } - acc[supplierId].push(req); - return acc; - }, {} as Record) : - {}; - - const handleQuantityChange = (requirementId: string, value: string) => { - const numValue = parseFloat(value) || 0; - setQuantities(prev => ({ - ...prev, - [requirementId]: numValue - })); - }; - - const handleSelectRequirement = (requirementId: string, checked: boolean) => { - setSelectedRequirements(prev => ({ - ...prev, - [requirementId]: checked - })); - }; - - const handleSelectAll = (supplierId: string, checked: boolean) => { - const supplierRequirements = groupedRequirements[supplierId] || []; - const updatedSelected = { ...selectedRequirements }; - - supplierRequirements.forEach(req => { - updatedSelected[req.id] = checked; - }); - - setSelectedRequirements(updatedSelected); - }; - - // Manual item functions - const handleAddManualItem = () => { - if (!manualItemInputs.product_name || !manualItemInputs.unit_of_measure || !manualItemInputs.unit_price) { - return; - } - - const newItem = { - id: `manual-${Date.now()}`, - product_name: manualItemInputs.product_name, - product_sku: manualItemInputs.product_sku || undefined, - unit_of_measure: manualItemInputs.unit_of_measure, - unit_price: parseFloat(manualItemInputs.unit_price) || 0 - }; - - setManualItems(prev => [...prev, newItem]); - - // Reset inputs - setManualItemInputs({ - product_name: '', - product_sku: '', - unit_of_measure: '', - unit_price: '', - quantity: '' - }); - }; - - const handleRemoveManualItem = (id: string) => { - setManualItems(prev => prev.filter(item => item.id !== id)); - }; - - const handleManualItemQuantityChange = (id: string, value: string) => { - const numValue = parseFloat(value) || 0; - setQuantities(prev => ({ - ...prev, - [id]: numValue - })); - }; - - const handleCreatePurchaseOrder = async () => { - if (!selectedSupplierId) { - setError('Por favor, selecciona un proveedor'); - return; - } - - let items: PurchaseOrderItem[] = []; - - if (requirements && requirements.length > 0) { - // Create items from requirements - const selectedReqs = requirements.filter(req => selectedRequirements[req.id]); - - if (selectedReqs.length === 0) { - setError('Por favor, selecciona al menos un ingrediente'); - return; - } - - // Validate quantities - const invalidQuantities = selectedReqs.some(req => quantities[req.id] <= 0); - if (invalidQuantities) { - setError('Todas las cantidades deben ser mayores a 0'); - return; - } - - // Prepare purchase order items from requirements - items = selectedReqs.map(req => ({ - inventory_product_id: req.product_id, - product_code: req.product_sku || '', - product_name: req.product_name, - ordered_quantity: quantities[req.id], - unit_of_measure: req.unit_of_measure, - unit_price: req.estimated_unit_cost || 0, - quality_requirements: req.quality_specifications ? JSON.stringify(req.quality_specifications) : undefined, - notes: req.special_requirements || undefined - })); - } else { - // Create items from manual entries - if (manualItems.length === 0) { - setError('Por favor, agrega al menos un producto'); - return; - } - - // Validate quantities for manual items - const invalidQuantities = manualItems.some(item => quantities[item.id] <= 0); - if (invalidQuantities) { - setError('Todas las cantidades deben ser mayores a 0'); - return; - } - - // Prepare purchase order items from manual entries - items = manualItems.map(item => ({ - inventory_product_id: '', // Not applicable for manual items - product_code: item.product_sku || '', - product_name: item.product_name, - ordered_quantity: quantities[item.id], - unit_of_measure: item.unit_of_measure, - unit_price: item.unit_price, - quality_requirements: undefined, - notes: undefined - })); - } + // Unit options for select field + const unitOptions = [ + { value: 'kg', label: 'Kilogramos' }, + { value: 'g', label: 'Gramos' }, + { value: 'l', label: 'Litros' }, + { value: 'ml', label: 'Mililitros' }, + { value: 'units', label: 'Unidades' }, + { value: 'boxes', label: 'Cajas' }, + { value: 'bags', label: 'Bolsas' } + ]; + const handleSave = async (formData: Record) => { setLoading(true); - setError(null); + + // Update selectedSupplier if it changed + if (formData.supplier_id && formData.supplier_id !== selectedSupplier) { + setSelectedSupplier(formData.supplier_id); + } try { + let items: PurchaseOrderItem[] = []; + + if (requirements && requirements.length > 0) { + // Create items from requirements list + const requiredIngredients = formData.required_ingredients || []; + + if (requiredIngredients.length === 0) { + throw new Error('Por favor, selecciona al menos un ingrediente'); + } + + // Validate quantities + const invalidQuantities = requiredIngredients.some((item: any) => item.quantity <= 0); + if (invalidQuantities) { + throw new Error('Todas las cantidades deben ser mayores a 0'); + } + + // Prepare purchase order items from requirements + items = requiredIngredients.map((item: any) => { + // Find original requirement to get product_id + const originalReq = requirements.find(req => req.id === item.id); + return { + inventory_product_id: originalReq?.product_id || '', + product_code: item.product_sku || '', + product_name: item.product_name, + ordered_quantity: item.quantity, + unit_of_measure: item.unit_of_measure, + unit_price: item.unit_price, + quality_requirements: originalReq?.quality_specifications ? JSON.stringify(originalReq.quality_specifications) : undefined, + notes: originalReq?.special_requirements || undefined + }; + }); + } else { + // Create items from manual entries + const manualProducts = formData.manual_products || []; + + if (manualProducts.length === 0) { + throw new Error('Por favor, agrega al menos un producto'); + } + + // Validate quantities for manual items + const invalidQuantities = manualProducts.some((item: any) => item.quantity <= 0); + if (invalidQuantities) { + throw new Error('Todas las cantidades deben ser mayores a 0'); + } + + // Validate required fields + const invalidProducts = manualProducts.some((item: any) => !item.ingredient_id); + if (invalidProducts) { + throw new Error('Todos los productos deben tener un ingrediente seleccionado'); + } + + // Prepare purchase order items from manual entries with ingredient data + items = manualProducts.map((item: any) => { + // Find the selected ingredient data + const selectedIngredient = ingredientsData.find(ing => ing.id === item.ingredient_id); + + return { + inventory_product_id: item.ingredient_id, + product_code: selectedIngredient?.sku || '', + product_name: selectedIngredient?.name || 'Ingrediente desconocido', + ordered_quantity: item.quantity, + unit_of_measure: item.unit_of_measure, + unit_price: item.unit_price, + quality_requirements: undefined, + notes: undefined + }; + }); + } + // Create purchase order await createPurchaseOrderMutation.mutateAsync({ - supplier_id: selectedSupplierId, + supplier_id: formData.supplier_id, priority: 'normal', - required_delivery_date: deliveryDate || undefined, - notes: notes || undefined, + required_delivery_date: formData.delivery_date || undefined, + notes: formData.notes || undefined, items }); - // Close modal and trigger success callback - onClose(); + // Purchase order created successfully + + // Trigger success callback if (onSuccess) { onSuccess(); } - } catch (err) { - console.error('Error creating purchase order:', err); - setError('Error al crear la orden de compra. Por favor, intenta de nuevo.'); + } catch (error) { + console.error('Error creating purchase order:', error); + throw error; // Let AddModal handle error display } finally { setLoading(false); } }; - // Log suppliers when they change for debugging - useEffect(() => { - // console.log('Suppliers updated:', suppliers); - }, [suppliers]); + const statusConfig = { + color: statusColors.inProgress.primary, + text: 'Nueva Orden', + icon: Plus, + isCritical: false, + isHighlight: true + }; - if (!isOpen) return null; + + const sections = [ + { + title: 'Información del Proveedor', + icon: Building2, + fields: [ + { + label: 'Proveedor', + name: 'supplier_id', + type: 'select' as const, + required: true, + options: supplierOptions, + placeholder: 'Seleccionar proveedor...', + span: 2 + } + ] + }, + { + title: 'Detalles de la Orden', + icon: Calendar, + fields: [ + { + label: 'Fecha de Entrega Requerida', + name: 'delivery_date', + type: 'date' as const, + helpText: 'Fecha límite para la entrega (opcional)' + }, + { + label: 'Notas', + name: 'notes', + type: 'textarea' as const, + placeholder: 'Instrucciones especiales para el proveedor...', + span: 2, + helpText: 'Información adicional o instrucciones especiales' + } + ] + }, + { + title: requirements && requirements.length > 0 ? 'Ingredientes Requeridos' : 'Productos a Comprar', + icon: Package, + fields: [ + requirements && requirements.length > 0 ? { + label: 'Ingredientes Requeridos', + name: 'required_ingredients', + type: 'list' as const, + span: 2, + defaultValue: requirements.map(req => ({ + id: req.id, + product_name: req.product_name, + product_sku: req.product_sku || '', + quantity: req.approved_quantity || req.net_requirement || req.required_quantity, + unit_of_measure: req.unit_of_measure, + unit_price: req.estimated_unit_cost || 0, + selected: true + })), + listConfig: { + itemFields: [ + { + name: 'product_name', + label: 'Producto', + type: 'text', + required: false // Read-only display + }, + { + name: 'product_sku', + label: 'SKU', + type: 'text', + required: false + }, + { + name: 'quantity', + label: 'Cantidad Requerida', + type: 'number', + required: true + }, + { + name: 'unit_of_measure', + label: 'Unidad', + type: 'text', + required: false + }, + { + name: 'unit_price', + label: 'Precio Est. (€)', + type: 'currency', + required: true + } + ], + addButtonLabel: 'Agregar Ingrediente', + emptyStateText: 'No hay ingredientes requeridos', + showSubtotals: true, + subtotalFields: { quantity: 'quantity', price: 'unit_price' } + }, + helpText: 'Revisa y ajusta las cantidades y precios de los ingredientes requeridos' + } : { + label: 'Productos a Comprar', + name: 'manual_products', + type: 'list' as const, + span: 2, + defaultValue: [], + listConfig: { + itemFields: [ + { + name: 'ingredient_id', + label: 'Ingrediente', + type: 'select', + required: true, + options: ingredientOptions, + placeholder: 'Seleccionar ingrediente...', + disabled: false + }, + { + name: 'quantity', + label: 'Cantidad', + type: 'number', + required: true, + defaultValue: 1 + }, + { + name: 'unit_of_measure', + label: 'Unidad', + type: 'select', + required: true, + defaultValue: 'kg', + options: unitOptions + }, + { + name: 'unit_price', + label: 'Precio Unitario (€)', + type: 'currency', + required: true, + defaultValue: 0, + placeholder: '0.00' + } + ], + addButtonLabel: 'Agregar Ingrediente', + emptyStateText: 'No hay ingredientes disponibles para este proveedor', + showSubtotals: true, + subtotalFields: { quantity: 'quantity', price: 'unit_price' }, + disabled: !selectedSupplier + }, + helpText: 'Selecciona ingredientes disponibles del proveedor seleccionado' + } + ] + }, + ]; return ( -
-
- {/* Header */} -
-
-
- -
-
-

- Crear Orden de Compra -

-

- Selecciona proveedor e ingredientes para crear una orden de compra -

-
-
- -
+ <> + 0 + ? "Generar orden de compra desde requerimientos de procuración" + : "Crear orden de compra manual"} + statusIndicator={statusConfig} + sections={sections} + size="xl" + loading={loading} + onSave={handleSave} + /> - {/* Content */} -
- {error && ( -
- - {error} -
- )} - - {/* Supplier Selection */} -
- - {!tenantId ? ( -
- Cargando información del tenant... -
- ) : isLoadingSuppliers ? ( -
- ) : isSuppliersError ? ( -
- Error al cargar proveedores: {suppliersError?.message || 'Error desconocido'} -
- ) : ( - - )} -
- - {/* Delivery Date */} -
- - setDeliveryDate(e.target.value)} - className="w-full" - disabled={loading} - /> -
- - {/* Notes */} -
- -