diff --git a/frontend/src/api/hooks/inventory.ts b/frontend/src/api/hooks/inventory.ts index d237e057..f0639ec5 100644 --- a/frontend/src/api/hooks/inventory.ts +++ b/frontend/src/api/hooks/inventory.ts @@ -174,9 +174,9 @@ export const useStockMovements = ( ingredientId?: string, limit: number = 50, offset: number = 0, - options?: Omit, ApiError>, 'queryKey' | 'queryFn'> + options?: Omit, 'queryKey' | 'queryFn'> ) => { - return useQuery, ApiError>({ + return useQuery({ queryKey: inventoryKeys.stock.movements(tenantId, ingredientId), queryFn: () => inventoryService.getStockMovements(tenantId, ingredientId, limit, offset), enabled: !!tenantId, @@ -339,34 +339,117 @@ export const useConsumeStock = ( export const useCreateStockMovement = ( options?: UseMutationOptions< - StockMovementResponse, - ApiError, + StockMovementResponse, + ApiError, { tenantId: string; movementData: StockMovementCreate } > ) => { const queryClient = useQueryClient(); - + return useMutation< - StockMovementResponse, - ApiError, + StockMovementResponse, + ApiError, { tenantId: string; movementData: StockMovementCreate } >({ mutationFn: ({ tenantId, movementData }) => inventoryService.createStockMovement(tenantId, movementData), onSuccess: (data, { tenantId, movementData }) => { // Invalidate movement queries queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.movements(tenantId) }); - queryClient.invalidateQueries({ - queryKey: inventoryKeys.stock.movements(tenantId, movementData.ingredient_id) + queryClient.invalidateQueries({ + queryKey: inventoryKeys.stock.movements(tenantId, movementData.ingredient_id) }); // Invalidate stock queries if this affects stock levels if (['in', 'out', 'adjustment'].includes(movementData.movement_type)) { queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.lists() }); - queryClient.invalidateQueries({ - queryKey: inventoryKeys.stock.byIngredient(tenantId, movementData.ingredient_id) + queryClient.invalidateQueries({ + queryKey: inventoryKeys.stock.byIngredient(tenantId, movementData.ingredient_id) }); queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.lists() }); } }, ...options, }); +}; + +// Custom hooks for stock management operations +export const useStockOperations = (tenantId: string) => { + const queryClient = useQueryClient(); + + const addStock = useMutation({ + mutationFn: async ({ ingredientId, quantity, unit_cost, notes }: { + ingredientId: string; + quantity: number; + unit_cost?: number; + notes?: string; + }) => { + // Create stock entry via backend API + const stockData: StockCreate = { + ingredient_id: ingredientId, + quantity, + unit_price: unit_cost || 0, + notes + }; + return inventoryService.addStock(tenantId, stockData); + }, + onSuccess: (data, variables) => { + // Invalidate relevant queries + queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.lists() }); + queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.movements(tenantId) }); + queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.movements(tenantId, variables.ingredientId) }); + } + }); + + const consumeStock = useMutation({ + mutationFn: async ({ ingredientId, quantity, reference_number, notes, fifo = true }: { + ingredientId: string; + quantity: number; + reference_number?: string; + notes?: string; + fifo?: boolean; + }) => { + const consumptionData: StockConsumptionRequest = { + ingredient_id: ingredientId, + quantity, + reference_number, + notes, + fifo + }; + return inventoryService.consumeStock(tenantId, consumptionData); + }, + onSuccess: (data, variables) => { + // Invalidate relevant queries + queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.lists() }); + queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.movements(tenantId) }); + queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.movements(tenantId, variables.ingredientId) }); + } + }); + + const adjustStock = useMutation({ + mutationFn: async ({ ingredientId, quantity, notes }: { + ingredientId: string; + quantity: number; + notes?: string; + }) => { + // Create adjustment movement via backend API + const movementData: StockMovementCreate = { + ingredient_id: ingredientId, + movement_type: 'adjustment', + quantity, + notes + }; + return inventoryService.createStockMovement(tenantId, movementData); + }, + onSuccess: (data, variables) => { + // Invalidate relevant queries + queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.lists() }); + queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.movements(tenantId) }); + queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.movements(tenantId, variables.ingredientId) }); + } + }); + + return { + addStock, + consumeStock, + adjustStock + }; }; \ No newline at end of file diff --git a/frontend/src/api/services/inventory.ts b/frontend/src/api/services/inventory.ts index 3ffd6025..08aff069 100644 --- a/frontend/src/api/services/inventory.ts +++ b/frontend/src/api/services/inventory.ts @@ -172,15 +172,23 @@ export class InventoryService { ingredientId?: string, limit: number = 50, offset: number = 0 - ): Promise> { + ): Promise { const queryParams = new URLSearchParams(); if (ingredientId) queryParams.append('ingredient_id', ingredientId); queryParams.append('limit', limit.toString()); - queryParams.append('offset', offset.toString()); + queryParams.append('skip', offset.toString()); // Backend expects 'skip' not 'offset' - return apiClient.get>( - `${this.baseUrl}/${tenantId}/stock/movements?${queryParams.toString()}` - ); + const url = `${this.baseUrl}/${tenantId}/stock/movements?${queryParams.toString()}`; + console.log('🔍 Frontend calling API:', url); + + try { + const result = await apiClient.get(url); + console.log('✅ Frontend API response:', result); + return result; + } catch (error) { + console.error('❌ Frontend API error:', error); + throw error; + } } // Expiry Management diff --git a/frontend/src/api/types/inventory.ts b/frontend/src/api/types/inventory.ts index 3a3690bd..7a9b7d85 100644 --- a/frontend/src/api/types/inventory.ts +++ b/frontend/src/api/types/inventory.ts @@ -183,27 +183,36 @@ export interface StockResponse { export interface StockMovementCreate { ingredient_id: string; - movement_type: 'in' | 'out' | 'adjustment' | 'transfer' | 'waste'; + stock_id?: string; + movement_type: 'purchase' | 'production_use' | 'adjustment' | 'waste' | 'transfer' | 'return' | 'initial_stock'; quantity: number; - unit_price?: number; + unit_cost?: number; reference_number?: string; + supplier_id?: string; notes?: string; - related_stock_id?: string; + reason_code?: string; + movement_date?: string; } export interface StockMovementResponse { id: string; - ingredient_id: string; tenant_id: string; - movement_type: 'in' | 'out' | 'adjustment' | 'transfer' | 'waste'; + ingredient_id: string; + stock_id?: string; + movement_type: 'purchase' | 'production_use' | 'adjustment' | 'waste' | 'transfer' | 'return' | 'initial_stock'; quantity: number; - unit_price?: number; - total_value?: number; + unit_cost?: number; + total_cost?: number; + quantity_before?: number; + quantity_after?: number; reference_number?: string; + supplier_id?: string; notes?: string; - related_stock_id?: string; + reason_code?: string; + movement_date: string; created_at: string; created_by?: string; + ingredient?: IngredientResponse; } // Filter and Query Types diff --git a/frontend/src/components/domain/inventory/InventoryForm.tsx b/frontend/src/components/domain/inventory/InventoryForm.tsx deleted file mode 100644 index f367b974..00000000 --- a/frontend/src/components/domain/inventory/InventoryForm.tsx +++ /dev/null @@ -1,364 +0,0 @@ -import React, { useState, useEffect, useCallback } from 'react'; -import { clsx } from 'clsx'; -import { Button } from '../../ui'; -import { Input } from '../../ui'; -import { Select } from '../../ui'; -import { Card } from '../../ui'; -import { Badge } from '../../ui'; -import { Modal } from '../../ui'; -import { IngredientCreate, IngredientResponse } from '../../../api/types/inventory'; - -export interface InventoryFormProps { - item?: IngredientResponse; - open?: boolean; - onClose?: () => void; - onSubmit?: (data: IngredientCreate) => Promise; - loading?: boolean; - className?: string; -} - -// Spanish bakery categories -const BAKERY_CATEGORIES = [ - { value: 'harinas', label: 'Harinas' }, - { value: 'levaduras', label: 'Levaduras' }, - { value: 'azucares', label: 'Azúcares y Endulzantes' }, - { value: 'chocolates', label: 'Chocolates y Cacao' }, - { value: 'frutas', label: 'Frutas y Frutos Secos' }, - { value: 'lacteos', label: 'Lácteos' }, - { value: 'huevos', label: 'Huevos' }, - { value: 'mantequillas', label: 'Mantequillas y Grasas' }, - { value: 'especias', label: 'Especias y Aromas' }, - { value: 'conservantes', label: 'Conservantes y Aditivos' }, - { value: 'decoracion', label: 'Decoración' }, - { value: 'envases', label: 'Envases y Embalajes' }, - { value: 'utensilios', label: 'Utensilios y Equipos' }, - { value: 'limpieza', label: 'Limpieza e Higiene' }, -]; - -const UNITS_OF_MEASURE = [ - { value: 'kg', label: 'Kilogramo (kg)' }, - { value: 'g', label: 'Gramo (g)' }, - { value: 'l', label: 'Litro (l)' }, - { value: 'ml', label: 'Mililitro (ml)' }, - { value: 'pz', label: 'Pieza (pz)' }, - { value: 'pkg', label: 'Paquete' }, - { value: 'bag', label: 'Bolsa' }, - { value: 'box', label: 'Caja' }, - { value: 'dozen', label: 'Docena' }, - { value: 'cup', label: 'Taza' }, - { value: 'tbsp', label: 'Cucharada' }, - { value: 'tsp', label: 'Cucharadita' }, - { value: 'lb', label: 'Libra (lb)' }, - { value: 'oz', label: 'Onza (oz)' }, -]; - -const initialFormData: IngredientCreate = { - name: '', - description: '', - category: '', - unit_of_measure: 'kg', - low_stock_threshold: 10, - max_stock_level: 100, - reorder_point: 20, - shelf_life_days: undefined, - requires_refrigeration: false, - requires_freezing: false, - is_seasonal: false, - supplier_id: undefined, - average_cost: undefined, - notes: '', -}; - -export const InventoryForm: React.FC = ({ - item, - open = false, - onClose, - onSubmit, - loading = false, - className, -}) => { - const [formData, setFormData] = useState(initialFormData); - const [errors, setErrors] = useState>({}); - - const isEditing = !!item; - - // Initialize form data when item changes - useEffect(() => { - if (item) { - setFormData({ - name: item.name, - description: item.description || '', - category: item.category, - unit_of_measure: item.unit_of_measure, - low_stock_threshold: item.low_stock_threshold, - max_stock_level: item.max_stock_level, - reorder_point: item.reorder_point, - shelf_life_days: item.shelf_life_days, - requires_refrigeration: item.requires_refrigeration, - requires_freezing: item.requires_freezing, - is_seasonal: item.is_seasonal, - supplier_id: item.supplier_id, - average_cost: item.average_cost, - notes: item.notes || '', - }); - } else { - setFormData(initialFormData); - } - setErrors({}); - }, [item]); - - // Handle input changes - const handleInputChange = useCallback((field: keyof IngredientCreate, value: any) => { - setFormData(prev => ({ ...prev, [field]: value })); - // Clear error for this field - if (errors[field]) { - setErrors(prev => ({ ...prev, [field]: '' })); - } - }, [errors]); - - // Validate form - const validateForm = useCallback((): boolean => { - const newErrors: Record = {}; - - if (!formData.name.trim()) { - newErrors.name = 'El nombre es obligatorio'; - } - - if (!formData.unit_of_measure) { - newErrors.unit_of_measure = 'La unidad de medida es obligatoria'; - } - - if (formData.low_stock_threshold < 0) { - newErrors.low_stock_threshold = 'El stock mínimo no puede ser negativo'; - } - - if (formData.reorder_point < 0) { - newErrors.reorder_point = 'El punto de reorden no puede ser negativo'; - } - - if (formData.max_stock_level !== undefined && formData.max_stock_level < formData.low_stock_threshold) { - newErrors.max_stock_level = 'El stock máximo debe ser mayor que el mínimo'; - } - - if (formData.average_cost !== undefined && formData.average_cost < 0) { - newErrors.average_cost = 'El precio no puede ser negativo'; - } - - if (formData.shelf_life_days !== undefined && formData.shelf_life_days <= 0) { - newErrors.shelf_life_days = 'La vida útil debe ser mayor que 0'; - } - - setErrors(newErrors); - return Object.keys(newErrors).length === 0; - }, [formData]); - - // Handle form submission - const handleSubmit = useCallback(async (e: React.FormEvent) => { - e.preventDefault(); - - if (!validateForm()) return; - - try { - await onSubmit?.(formData); - } catch (error) { - console.error('Form submission failed:', error); - } - }, [formData, validateForm, onSubmit]); - - return ( - -
-
- {/* Left Column - Basic Information */} -
- -

Información Básica

- -
- handleInputChange('name', e.target.value)} - error={errors.name} - placeholder="Ej. Harina de trigo" - /> - - handleInputChange('description', e.target.value)} - placeholder="Descripción detallada del ingrediente" - /> - - handleInputChange('notes', e.target.value)} - placeholder="Notas adicionales sobre el ingrediente" - /> -
-
-
- - {/* Right Column - Specifications */} -
- -

Precios y Costos

- -
- handleInputChange('average_cost', e.target.value ? parseFloat(e.target.value) : undefined)} - error={errors.average_cost} - leftAddon="€" - placeholder="0.00" - /> -
-
- - -

Gestión de Stock

- -
-
- handleInputChange('low_stock_threshold', parseFloat(e.target.value) || 0)} - error={errors.low_stock_threshold} - placeholder="10" - /> - handleInputChange('reorder_point', parseFloat(e.target.value) || 0)} - error={errors.reorder_point} - placeholder="20" - /> -
- - handleInputChange('max_stock_level', e.target.value ? parseFloat(e.target.value) : undefined)} - error={errors.max_stock_level} - placeholder="Ej. 100" - helperText="Dejar vacío para stock ilimitado" - /> -
-
-
-
- - {/* Storage and Preservation */} - -

Almacenamiento y Conservación

- -
-
- - - - - -
- - handleInputChange('shelf_life_days', e.target.value ? parseFloat(e.target.value) : undefined)} - error={errors.shelf_life_days} - placeholder="Ej. 30" - /> -
-
- - {/* Form Actions */} -
- - -
-
-
- ); -}; - -export default InventoryForm; \ No newline at end of file diff --git a/frontend/src/components/domain/inventory/InventoryItemModal.tsx b/frontend/src/components/domain/inventory/InventoryItemModal.tsx new file mode 100644 index 00000000..05745f1c --- /dev/null +++ b/frontend/src/components/domain/inventory/InventoryItemModal.tsx @@ -0,0 +1,1317 @@ +import React, { useState, useMemo } from 'react'; +import { Package, Calendar, AlertTriangle, TrendingUp, BarChart3, DollarSign, Thermometer, FileText, Layers, Plus, Minus, RotateCcw, Trash2, ArrowUpDown, History, User, Clock } from 'lucide-react'; +import { StatusModal, StatusModalSection, getStatusColor, Button, Badge } from '../../ui'; +import { useStockMovements, useStockOperations, useStockByIngredient, useUpdateIngredient } from '../../../api/hooks/inventory'; +import { useCurrentTenant } from '../../../stores/tenant.store'; +import { IngredientResponse, IngredientUpdate } from '../../../api/types/inventory'; +import { formatters } from '../../ui/Stats/StatsPresets'; + +interface InventoryItemModalProps { + isOpen: boolean; + onClose: () => void; + ingredient: IngredientResponse; + mode?: 'overview' | 'batches' | 'movements' | 'details' | 'edit'; + onModeChange?: (mode: 'overview' | 'batches' | 'movements' | 'details' | 'edit') => void; +} + +// Stock movement type configuration +const movementTypeConfig = { + purchase: { + label: 'Compra', + icon: Plus, + color: 'text-green-600', + bgColor: 'bg-green-50', + description: 'Ingreso de stock' + }, + production_use: { + label: 'Producción', + icon: Minus, + color: 'text-blue-600', + bgColor: 'bg-blue-50', + description: 'Uso en producción' + }, + adjustment: { + label: 'Ajuste', + icon: RotateCcw, + color: 'text-orange-600', + bgColor: 'bg-orange-50', + description: 'Ajuste de inventario' + }, + waste: { + label: 'Desperdicio', + icon: Trash2, + color: 'text-red-600', + bgColor: 'bg-red-50', + description: 'Producto desperdiciado' + }, + transfer: { + label: 'Transferencia', + icon: ArrowUpDown, + color: 'text-purple-600', + bgColor: 'bg-purple-50', + description: 'Transferencia entre ubicaciones' + }, + return: { + label: 'Devolución', + icon: Package, + color: 'text-indigo-600', + bgColor: 'bg-indigo-50', + description: 'Devolución de producto' + }, + initial_stock: { + label: 'Stock Inicial', + icon: Package, + color: 'text-gray-600', + bgColor: 'bg-gray-50', + description: 'Stock inicial del sistema' + } +}; + +/** + * Inventory Item Modal Component + * Displays comprehensive inventory item information and operations using StatusModal for consistency + */ +export const InventoryItemModal: React.FC = ({ + isOpen, + onClose, + ingredient, + mode = 'overview', + onModeChange +}) => { + const [activeView, setActiveView] = useState<'overview' | 'batches' | 'movements' | 'details' | 'edit'>(mode); + const [operationModal, setOperationModal] = useState<'add' | 'consume' | 'adjust' | null>(null); + const [operationLoading, setOperationLoading] = useState(false); + const [editLoading, setEditLoading] = useState(false); + + // Edit form state + const [editForm, setEditForm] = useState>({}); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + + const currentTenant = useCurrentTenant(); + const tenantId = currentTenant?.id || ''; + + // Form states for stock operations + const [addStockForm, setAddStockForm] = useState({ + quantity: 0, + unit_cost: 0, + notes: '' + }); + + const [consumeStockForm, setConsumeStockForm] = useState({ + quantity: 0, + reference_number: '', + notes: '', + fifo: true + }); + + const [adjustStockForm, setAdjustStockForm] = useState({ + quantity: 0, + notes: '' + }); + + // Fetch stock movements for this ingredient + const { + data: movementsData, + isLoading: movementsLoading, + refetch: refetchMovements + } = useStockMovements(tenantId, ingredient.id); + + // Fetch detailed stock entries (batches) for this ingredient + const { + data: stockData, + isLoading: stockLoading, + refetch: refetchStock + } = useStockByIngredient(tenantId, ingredient.id, true); + + // Stock operations hook + const stockOperations = useStockOperations(tenantId); + + // Update ingredient hook + const updateIngredient = useUpdateIngredient(); + + const movements = movementsData || []; + const batches = stockData || []; + + // Sort movements by date (newest first) + const sortedMovements = useMemo(() => { + return [...movements].sort((a, b) => + new Date(b.movement_date).getTime() - new Date(a.movement_date).getTime() + ); + }, [movements]); + + // Calculate batch statistics + const now = new Date(); + const expiredBatches = batches.filter(batch => + batch.is_expired || (batch.expiration_date && new Date(batch.expiration_date) < now) + ); + const expiringSoonBatches = batches.filter(batch => { + if (!batch.expiration_date || batch.is_expired) return false; + const expirationDate = new Date(batch.expiration_date); + const daysUntilExpiry = Math.ceil((expirationDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); + return daysUntilExpiry <= 3 && daysUntilExpiry > 0; + }); + + // Get status indicator based on stock status + const getStatusIndicator = () => { + const { stock_status } = ingredient; + + switch (stock_status) { + case 'out_of_stock': + return { + color: getStatusColor('cancelled'), + text: 'Sin Stock', + icon: AlertTriangle, + isCritical: true, + isHighlight: false + }; + case 'low_stock': + return { + color: getStatusColor('pending'), + text: 'Stock Bajo', + icon: AlertTriangle, + isCritical: false, + isHighlight: true + }; + case 'overstock': + return { + color: getStatusColor('info'), + text: 'Sobrestock', + icon: TrendingUp, + isCritical: false, + isHighlight: false + }; + case 'in_stock': + default: + return { + color: getStatusColor('completed'), + text: 'Stock Normal', + icon: Package, + isCritical: false, + isHighlight: false + }; + } + }; + + // Field mapping for edit form + const editFieldMap: Record = { + 'Nombre': 'name', + 'Categoría': 'category', + 'Subcategoría': 'subcategory', + 'Marca': 'brand', + 'Descripción': 'description', + 'Unidad de medida': 'unit_of_measure', + 'Tamaño del paquete': 'package_size', + 'Estado': 'is_active', + 'Es perecedero': 'is_perishable', + 'Costo promedio': 'average_cost', + 'Costo estándar': 'standard_cost', + 'Umbral de stock bajo': 'low_stock_threshold', + 'Punto de reorden': 'reorder_point', + 'Cantidad de reorden': 'reorder_quantity', + 'Nivel máximo de stock': 'max_stock_level', + 'Requiere refrigeración': 'requires_refrigeration', + 'Requiere congelación': 'requires_freezing', + 'Temperatura mínima (°C)': 'storage_temperature_min', + 'Temperatura máxima (°C)': 'storage_temperature_max', + 'Humedad máxima (%)': 'storage_humidity_max', + 'Vida útil (días)': 'shelf_life_days', + 'Instrucciones de almacenamiento': 'storage_instructions' + }; + + // Edit handlers + const handleEditFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number | boolean) => { + const section = getEditSections()[sectionIndex]; + if (!section) return; + + const field = section.fields[fieldIndex]; + const fieldKey = editFieldMap[field.label]; + if (!fieldKey) return; + + let processedValue: any = value; + if (fieldKey === 'is_active' || fieldKey === 'is_perishable' || fieldKey === 'requires_refrigeration' || fieldKey === 'requires_freezing') { + processedValue = value === 'true'; + } else if (typeof value === 'string' && (field.type === 'number' || field.type === 'currency')) { + processedValue = parseFloat(value) || 0; + } + + setEditForm(prev => ({ + ...prev, + [fieldKey]: processedValue + })); + setHasUnsavedChanges(true); + }; + + const handleSaveChanges = async () => { + if (!hasUnsavedChanges) return; + + setEditLoading(true); + try { + await updateIngredient.mutateAsync({ + tenantId, + ingredientId: ingredient.id, + updateData: editForm + }); + setHasUnsavedChanges(false); + setActiveView('overview'); + } catch (error) { + console.error('Error updating ingredient:', error); + } finally { + setEditLoading(false); + } + }; + + const handleCancelEdit = () => { + if (hasUnsavedChanges) { + if (!confirm('¿Estás seguro de que quieres cancelar? Los cambios no guardados se perderán.')) { + return; + } + } + setEditForm({}); + setHasUnsavedChanges(false); + setActiveView('overview'); + }; + + // Get modal title + const getTitle = () => ingredient.name; + + // Get modal subtitle + const getSubtitle = () => { + const category = ingredient.category || 'Sin categoría'; + const stock = `${(ingredient.current_stock || 0).toFixed(1)} ${ingredient.unit_of_measure}`; + return `${category} • ${stock} disponibles`; + }; + + // Initialize edit form when entering edit mode + React.useEffect(() => { + if (activeView === 'edit') { + setEditForm({ + name: ingredient.name, + category: ingredient.category, + subcategory: ingredient.subcategory, + description: ingredient.description, + brand: ingredient.brand, + unit_of_measure: ingredient.unit_of_measure, + package_size: ingredient.package_size, + average_cost: ingredient.average_cost || undefined, + standard_cost: ingredient.standard_cost || undefined, + low_stock_threshold: ingredient.low_stock_threshold, + reorder_point: ingredient.reorder_point, + reorder_quantity: ingredient.reorder_quantity, + max_stock_level: ingredient.max_stock_level || undefined, + requires_refrigeration: ingredient.requires_refrigeration, + requires_freezing: ingredient.requires_freezing, + storage_temperature_min: ingredient.storage_temperature_min || undefined, + storage_temperature_max: ingredient.storage_temperature_max || undefined, + storage_humidity_max: ingredient.storage_humidity_max || undefined, + shelf_life_days: ingredient.shelf_life_days || undefined, + storage_instructions: ingredient.storage_instructions || undefined, + is_active: ingredient.is_active, + is_perishable: ingredient.is_perishable, + allergen_info: ingredient.allergen_info || undefined + }); + setHasUnsavedChanges(false); + } + }, [activeView, ingredient]); + + // Handle view change + const handleViewChange = (newView: typeof activeView) => { + if (hasUnsavedChanges && activeView === 'edit') { + if (!confirm('Tienes cambios sin guardar. ¿Estás seguro de que quieres salir del modo edición?')) { + return; + } + } + setActiveView(newView); + if (onModeChange) { + onModeChange(newView); + } + }; + + // Stock operation handlers + const handleAddStock = async () => { + if (addStockForm.quantity > 0) { + setOperationLoading(true); + try { + await stockOperations.addStock.mutateAsync({ + ingredientId: ingredient.id, + quantity: addStockForm.quantity, + unit_cost: addStockForm.unit_cost || undefined, + notes: addStockForm.notes || undefined + }); + setAddStockForm({ quantity: 0, unit_cost: 0, notes: '' }); + setOperationModal(null); + refetchMovements(); + } finally { + setOperationLoading(false); + } + } + }; + + const handleConsumeStock = async () => { + if (consumeStockForm.quantity > 0 && consumeStockForm.quantity <= (ingredient.current_stock || 0)) { + setOperationLoading(true); + try { + await stockOperations.consumeStock.mutateAsync({ + ingredientId: ingredient.id, + quantity: consumeStockForm.quantity, + reference_number: consumeStockForm.reference_number || undefined, + notes: consumeStockForm.notes || undefined, + fifo: consumeStockForm.fifo + }); + setConsumeStockForm({ quantity: 0, reference_number: '', notes: '', fifo: true }); + setOperationModal(null); + refetchMovements(); + } finally { + setOperationLoading(false); + } + } + }; + + const handleAdjustStock = async () => { + if (adjustStockForm.quantity !== (ingredient.current_stock || 0)) { + setOperationLoading(true); + try { + await stockOperations.adjustStock.mutateAsync({ + ingredientId: ingredient.id, + quantity: adjustStockForm.quantity, + notes: adjustStockForm.notes || undefined + }); + setAdjustStockForm({ quantity: 0, notes: '' }); + setOperationModal(null); + refetchMovements(); + } finally { + setOperationLoading(false); + } + } + }; + + // Format movement date + const formatMovementDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleString('es-ES', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + }; + + // Render stock operations buttons + const renderStockOperations = () => { + const quickActions = [ + { + label: 'Agregar Stock', + icon: Plus, + variant: 'primary' as const, + onClick: () => setOperationModal('add') + }, + { + label: 'Consumir', + icon: Minus, + variant: 'outline' as const, + onClick: () => setOperationModal('consume') + }, + { + label: 'Ajustar', + icon: RotateCcw, + variant: 'outline' as const, + onClick: () => setOperationModal('adjust') + } + ]; + + return ( +
+ {quickActions.map((action, index) => ( + + ))} +
+ ); + }; + + // Render movements list with improved UI + const renderMovementsList = () => { + if (sortedMovements.length === 0) { + return ( +
+ +

No hay movimientos registrados para este ingrediente.

+

Los movimientos aparecerán aquí cuando agregues, consumas o ajustes stock.

+
+ ); + } + + return ( +
+ {sortedMovements.slice(0, 15).map((movement) => { + const config = movementTypeConfig[movement.movement_type]; + if (!config) return null; + + const IconComponent = config.icon; + const isNegative = ['production_use', 'waste'].includes(movement.movement_type); + const displayQuantity = isNegative ? -movement.quantity : movement.quantity; + + return ( +
+
+
+ +
+
+
+ + {config.label} + + + + {formatMovementDate(movement.movement_date)} + +
+

{config.description}

+ + {movement.notes && ( +

+ 💬 {movement.notes} +

+ )} + + {movement.reference_number && ( +

+ 📋 Ref: {movement.reference_number} +

+ )} + + {movement.created_by && ( +

+ + {movement.created_by} +

+ )} +
+
+ +
+

+ {displayQuantity > 0 ? '+' : ''}{displayQuantity.toFixed(2)} {ingredient.unit_of_measure} +

+ + {movement.unit_cost && ( +

+ {formatters.currency(movement.unit_cost)}/{ingredient.unit_of_measure} +

+ )} + + {movement.quantity_after !== undefined && movement.quantity_after !== null && ( +

+ Stock: {movement.quantity_after.toFixed(2)} {ingredient.unit_of_measure} +

+ )} +
+
+ ); + })} + + {sortedMovements.length > 15 && ( +
+ Mostrando los últimos 15 movimientos • Total: {sortedMovements.length} +
+ )} +
+ ); + }; + + // Handle field changes for stock operations modal + const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number) => { + if (operationModal === 'add') { + const fieldName = ['quantity', 'unit_cost', 'notes'][fieldIndex]; + if (fieldName) { + setAddStockForm(prev => ({ + ...prev, + [fieldName]: fieldName === 'notes' ? value : Number(value) || 0 + })); + } + } else if (operationModal === 'consume') { + const fieldName = ['quantity', 'reference_number', 'notes', 'fifo'][fieldIndex]; + if (fieldName) { + setConsumeStockForm(prev => ({ + ...prev, + [fieldName]: fieldName === 'fifo' ? value === 'true' : + fieldName === 'quantity' ? Number(value) || 0 : value + })); + } + } else if (operationModal === 'adjust') { + const fieldName = ['quantity', 'notes'][fieldIndex]; + if (fieldName) { + setAdjustStockForm(prev => ({ + ...prev, + [fieldName]: fieldName === 'notes' ? value : Number(value) || 0 + })); + } + } + }; + + // Define sections based on active view + const getSections = (): StatusModalSection[] => { + switch (activeView) { + case 'overview': + return [ + { + title: 'Información del Stock', + icon: Package, + fields: [ + { + label: 'Stock actual', + value: `${(ingredient.current_stock || 0).toFixed(2)} ${ingredient.unit_of_measure}`, + highlight: true + }, + { + label: 'Estado del stock', + value: getStatusIndicator().text, + type: 'status' + }, + { + label: 'Umbral mínimo', + value: `${ingredient.low_stock_threshold || 0} ${ingredient.unit_of_measure}` + }, + { + label: 'Punto de reorden', + value: `${ingredient.reorder_point || 0} ${ingredient.unit_of_measure}` + } + ] + }, + { + title: 'Información Financiera', + icon: DollarSign, + fields: [ + { + label: 'Costo promedio', + value: ingredient.average_cost || 0, + type: 'currency' + }, + { + label: 'Último precio de compra', + value: ingredient.last_purchase_price || 0, + type: 'currency' + }, + { + label: 'Valor total en stock', + value: (ingredient.current_stock || 0) * (ingredient.average_cost || 0), + type: 'currency', + highlight: true + }, + { + label: 'Costo estándar', + value: ingredient.standard_cost || 0, + type: 'currency' + } + ] + }, + { + title: 'Alertas y Estado', + icon: AlertTriangle, + fields: [ + { + label: 'Total de lotes', + value: batches.length, + highlight: true + }, + { + label: 'Lotes vencidos', + value: expiredBatches.length, + type: 'status' + }, + { + label: 'Lotes venciendo pronto', + value: expiringSoonBatches.length, + type: 'status' + }, + { + label: 'Último movimiento', + value: movements.length > 0 ? movements[0]?.created_at : 'Sin movimientos', + type: movements.length > 0 ? 'datetime' : 'text' + } + ] + } + ]; + + case 'batches': + return [ + { + title: 'Resumen de Lotes', + icon: Layers, + fields: [ + { + label: 'Total de lotes', + value: batches.length, + highlight: true + }, + { + label: 'Lotes vencidos', + value: expiredBatches.length, + type: 'status' + }, + { + label: 'Lotes venciendo pronto', + value: expiringSoonBatches.length, + type: 'status' + } + ] + }, + { + title: 'Desglose por Lotes', + icon: Package, + fields: [ + { + label: 'Información detallada de cada lote', + value: renderBatchBreakdown(), + span: 2 + } + ] + } + ]; + + case 'movements': + return [ + { + title: 'Resumen de Stock', + icon: Package, + fields: [ + { + label: 'Stock actual', + value: `${(ingredient.current_stock || 0).toFixed(2)} ${ingredient.unit_of_measure}`, + highlight: true + }, + { + label: 'Total de movimientos', + value: movements.length + }, + { + label: 'Operaciones de stock', + value: renderStockOperations(), + span: 2 + } + ] + }, + { + title: 'Historial de Movimientos', + icon: History, + fields: [ + { + label: 'Movimientos recientes', + value: renderMovementsList(), + span: 2 + } + ] + } + ]; + + case 'details': + return [ + { + title: 'Información Básica', + icon: FileText, + fields: [ + { + label: 'Nombre', + value: ingredient.name, + highlight: true + }, + { + label: 'Categoría', + value: ingredient.category || 'Sin categoría' + }, + { + label: 'Subcategoría', + value: ingredient.subcategory || 'N/A' + }, + { + label: 'Unidad de medida', + value: ingredient.unit_of_measure + }, + { + label: 'Marca', + value: ingredient.brand || 'N/A' + }, + { + label: 'Descripción', + value: ingredient.description || 'N/A' + } + ] + }, + { + title: 'Almacenamiento', + icon: Thermometer, + fields: [ + { + label: 'Requiere refrigeración', + value: ingredient.requires_refrigeration ? 'Sí' : 'No' + }, + { + label: 'Requiere congelación', + value: ingredient.requires_freezing ? 'Sí' : 'No' + }, + { + label: 'Vida útil', + value: ingredient.shelf_life_days ? `${ingredient.shelf_life_days} días` : 'No especificada' + }, + { + label: 'Es perecedero', + value: ingredient.is_perishable ? 'Sí' : 'No' + } + ] + }, + { + title: 'Información Adicional', + icon: Calendar, + fields: [ + { + label: 'Creado', + value: ingredient.created_at, + type: 'datetime' + }, + { + label: 'Última actualización', + value: ingredient.updated_at, + type: 'datetime' + }, + { + label: 'Producto estacional', + value: ingredient.is_seasonal ? 'Sí' : 'No' + }, + { + label: 'Estado', + value: ingredient.is_active ? 'Activo' : 'Inactivo' + } + ] + } + ]; + + case 'edit': + return getEditSections(); + + default: + return []; + } + }; + + // Get edit sections + const getEditSections = (): StatusModalSection[] => { + return [ + { + title: 'Información Básica', + icon: FileText, + fields: [ + { + label: 'Nombre', + value: editForm.name || '', + editable: true, + required: true, + placeholder: 'Nombre del ingrediente' + }, + { + label: 'Categoría', + value: editForm.category || '', + editable: true, + placeholder: 'Categoría del producto' + }, + { + label: 'Subcategoría', + value: editForm.subcategory || '', + editable: true, + placeholder: 'Subcategoría' + }, + { + label: 'Marca', + value: editForm.brand || '', + editable: true, + placeholder: 'Marca del producto' + }, + { + label: 'Descripción', + value: editForm.description || '', + editable: true, + placeholder: 'Descripción del ingrediente', + span: 2 + } + ] + }, + { + title: 'Especificaciones', + icon: Package, + fields: [ + { + label: 'Unidad de medida', + value: editForm.unit_of_measure || ingredient.unit_of_measure, + type: 'select', + editable: true, + options: [ + { label: 'Kilogramo (kg)', value: 'kg' }, + { label: 'Gramo (g)', value: 'g' }, + { label: 'Litro (l)', value: 'l' }, + { label: 'Mililitro (ml)', value: 'ml' }, + { label: 'Pieza (pz)', value: 'piece' }, + { label: 'Paquete', value: 'package' }, + { label: 'Bolsa', value: 'bag' }, + { label: 'Caja', value: 'box' }, + { label: 'Docena', value: 'dozen' } + ] + }, + { + label: 'Tamaño del paquete', + value: editForm.package_size || 0, + type: 'number', + editable: true, + placeholder: 'Tamaño del paquete' + }, + { + label: 'Estado', + value: editForm.is_active !== undefined ? (editForm.is_active ? 'true' : 'false') : (ingredient.is_active ? 'true' : 'false'), + type: 'select', + editable: true, + options: [ + { label: 'Activo', value: 'true' }, + { label: 'Inactivo', value: 'false' } + ] + }, + { + label: 'Es perecedero', + value: editForm.is_perishable !== undefined ? (editForm.is_perishable ? 'true' : 'false') : (ingredient.is_perishable ? 'true' : 'false'), + type: 'select', + editable: true, + options: [ + { label: 'Sí', value: 'true' }, + { label: 'No', value: 'false' } + ] + } + ] + }, + { + title: 'Costos y Precios', + icon: DollarSign, + fields: [ + { + label: 'Costo promedio', + value: editForm.average_cost || 0, + type: 'currency', + editable: true, + placeholder: 'Costo promedio por unidad' + }, + { + label: 'Costo estándar', + value: editForm.standard_cost || 0, + type: 'currency', + editable: true, + placeholder: 'Costo estándar por unidad' + } + ] + }, + { + title: 'Gestión de Stock', + icon: BarChart3, + fields: [ + { + label: 'Umbral de stock bajo', + value: editForm.low_stock_threshold || 0, + type: 'number', + editable: true, + required: true, + placeholder: 'Umbral mínimo de stock' + }, + { + label: 'Punto de reorden', + value: editForm.reorder_point || 0, + type: 'number', + editable: true, + required: true, + placeholder: 'Punto de reorden' + }, + { + label: 'Cantidad de reorden', + value: editForm.reorder_quantity || 0, + type: 'number', + editable: true, + required: true, + placeholder: 'Cantidad por defecto de reorden' + }, + { + label: 'Nivel máximo de stock', + value: editForm.max_stock_level || 0, + type: 'number', + editable: true, + placeholder: 'Nivel máximo de stock' + } + ] + }, + { + title: 'Almacenamiento', + icon: Thermometer, + fields: [ + { + label: 'Requiere refrigeración', + value: editForm.requires_refrigeration !== undefined ? (editForm.requires_refrigeration ? 'true' : 'false') : (ingredient.requires_refrigeration ? 'true' : 'false'), + type: 'select', + editable: true, + options: [ + { label: 'Sí', value: 'true' }, + { label: 'No', value: 'false' } + ] + }, + { + label: 'Requiere congelación', + value: editForm.requires_freezing !== undefined ? (editForm.requires_freezing ? 'true' : 'false') : (ingredient.requires_freezing ? 'true' : 'false'), + type: 'select', + editable: true, + options: [ + { label: 'Sí', value: 'true' }, + { label: 'No', value: 'false' } + ] + }, + { + label: 'Temperatura mínima (°C)', + value: editForm.storage_temperature_min || 0, + type: 'number', + editable: true, + placeholder: 'Temperatura mínima de almacenamiento' + }, + { + label: 'Temperatura máxima (°C)', + value: editForm.storage_temperature_max || 0, + type: 'number', + editable: true, + placeholder: 'Temperatura máxima de almacenamiento' + }, + { + label: 'Humedad máxima (%)', + value: editForm.storage_humidity_max || 0, + type: 'number', + editable: true, + placeholder: 'Humedad máxima (%)' + }, + { + label: 'Vida útil (días)', + value: editForm.shelf_life_days || 0, + type: 'number', + editable: true, + placeholder: 'Días de vida útil' + }, + { + label: 'Instrucciones de almacenamiento', + value: editForm.storage_instructions || '', + editable: true, + placeholder: 'Instrucciones especiales de almacenamiento', + span: 2 + } + ] + } + ]; + }; + + // Render batch breakdown + const renderBatchBreakdown = () => { + if (stockLoading) { + return ( +
+ Cargando información de lotes... +
+ ); + } + + if (batches.length === 0) { + return ( +
+ No hay lotes registrados para este ingrediente. +
+ ); + } + + const sortedBatches = [...batches].sort((a, b) => { + if (!a.expiration_date) return 1; + if (!b.expiration_date) return -1; + return new Date(a.expiration_date).getTime() - new Date(b.expiration_date).getTime(); + }); + + return ( +
+ {sortedBatches.map((batch, index) => { + const isExpired = batch.is_expired || (batch.expiration_date && new Date(batch.expiration_date) < now); + const daysUntilExpiry = batch.expiration_date + ? Math.ceil((new Date(batch.expiration_date).getTime() - now.getTime()) / (1000 * 60 * 60 * 24)) + : null; + + const getStatus = () => { + if (isExpired) return { color: 'text-red-600', bg: 'bg-red-50 border-red-200', label: 'Vencido' }; + if (daysUntilExpiry !== null && daysUntilExpiry <= 3) return { color: 'text-orange-600', bg: 'bg-orange-50 border-orange-200', label: `${daysUntilExpiry} días` }; + return { color: 'text-green-600', bg: 'bg-green-50 border-green-200', label: 'Fresco' }; + }; + + const status = getStatus(); + + return ( +
+
+
+ + Lote #{batch.batch_number || `${index + 1}`} + + + {status.label} + +
+ + {batch.current_quantity} {ingredient.unit_of_measure} + +
+
+ {batch.received_date && ( +
+ Recibido +

{new Date(batch.received_date).toLocaleDateString('es-ES')}

+
+ )} + {batch.expiration_date && ( +
+ Vencimiento +

{new Date(batch.expiration_date).toLocaleDateString('es-ES')}

+
+ )} + {batch.unit_cost && ( +
+ Costo Unitario +

{formatters.currency(batch.unit_cost)}

+
+ )} + {batch.storage_location && ( +
+ Ubicación +

{batch.storage_location}

+
+ )} +
+
+ ); + })} +
+ ); + }; + + // Get operation modal sections + const getOperationModalSections = (): StatusModalSection[] => { + switch (operationModal) { + case 'add': + return [ + { + title: 'Información del Stock', + icon: Package, + fields: [ + { + label: 'Cantidad a agregar', + value: addStockForm.quantity, + type: 'number', + editable: true, + required: true, + placeholder: `Cantidad en ${ingredient.unit_of_measure}` + }, + { + label: 'Costo unitario', + value: addStockForm.unit_cost, + type: 'currency', + editable: true, + placeholder: 'Costo por unidad' + }, + { + label: 'Notas', + value: addStockForm.notes, + editable: true, + placeholder: 'Observaciones adicionales', + span: 2 + } + ] + } + ]; + + case 'consume': + return [ + { + title: 'Información del Consumo', + icon: Minus, + fields: [ + { + label: 'Cantidad a consumir', + value: consumeStockForm.quantity, + type: 'number', + editable: true, + required: true, + placeholder: `Máximo ${ingredient.current_stock || 0} ${ingredient.unit_of_measure}` + }, + { + label: 'Número de referencia', + value: consumeStockForm.reference_number, + editable: true, + placeholder: 'Ej: Orden de producción #1234' + }, + { + label: 'Notas', + value: consumeStockForm.notes, + editable: true, + placeholder: 'Observaciones adicionales' + }, + { + label: 'Método de consumo', + value: consumeStockForm.fifo ? 'true' : 'false', + type: 'select', + editable: true, + options: [ + { label: 'FIFO (Primero en entrar, primero en salir)', value: 'true' }, + { label: 'LIFO (Último en entrar, primero en salir)', value: 'false' } + ] + } + ] + } + ]; + + case 'adjust': + return [ + { + title: 'Información del Ajuste', + icon: RotateCcw, + fields: [ + { + label: 'Nuevo stock', + value: adjustStockForm.quantity, + type: 'number', + editable: true, + required: true, + placeholder: `Stock correcto en ${ingredient.unit_of_measure}` + }, + { + label: 'Motivo del ajuste', + value: adjustStockForm.notes, + editable: true, + required: true, + placeholder: 'Describe el motivo del ajuste', + span: 2 + } + ] + } + ]; + + default: + return []; + } + }; + + const getOperationModalTitle = () => { + switch (operationModal) { + case 'add': return 'Agregar Stock'; + case 'consume': return 'Consumir Stock'; + case 'adjust': return 'Ajustar Stock'; + default: return ''; + } + }; + + const getOperationModalSubtitle = () => { + switch (operationModal) { + case 'add': return `Stock actual: ${(ingredient.current_stock || 0).toFixed(2)} ${ingredient.unit_of_measure}`; + case 'consume': return `Stock disponible: ${(ingredient.current_stock || 0).toFixed(2)} ${ingredient.unit_of_measure}`; + case 'adjust': return `Stock actual: ${(ingredient.current_stock || 0).toFixed(2)} ${ingredient.unit_of_measure}`; + default: return ''; + } + }; + + const handleOperationModalSave = async () => { + switch (operationModal) { + case 'add': return handleAddStock(); + case 'consume': return handleConsumeStock(); + case 'adjust': return handleAdjustStock(); + } + }; + + // Custom actions for view switching + const customActions = [ + { + label: 'Resumen', + variant: 'outline' as const, + onClick: () => handleViewChange('overview'), + disabled: activeView === 'overview' + }, + { + label: 'Lotes', + variant: 'outline' as const, + onClick: () => handleViewChange('batches'), + disabled: activeView === 'batches' + }, + { + label: 'Movimientos', + variant: 'outline' as const, + onClick: () => handleViewChange('movements'), + disabled: activeView === 'movements' + }, + { + label: 'Detalles', + variant: 'outline' as const, + onClick: () => handleViewChange('details'), + disabled: activeView === 'details' + }, + { + label: 'Editar', + variant: 'outline' as const, + onClick: () => handleViewChange('edit'), + disabled: activeView === 'edit' + } + ]; + + return ( + <> + {/* Main Stock Detail Modal */} + + + {/* Stock Operations Modal */} + setOperationModal(null)} + mode="edit" + title={getOperationModalTitle()} + subtitle={getOperationModalSubtitle()} + size="md" + sections={getOperationModalSections()} + onFieldChange={handleFieldChange} + onSave={handleOperationModalSave} + onCancel={() => setOperationModal(null)} + loading={operationLoading} + showDefaultActions={true} + /> + + ); +}; + +export default InventoryItemModal; \ No newline at end of file diff --git a/frontend/src/components/domain/inventory/InventoryModal.tsx b/frontend/src/components/domain/inventory/InventoryModal.tsx deleted file mode 100644 index d31ae046..00000000 --- a/frontend/src/components/domain/inventory/InventoryModal.tsx +++ /dev/null @@ -1,389 +0,0 @@ -import React from 'react'; -import { Layers, Settings, Calculator, FileText, Calendar } from 'lucide-react'; -import { StatusModal, StatusModalSection, getStatusColor } from '../../ui'; -import { IngredientResponse, ProductType, UnitOfMeasure, IngredientCategory, ProductCategory, IngredientCreate, IngredientUpdate } from '../../../api/types/inventory'; - -export interface InventoryModalProps { - isOpen: boolean; - onClose: () => void; - mode: 'create' | 'view' | 'edit'; - onModeChange?: (mode: 'view' | 'edit') => void; - selectedItem?: IngredientResponse | null; - formData: Partial; - onFieldChange: (sectionIndex: number, fieldIndex: number, value: string | number) => void; - onSave: () => Promise; - onCancel: () => void; - loading?: boolean; -} - -/** - * Unified Inventory Modal Component - * Handles create, view, and edit modes with consistent styling and UX - */ -export const InventoryModal: React.FC = ({ - isOpen, - onClose, - mode, - onModeChange, - selectedItem, - formData, - onFieldChange, - onSave, - onCancel, - loading = false -}) => { - const isCreating = mode === 'create'; - const isEditing = mode === 'edit'; - const isViewing = mode === 'view'; - - // Helper functions to get dropdown options - const getUnitOfMeasureOptions = () => { - return Object.entries(UnitOfMeasure).map(([, value]) => ({ - label: value, - value: value - })); - }; - - const getCategoryOptions = (productType?: ProductType) => { - if (productType === ProductType.INGREDIENT) { - return Object.entries(IngredientCategory).map(([, value]) => ({ - label: value.charAt(0).toUpperCase() + value.slice(1).replace('_', ' '), - value: value - })); - } else { - return Object.entries(ProductCategory).map(([, value]) => ({ - label: value.charAt(0).toUpperCase() + value.slice(1).replace('_', ' '), - value: value - })); - } - }; - - // Get status indicator based on mode and item - const getStatusIndicator = () => { - if (isCreating) { - return undefined; // No status indicator for create mode to reduce clutter - } - - if (!selectedItem) return undefined; - - const { stock_status } = selectedItem; - - switch (stock_status) { - case 'out_of_stock': - return { - color: getStatusColor('cancelled'), - text: 'Sin Stock', - icon: Calendar, - isCritical: true, - isHighlight: false - }; - case 'low_stock': - return { - color: getStatusColor('pending'), - text: 'Stock Bajo', - icon: Calendar, - isCritical: false, - isHighlight: true - }; - case 'overstock': - return { - color: getStatusColor('info'), - text: 'Sobrestock', - icon: Calendar, - isCritical: false, - isHighlight: false - }; - case 'in_stock': - default: - return { - color: getStatusColor('completed'), - text: 'Normal', - icon: Calendar, - isCritical: false, - isHighlight: false - }; - } - }; - - // Get modal title based on mode - const getTitle = () => { - if (isCreating) return 'Nuevo Artículo'; - return selectedItem?.name || 'Artículo de Inventario'; - }; - - // Get modal subtitle based on mode - const getSubtitle = () => { - if (isCreating) return ''; // No subtitle for create mode - if (!selectedItem) return ''; - - const category = selectedItem.category || ''; - const description = selectedItem.description || ''; - return `${category}${description ? ` - ${description}` : ''}`; - }; - - // Define modal sections with create-mode simplification - const sections: StatusModalSection[] = isCreating ? [ - // SIMPLIFIED CREATE MODE - Only essential fields - { - title: 'Información Básica', - icon: Layers, - fields: [ - { - label: 'Tipo de producto', - value: formData.product_type || ProductType.INGREDIENT, - type: 'select', - editable: true, - required: true, - placeholder: 'Seleccionar tipo', - options: [ - { label: 'Ingrediente', value: ProductType.INGREDIENT }, - { label: 'Producto Final', value: ProductType.FINISHED_PRODUCT } - ] - }, - { - label: 'Nombre', - value: formData.name || '', - highlight: true, - editable: true, - required: true, - placeholder: 'Ej: Harina de trigo o Croissant de chocolate' - }, - { - label: 'Categoría', - value: formData.category || '', - type: 'select', - editable: true, - required: true, - placeholder: 'Seleccionar categoría', - options: getCategoryOptions(formData.product_type as ProductType || ProductType.INGREDIENT) - }, - { - label: 'Unidad de medida', - value: formData.unit_of_measure || '', - type: 'select', - editable: true, - required: true, - placeholder: 'Seleccionar unidad', - options: getUnitOfMeasureOptions() - } - ] - }, - { - title: 'Configuración de Stock', - icon: Settings, - fields: [ - { - label: 'Stock inicial (opcional)', - value: formData.initial_stock || '', - type: 'number' as const, - editable: true, - required: false, - placeholder: 'Ej: 50 (si ya tienes este producto)' - }, - { - label: 'Umbral mínimo', - value: formData.low_stock_threshold || 10, - type: 'number' as const, - editable: true, - required: true, - placeholder: 'Ej: 10' - }, - { - label: 'Punto de reorden', - value: formData.reorder_point || 20, - type: 'number' as const, - editable: true, - required: true, - placeholder: 'Ej: 20' - } - ] - } - ] : [ - // FULL VIEW/EDIT MODE - Complete information - { - title: 'Información Básica', - icon: Layers, - fields: [ - { - label: 'Nombre', - value: formData.name || selectedItem?.name || '', - highlight: true, - editable: isEditing, - required: true, - placeholder: 'Nombre del ingrediente' - }, - { - label: 'Categoría', - value: formData.category || selectedItem?.category || '', - type: 'select', - editable: isEditing, - placeholder: 'Seleccionar categoría', - options: getCategoryOptions(selectedItem?.product_type || ProductType.INGREDIENT) - }, - { - label: 'Descripción', - value: formData.description || selectedItem?.description || '', - editable: isEditing, - placeholder: 'Descripción del producto' - }, - { - label: 'Unidad de medida', - value: formData.unit_of_measure || selectedItem?.unit_of_measure || '', - type: 'select', - editable: isEditing, - required: true, - placeholder: 'Seleccionar unidad', - options: getUnitOfMeasureOptions() - } - ] - }, - { - title: 'Gestión de Stock', - icon: Settings, - fields: [ - { - label: 'Stock actual', - value: `${Number(selectedItem?.current_stock) || 0} ${selectedItem?.unit_of_measure || ''}`, - highlight: true - }, - { - label: 'Umbral mínimo', - value: formData.low_stock_threshold || selectedItem?.low_stock_threshold || 10, - type: 'number' as const, - editable: isEditing, - required: true, - placeholder: 'Cantidad mínima' - }, - { - label: 'Stock máximo', - value: formData.max_stock_level || selectedItem?.max_stock_level || '', - type: 'number' as const, - editable: isEditing, - placeholder: 'Cantidad máxima (opcional)' - }, - { - label: 'Punto de reorden', - value: formData.reorder_point || selectedItem?.reorder_point || 20, - type: 'number' as const, - editable: isEditing, - required: true, - placeholder: 'Punto de reorden' - }, - { - label: 'Cantidad a reordenar', - value: formData.reorder_quantity || selectedItem?.reorder_quantity || 50, - type: 'number' as const, - editable: isEditing, - required: true, - placeholder: 'Cantidad por pedido' - } - ] - }, - { - title: 'Información Financiera', - icon: Calculator, - fields: [ - { - label: 'Costo promedio por unidad', - value: formData.average_cost || selectedItem?.average_cost || 0, - type: 'currency', - editable: isEditing, - placeholder: 'Costo por unidad' - }, - { - label: 'Valor total en stock', - value: (Number(selectedItem?.current_stock) || 0) * (Number(formData.average_cost || selectedItem?.average_cost) || 0), - type: 'currency' as const, - highlight: true - } - ] - }, - { - title: 'Información Adicional', - icon: Calendar, - fields: [ - { - label: 'Último restock', - value: selectedItem?.last_restocked || 'Sin historial', - type: selectedItem?.last_restocked ? 'datetime' as const : 'text' as const - }, - { - label: 'Vida útil (días)', - value: formData.shelf_life_days || selectedItem?.shelf_life_days || '', - type: 'number', - editable: isEditing, - placeholder: 'Días de vida útil' - }, - { - label: 'Requiere refrigeración', - value: (formData.requires_refrigeration !== undefined ? formData.requires_refrigeration : selectedItem?.requires_refrigeration) ? 'Sí' : 'No', - type: 'select', - editable: isEditing, - options: [ - { label: 'No', value: 'false' }, - { label: 'Sí', value: 'true' } - ] - }, - { - label: 'Requiere congelación', - value: (formData.requires_freezing !== undefined ? formData.requires_freezing : selectedItem?.requires_freezing) ? 'Sí' : 'No', - type: 'select', - editable: isEditing, - options: [ - { label: 'No', value: 'false' }, - { label: 'Sí', value: 'true' } - ] - }, - { - label: 'Producto estacional', - value: (formData.is_seasonal !== undefined ? formData.is_seasonal : selectedItem?.is_seasonal) ? 'Sí' : 'No', - type: 'select', - editable: isEditing, - options: [ - { label: 'No', value: 'false' }, - { label: 'Sí', value: 'true' } - ] - }, - { - label: 'Creado', - value: selectedItem?.created_at, - type: 'datetime' as const - } - ] - }, - { - title: 'Notas', - icon: FileText, - fields: [ - { - label: 'Observaciones', - value: formData.notes || selectedItem?.notes || '', - span: 2 as const, - editable: isEditing, - placeholder: 'Notas adicionales sobre el producto' - } - ] - } - ]; - - return ( - - ); -}; - -export default InventoryModal; \ No newline at end of file diff --git a/frontend/src/components/domain/inventory/InventoryTable.tsx b/frontend/src/components/domain/inventory/InventoryTable.tsx deleted file mode 100644 index a411926e..00000000 --- a/frontend/src/components/domain/inventory/InventoryTable.tsx +++ /dev/null @@ -1,627 +0,0 @@ -import React, { useState, useCallback, useMemo } from 'react'; -import { clsx } from 'clsx'; -import { Table, TableColumn } from '../../ui'; -import { Badge } from '../../ui'; -import { Button } from '../../ui'; -import { Input } from '../../ui'; -import { Select } from '../../ui'; -import { Modal } from '../../ui'; -import { ConfirmDialog } from '../../shared'; -import { InventoryFilters, IngredientResponse, UnitOfMeasure, SortOrder } from '../../../types/inventory.types'; -import { inventoryService } from '../../../api/services/inventory.service'; -import { StockLevelIndicator } from './StockLevelIndicator'; - -export interface InventoryTableProps { - data: IngredientResponse[]; - loading?: boolean; - total?: number; - page?: number; - pageSize?: number; - filters?: InventoryFilters; - selectedItems?: string[]; - className?: string; - onPageChange?: (page: number, pageSize: number) => void; - onFiltersChange?: (filters: InventoryFilters) => void; - onSelectionChange?: (selectedIds: string[]) => void; - onEdit?: (item: IngredientResponse) => void; - onDelete?: (item: IngredientResponse) => void; - onAdjustStock?: (item: IngredientResponse) => void; - onMarkExpired?: (item: IngredientResponse) => void; - onRefresh?: () => void; - onExport?: () => void; - onBulkAction?: (action: string, items: IngredientResponse[]) => void; -} - -// Spanish bakery categories -const BAKERY_CATEGORIES = [ - { value: '', label: 'Todas las categorías' }, - { value: 'harinas', label: 'Harinas' }, - { value: 'levaduras', label: 'Levaduras' }, - { value: 'azucares', label: 'Azúcares y Endulzantes' }, - { value: 'chocolates', label: 'Chocolates y Cacao' }, - { value: 'frutas', label: 'Frutas y Frutos Secos' }, - { value: 'lacteos', label: 'Lácteos' }, - { value: 'huevos', label: 'Huevos' }, - { value: 'mantequillas', label: 'Mantequillas y Grasas' }, - { value: 'especias', label: 'Especias y Aromas' }, - { value: 'conservantes', label: 'Conservantes y Aditivos' }, - { value: 'decoracion', label: 'Decoración' }, - { value: 'envases', label: 'Envases y Embalajes' }, - { value: 'utensilios', label: 'Utensilios y Equipos' }, - { value: 'limpieza', label: 'Limpieza e Higiene' }, -]; - -const STOCK_LEVEL_FILTERS = [ - { value: '', label: 'Todos los niveles' }, - { value: 'good', label: 'Stock Normal' }, - { value: 'low', label: 'Stock Bajo' }, - { value: 'critical', label: 'Stock Crítico' }, - { value: 'out', label: 'Sin Stock' }, -]; - -const SORT_OPTIONS = [ - { value: 'name_asc', label: 'Nombre (A-Z)' }, - { value: 'name_desc', label: 'Nombre (Z-A)' }, - { value: 'category_asc', label: 'Categoría (A-Z)' }, - { value: 'current_stock_asc', label: 'Stock (Menor a Mayor)' }, - { value: 'current_stock_desc', label: 'Stock (Mayor a Menor)' }, - { value: 'updated_at_desc', label: 'Actualizado Recientemente' }, - { value: 'created_at_desc', label: 'Agregado Recientemente' }, -]; - -export const InventoryTable: React.FC = ({ - data, - loading = false, - total = 0, - page = 1, - pageSize = 20, - filters = {}, - selectedItems = [], - className, - onPageChange, - onFiltersChange, - onSelectionChange, - onEdit, - onDelete, - onAdjustStock, - onMarkExpired, - onRefresh, - onExport, - onBulkAction, -}) => { - const [localFilters, setLocalFilters] = useState(filters); - const [searchValue, setSearchValue] = useState(filters.search || ''); - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const [showBulkActionModal, setShowBulkActionModal] = useState(false); - const [itemToDelete, setItemToDelete] = useState(null); - const [selectedBulkAction, setSelectedBulkAction] = useState(''); - - // Get selected items data - const selectedItemsData = useMemo(() => { - return (data || []).filter(item => selectedItems.includes(item.id)); - }, [data, selectedItems]); - - // Handle search with debouncing - const handleSearch = useCallback((value: string) => { - setSearchValue(value); - const newFilters = { ...localFilters, search: value || undefined }; - setLocalFilters(newFilters); - onFiltersChange?.(newFilters); - }, [localFilters, onFiltersChange]); - - // Handle filter changes - const handleFilterChange = useCallback((key: keyof InventoryFilters, value: any) => { - const newFilters = { ...localFilters, [key]: value || undefined }; - setLocalFilters(newFilters); - onFiltersChange?.(newFilters); - }, [localFilters, onFiltersChange]); - - // Handle sort changes - const handleSortChange = useCallback((value: string) => { - if (!value) return; - - const [field, order] = value.split('_'); - const newFilters = { - ...localFilters, - sort_by: field as any, - sort_order: order as SortOrder, - }; - setLocalFilters(newFilters); - onFiltersChange?.(newFilters); - }, [localFilters, onFiltersChange]); - - // Clear all filters - const handleClearFilters = useCallback(() => { - const clearedFilters = { search: undefined }; - setLocalFilters(clearedFilters); - setSearchValue(''); - onFiltersChange?.(clearedFilters); - }, [onFiltersChange]); - - // Handle delete confirmation - const handleDeleteClick = useCallback((item: IngredientResponse) => { - setItemToDelete(item); - setShowDeleteDialog(true); - }, []); - - const handleDeleteConfirm = useCallback(() => { - if (itemToDelete) { - onDelete?.(itemToDelete); - setItemToDelete(null); - } - setShowDeleteDialog(false); - }, [itemToDelete, onDelete]); - - // Handle bulk actions - const handleBulkAction = useCallback((action: string) => { - if (selectedItemsData.length === 0) return; - - if (action === 'delete') { - setSelectedBulkAction(action); - setShowBulkActionModal(true); - } else { - onBulkAction?.(action, selectedItemsData); - } - }, [selectedItemsData, onBulkAction]); - - const handleBulkActionConfirm = useCallback(() => { - if (selectedBulkAction && selectedItemsData.length > 0) { - onBulkAction?.(selectedBulkAction, selectedItemsData); - } - setShowBulkActionModal(false); - setSelectedBulkAction(''); - }, [selectedBulkAction, selectedItemsData, onBulkAction]); - - // Get stock level for filtering - const getStockLevel = useCallback((item: IngredientResponse): string => { - if (!item.current_stock || item.current_stock <= 0) return 'out'; - if (item.needs_reorder) return 'critical'; - if (item.is_low_stock) return 'low'; - return 'good'; - }, []); - - // Apply local filtering for stock levels - const filteredData = useMemo(() => { - const dataArray = data || []; - if (!localFilters.is_low_stock && !localFilters.needs_reorder) return dataArray; - - return dataArray.filter(item => { - const stockLevel = getStockLevel(item); - if (localFilters.is_low_stock && stockLevel !== 'low') return false; - if (localFilters.needs_reorder && stockLevel !== 'critical') return false; - return true; - }); - }, [data, localFilters.is_low_stock, localFilters.needs_reorder, getStockLevel]); - - // Table columns configuration - const columns: TableColumn[] = [ - { - key: 'name', - title: 'Nombre', - dataIndex: 'name', - sortable: true, - width: '25%', - render: (value: string, record: IngredientResponse) => ( -
-
{value}
- {record.brand && ( -
{record.brand}
- )} - {record.sku && ( -
SKU: {record.sku}
- )} -
- ), - }, - { - key: 'category', - title: 'Categoría', - dataIndex: 'category', - sortable: true, - width: '15%', - render: (value: string) => ( - - {BAKERY_CATEGORIES.find(cat => cat.value === value)?.label || value || 'Sin categoría'} - - ), - }, - { - key: 'current_stock', - title: 'Stock Actual', - dataIndex: 'current_stock', - sortable: true, - width: '15%', - align: 'right', - render: (value: number | undefined, record: IngredientResponse) => ( -
-
- - {value?.toFixed(2) ?? '0.00'} - - - {record.unit_of_measure} - -
- -
- ), - }, - { - key: 'thresholds', - title: 'Mín / Máx', - width: '12%', - align: 'center', - render: (_, record: IngredientResponse) => ( -
-
- {record.low_stock_threshold} / {record.max_stock_level || '∞'} -
-
- Reorden: {record.reorder_point} -
-
- ), - }, - { - key: 'price', - title: 'Precio', - width: '10%', - align: 'right', - render: (_, record: IngredientResponse) => ( -
- {record.standard_cost && ( -
- €{record.standard_cost.toFixed(2)} -
- )} - {record.last_purchase_price && record.last_purchase_price !== record.standard_cost && ( -
- Último: €{record.last_purchase_price.toFixed(2)} -
- )} -
- ), - }, - { - key: 'status', - title: 'Estado', - width: '10%', - align: 'center', - render: (_, record: IngredientResponse) => { - const badges = []; - - if (record.needs_reorder) { - badges.push( - - Crítico - - ); - } else if (record.is_low_stock) { - badges.push( - - Bajo - - ); - } else { - badges.push( - - OK - - ); - } - - if (!record.is_active) { - badges.push( - - Inactivo - - ); - } - - if (record.is_perishable) { - badges.push( - - P - - ); - } - - return ( -
- {badges} -
- ); - }, - }, - { - key: 'actions', - title: 'Acciones', - width: '13%', - align: 'center', - render: (_, record: IngredientResponse) => ( -
- - - -
- ), - }, - ]; - - const currentSortValue = localFilters.sort_by && localFilters.sort_order - ? `${localFilters.sort_by}_${localFilters.sort_order}` - : ''; - - return ( -
- {/* Filters and Actions */} -
- {/* Search and Quick Actions */} -
-
- handleSearch(e.target.value)} - leftIcon={ - - - - } - /> -
-
- - -
-
- - {/* Advanced Filters */} -
- { - handleFilterChange('needs_reorder', value === 'critical'); - handleFilterChange('is_low_stock', value === 'low'); - }} - options={STOCK_LEVEL_FILTERS} - /> -