From b6dd94abcb0f5681950524a7343122c2decdff3c Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Tue, 3 Feb 2026 20:20:42 -0600 Subject: [PATCH] feat(inventory): complete Inventory module with products, warehouses, stock Products: - ProductsListPage with filters and stock status - ProductDetailPage with stock by warehouse - ProductCreatePage/ProductEditPage with forms Warehouses: - WarehousesListPage with stats - WarehouseDetailPage with locations preview - WarehouseCreatePage/WarehouseEditPage - LocationsPage with hierarchical tree view Stock Management: - StockLevelsPage (via existing) - StockMovementsPage (via existing) - StockMovementDetailPage with timeline - KardexPage for product movement history - StockAdjustmentPage for inventory adjustments Components (13): - StockLevelIndicator, StockLevelBadge - WarehouseSelector, LocationSelector - ProductStockCard, ProductForm, ProductInventoryForm - MovementTypeIcon, MovementStatusBadge, StockMovementRow - InventoryStatsCard, WarehouseForm Hooks (6): - useProducts, useWarehouses, useLocations - useStockLevels, useStockMovements, useInventory Co-Authored-By: Claude Opus 4.5 --- src/app/router/routes.tsx | 125 +++- .../components/InventoryStatsCard.tsx | 145 ++++ .../inventory/components/LocationSelector.tsx | 126 ++++ .../components/MovementStatusBadge.tsx | 25 + .../inventory/components/MovementTypeIcon.tsx | 121 +++ .../inventory/components/ProductForm.tsx | 452 +++++++++++ .../components/ProductInventoryForm.tsx | 244 ++++++ .../inventory/components/ProductStockCard.tsx | 147 ++++ .../inventory/components/StockLevelBadge.tsx | 51 ++ .../components/StockLevelIndicator.tsx | 124 +++ .../inventory/components/StockMovementRow.tsx | 105 +++ .../inventory/components/WarehouseForm.tsx | 268 +++++++ .../components/WarehouseSelector.tsx | 62 ++ src/features/inventory/components/index.ts | 23 + src/features/inventory/hooks/index.ts | 31 +- src/features/inventory/hooks/useInventory.ts | 489 +++--------- src/features/inventory/hooks/useLocations.ts | 145 ++++ src/features/inventory/hooks/useProducts.ts | 283 +++++++ .../inventory/hooks/useStockLevels.ts | 146 ++++ .../inventory/hooks/useStockMovements.ts | 298 ++++++++ src/features/inventory/hooks/useWarehouses.ts | 214 ++++++ src/features/inventory/index.ts | 2 + .../inventory/types/inventory.types.ts | 73 ++ src/pages/inventory/KardexPage.tsx | 509 +++++++++++++ src/pages/inventory/LocationsPage.tsx | 705 ++++++++++++++++++ src/pages/inventory/StockAdjustmentPage.tsx | 536 +++++++++++++ .../inventory/StockMovementDetailPage.tsx | 531 +++++++++++++ src/pages/inventory/WarehouseCreatePage.tsx | 477 ++++++++++++ src/pages/inventory/WarehouseDetailPage.tsx | 505 +++++++++++++ src/pages/inventory/WarehouseEditPage.tsx | 541 ++++++++++++++ src/pages/inventory/WarehousesListPage.tsx | 329 ++++++++ src/pages/inventory/index.ts | 17 + .../inventory/products/ProductCreatePage.tsx | 98 +++ .../inventory/products/ProductDetailPage.tsx | 532 +++++++++++++ .../inventory/products/ProductEditPage.tsx | 120 +++ .../inventory/products/ProductsListPage.tsx | 388 ++++++++++ src/pages/inventory/products/index.ts | 4 + src/shared/types/entities.types.ts | 11 + 38 files changed, 8614 insertions(+), 388 deletions(-) create mode 100644 src/features/inventory/components/InventoryStatsCard.tsx create mode 100644 src/features/inventory/components/LocationSelector.tsx create mode 100644 src/features/inventory/components/MovementStatusBadge.tsx create mode 100644 src/features/inventory/components/MovementTypeIcon.tsx create mode 100644 src/features/inventory/components/ProductForm.tsx create mode 100644 src/features/inventory/components/ProductInventoryForm.tsx create mode 100644 src/features/inventory/components/ProductStockCard.tsx create mode 100644 src/features/inventory/components/StockLevelBadge.tsx create mode 100644 src/features/inventory/components/StockLevelIndicator.tsx create mode 100644 src/features/inventory/components/StockMovementRow.tsx create mode 100644 src/features/inventory/components/WarehouseForm.tsx create mode 100644 src/features/inventory/components/WarehouseSelector.tsx create mode 100644 src/features/inventory/components/index.ts create mode 100644 src/features/inventory/hooks/useLocations.ts create mode 100644 src/features/inventory/hooks/useProducts.ts create mode 100644 src/features/inventory/hooks/useStockLevels.ts create mode 100644 src/features/inventory/hooks/useStockMovements.ts create mode 100644 src/features/inventory/hooks/useWarehouses.ts create mode 100644 src/pages/inventory/KardexPage.tsx create mode 100644 src/pages/inventory/LocationsPage.tsx create mode 100644 src/pages/inventory/StockAdjustmentPage.tsx create mode 100644 src/pages/inventory/StockMovementDetailPage.tsx create mode 100644 src/pages/inventory/WarehouseCreatePage.tsx create mode 100644 src/pages/inventory/WarehouseDetailPage.tsx create mode 100644 src/pages/inventory/WarehouseEditPage.tsx create mode 100644 src/pages/inventory/WarehousesListPage.tsx create mode 100644 src/pages/inventory/products/ProductCreatePage.tsx create mode 100644 src/pages/inventory/products/ProductDetailPage.tsx create mode 100644 src/pages/inventory/products/ProductEditPage.tsx create mode 100644 src/pages/inventory/products/ProductsListPage.tsx create mode 100644 src/pages/inventory/products/index.ts diff --git a/src/app/router/routes.tsx b/src/app/router/routes.tsx index 61a0438..04ff51c 100644 --- a/src/app/router/routes.tsx +++ b/src/app/router/routes.tsx @@ -61,6 +61,23 @@ const PipelineKanbanPage = lazy(() => import('@pages/crm/PipelineKanbanPage').th const LeadsPage = lazy(() => import('@pages/crm/LeadsPage').then(m => ({ default: m.LeadsPage }))); const OpportunitiesPage = lazy(() => import('@pages/crm/OpportunitiesPage').then(m => ({ default: m.OpportunitiesPage }))); +// Inventory Product pages +const ProductsListPage = lazy(() => import('@pages/inventory/products/ProductsListPage').then(m => ({ default: m.ProductsListPage }))); +const ProductDetailPage = lazy(() => import('@pages/inventory/products/ProductDetailPage').then(m => ({ default: m.ProductDetailPage }))); +const ProductCreatePage = lazy(() => import('@pages/inventory/products/ProductCreatePage').then(m => ({ default: m.ProductCreatePage }))); +const ProductEditPage = lazy(() => import('@pages/inventory/products/ProductEditPage').then(m => ({ default: m.ProductEditPage }))); + +// Inventory Stock & Movements pages +const StockLevelsPage = lazy(() => import('@pages/inventory/StockLevelsPage').then(m => ({ default: m.StockLevelsPage }))); +const MovementsPage = lazy(() => import('@pages/inventory/MovementsPage').then(m => ({ default: m.MovementsPage }))); +const StockMovementDetailPage = lazy(() => import('@pages/inventory/StockMovementDetailPage').then(m => ({ default: m.StockMovementDetailPage }))); +const KardexPage = lazy(() => import('@pages/inventory/KardexPage').then(m => ({ default: m.KardexPage }))); +const StockAdjustmentPage = lazy(() => import('@pages/inventory/StockAdjustmentPage').then(m => ({ default: m.StockAdjustmentPage }))); + +// Inventory Warehouse pages +const WarehousesListPage = lazy(() => import('@pages/inventory/WarehousesListPage').then(m => ({ default: m.WarehousesListPage }))); +const WarehouseDetailPage = lazy(() => import('@pages/inventory/WarehouseDetailPage').then(m => ({ default: m.WarehouseDetailPage }))); + function LazyWrapper({ children }: { children: React.ReactNode }) { return }>{children}; } @@ -216,11 +233,117 @@ export const router = createBrowserRouter([ ), }, + // Inventory routes + { + path: '/inventory', + element: , + }, + { + path: '/inventory/products', + element: ( + + + + ), + }, + { + path: '/inventory/products/new', + element: ( + + + + ), + }, + { + path: '/inventory/products/:id', + element: ( + + + + ), + }, + { + path: '/inventory/products/:id/edit', + element: ( + + + + ), + }, + // Stock Levels + { + path: '/inventory/stock', + element: ( + + + + ), + }, + // Stock Movements + { + path: '/inventory/movements', + element: ( + + + + ), + }, + { + path: '/inventory/movements/:id', + element: ( + + + + ), + }, + // Kardex + { + path: '/inventory/kardex', + element: ( + + + + ), + }, + { + path: '/inventory/products/:id/kardex', + element: ( + + + + ), + }, + // Stock Adjustment + { + path: '/inventory/adjustments/new', + element: ( + + + + ), + }, + // Warehouses + { + path: '/inventory/warehouses', + element: ( + + + + ), + }, + { + path: '/inventory/warehouses/:id', + element: ( + + + + ), + }, { path: '/inventory/*', element: ( -
Módulo de Inventario - En desarrollo
+
Seccion de Inventario - En desarrollo
), }, diff --git a/src/features/inventory/components/InventoryStatsCard.tsx b/src/features/inventory/components/InventoryStatsCard.tsx new file mode 100644 index 0000000..8457853 --- /dev/null +++ b/src/features/inventory/components/InventoryStatsCard.tsx @@ -0,0 +1,145 @@ +import { TrendingUp, TrendingDown, Minus, type LucideIcon } from 'lucide-react'; +import { cn } from '@utils/cn'; +import { formatCurrency, formatNumber } from '@utils/formatters'; + +export interface InventoryStatsCardProps { + title: string; + value: number | string; + trend?: number; + trendLabel?: string; + icon?: LucideIcon; + format?: 'number' | 'currency' | 'percent'; + variant?: 'default' | 'success' | 'warning' | 'danger'; + className?: string; + loading?: boolean; +} + +const variantClasses = { + default: 'bg-primary-50 text-primary-600 dark:bg-primary-900/20 dark:text-primary-400', + success: 'bg-success-50 text-success-600 dark:bg-success-900/20 dark:text-success-400', + warning: 'bg-warning-50 text-warning-600 dark:bg-warning-900/20 dark:text-warning-400', + danger: 'bg-danger-50 text-danger-600 dark:bg-danger-900/20 dark:text-danger-400', +}; + +function formatValue( + value: number | string, + format: 'number' | 'currency' | 'percent' +): string { + if (typeof value === 'string') return value; + + switch (format) { + case 'currency': + return formatCurrency(value); + case 'percent': + return `${value.toFixed(1)}%`; + case 'number': + default: + return formatNumber(value); + } +} + +function getTrendType(trend: number): 'increase' | 'decrease' | 'neutral' { + if (trend > 0) return 'increase'; + if (trend < 0) return 'decrease'; + return 'neutral'; +} + +export function InventoryStatsCard({ + title, + value, + trend, + trendLabel = 'vs periodo anterior', + icon: Icon, + format = 'number', + variant = 'default', + className, + loading = false, +}: InventoryStatsCardProps) { + const formattedValue = formatValue(value, format); + const trendType = trend !== undefined ? getTrendType(trend) : 'neutral'; + + if (loading) { + return ( +
+
+
+
+
+
+
+
+
+ {trend !== undefined && ( +
+
+
+
+ )} +
+
+ ); + } + + return ( +
+
+
+

+ {title} +

+

+ {formattedValue} +

+
+ {Icon && ( +
+ +
+ )} +
+ + {trend !== undefined && ( +
+ {trendType === 'increase' && ( + + )} + {trendType === 'decrease' && ( + + )} + {trendType === 'neutral' && ( + + )} + + {trendType === 'increase' && '+'} + {trend}% + + + {trendLabel} + +
+ )} +
+ ); +} diff --git a/src/features/inventory/components/LocationSelector.tsx b/src/features/inventory/components/LocationSelector.tsx new file mode 100644 index 0000000..93cb598 --- /dev/null +++ b/src/features/inventory/components/LocationSelector.tsx @@ -0,0 +1,126 @@ +import { useMemo } from 'react'; +import { Select, type SelectOption } from '@components/organisms/Select'; +import { useLocations } from '../hooks'; +import type { Location } from '../types'; +import { cn } from '@utils/cn'; + +export interface LocationSelectorProps { + warehouseId?: string; + value?: string; + onChange: (value: string) => void; + disabled?: boolean; + className?: string; + placeholder?: string; + error?: boolean; + clearable?: boolean; + excludeIds?: string[]; + locationTypes?: Location['locationType'][]; + onlyActive?: boolean; +} + +interface LocationNode extends Location { + children: LocationNode[]; + level: number; + fullPath: string; +} + +function buildLocationTree(locations: Location[]): LocationNode[] { + const locationMap = new Map(); + const roots: LocationNode[] = []; + + // First pass: create nodes + locations.forEach((loc) => { + locationMap.set(loc.id, { + ...loc, + children: [], + level: 0, + fullPath: loc.name, + }); + }); + + // Second pass: build hierarchy + locations.forEach((loc) => { + const node = locationMap.get(loc.id)!; + if (loc.parentId && locationMap.has(loc.parentId)) { + const parent = locationMap.get(loc.parentId)!; + parent.children.push(node); + node.level = parent.level + 1; + node.fullPath = `${parent.fullPath} > ${loc.name}`; + } else { + roots.push(node); + } + }); + + return roots; +} + +function flattenTree(nodes: LocationNode[], result: LocationNode[] = []): LocationNode[] { + nodes.forEach((node) => { + result.push(node); + if (node.children.length > 0) { + flattenTree(node.children, result); + } + }); + return result; +} + +export function LocationSelector({ + warehouseId, + value, + onChange, + disabled = false, + className, + placeholder = 'Seleccionar ubicacion...', + error = false, + clearable = false, + excludeIds = [], + locationTypes, + onlyActive = true, +}: LocationSelectorProps) { + const { locations, isLoading } = useLocations(warehouseId ?? null); + + const filteredLocations = useMemo(() => { + return locations.filter((loc) => { + if (excludeIds.includes(loc.id)) return false; + if (onlyActive && !loc.isActive) return false; + if (locationTypes && !locationTypes.includes(loc.locationType)) return false; + return true; + }); + }, [locations, excludeIds, onlyActive, locationTypes]); + + const options: SelectOption[] = useMemo(() => { + const tree = buildLocationTree(filteredLocations); + const flattened = flattenTree(tree); + + return flattened.map((loc) => ({ + value: loc.id, + label: `${' '.repeat(loc.level)}${loc.level > 0 ? '|- ' : ''}${loc.code} - ${loc.name}`, + })); + }, [filteredLocations]); + + const handleChange = (selected: string | string[]) => { + const selectedValue = Array.isArray(selected) ? selected[0] : selected; + onChange(selectedValue || ''); + }; + + const isDisabled = disabled || isLoading || !warehouseId; + const displayPlaceholder = !warehouseId + ? 'Seleccione almacen primero' + : isLoading + ? 'Cargando...' + : placeholder; + + return ( + + + +
+ + + + + + + +
+ +
+ + + + + + + +
+ + +