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 (
+