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 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-03 20:20:42 -06:00
parent 36ba16e2a2
commit b6dd94abcb
38 changed files with 8614 additions and 388 deletions

View File

@ -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 <Suspense fallback={<FullPageSpinner />}>{children}</Suspense>;
}
@ -216,11 +233,117 @@ export const router = createBrowserRouter([
</DashboardWrapper>
),
},
// Inventory routes
{
path: '/inventory',
element: <Navigate to="/inventory/products" replace />,
},
{
path: '/inventory/products',
element: (
<DashboardWrapper>
<ProductsListPage />
</DashboardWrapper>
),
},
{
path: '/inventory/products/new',
element: (
<DashboardWrapper>
<ProductCreatePage />
</DashboardWrapper>
),
},
{
path: '/inventory/products/:id',
element: (
<DashboardWrapper>
<ProductDetailPage />
</DashboardWrapper>
),
},
{
path: '/inventory/products/:id/edit',
element: (
<DashboardWrapper>
<ProductEditPage />
</DashboardWrapper>
),
},
// Stock Levels
{
path: '/inventory/stock',
element: (
<DashboardWrapper>
<StockLevelsPage />
</DashboardWrapper>
),
},
// Stock Movements
{
path: '/inventory/movements',
element: (
<DashboardWrapper>
<MovementsPage />
</DashboardWrapper>
),
},
{
path: '/inventory/movements/:id',
element: (
<DashboardWrapper>
<StockMovementDetailPage />
</DashboardWrapper>
),
},
// Kardex
{
path: '/inventory/kardex',
element: (
<DashboardWrapper>
<KardexPage />
</DashboardWrapper>
),
},
{
path: '/inventory/products/:id/kardex',
element: (
<DashboardWrapper>
<KardexPage />
</DashboardWrapper>
),
},
// Stock Adjustment
{
path: '/inventory/adjustments/new',
element: (
<DashboardWrapper>
<StockAdjustmentPage />
</DashboardWrapper>
),
},
// Warehouses
{
path: '/inventory/warehouses',
element: (
<DashboardWrapper>
<WarehousesListPage />
</DashboardWrapper>
),
},
{
path: '/inventory/warehouses/:id',
element: (
<DashboardWrapper>
<WarehouseDetailPage />
</DashboardWrapper>
),
},
{
path: '/inventory/*',
element: (
<DashboardWrapper>
<div className="text-center text-gray-500">Módulo de Inventario - En desarrollo</div>
<div className="text-center text-gray-500">Seccion de Inventario - En desarrollo</div>
</DashboardWrapper>
),
},

View File

@ -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 (
<div
className={cn(
'rounded-lg border bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-800',
className
)}
>
<div className="animate-pulse">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="h-4 w-24 rounded bg-gray-200 dark:bg-gray-700" />
<div className="mt-2 h-8 w-32 rounded bg-gray-200 dark:bg-gray-700" />
</div>
<div className="h-12 w-12 rounded-lg bg-gray-200 dark:bg-gray-700" />
</div>
{trend !== undefined && (
<div className="mt-4 flex items-center gap-2">
<div className="h-4 w-4 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-16 rounded bg-gray-200 dark:bg-gray-700" />
</div>
)}
</div>
</div>
);
}
return (
<div
className={cn(
'rounded-lg border bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-800',
className
)}
>
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<p className="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
{title}
</p>
<p className="mt-1 truncate text-2xl font-semibold text-gray-900 dark:text-white">
{formattedValue}
</p>
</div>
{Icon && (
<div
className={cn(
'ml-4 flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-lg',
variantClasses[variant]
)}
>
<Icon className="h-6 w-6" />
</div>
)}
</div>
{trend !== undefined && (
<div className="mt-4 flex items-center">
{trendType === 'increase' && (
<TrendingUp className="h-4 w-4 text-success-500" />
)}
{trendType === 'decrease' && (
<TrendingDown className="h-4 w-4 text-danger-500" />
)}
{trendType === 'neutral' && (
<Minus className="h-4 w-4 text-gray-400" />
)}
<span
className={cn(
'ml-1 text-sm font-medium',
trendType === 'increase' && 'text-success-600',
trendType === 'decrease' && 'text-danger-600',
trendType === 'neutral' && 'text-gray-500'
)}
>
{trendType === 'increase' && '+'}
{trend}%
</span>
<span className="ml-2 text-sm text-gray-500 dark:text-gray-400">
{trendLabel}
</span>
</div>
)}
</div>
);
}

View File

@ -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<string, LocationNode>();
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 (
<Select
options={options}
value={value || ''}
onChange={handleChange}
placeholder={displayPlaceholder}
disabled={isDisabled}
error={error}
clearable={clearable}
searchable={options.length > 5}
className={cn(className)}
/>
);
}

View File

@ -0,0 +1,25 @@
import { Badge } from '@components/atoms/Badge';
import type { MovementStatus } from '../types';
export interface MovementStatusBadgeProps {
status: MovementStatus;
className?: string;
}
const statusConfig: Record<
MovementStatus,
{ label: string; variant: 'success' | 'danger' | 'warning' | 'default' | 'info' | 'primary' }
> = {
draft: { label: 'Borrador', variant: 'default' },
confirmed: { label: 'Confirmado', variant: 'success' },
cancelled: { label: 'Cancelado', variant: 'danger' },
};
export function MovementStatusBadge({ status, className }: MovementStatusBadgeProps) {
const config = statusConfig[status];
return (
<Badge variant={config.variant} className={className}>
{config.label}
</Badge>
);
}

View File

@ -0,0 +1,121 @@
import {
ArrowDownToLine,
ArrowUpFromLine,
ArrowLeftRight,
ClipboardEdit,
CornerDownLeft,
Factory,
Flame,
type LucideIcon,
} from 'lucide-react';
import { cn } from '@utils/cn';
import type { MovementType } from '../types';
export interface MovementTypeIconProps {
type: MovementType;
size?: 'sm' | 'md' | 'lg';
showLabel?: boolean;
className?: string;
}
interface MovementConfig {
icon: LucideIcon;
label: string;
color: string;
bgColor: string;
}
const movementConfig: Record<MovementType, MovementConfig> = {
receipt: {
icon: ArrowDownToLine,
label: 'Recepcion',
color: 'text-success-600 dark:text-success-400',
bgColor: 'bg-success-50 dark:bg-success-900/20',
},
shipment: {
icon: ArrowUpFromLine,
label: 'Envio',
color: 'text-blue-600 dark:text-blue-400',
bgColor: 'bg-blue-50 dark:bg-blue-900/20',
},
transfer: {
icon: ArrowLeftRight,
label: 'Transferencia',
color: 'text-purple-600 dark:text-purple-400',
bgColor: 'bg-purple-50 dark:bg-purple-900/20',
},
adjustment: {
icon: ClipboardEdit,
label: 'Ajuste',
color: 'text-warning-600 dark:text-warning-400',
bgColor: 'bg-warning-50 dark:bg-warning-900/20',
},
return: {
icon: CornerDownLeft,
label: 'Devolucion',
color: 'text-orange-600 dark:text-orange-400',
bgColor: 'bg-orange-50 dark:bg-orange-900/20',
},
production: {
icon: Factory,
label: 'Produccion',
color: 'text-cyan-600 dark:text-cyan-400',
bgColor: 'bg-cyan-50 dark:bg-cyan-900/20',
},
consumption: {
icon: Flame,
label: 'Consumo',
color: 'text-danger-600 dark:text-danger-400',
bgColor: 'bg-danger-50 dark:bg-danger-900/20',
},
};
const sizeClasses = {
sm: { icon: 'h-4 w-4', container: 'h-6 w-6', text: 'text-xs' },
md: { icon: 'h-5 w-5', container: 'h-8 w-8', text: 'text-sm' },
lg: { icon: 'h-6 w-6', container: 'h-10 w-10', text: 'text-base' },
};
export function MovementTypeIcon({
type,
size = 'md',
showLabel = false,
className,
}: MovementTypeIconProps) {
const config = movementConfig[type];
const Icon = config.icon;
const sizes = sizeClasses[size];
if (showLabel) {
return (
<div className={cn('flex items-center gap-2', className)}>
<div
className={cn(
'flex items-center justify-center rounded-full',
config.bgColor,
sizes.container
)}
>
<Icon className={cn(config.color, sizes.icon)} />
</div>
<span className={cn('font-medium', config.color, sizes.text)}>
{config.label}
</span>
</div>
);
}
return (
<div
className={cn(
'flex items-center justify-center rounded-full',
config.bgColor,
sizes.container,
className
)}
title={config.label}
>
<Icon className={cn(config.color, sizes.icon)} />
</div>
);
}

View File

@ -0,0 +1,452 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@components/atoms/Button';
import { FormField } from '@components/molecules/FormField';
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
import type { CreateProductDto, UpdateProductDto } from '@services/api/products.api';
const productSchema = z.object({
// Basic Info
name: z.string().min(1, 'El nombre es requerido').max(200, 'Maximo 200 caracteres'),
code: z.string().max(50, 'Maximo 50 caracteres').optional(),
barcode: z.string().max(50, 'Maximo 50 caracteres').optional(),
description: z.string().max(1000, 'Maximo 1000 caracteres').optional(),
type: z.enum(['goods', 'service', 'consumable'], {
required_error: 'Selecciona un tipo de producto',
}),
categoryId: z.string().optional(),
// Inventory Settings
uomId: z.string().optional(),
minStock: z.number().min(0, 'Debe ser mayor o igual a 0').optional().nullable(),
maxStock: z.number().min(0, 'Debe ser mayor o igual a 0').optional().nullable(),
reorderPoint: z.number().min(0, 'Debe ser mayor o igual a 0').optional().nullable(),
// Physical
weight: z.number().min(0, 'Debe ser mayor o igual a 0').optional().nullable(),
length: z.number().min(0, 'Debe ser mayor o igual a 0').optional().nullable(),
width: z.number().min(0, 'Debe ser mayor o igual a 0').optional().nullable(),
height: z.number().min(0, 'Debe ser mayor o igual a 0').optional().nullable(),
// Pricing
cost: z.number().min(0, 'Debe ser mayor o igual a 0').optional().nullable(),
salePrice: z.number().min(0, 'Debe ser mayor o igual a 0').optional().nullable(),
// Status
isActive: z.boolean(),
});
type ProductFormData = z.infer<typeof productSchema>;
const productTypeLabels: Record<string, string> = {
goods: 'Bienes',
service: 'Servicio',
consumable: 'Consumible',
};
// Accept Product or extended types (like ProductWithStock) that include Product fields
// Using Partial with nullable fields to allow for ProductWithStock compatibility
interface ProductLike {
id: string;
name: string;
code?: string | null;
barcode?: string | null;
description?: string | null;
type: 'goods' | 'service' | 'consumable';
categoryId?: string | null;
categoryName?: string | null;
uomId?: string | null;
uomName?: string | null;
salePrice?: number | null;
cost?: number | null;
isActive: boolean;
minStock?: number | null;
maxStock?: number | null;
reorderPoint?: number | null;
weight?: number | null;
length?: number | null;
width?: number | null;
height?: number | null;
createdAt?: string;
updatedAt?: string | null;
}
export interface ProductFormProps {
product?: ProductLike | null;
onSubmit: (data: CreateProductDto | UpdateProductDto, saveAsDraft?: boolean) => Promise<void>;
onCancel?: () => void;
isLoading?: boolean;
showDraftButton?: boolean;
}
export function ProductForm({
product,
onSubmit,
onCancel,
isLoading = false,
showDraftButton = false,
}: ProductFormProps) {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ProductFormData>({
resolver: zodResolver(productSchema),
defaultValues: {
name: product?.name ?? '',
code: product?.code ?? '',
barcode: product?.barcode ?? '',
description: product?.description ?? '',
type: product?.type ?? 'goods',
categoryId: product?.categoryId ?? '',
uomId: product?.uomId ?? '',
minStock: (product as any)?.minStock ?? null,
maxStock: (product as any)?.maxStock ?? null,
reorderPoint: (product as any)?.reorderPoint ?? null,
weight: (product as any)?.weight ?? null,
length: (product as any)?.length ?? null,
width: (product as any)?.width ?? null,
height: (product as any)?.height ?? null,
cost: product?.cost ?? null,
salePrice: product?.salePrice ?? null,
isActive: product?.isActive ?? true,
},
});
const handleFormSubmit = async (data: ProductFormData) => {
const cleanData = {
...data,
code: data.code || undefined,
barcode: data.barcode || undefined,
description: data.description || undefined,
categoryId: data.categoryId || undefined,
uomId: data.uomId || undefined,
cost: data.cost ?? undefined,
salePrice: data.salePrice ?? undefined,
};
await onSubmit(cleanData as CreateProductDto);
};
const handleSaveAsDraft = async () => {
const data = {
name: (document.querySelector('input[name="name"]') as HTMLInputElement)?.value || 'Borrador',
type: 'goods' as const,
isActive: false,
};
await onSubmit(data as CreateProductDto, true);
};
return (
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
{/* Basic Info Section */}
<Card>
<CardHeader>
<CardTitle>Informacion Basica</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FormField
label="Nombre del Producto"
error={errors.name?.message}
required
>
<input
type="text"
{...register('name')}
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="Ej: Tornillo galvanizado 1/4"
/>
</FormField>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
label="SKU / Codigo"
error={errors.code?.message}
>
<input
type="text"
{...register('code')}
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="Ej: TORN-001"
/>
</FormField>
<FormField
label="Codigo de Barras"
error={errors.barcode?.message}
>
<input
type="text"
{...register('barcode')}
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="Ej: 7501234567890"
/>
</FormField>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
label="Tipo de Producto"
error={errors.type?.message}
required
>
<select
{...register('type')}
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
{Object.entries(productTypeLabels).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</FormField>
<FormField
label="Categoria"
error={errors.categoryId?.message}
>
<select
{...register('categoryId')}
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">Sin categoria</option>
{/* Categories would be loaded from API */}
</select>
</FormField>
</div>
<FormField
label="Descripcion"
error={errors.description?.message}
>
<textarea
{...register('description')}
rows={3}
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="Descripcion detallada del producto..."
/>
</FormField>
</CardContent>
</Card>
{/* Inventory Settings Section */}
<Card>
<CardHeader>
<CardTitle>Configuracion de Inventario</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField
label="Stock Minimo"
error={errors.minStock?.message}
hint="Cantidad minima a mantener"
>
<input
type="number"
{...register('minStock', { valueAsNumber: true })}
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="0"
min={0}
/>
</FormField>
<FormField
label="Stock Maximo"
error={errors.maxStock?.message}
hint="Cantidad maxima a almacenar"
>
<input
type="number"
{...register('maxStock', { valueAsNumber: true })}
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="0"
min={0}
/>
</FormField>
<FormField
label="Punto de Reorden"
error={errors.reorderPoint?.message}
hint="Nivel para generar alerta"
>
<input
type="number"
{...register('reorderPoint', { valueAsNumber: true })}
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="0"
min={0}
/>
</FormField>
</div>
<FormField
label="Unidad de Medida"
error={errors.uomId?.message}
>
<select
{...register('uomId')}
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">Unidad (default)</option>
<option value="pz">Pieza</option>
<option value="kg">Kilogramo</option>
<option value="lt">Litro</option>
<option value="mt">Metro</option>
<option value="m2">Metro cuadrado</option>
<option value="m3">Metro cubico</option>
{/* UoMs would be loaded from API */}
</select>
</FormField>
</CardContent>
</Card>
{/* Physical Section */}
<Card>
<CardHeader>
<CardTitle>Caracteristicas Fisicas</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<FormField
label="Peso (kg)"
error={errors.weight?.message}
>
<input
type="number"
step="0.01"
{...register('weight', { valueAsNumber: true })}
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="0.00"
min={0}
/>
</FormField>
<FormField
label="Largo (cm)"
error={errors.length?.message}
>
<input
type="number"
step="0.1"
{...register('length', { valueAsNumber: true })}
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="0.0"
min={0}
/>
</FormField>
<FormField
label="Ancho (cm)"
error={errors.width?.message}
>
<input
type="number"
step="0.1"
{...register('width', { valueAsNumber: true })}
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="0.0"
min={0}
/>
</FormField>
<FormField
label="Alto (cm)"
error={errors.height?.message}
>
<input
type="number"
step="0.1"
{...register('height', { valueAsNumber: true })}
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="0.0"
min={0}
/>
</FormField>
</div>
</CardContent>
</Card>
{/* Pricing Section */}
<Card>
<CardHeader>
<CardTitle>Precios</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
label="Costo"
error={errors.cost?.message}
hint="Costo de adquisicion"
>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
type="number"
step="0.01"
{...register('cost', { valueAsNumber: true })}
className="w-full rounded-md border border-gray-300 py-2 pl-8 pr-3 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="0.00"
min={0}
/>
</div>
</FormField>
<FormField
label="Precio de Venta"
error={errors.salePrice?.message}
hint="Precio al publico"
>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
type="number"
step="0.01"
{...register('salePrice', { valueAsNumber: true })}
className="w-full rounded-md border border-gray-300 py-2 pl-8 pr-3 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="0.00"
min={0}
/>
</div>
</FormField>
</div>
</CardContent>
</Card>
{/* Status */}
<Card>
<CardContent className="py-4">
<div className="flex items-center gap-3">
<input
type="checkbox"
{...register('isActive')}
id="isActive"
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<label htmlFor="isActive" className="text-sm font-medium text-gray-700">
Producto activo
</label>
<span className="text-sm text-gray-500">
Los productos inactivos no apareceran en busquedas ni podran ser vendidos
</span>
</div>
</CardContent>
</Card>
{/* Actions */}
<div className="flex justify-end gap-3 border-t pt-6">
{onCancel && (
<Button type="button" variant="outline" onClick={onCancel} disabled={isLoading}>
Cancelar
</Button>
)}
{showDraftButton && (
<Button type="button" variant="outline" onClick={handleSaveAsDraft} disabled={isLoading}>
Guardar como borrador
</Button>
)}
<Button type="submit" isLoading={isLoading}>
{product ? 'Guardar cambios' : 'Crear producto'}
</Button>
</div>
</form>
);
}

View File

@ -0,0 +1,244 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@components/atoms/Button';
import { FormField } from '@components/molecules/FormField';
import { Switch } from '@components/atoms/Switch';
import { cn } from '@utils/cn';
const productInventorySchema = z.object({
minStock: z.number().min(0, 'Debe ser mayor o igual a 0').optional(),
maxStock: z.number().min(0, 'Debe ser mayor o igual a 0').optional(),
reorderPoint: z.number().min(0, 'Debe ser mayor o igual a 0').optional(),
reorderQty: z.number().min(0, 'Debe ser mayor o igual a 0').optional(),
trackByLot: z.boolean(),
trackBySerial: z.boolean(),
trackExpiry: z.boolean(),
leadTimeDays: z.number().min(0).optional(),
safetyStockDays: z.number().min(0).optional(),
}).refine(
(data) => {
if (data.minStock !== undefined && data.maxStock !== undefined) {
return data.maxStock >= data.minStock;
}
return true;
},
{
message: 'Stock maximo debe ser mayor o igual al stock minimo',
path: ['maxStock'],
}
).refine(
(data) => {
if (data.reorderPoint !== undefined && data.minStock !== undefined) {
return data.reorderPoint >= data.minStock;
}
return true;
},
{
message: 'Punto de reorden debe ser mayor o igual al stock minimo',
path: ['reorderPoint'],
}
);
export type ProductInventoryFormData = z.infer<typeof productInventorySchema>;
export interface ProductInventoryFormProps {
initialData?: Partial<ProductInventoryFormData>;
onSubmit: (data: ProductInventoryFormData) => Promise<void>;
onCancel?: () => void;
isLoading?: boolean;
className?: string;
}
export function ProductInventoryForm({
initialData,
onSubmit,
onCancel,
isLoading = false,
className,
}: ProductInventoryFormProps) {
const {
register,
handleSubmit,
watch,
setValue,
formState: { errors },
} = useForm<ProductInventoryFormData>({
resolver: zodResolver(productInventorySchema),
defaultValues: {
minStock: initialData?.minStock ?? undefined,
maxStock: initialData?.maxStock ?? undefined,
reorderPoint: initialData?.reorderPoint ?? undefined,
reorderQty: initialData?.reorderQty ?? undefined,
trackByLot: initialData?.trackByLot ?? false,
trackBySerial: initialData?.trackBySerial ?? false,
trackExpiry: initialData?.trackExpiry ?? false,
leadTimeDays: initialData?.leadTimeDays ?? undefined,
safetyStockDays: initialData?.safetyStockDays ?? undefined,
},
});
const trackByLot = watch('trackByLot');
const trackBySerial = watch('trackBySerial');
const trackExpiry = watch('trackExpiry');
const handleFormSubmit = async (data: ProductInventoryFormData) => {
await onSubmit(data);
};
return (
<form
onSubmit={handleSubmit(handleFormSubmit)}
className={cn('space-y-6', className)}
>
{/* Stock Levels */}
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
Niveles de Stock
</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
label="Stock Minimo"
error={errors.minStock?.message}
hint="Cantidad minima que debe mantenerse"
>
<input
type="number"
{...register('minStock', { valueAsNumber: true })}
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="0"
min={0}
/>
</FormField>
<FormField
label="Stock Maximo"
error={errors.maxStock?.message}
hint="Cantidad maxima a almacenar"
>
<input
type="number"
{...register('maxStock', { valueAsNumber: true })}
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="0"
min={0}
/>
</FormField>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
label="Punto de Reorden"
error={errors.reorderPoint?.message}
hint="Nivel para generar alerta de reabastecimiento"
>
<input
type="number"
{...register('reorderPoint', { valueAsNumber: true })}
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="0"
min={0}
/>
</FormField>
<FormField
label="Cantidad a Reordenar"
error={errors.reorderQty?.message}
hint="Cantidad sugerida para reabastecimiento"
>
<input
type="number"
{...register('reorderQty', { valueAsNumber: true })}
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="0"
min={0}
/>
</FormField>
</div>
</div>
{/* Tracking Options */}
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
Seguimiento
</h3>
<div className="space-y-3">
<Switch
checked={trackByLot}
onChange={(checked) => setValue('trackByLot', checked)}
label="Seguimiento por Lote"
/>
<p className="ml-11 text-sm text-gray-500 dark:text-gray-400">
Permite rastrear productos por numero de lote
</p>
<Switch
checked={trackBySerial}
onChange={(checked) => setValue('trackBySerial', checked)}
label="Seguimiento por Serie"
/>
<p className="ml-11 text-sm text-gray-500 dark:text-gray-400">
Permite rastrear cada unidad con numero de serie unico
</p>
<Switch
checked={trackExpiry}
onChange={(checked) => setValue('trackExpiry', checked)}
label="Seguimiento de Caducidad"
/>
<p className="ml-11 text-sm text-gray-500 dark:text-gray-400">
Permite registrar y alertar fechas de caducidad
</p>
</div>
</div>
{/* Lead Time */}
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
Tiempos de Aprovisionamiento
</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
label="Tiempo de Entrega (dias)"
error={errors.leadTimeDays?.message}
hint="Dias promedio de entrega del proveedor"
>
<input
type="number"
{...register('leadTimeDays', { valueAsNumber: true })}
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="0"
min={0}
/>
</FormField>
<FormField
label="Stock de Seguridad (dias)"
error={errors.safetyStockDays?.message}
hint="Dias de stock adicional de seguridad"
>
<input
type="number"
{...register('safetyStockDays', { valueAsNumber: true })}
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="0"
min={0}
/>
</FormField>
</div>
</div>
{/* Actions */}
<div className="flex justify-end gap-3 border-t pt-4 dark:border-gray-700">
{onCancel && (
<Button type="button" variant="outline" onClick={onCancel}>
Cancelar
</Button>
)}
<Button type="submit" isLoading={isLoading}>
Guardar Configuracion
</Button>
</div>
</form>
);
}

View File

@ -0,0 +1,147 @@
import { Card, CardContent, CardHeader, CardTitle } from '@components/molecules/Card';
import { StockLevelIndicator } from './StockLevelIndicator';
import { StockLevelBadge } from './StockLevelBadge';
import { cn } from '@utils/cn';
import { Package, MapPin } from 'lucide-react';
import type { StockLevel, Warehouse } from '../types';
export interface ProductStockInfo {
productId: string;
productName: string;
productCode?: string;
productImage?: string;
stockLevels: Array<StockLevel & { warehouse?: Warehouse }>;
minStock?: number;
maxStock?: number;
reorderPoint?: number;
}
export interface ProductStockCardProps {
product: ProductStockInfo;
className?: string;
onViewDetails?: () => void;
}
export function ProductStockCard({
product,
className,
onViewDetails,
}: ProductStockCardProps) {
const totalQuantity = product.stockLevels.reduce(
(sum, level) => sum + level.quantityOnHand,
0
);
const totalAvailable = product.stockLevels.reduce(
(sum, level) => sum + level.quantityAvailable,
0
);
const totalReserved = product.stockLevels.reduce(
(sum, level) => sum + level.quantityReserved,
0
);
return (
<Card
className={cn(
'transition-shadow hover:shadow-md dark:bg-gray-800',
onViewDetails && 'cursor-pointer',
className
)}
onClick={onViewDetails}
>
<CardHeader className="pb-2">
<div className="flex items-start gap-3">
{product.productImage ? (
<img
src={product.productImage}
alt={product.productName}
className="h-12 w-12 rounded-md object-cover"
/>
) : (
<div className="flex h-12 w-12 items-center justify-center rounded-md bg-gray-100 dark:bg-gray-700">
<Package className="h-6 w-6 text-gray-400" />
</div>
)}
<div className="flex-1 min-w-0">
<CardTitle className="text-base truncate">{product.productName}</CardTitle>
{product.productCode && (
<p className="text-sm text-gray-500 dark:text-gray-400">
{product.productCode}
</p>
)}
</div>
<StockLevelBadge
quantity={totalQuantity}
minStock={product.minStock}
maxStock={product.maxStock}
reorderPoint={product.reorderPoint}
/>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Total Stock Summary */}
<div className="grid grid-cols-3 gap-2 rounded-lg bg-gray-50 p-3 dark:bg-gray-700/50">
<div className="text-center">
<p className="text-lg font-semibold text-gray-900 dark:text-white">
{totalQuantity}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">En mano</p>
</div>
<div className="text-center">
<p className="text-lg font-semibold text-success-600 dark:text-success-400">
{totalAvailable}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">Disponible</p>
</div>
<div className="text-center">
<p className="text-lg font-semibold text-warning-600 dark:text-warning-400">
{totalReserved}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">Reservado</p>
</div>
</div>
{/* Stock Level Indicator */}
<StockLevelIndicator
quantity={totalQuantity}
minStock={product.minStock}
maxStock={product.maxStock}
reorderPoint={product.reorderPoint}
showLabels
/>
{/* Stock by Warehouse */}
{product.stockLevels.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
Stock por almacen
</p>
<div className="space-y-1">
{product.stockLevels.map((level) => (
<div
key={level.id}
className="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2 text-sm dark:bg-gray-700/50"
>
<div className="flex items-center gap-2">
<MapPin className="h-4 w-4 text-gray-400" />
<span className="text-gray-700 dark:text-gray-300">
{level.warehouse?.name || level.warehouseId}
</span>
</div>
<div className="flex items-center gap-4">
<span className="text-gray-900 dark:text-white">
{level.quantityOnHand}
</span>
<span className="text-success-600 dark:text-success-400">
({level.quantityAvailable} disp.)
</span>
</div>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,51 @@
import { Badge } from '@components/atoms/Badge';
export interface StockLevelBadgeProps {
quantity: number;
reorderPoint?: number;
minStock?: number;
maxStock?: number;
className?: string;
}
type StockStatus = 'out-of-stock' | 'low' | 'normal' | 'high';
function getStockStatus(
quantity: number,
reorderPoint?: number,
minStock?: number,
maxStock?: number
): StockStatus {
if (quantity <= 0) return 'out-of-stock';
if (minStock !== undefined && quantity < minStock) return 'low';
if (reorderPoint !== undefined && quantity <= reorderPoint) return 'low';
if (maxStock !== undefined && quantity > maxStock) return 'high';
return 'normal';
}
const statusConfig: Record<
StockStatus,
{ label: string; variant: 'success' | 'danger' | 'warning' | 'default' | 'info' | 'primary' }
> = {
'out-of-stock': { label: 'Sin Stock', variant: 'danger' },
low: { label: 'Bajo', variant: 'warning' },
normal: { label: 'Normal', variant: 'success' },
high: { label: 'Alto', variant: 'info' },
};
export function StockLevelBadge({
quantity,
reorderPoint,
minStock,
maxStock,
className,
}: StockLevelBadgeProps) {
const status = getStockStatus(quantity, reorderPoint, minStock, maxStock);
const config = statusConfig[status];
return (
<Badge variant={config.variant} className={className}>
{config.label}
</Badge>
);
}

View File

@ -0,0 +1,124 @@
import { cn } from '@utils/cn';
import { Tooltip } from '@components/atoms/Tooltip';
export interface StockLevelIndicatorProps {
quantity: number;
minStock?: number;
maxStock?: number;
reorderPoint?: number;
showLabels?: boolean;
className?: string;
}
type StockStatus = 'out-of-stock' | 'low' | 'warning' | 'normal' | 'overstock';
function getStockStatus(
quantity: number,
minStock?: number,
maxStock?: number,
reorderPoint?: number
): StockStatus {
if (quantity <= 0) return 'out-of-stock';
if (minStock !== undefined && quantity < minStock) return 'low';
if (reorderPoint !== undefined && quantity <= reorderPoint) return 'warning';
if (maxStock !== undefined && quantity > maxStock) return 'overstock';
return 'normal';
}
const statusColors: Record<StockStatus, { bg: string; fill: string }> = {
'out-of-stock': { bg: 'bg-gray-200 dark:bg-gray-700', fill: 'bg-danger-500' },
low: { bg: 'bg-gray-200 dark:bg-gray-700', fill: 'bg-danger-500' },
warning: { bg: 'bg-gray-200 dark:bg-gray-700', fill: 'bg-warning-500' },
normal: { bg: 'bg-gray-200 dark:bg-gray-700', fill: 'bg-success-500' },
overstock: { bg: 'bg-gray-200 dark:bg-gray-700', fill: 'bg-blue-500' },
};
const statusLabels: Record<StockStatus, string> = {
'out-of-stock': 'Sin stock',
low: 'Bajo',
warning: 'Reordenar',
normal: 'Normal',
overstock: 'Sobrestock',
};
export function StockLevelIndicator({
quantity,
minStock,
maxStock,
reorderPoint,
showLabels = false,
className,
}: StockLevelIndicatorProps) {
const status = getStockStatus(quantity, minStock, maxStock, reorderPoint);
const colors = statusColors[status];
// Calculate percentage for the bar
const effectiveMax = maxStock || Math.max(quantity * 1.5, 100);
const percentage = Math.min((quantity / effectiveMax) * 100, 100);
// Calculate marker positions
const minMarker = minStock !== undefined ? (minStock / effectiveMax) * 100 : null;
const reorderMarker = reorderPoint !== undefined ? (reorderPoint / effectiveMax) * 100 : null;
const maxMarker = maxStock !== undefined ? (maxStock / effectiveMax) * 100 : null;
const tooltipContent = (
<div className="space-y-1 text-xs">
<div>Cantidad: {quantity}</div>
{minStock !== undefined && <div>Stock minimo: {minStock}</div>}
{reorderPoint !== undefined && <div>Punto de reorden: {reorderPoint}</div>}
{maxStock !== undefined && <div>Stock maximo: {maxStock}</div>}
<div className="font-medium">Estado: {statusLabels[status]}</div>
</div>
);
return (
<Tooltip content={tooltipContent}>
<div className={cn('w-full', className)}>
{showLabels && (
<div className="mb-1 flex justify-between text-xs text-gray-500 dark:text-gray-400">
<span>{quantity} unidades</span>
<span className={cn(
status === 'out-of-stock' && 'text-danger-600',
status === 'low' && 'text-danger-600',
status === 'warning' && 'text-warning-600',
status === 'normal' && 'text-success-600',
status === 'overstock' && 'text-blue-600',
)}>
{statusLabels[status]}
</span>
</div>
)}
<div className={cn('relative h-2 w-full rounded-full', colors.bg)}>
{/* Fill bar */}
<div
className={cn('h-full rounded-full transition-all duration-300', colors.fill)}
style={{ width: `${percentage}%` }}
/>
{/* Markers */}
{minMarker !== null && minMarker <= 100 && (
<div
className="absolute top-0 h-full w-0.5 bg-danger-700"
style={{ left: `${minMarker}%` }}
title={`Min: ${minStock}`}
/>
)}
{reorderMarker !== null && reorderMarker <= 100 && (
<div
className="absolute top-0 h-full w-0.5 bg-warning-700"
style={{ left: `${reorderMarker}%` }}
title={`Reorden: ${reorderPoint}`}
/>
)}
{maxMarker !== null && maxMarker <= 100 && (
<div
className="absolute top-0 h-full w-0.5 bg-blue-700"
style={{ left: `${maxMarker}%` }}
title={`Max: ${maxStock}`}
/>
)}
</div>
</div>
</Tooltip>
);
}

View File

@ -0,0 +1,105 @@
import { MovementTypeIcon } from './MovementTypeIcon';
import { MovementStatusBadge } from './MovementStatusBadge';
import { cn } from '@utils/cn';
import { formatDate } from '@utils/formatters';
import { ArrowRight, Package, MapPin, FileText } from 'lucide-react';
import type { StockMovement, Warehouse } from '../types';
export interface StockMovementData extends StockMovement {
productName?: string;
productCode?: string;
sourceWarehouse?: Warehouse;
destWarehouse?: Warehouse;
}
export interface StockMovementRowProps {
movement: StockMovementData;
onClick?: () => void;
className?: string;
}
export function StockMovementRow({
movement,
onClick,
className,
}: StockMovementRowProps) {
const hasReference = movement.referenceType && movement.referenceId;
return (
<div
className={cn(
'flex items-center gap-4 rounded-lg border bg-white p-4 transition-colors dark:border-gray-700 dark:bg-gray-800',
onClick && 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-750',
className
)}
onClick={onClick}
>
{/* Movement Type Icon */}
<MovementTypeIcon type={movement.movementType} size="md" />
{/* Main Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-white">
{movement.movementNumber}
</span>
<MovementStatusBadge status={movement.status} />
</div>
{/* Product Info */}
<div className="mt-1 flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400">
<Package className="h-3.5 w-3.5" />
<span className="truncate">
{movement.productName || movement.productCode || movement.productId}
</span>
</div>
{/* Warehouse Flow */}
{(movement.sourceWarehouse || movement.destWarehouse) && (
<div className="mt-1 flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<MapPin className="h-3.5 w-3.5 flex-shrink-0" />
{movement.sourceWarehouse && (
<span className="truncate">{movement.sourceWarehouse.name}</span>
)}
{movement.sourceWarehouse && movement.destWarehouse && (
<ArrowRight className="h-3 w-3 flex-shrink-0" />
)}
{movement.destWarehouse && (
<span className="truncate">{movement.destWarehouse.name}</span>
)}
</div>
)}
{/* Reference */}
{hasReference && (
<div className="mt-1 flex items-center gap-1 text-xs text-gray-400 dark:text-gray-500">
<FileText className="h-3 w-3" />
<span>
{movement.referenceType}: {movement.referenceId}
</span>
</div>
)}
</div>
{/* Quantity */}
<div className="text-right">
<p className="text-lg font-semibold text-gray-900 dark:text-white">
{movement.movementType === 'shipment' || movement.movementType === 'consumption'
? `-${movement.quantity}`
: `+${movement.quantity}`}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">unidades</p>
</div>
{/* Date */}
<div className="text-right text-sm text-gray-500 dark:text-gray-400">
<p>{formatDate(movement.createdAt, 'dd/MM/yy')}</p>
{movement.confirmedAt && (
<p className="text-xs text-success-600 dark:text-success-400">
Confirmado: {formatDate(movement.confirmedAt, 'dd/MM/yy')}
</p>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,268 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@components/atoms/Button';
import { FormField } from '@components/molecules/FormField';
import { Select } from '@components/organisms/Select';
import { Switch } from '@components/atoms/Switch';
import { cn } from '@utils/cn';
import type { Warehouse, CreateWarehouseDto, UpdateWarehouseDto } from '../types';
const warehouseSchema = z.object({
code: z
.string()
.min(2, 'Minimo 2 caracteres')
.max(20, 'Maximo 20 caracteres')
.regex(/^[A-Z0-9_-]+$/i, 'Solo letras, numeros, guiones y guiones bajos'),
name: z.string().min(2, 'Minimo 2 caracteres').max(100, 'Maximo 100 caracteres'),
warehouseType: z.enum(['main', 'transit', 'customer', 'supplier', 'virtual']),
isActive: z.boolean(),
street: z.string().optional(),
city: z.string().optional(),
state: z.string().optional(),
zipCode: z.string().optional(),
country: z.string().optional(),
});
type FormData = z.infer<typeof warehouseSchema>;
export interface WarehouseFormProps {
initialData?: Warehouse;
onSubmit: (data: CreateWarehouseDto | UpdateWarehouseDto) => Promise<void>;
onCancel?: () => void;
isLoading?: boolean;
mode?: 'create' | 'edit';
className?: string;
}
const warehouseTypeOptions = [
{ value: 'main', label: 'Principal' },
{ value: 'transit', label: 'Transito' },
{ value: 'customer', label: 'Cliente' },
{ value: 'supplier', label: 'Proveedor' },
{ value: 'virtual', label: 'Virtual' },
];
export function WarehouseForm({
initialData,
onSubmit,
onCancel,
isLoading = false,
mode = 'create',
className,
}: WarehouseFormProps) {
const isEditing = mode === 'edit';
const {
register,
handleSubmit,
watch,
setValue,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(warehouseSchema),
defaultValues: initialData
? {
code: initialData.code,
name: initialData.name,
warehouseType: initialData.warehouseType,
isActive: initialData.isActive,
street: (initialData.address as any)?.street || '',
city: (initialData.address as any)?.city || '',
state: (initialData.address as any)?.state || '',
zipCode: (initialData.address as any)?.zipCode || '',
country: (initialData.address as any)?.country || '',
}
: {
code: '',
name: '',
warehouseType: 'main',
isActive: true,
street: '',
city: '',
state: '',
zipCode: '',
country: '',
},
});
const selectedType = watch('warehouseType');
const isActive = watch('isActive');
const handleFormSubmit = async (data: FormData) => {
const address =
data.street || data.city || data.state || data.zipCode || data.country
? {
street: data.street || undefined,
city: data.city || undefined,
state: data.state || undefined,
zipCode: data.zipCode || undefined,
country: data.country || undefined,
}
: undefined;
const submitData: CreateWarehouseDto | UpdateWarehouseDto = {
code: data.code,
name: data.name,
warehouseType: data.warehouseType,
...(isEditing && { isActive: data.isActive }),
...(address && { address }),
};
await onSubmit(submitData);
};
return (
<form
onSubmit={handleSubmit(handleFormSubmit)}
className={cn('space-y-6', className)}
>
{/* Basic Info */}
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
Informacion Basica
</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
label="Codigo"
error={errors.code?.message}
required
hint="Identificador unico del almacen"
>
<input
{...register('code')}
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 uppercase focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="ALM-001"
/>
</FormField>
<FormField
label="Tipo de Almacen"
error={errors.warehouseType?.message}
required
>
<Select
options={warehouseTypeOptions}
value={selectedType}
onChange={(value) =>
setValue(
'warehouseType',
value as FormData['warehouseType']
)
}
placeholder="Seleccionar tipo..."
/>
</FormField>
</div>
<FormField
label="Nombre"
error={errors.name?.message}
required
>
<input
{...register('name')}
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Almacen Principal"
/>
</FormField>
{isEditing && (
<div className="flex items-center gap-2">
<Switch
checked={isActive}
onChange={(checked) => setValue('isActive', checked)}
label="Almacen Activo"
/>
</div>
)}
</div>
{/* Address */}
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
Direccion (Opcional)
</h3>
<FormField
label="Calle y Numero"
error={errors.street?.message}
>
<input
{...register('street')}
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Av. Principal #123"
/>
</FormField>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
label="Ciudad"
error={errors.city?.message}
>
<input
{...register('city')}
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Ciudad de Mexico"
/>
</FormField>
<FormField
label="Estado"
error={errors.state?.message}
>
<input
{...register('state')}
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="CDMX"
/>
</FormField>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
label="Codigo Postal"
error={errors.zipCode?.message}
>
<input
{...register('zipCode')}
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="06600"
/>
</FormField>
<FormField
label="Pais"
error={errors.country?.message}
>
<input
{...register('country')}
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Mexico"
/>
</FormField>
</div>
</div>
{/* Actions */}
<div className="flex justify-end gap-3 border-t pt-4 dark:border-gray-700">
{onCancel && (
<Button type="button" variant="outline" onClick={onCancel}>
Cancelar
</Button>
)}
<Button type="submit" isLoading={isLoading}>
{isEditing ? 'Guardar Cambios' : 'Crear Almacen'}
</Button>
</div>
</form>
);
}

View File

@ -0,0 +1,62 @@
import { Select, type SelectOption } from '@components/organisms/Select';
import { useWarehouses } from '../hooks';
import { cn } from '@utils/cn';
export interface WarehouseSelectorProps {
value?: string;
onChange: (value: string) => void;
disabled?: boolean;
className?: string;
placeholder?: string;
error?: boolean;
clearable?: boolean;
excludeIds?: string[];
warehouseTypes?: Array<'main' | 'transit' | 'customer' | 'supplier' | 'virtual'>;
onlyActive?: boolean;
}
export function WarehouseSelector({
value,
onChange,
disabled = false,
className,
placeholder = 'Seleccionar almacen...',
error = false,
clearable = false,
excludeIds = [],
warehouseTypes,
onlyActive = true,
}: WarehouseSelectorProps) {
const { warehouses, isLoading } = useWarehouses();
const filteredWarehouses = warehouses.filter((w) => {
if (excludeIds.includes(w.id)) return false;
if (onlyActive && !w.isActive) return false;
if (warehouseTypes && !warehouseTypes.includes(w.warehouseType)) return false;
return true;
});
const options: SelectOption[] = filteredWarehouses.map((warehouse) => ({
value: warehouse.id,
label: `${warehouse.code} - ${warehouse.name}`,
}));
const handleChange = (selected: string | string[]) => {
const selectedValue = Array.isArray(selected) ? selected[0] : selected;
onChange(selectedValue || '');
};
return (
<Select
options={options}
value={value || ''}
onChange={handleChange}
placeholder={isLoading ? 'Cargando...' : placeholder}
disabled={disabled || isLoading}
error={error}
clearable={clearable}
searchable={options.length > 5}
className={cn(className)}
/>
);
}

View File

@ -0,0 +1,23 @@
// Stock Level Components
export { StockLevelIndicator, type StockLevelIndicatorProps } from './StockLevelIndicator';
export { StockLevelBadge, type StockLevelBadgeProps } from './StockLevelBadge';
// Warehouse & Location Selectors
export { WarehouseSelector, type WarehouseSelectorProps } from './WarehouseSelector';
export { LocationSelector, type LocationSelectorProps } from './LocationSelector';
// Product Stock
export { ProductStockCard, type ProductStockCardProps, type ProductStockInfo } from './ProductStockCard';
// Movement Components
export { MovementTypeIcon, type MovementTypeIconProps } from './MovementTypeIcon';
export { MovementStatusBadge, type MovementStatusBadgeProps } from './MovementStatusBadge';
export { StockMovementRow, type StockMovementRowProps, type StockMovementData } from './StockMovementRow';
// Stats & Dashboard
export { InventoryStatsCard, type InventoryStatsCardProps } from './InventoryStatsCard';
// Forms
export { ProductForm, type ProductFormProps } from './ProductForm';
export { ProductInventoryForm, type ProductInventoryFormProps, type ProductInventoryFormData } from './ProductInventoryForm';
export { WarehouseForm, type WarehouseFormProps } from './WarehouseForm';

View File

@ -1,16 +1,33 @@
// Main inventory hook with overview and re-exports
export {
useStockLevels,
useMovements,
useWarehouses,
useLocations,
useStockOperations,
useInventoryOverview,
useInventoryCounts,
useInventoryAdjustments,
useStockValuations,
} from './useInventory';
export type {
UseStockLevelsOptions,
UseMovementsOptions,
InventoryOverview,
UseInventoryCountsOptions,
UseInventoryAdjustmentsOptions,
UseStockValuationsOptions,
} from './useInventory';
// Products hooks
export { useProducts, useProduct } from './useProducts';
export type { UseProductsOptions } from './useProducts';
// Warehouses hooks
export { useWarehouses, useWarehouse, useWarehouseStock } from './useWarehouses';
export type { UseWarehousesOptions } from './useWarehouses';
// Stock levels hooks
export { useStockLevels, useProductStock, useLowStockProducts } from './useStockLevels';
export type { UseStockLevelsOptions } from './useStockLevels';
// Stock movements hooks
export { useStockMovements, useStockMovements as useMovements, useStockMovement, useProductMovements, useStockOperations } from './useStockMovements';
export type { UseStockMovementsOptions, UseStockMovementsOptions as UseMovementsOptions } from './useStockMovements';
// Locations hooks
export { useLocations, useLocation } from './useLocations';

View File

@ -1,412 +1,84 @@
import { useState, useEffect, useCallback } from 'react';
import { inventoryApi } from '../api/inventory.api';
import type {
StockLevel,
StockMovement,
StockSearchParams,
MovementSearchParams,
CreateStockMovementDto,
AdjustStockDto,
TransferStockDto,
Warehouse,
CreateWarehouseDto,
UpdateWarehouseDto,
Location,
CreateLocationDto,
UpdateLocationDto,
InventoryCount,
CreateInventoryCountDto,
InventoryAdjustment,
ValuationSummary,
CountType,
CountStatus,
InventoryAdjustment,
CreateInventoryAdjustmentDto,
AdjustmentStatus,
CreateInventoryCountDto,
CreateInventoryAdjustmentDto,
} from '../types';
// ==================== Stock Levels Hook ====================
// Re-export all hooks for convenient access
export { useProducts, useProduct } from './useProducts';
export type { UseProductsOptions } from './useProducts';
export interface UseStockLevelsOptions extends StockSearchParams {
autoFetch?: boolean;
export { useWarehouses, useWarehouse, useWarehouseStock } from './useWarehouses';
export type { UseWarehousesOptions } from './useWarehouses';
export { useStockLevels, useProductStock, useLowStockProducts } from './useStockLevels';
export type { UseStockLevelsOptions } from './useStockLevels';
export { useStockMovements, useStockMovement, useProductMovements, useStockOperations } from './useStockMovements';
export type { UseStockMovementsOptions } from './useStockMovements';
export { useLocations, useLocation } from './useLocations';
// ==================== Inventory Overview/Dashboard Hook ====================
export interface InventoryOverview {
totalProducts: number;
totalWarehouses: number;
lowStockCount: number;
totalValue: number;
recentMovements: number;
pendingCounts: number;
pendingAdjustments: number;
}
export function useStockLevels(options: UseStockLevelsOptions = {}) {
const { autoFetch = true, ...params } = options;
const [stockLevels, setStockLevels] = useState<StockLevel[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(params.page || 1);
const [totalPages, setTotalPages] = useState(1);
export function useInventoryOverview() {
const [overview, setOverview] = useState<InventoryOverview | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchStockLevels = useCallback(async () => {
const fetchOverview = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await inventoryApi.getStockLevels({ ...params, page });
setStockLevels(response.data);
setTotal(response.meta.total);
setTotalPages(response.meta.totalPages);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar niveles de stock');
} finally {
setIsLoading(false);
}
}, [params.productId, params.warehouseId, params.locationId, params.lotNumber, params.hasStock, params.lowStock, params.limit, page]);
const [stockResponse, warehousesResponse, lowStockResponse, valuationResponse] = await Promise.all([
inventoryApi.getStockLevels({ limit: 1 }),
inventoryApi.getWarehouses(1, 1),
inventoryApi.getStockLevels({ lowStock: true, limit: 1 }),
inventoryApi.getValuationSummary(),
]);
useEffect(() => {
if (autoFetch) {
fetchStockLevels();
}
}, [fetchStockLevels, autoFetch]);
return {
stockLevels,
total,
page,
totalPages,
isLoading,
error,
setPage,
refresh: fetchStockLevels,
};
}
// ==================== Stock Movements Hook ====================
export interface UseMovementsOptions extends MovementSearchParams {
autoFetch?: boolean;
}
export function useMovements(options: UseMovementsOptions = {}) {
const { autoFetch = true, ...params } = options;
const [movements, setMovements] = useState<StockMovement[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(params.page || 1);
const [totalPages, setTotalPages] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchMovements = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await inventoryApi.getMovements({ ...params, page });
setMovements(response.data);
setTotal(response.meta.total);
setTotalPages(response.meta.totalPages);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar movimientos');
} finally {
setIsLoading(false);
}
}, [params.movementType, params.productId, params.warehouseId, params.status, params.fromDate, params.toDate, params.limit, page]);
useEffect(() => {
if (autoFetch) {
fetchMovements();
}
}, [fetchMovements, autoFetch]);
const createMovement = async (data: CreateStockMovementDto) => {
setIsLoading(true);
try {
const newMovement = await inventoryApi.createMovement(data);
await fetchMovements();
return newMovement;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al crear movimiento');
throw err;
} finally {
setIsLoading(false);
}
};
const confirmMovement = async (id: string) => {
setIsLoading(true);
try {
await inventoryApi.confirmMovement(id);
await fetchMovements();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al confirmar movimiento');
throw err;
} finally {
setIsLoading(false);
}
};
const cancelMovement = async (id: string) => {
setIsLoading(true);
try {
await inventoryApi.cancelMovement(id);
await fetchMovements();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cancelar movimiento');
throw err;
} finally {
setIsLoading(false);
}
};
return {
movements,
total,
page,
totalPages,
isLoading,
error,
setPage,
refresh: fetchMovements,
createMovement,
confirmMovement,
cancelMovement,
};
}
// ==================== Warehouses Hook ====================
export function useWarehouses() {
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchWarehouses = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await inventoryApi.getWarehouses(page, 20);
setWarehouses(response.data);
setTotal(response.meta.total);
setTotalPages(response.meta.totalPages);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar almacenes');
} finally {
setIsLoading(false);
}
}, [page]);
useEffect(() => {
fetchWarehouses();
}, [fetchWarehouses]);
const createWarehouse = async (data: CreateWarehouseDto) => {
setIsLoading(true);
try {
const newWarehouse = await inventoryApi.createWarehouse(data);
await fetchWarehouses();
return newWarehouse;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al crear almacén');
throw err;
} finally {
setIsLoading(false);
}
};
const updateWarehouse = async (id: string, data: UpdateWarehouseDto) => {
setIsLoading(true);
try {
const updated = await inventoryApi.updateWarehouse(id, data);
await fetchWarehouses();
return updated;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al actualizar almacén');
throw err;
} finally {
setIsLoading(false);
}
};
const deleteWarehouse = async (id: string) => {
setIsLoading(true);
try {
await inventoryApi.deleteWarehouse(id);
await fetchWarehouses();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al eliminar almacén');
throw err;
} finally {
setIsLoading(false);
}
};
return {
warehouses,
total,
page,
totalPages,
isLoading,
error,
setPage,
refresh: fetchWarehouses,
createWarehouse,
updateWarehouse,
deleteWarehouse,
};
}
// ==================== Locations Hook ====================
export function useLocations(warehouseId?: string) {
const [locations, setLocations] = useState<Location[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchLocations = useCallback(async () => {
if (!warehouseId) {
setLocations([]);
return;
}
setIsLoading(true);
setError(null);
try {
const response = await inventoryApi.getLocationsByWarehouse(warehouseId);
setLocations(response.data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar ubicaciones');
} finally {
setIsLoading(false);
}
}, [warehouseId]);
useEffect(() => {
fetchLocations();
}, [fetchLocations]);
const createLocation = async (data: CreateLocationDto) => {
setIsLoading(true);
try {
const newLocation = await inventoryApi.createLocation(data);
await fetchLocations();
return newLocation;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al crear ubicación');
throw err;
} finally {
setIsLoading(false);
}
};
const updateLocation = async (id: string, data: UpdateLocationDto) => {
setIsLoading(true);
try {
const updated = await inventoryApi.updateLocation(id, data);
await fetchLocations();
return updated;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al actualizar ubicación');
throw err;
} finally {
setIsLoading(false);
}
};
const deleteLocation = async (id: string) => {
setIsLoading(true);
try {
await inventoryApi.deleteLocation(id);
await fetchLocations();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al eliminar ubicación');
throw err;
} finally {
setIsLoading(false);
}
};
return {
locations,
isLoading,
error,
refresh: fetchLocations,
createLocation,
updateLocation,
deleteLocation,
};
}
// ==================== Stock Operations Hook ====================
export function useStockOperations() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const adjustStock = async (data: AdjustStockDto) => {
setIsLoading(true);
setError(null);
try {
const result = await inventoryApi.adjustStock(data);
return result;
} catch (err) {
const message = err instanceof Error ? err.message : 'Error al ajustar stock';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
const transferStock = async (data: TransferStockDto) => {
setIsLoading(true);
setError(null);
try {
const result = await inventoryApi.transferStock(data);
return result;
} catch (err) {
const message = err instanceof Error ? err.message : 'Error al transferir stock';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
const reserveStock = async (productId: string, warehouseId: string, quantity: number, referenceType?: string, referenceId?: string) => {
setIsLoading(true);
setError(null);
try {
const result = await inventoryApi.reserveStock({
productId,
warehouseId,
quantity,
referenceType,
referenceId,
setOverview({
totalProducts: stockResponse.meta.total,
totalWarehouses: warehousesResponse.meta.total,
lowStockCount: lowStockResponse.meta.total,
totalValue: valuationResponse.totalValue,
recentMovements: 0,
pendingCounts: 0,
pendingAdjustments: 0,
});
return result;
} catch (err) {
const message = err instanceof Error ? err.message : 'Error al reservar stock';
setError(message);
throw err;
setError(err instanceof Error ? err.message : 'Error al cargar resumen de inventario');
} finally {
setIsLoading(false);
}
};
}, []);
const releaseReservation = async (productId: string, warehouseId: string, quantity: number) => {
setIsLoading(true);
setError(null);
try {
const result = await inventoryApi.releaseReservation(productId, warehouseId, quantity);
return result;
} catch (err) {
const message = err instanceof Error ? err.message : 'Error al liberar reserva';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchOverview();
}, [fetchOverview]);
return {
overview,
isLoading,
error,
adjustStock,
transferStock,
reserveStock,
releaseReservation,
refresh: fetchOverview,
};
}
@ -630,3 +302,60 @@ export function useInventoryAdjustments(options: UseInventoryAdjustmentsOptions
cancelAdjustment,
};
}
// ==================== Stock Valuations Hook ====================
export interface UseStockValuationsOptions {
warehouseId?: string;
asOfDate?: string;
autoFetch?: boolean;
}
export function useStockValuations(options: UseStockValuationsOptions = {}) {
const { autoFetch = true, warehouseId, asOfDate } = options;
const [valuations, setValuations] = useState<ValuationSummary[]>([]);
const [totalValue, setTotalValue] = useState(0);
const [totalProducts, setTotalProducts] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchValuations = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await inventoryApi.getValuationSummary({ warehouseId, asOfDate });
setValuations(response.data);
setTotalValue(response.totalValue);
setTotalProducts(response.totalProducts);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar valuaciones');
} finally {
setIsLoading(false);
}
}, [warehouseId, asOfDate]);
useEffect(() => {
if (autoFetch) {
fetchValuations();
}
}, [fetchValuations, autoFetch]);
const getProductValuation = async (productId: string) => {
try {
return await inventoryApi.getProductValuation(productId);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar valuacion del producto');
throw err;
}
};
return {
valuations,
totalValue,
totalProducts,
isLoading,
error,
refresh: fetchValuations,
getProductValuation,
};
}

View File

@ -0,0 +1,145 @@
import { useState, useEffect, useCallback } from 'react';
import { inventoryApi } from '../api/inventory.api';
import type {
Location,
CreateLocationDto,
UpdateLocationDto,
} from '../types';
// ==================== Locations Hook ====================
export function useLocations(warehouseId: string | null) {
const [locations, setLocations] = useState<Location[]>([]);
const [total, setTotal] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchLocations = useCallback(async () => {
if (!warehouseId) {
setLocations([]);
setTotal(0);
return;
}
setIsLoading(true);
setError(null);
try {
const response = await inventoryApi.getLocationsByWarehouse(warehouseId);
setLocations(response.data);
setTotal(response.meta.total);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar ubicaciones');
} finally {
setIsLoading(false);
}
}, [warehouseId]);
useEffect(() => {
fetchLocations();
}, [fetchLocations]);
const createLocation = async (data: CreateLocationDto) => {
setIsLoading(true);
try {
const newLocation = await inventoryApi.createLocation(data);
await fetchLocations();
return newLocation;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al crear ubicacion');
throw err;
} finally {
setIsLoading(false);
}
};
const updateLocation = async (id: string, data: UpdateLocationDto) => {
setIsLoading(true);
try {
const updated = await inventoryApi.updateLocation(id, data);
await fetchLocations();
return updated;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al actualizar ubicacion');
throw err;
} finally {
setIsLoading(false);
}
};
const deleteLocation = async (id: string) => {
setIsLoading(true);
try {
await inventoryApi.deleteLocation(id);
await fetchLocations();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al eliminar ubicacion');
throw err;
} finally {
setIsLoading(false);
}
};
return {
locations,
total,
isLoading,
error,
refresh: fetchLocations,
createLocation,
updateLocation,
deleteLocation,
};
}
// ==================== Single Location Hook ====================
export function useLocation(locationId: string | null) {
const [location, setLocation] = useState<Location | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchLocation = useCallback(async () => {
if (!locationId) {
setLocation(null);
return;
}
setIsLoading(true);
setError(null);
try {
const data = await inventoryApi.getLocationById(locationId);
setLocation(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar ubicacion');
} finally {
setIsLoading(false);
}
}, [locationId]);
useEffect(() => {
fetchLocation();
}, [fetchLocation]);
const updateLocation = async (data: UpdateLocationDto) => {
if (!locationId) return;
setIsLoading(true);
try {
const updated = await inventoryApi.updateLocation(locationId, data);
setLocation(updated);
return updated;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al actualizar ubicacion');
throw err;
} finally {
setIsLoading(false);
}
};
return {
location,
isLoading,
error,
refresh: fetchLocation,
updateLocation,
};
}

View File

@ -0,0 +1,283 @@
import { useState, useEffect, useCallback } from 'react';
import { productsApi } from '@services/api/products.api';
import { inventoryApi } from '../api/inventory.api';
import type { ProductFilters, CreateProductDto, UpdateProductDto } from '@services/api/products.api';
import type { ProductWithStock, ProductsStats, StockByWarehouse, StockStatus, StockMovement } from '../types';
// ==================== Products List Hook (with stock info) ====================
export interface UseProductsOptions extends ProductFilters {
autoFetch?: boolean;
stockStatus?: StockStatus;
}
export function useProducts(options: UseProductsOptions = {}) {
const { autoFetch = true, stockStatus, ...filters } = options;
const [products, setProducts] = useState<ProductWithStock[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(filters.page || 1);
const [totalPages, setTotalPages] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [stats, setStats] = useState<ProductsStats | null>(null);
const fetchProducts = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await productsApi.list({ ...filters, page });
// Enrich products with stock information
const productsWithStock: ProductWithStock[] = await Promise.all(
response.data.map(async (product) => {
try {
const stockLevels = await inventoryApi.getStockByProduct(product.id);
const totalOnHand = stockLevels.reduce((sum, s) => sum + s.quantityOnHand, 0);
const totalReserved = stockLevels.reduce((sum, s) => sum + s.quantityReserved, 0);
const totalAvailable = stockLevels.reduce((sum, s) => sum + s.quantityAvailable, 0);
// Determine stock status
let productStockStatus: StockStatus = 'normal';
if (totalAvailable <= 0) {
productStockStatus = 'out';
} else if (product.reorderPoint && totalAvailable <= product.reorderPoint) {
productStockStatus = 'low';
} else if (product.maxStock && totalOnHand >= product.maxStock) {
productStockStatus = 'overstock';
}
return {
...product,
quantityOnHand: totalOnHand,
quantityReserved: totalReserved,
quantityAvailable: totalAvailable,
stockStatus: productStockStatus,
} as ProductWithStock;
} catch {
return {
...product,
quantityOnHand: 0,
quantityReserved: 0,
quantityAvailable: 0,
stockStatus: 'out' as StockStatus,
} as ProductWithStock;
}
})
);
// Filter by stock status if specified
let filteredProducts = productsWithStock;
if (stockStatus) {
filteredProducts = productsWithStock.filter(p => p.stockStatus === stockStatus);
}
setProducts(filteredProducts);
setTotal(response.pagination.total);
setTotalPages(response.pagination.totalPages);
// Calculate stats
const lowCount = productsWithStock.filter(p => p.stockStatus === 'low').length;
const outCount = productsWithStock.filter(p => p.stockStatus === 'out').length;
const totalValue = productsWithStock.reduce((sum, p) => sum + ((p.cost ?? 0) * (p.quantityOnHand ?? 0)), 0);
setStats({
totalProducts: response.pagination.total,
lowStockCount: lowCount,
outOfStockCount: outCount,
totalValue,
});
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar productos');
} finally {
setIsLoading(false);
}
}, [filters.categoryId, filters.type, filters.isActive, filters.search, filters.limit, filters.sortBy, filters.sortOrder, stockStatus, page]);
useEffect(() => {
if (autoFetch) {
fetchProducts();
}
}, [fetchProducts, autoFetch]);
const createProduct = async (data: CreateProductDto) => {
setIsLoading(true);
try {
const newProduct = await productsApi.create(data);
await fetchProducts();
return newProduct;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al crear producto');
throw err;
} finally {
setIsLoading(false);
}
};
const updateProduct = async (id: string, data: UpdateProductDto) => {
setIsLoading(true);
try {
const updated = await productsApi.update(id, data);
await fetchProducts();
return updated;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al actualizar producto');
throw err;
} finally {
setIsLoading(false);
}
};
const deleteProduct = async (id: string) => {
setIsLoading(true);
try {
await productsApi.delete(id);
await fetchProducts();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al eliminar producto');
throw err;
} finally {
setIsLoading(false);
}
};
const searchProducts = async (searchTerm: string) => {
setIsLoading(true);
setError(null);
try {
const response = await productsApi.list({ ...filters, search: searchTerm, page: 1 });
setProducts(response.data);
setTotal(response.pagination.total);
setTotalPages(response.pagination.totalPages);
setPage(1);
return response.data;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al buscar productos');
throw err;
} finally {
setIsLoading(false);
}
};
return {
products,
total,
page,
totalPages,
isLoading,
error,
stats,
setPage,
refresh: fetchProducts,
createProduct,
updateProduct,
deleteProduct,
searchProducts,
};
}
// ==================== Single Product Hook (with stock details) ====================
export function useProduct(productId: string | null) {
const [product, setProduct] = useState<ProductWithStock | null>(null);
const [stockByWarehouse, setStockByWarehouse] = useState<StockByWarehouse[]>([]);
const [recentMovements, setRecentMovements] = useState<StockMovement[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchProduct = useCallback(async () => {
if (!productId) {
setProduct(null);
setStockByWarehouse([]);
setRecentMovements([]);
return;
}
setIsLoading(true);
setError(null);
try {
// Fetch product and stock data in parallel
const [productData, stockLevels, movements] = await Promise.all([
productsApi.getById(productId),
inventoryApi.getStockByProduct(productId),
inventoryApi.getMovements({ productId, limit: 10 }),
]);
// Calculate totals from stock levels
const totalOnHand = stockLevels.reduce((sum, s) => sum + s.quantityOnHand, 0);
const totalReserved = stockLevels.reduce((sum, s) => sum + s.quantityReserved, 0);
const totalAvailable = stockLevels.reduce((sum, s) => sum + s.quantityAvailable, 0);
const totalIncoming = stockLevels.reduce((sum, s) => sum + s.quantityIncoming, 0);
const totalOutgoing = stockLevels.reduce((sum, s) => sum + s.quantityOutgoing, 0);
// Determine stock status
let productStockStatus: StockStatus = 'normal';
if (totalAvailable <= 0) {
productStockStatus = 'out';
} else if (productData.reorderPoint && totalAvailable <= productData.reorderPoint) {
productStockStatus = 'low';
} else if (productData.maxStock && totalOnHand >= productData.maxStock) {
productStockStatus = 'overstock';
}
const productWithStock: ProductWithStock = {
...productData,
quantityOnHand: totalOnHand,
quantityReserved: totalReserved,
quantityAvailable: totalAvailable,
quantityIncoming: totalIncoming,
quantityOutgoing: totalOutgoing,
stockStatus: productStockStatus,
};
setProduct(productWithStock);
// Group stock by warehouse
const warehouseStock: StockByWarehouse[] = stockLevels.map(sl => ({
warehouseId: sl.warehouseId,
warehouseName: sl.warehouseName || sl.warehouseId,
warehouseCode: sl.warehouseCode || '',
quantityOnHand: sl.quantityOnHand,
quantityReserved: sl.quantityReserved,
quantityAvailable: sl.quantityAvailable,
quantityIncoming: sl.quantityIncoming,
quantityOutgoing: sl.quantityOutgoing,
lastMovementAt: sl.lastMovementAt,
}));
setStockByWarehouse(warehouseStock);
setRecentMovements(movements.data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar producto');
} finally {
setIsLoading(false);
}
}, [productId]);
useEffect(() => {
fetchProduct();
}, [fetchProduct]);
const updateProduct = async (data: UpdateProductDto) => {
if (!productId) return;
setIsLoading(true);
try {
const updated = await productsApi.update(productId, data);
await fetchProduct(); // Refresh all data
return updated;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al actualizar producto');
throw err;
} finally {
setIsLoading(false);
}
};
return {
product,
stockByWarehouse,
recentMovements,
isLoading,
error,
refresh: fetchProduct,
updateProduct,
};
}

View File

@ -0,0 +1,146 @@
import { useState, useEffect, useCallback } from 'react';
import { inventoryApi } from '../api/inventory.api';
import type {
StockLevel,
StockSearchParams,
} from '../types';
// ==================== Stock Levels Hook ====================
export interface UseStockLevelsOptions extends StockSearchParams {
autoFetch?: boolean;
}
export function useStockLevels(options: UseStockLevelsOptions = {}) {
const { autoFetch = true, ...params } = options;
const [stockLevels, setStockLevels] = useState<StockLevel[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(params.page || 1);
const [totalPages, setTotalPages] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchStockLevels = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await inventoryApi.getStockLevels({ ...params, page });
setStockLevels(response.data);
setTotal(response.meta.total);
setTotalPages(response.meta.totalPages);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar niveles de stock');
} finally {
setIsLoading(false);
}
}, [params.productId, params.warehouseId, params.locationId, params.lotNumber, params.hasStock, params.lowStock, params.limit, page]);
useEffect(() => {
if (autoFetch) {
fetchStockLevels();
}
}, [fetchStockLevels, autoFetch]);
return {
stockLevels,
total,
page,
totalPages,
isLoading,
error,
setPage,
refresh: fetchStockLevels,
};
}
// ==================== Product Stock Hook ====================
export function useProductStock(productId: string | null) {
const [stock, setStock] = useState<StockLevel[]>([]);
const [totalAvailable, setTotalAvailable] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchStock = useCallback(async () => {
if (!productId) {
setStock([]);
setTotalAvailable(0);
return;
}
setIsLoading(true);
setError(null);
try {
const data = await inventoryApi.getStockByProduct(productId);
setStock(data);
const available = data.reduce((sum, s) => sum + s.quantityAvailable, 0);
setTotalAvailable(available);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar stock del producto');
} finally {
setIsLoading(false);
}
}, [productId]);
useEffect(() => {
fetchStock();
}, [fetchStock]);
const getStockInWarehouse = async (warehouseId: string): Promise<number> => {
if (!productId) return 0;
try {
const available = await inventoryApi.getAvailableStock(productId, warehouseId);
return available;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al obtener stock disponible');
return 0;
}
};
return {
stock,
totalAvailable,
isLoading,
error,
refresh: fetchStock,
getStockInWarehouse,
};
}
// ==================== Low Stock Products Hook ====================
export function useLowStockProducts(options: { limit?: number; autoFetch?: boolean } = {}) {
const { limit = 20, autoFetch = true } = options;
const [products, setProducts] = useState<StockLevel[]>([]);
const [total, setTotal] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchLowStock = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await inventoryApi.getStockLevels({ lowStock: true, limit });
setProducts(response.data);
setTotal(response.meta.total);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar productos con bajo stock');
} finally {
setIsLoading(false);
}
}, [limit]);
useEffect(() => {
if (autoFetch) {
fetchLowStock();
}
}, [fetchLowStock, autoFetch]);
return {
products,
total,
isLoading,
error,
refresh: fetchLowStock,
};
}

View File

@ -0,0 +1,298 @@
import { useState, useEffect, useCallback } from 'react';
import { inventoryApi } from '../api/inventory.api';
import type {
StockMovement,
MovementSearchParams,
CreateStockMovementDto,
AdjustStockDto,
TransferStockDto,
ReserveStockDto,
} from '../types';
// ==================== Stock Movements Hook ====================
export interface UseStockMovementsOptions extends MovementSearchParams {
autoFetch?: boolean;
}
export function useStockMovements(options: UseStockMovementsOptions = {}) {
const { autoFetch = true, ...params } = options;
const [movements, setMovements] = useState<StockMovement[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(params.page || 1);
const [totalPages, setTotalPages] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchMovements = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await inventoryApi.getMovements({ ...params, page });
setMovements(response.data);
setTotal(response.meta.total);
setTotalPages(response.meta.totalPages);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar movimientos');
} finally {
setIsLoading(false);
}
}, [params.movementType, params.productId, params.warehouseId, params.status, params.referenceType, params.referenceId, params.fromDate, params.toDate, params.limit, page]);
useEffect(() => {
if (autoFetch) {
fetchMovements();
}
}, [fetchMovements, autoFetch]);
const createMovement = async (data: CreateStockMovementDto) => {
setIsLoading(true);
try {
const newMovement = await inventoryApi.createMovement(data);
await fetchMovements();
return newMovement;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al crear movimiento');
throw err;
} finally {
setIsLoading(false);
}
};
const confirmMovement = async (id: string) => {
setIsLoading(true);
try {
const confirmed = await inventoryApi.confirmMovement(id);
await fetchMovements();
return confirmed;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al confirmar movimiento');
throw err;
} finally {
setIsLoading(false);
}
};
const cancelMovement = async (id: string) => {
setIsLoading(true);
try {
const cancelled = await inventoryApi.cancelMovement(id);
await fetchMovements();
return cancelled;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cancelar movimiento');
throw err;
} finally {
setIsLoading(false);
}
};
return {
movements,
total,
page,
totalPages,
isLoading,
error,
setPage,
refresh: fetchMovements,
createMovement,
confirmMovement,
cancelMovement,
};
}
// ==================== Single Movement Hook ====================
export function useStockMovement(movementId: string | null) {
const [movement, setMovement] = useState<StockMovement | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchMovement = useCallback(async () => {
if (!movementId) {
setMovement(null);
return;
}
setIsLoading(true);
setError(null);
try {
const data = await inventoryApi.getMovementById(movementId);
setMovement(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar movimiento');
} finally {
setIsLoading(false);
}
}, [movementId]);
useEffect(() => {
fetchMovement();
}, [fetchMovement]);
const confirm = async () => {
if (!movementId) return;
setIsLoading(true);
try {
const confirmed = await inventoryApi.confirmMovement(movementId);
setMovement(confirmed);
return confirmed;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al confirmar movimiento');
throw err;
} finally {
setIsLoading(false);
}
};
const cancel = async () => {
if (!movementId) return;
setIsLoading(true);
try {
const cancelled = await inventoryApi.cancelMovement(movementId);
setMovement(cancelled);
return cancelled;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cancelar movimiento');
throw err;
} finally {
setIsLoading(false);
}
};
return {
movement,
isLoading,
error,
refresh: fetchMovement,
confirm,
cancel,
};
}
// ==================== Product Movements Hook (Kardex) ====================
export function useProductMovements(productId: string | null, options: { limit?: number; autoFetch?: boolean } = {}) {
const { limit = 50, autoFetch = true } = options;
const [movements, setMovements] = useState<StockMovement[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchMovements = useCallback(async () => {
if (!productId) {
setMovements([]);
setTotal(0);
return;
}
setIsLoading(true);
setError(null);
try {
const response = await inventoryApi.getMovements({ productId, page, limit });
setMovements(response.data);
setTotal(response.meta.total);
setTotalPages(response.meta.totalPages);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar movimientos del producto');
} finally {
setIsLoading(false);
}
}, [productId, page, limit]);
useEffect(() => {
if (autoFetch) {
fetchMovements();
}
}, [fetchMovements, autoFetch]);
return {
movements,
total,
page,
totalPages,
isLoading,
error,
setPage,
refresh: fetchMovements,
};
}
// ==================== Stock Operations Hook ====================
export function useStockOperations() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const adjustStock = async (data: AdjustStockDto) => {
setIsLoading(true);
setError(null);
try {
const result = await inventoryApi.adjustStock(data);
return result;
} catch (err) {
const message = err instanceof Error ? err.message : 'Error al ajustar stock';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
const transferStock = async (data: TransferStockDto) => {
setIsLoading(true);
setError(null);
try {
const result = await inventoryApi.transferStock(data);
return result;
} catch (err) {
const message = err instanceof Error ? err.message : 'Error al transferir stock';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
const reserveStock = async (data: ReserveStockDto) => {
setIsLoading(true);
setError(null);
try {
const result = await inventoryApi.reserveStock(data);
return result;
} catch (err) {
const message = err instanceof Error ? err.message : 'Error al reservar stock';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
const releaseReservation = async (productId: string, warehouseId: string, quantity: number) => {
setIsLoading(true);
setError(null);
try {
const result = await inventoryApi.releaseReservation(productId, warehouseId, quantity);
return result;
} catch (err) {
const message = err instanceof Error ? err.message : 'Error al liberar reserva';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return {
isLoading,
error,
adjustStock,
transferStock,
reserveStock,
releaseReservation,
};
}

View File

@ -0,0 +1,214 @@
import { useState, useEffect, useCallback } from 'react';
import { inventoryApi } from '../api/inventory.api';
import type {
Warehouse,
CreateWarehouseDto,
UpdateWarehouseDto,
StockLevel,
} from '../types';
// ==================== Warehouses List Hook ====================
export interface UseWarehousesOptions {
page?: number;
limit?: number;
autoFetch?: boolean;
}
export function useWarehouses(options: UseWarehousesOptions = {}) {
const { autoFetch = true, page: initialPage = 1, limit = 20 } = options;
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(initialPage);
const [totalPages, setTotalPages] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchWarehouses = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await inventoryApi.getWarehouses(page, limit);
setWarehouses(response.data);
setTotal(response.meta.total);
setTotalPages(response.meta.totalPages);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar almacenes');
} finally {
setIsLoading(false);
}
}, [page, limit]);
useEffect(() => {
if (autoFetch) {
fetchWarehouses();
}
}, [fetchWarehouses, autoFetch]);
const createWarehouse = async (data: CreateWarehouseDto) => {
setIsLoading(true);
try {
const newWarehouse = await inventoryApi.createWarehouse(data);
await fetchWarehouses();
return newWarehouse;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al crear almacen');
throw err;
} finally {
setIsLoading(false);
}
};
const updateWarehouse = async (id: string, data: UpdateWarehouseDto) => {
setIsLoading(true);
try {
const updated = await inventoryApi.updateWarehouse(id, data);
await fetchWarehouses();
return updated;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al actualizar almacen');
throw err;
} finally {
setIsLoading(false);
}
};
const deleteWarehouse = async (id: string) => {
setIsLoading(true);
try {
await inventoryApi.deleteWarehouse(id);
await fetchWarehouses();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al eliminar almacen');
throw err;
} finally {
setIsLoading(false);
}
};
return {
warehouses,
total,
page,
totalPages,
isLoading,
error,
setPage,
refresh: fetchWarehouses,
createWarehouse,
updateWarehouse,
deleteWarehouse,
};
}
// ==================== Single Warehouse Hook ====================
export function useWarehouse(warehouseId: string | null) {
const [warehouse, setWarehouse] = useState<Warehouse | null>(null);
const [stock, setStock] = useState<StockLevel[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchWarehouse = useCallback(async () => {
if (!warehouseId) {
setWarehouse(null);
setStock([]);
return;
}
setIsLoading(true);
setError(null);
try {
const data = await inventoryApi.getWarehouseById(warehouseId);
setWarehouse(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar almacen');
} finally {
setIsLoading(false);
}
}, [warehouseId]);
const fetchWarehouseStock = useCallback(async () => {
if (!warehouseId) {
setStock([]);
return;
}
setIsLoading(true);
setError(null);
try {
const stockData = await inventoryApi.getStockByWarehouse(warehouseId);
setStock(stockData);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar stock del almacen');
} finally {
setIsLoading(false);
}
}, [warehouseId]);
useEffect(() => {
fetchWarehouse();
}, [fetchWarehouse]);
const updateWarehouse = async (data: UpdateWarehouseDto) => {
if (!warehouseId) return;
setIsLoading(true);
try {
const updated = await inventoryApi.updateWarehouse(warehouseId, data);
setWarehouse(updated);
return updated;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al actualizar almacen');
throw err;
} finally {
setIsLoading(false);
}
};
return {
warehouse,
stock,
isLoading,
error,
refresh: fetchWarehouse,
refreshStock: fetchWarehouseStock,
updateWarehouse,
};
}
// ==================== Warehouse Stock Hook ====================
export function useWarehouseStock(warehouseId: string | null) {
const [stock, setStock] = useState<StockLevel[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchStock = useCallback(async () => {
if (!warehouseId) {
setStock([]);
return;
}
setIsLoading(true);
setError(null);
try {
const data = await inventoryApi.getStockByWarehouse(warehouseId);
setStock(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar stock del almacen');
} finally {
setIsLoading(false);
}
}, [warehouseId]);
useEffect(() => {
fetchStock();
}, [fetchStock]);
return {
stock,
isLoading,
error,
refresh: fetchStock,
};
}

View File

@ -1,2 +1,4 @@
export * from './api/inventory.api';
export * from './components';
export * from './hooks';
export * from './types';

View File

@ -6,6 +6,8 @@ export interface StockLevel {
tenantId: string;
productId: string;
warehouseId: string;
warehouseName?: string;
warehouseCode?: string;
locationId?: string | null;
lotNumber?: string | null;
serialNumber?: string | null;
@ -330,6 +332,77 @@ export interface InventoryAdjustmentsResponse {
};
}
// Product with Stock types
export type StockStatus = 'normal' | 'low' | 'out' | 'overstock';
export interface ProductWithStock {
id: string;
tenantId: string;
name: string;
code?: string | null;
barcode?: string | null;
description?: string | null;
type: 'goods' | 'service' | 'consumable';
categoryId?: string | null;
categoryName?: string | null;
uomId?: string | null;
uomName?: string | null;
salePrice?: number | null;
cost?: number | null;
isActive: boolean;
// Stock fields
quantityOnHand?: number;
quantityReserved?: number;
quantityAvailable?: number;
quantityIncoming?: number;
quantityOutgoing?: number;
reorderPoint?: number | null;
minStock?: number | null;
maxStock?: number | null;
stockStatus?: StockStatus;
createdAt: string;
updatedAt?: string | null;
}
export interface StockByWarehouse {
warehouseId: string;
warehouseName: string;
warehouseCode: string;
quantityOnHand: number;
quantityReserved: number;
quantityAvailable: number;
quantityIncoming: number;
quantityOutgoing: number;
lastMovementAt?: string | null;
}
export interface ProductsResponse {
data: ProductWithStock[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
export interface ProductsStats {
totalProducts: number;
lowStockCount: number;
outOfStockCount: number;
totalValue: number;
}
export interface ProductSearchParams {
search?: string;
categoryId?: string;
type?: 'goods' | 'service' | 'consumable';
stockStatus?: StockStatus;
isActive?: boolean;
page?: number;
limit?: number;
}
// Stock Valuation types
export interface StockValuationLayer {
id: string;

View File

@ -0,0 +1,509 @@
import { useState, useMemo } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
FileSpreadsheet,
Search,
Download,
Calendar,
Package,
ArrowDownToLine,
ArrowUpFromLine,
ArrowRightLeft,
Plus,
RefreshCw,
TrendingUp,
TrendingDown,
BarChart3,
} from 'lucide-react';
import { Button } from '@components/atoms/Button';
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
import { DataTable, type Column } from '@components/organisms/DataTable';
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
import { NoDataEmptyState, ErrorEmptyState } from '@components/templates/EmptyState';
import { useProducts, useProductMovements, useWarehouses } from '@features/inventory/hooks';
import type { StockMovement, MovementType } from '@features/inventory/types';
import { formatDate, formatNumber } from '@utils/formatters';
const movementTypeLabels: Record<MovementType, string> = {
receipt: 'Recepcion',
shipment: 'Envio',
transfer: 'Transferencia',
adjustment: 'Ajuste',
return: 'Devolucion',
production: 'Produccion',
consumption: 'Consumo',
};
const movementTypeIcons: Record<MovementType, typeof ArrowDownToLine> = {
receipt: ArrowDownToLine,
shipment: ArrowUpFromLine,
transfer: ArrowRightLeft,
adjustment: ArrowRightLeft,
return: ArrowDownToLine,
production: Plus,
consumption: ArrowUpFromLine,
};
interface KardexEntry extends StockMovement {
runningBalance: number;
inQuantity: number;
outQuantity: number;
}
export function KardexPage() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const initialProductId = searchParams.get('productId') || '';
const [selectedProductId, setSelectedProductId] = useState<string>(initialProductId);
const [productSearch, setProductSearch] = useState('');
const [dateRange, setDateRange] = useState<{ from: string; to: string }>({
from: searchParams.get('fromDate') || '',
to: searchParams.get('toDate') || '',
});
const [selectedWarehouse, setSelectedWarehouse] = useState<string>(searchParams.get('warehouseId') || '');
const { products } = useProducts({ limit: 100 });
const { warehouses } = useWarehouses();
const {
movements,
total,
page,
totalPages,
isLoading,
error,
setPage,
refresh,
} = useProductMovements(selectedProductId || null, { limit: 50 });
// Filter products by search
const filteredProducts = useMemo(() => {
if (!productSearch) return products;
const search = productSearch.toLowerCase();
return products.filter(
p => p.name.toLowerCase().includes(search) ||
(p.code && p.code.toLowerCase().includes(search))
);
}, [products, productSearch]);
// Calculate Kardex with running balance
const kardexEntries = useMemo((): KardexEntry[] => {
if (!movements.length) return [];
// Sort by date ascending for running balance calculation
const sortedMovements = [...movements].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
let runningBalance = 0;
const entries: KardexEntry[] = sortedMovements.map(movement => {
const isIncoming = ['receipt', 'return', 'production'].includes(movement.movementType);
const inQuantity = isIncoming ? movement.quantity : 0;
const outQuantity = isIncoming ? 0 : movement.quantity;
runningBalance += isIncoming ? movement.quantity : -movement.quantity;
return {
...movement,
runningBalance,
inQuantity,
outQuantity,
};
});
// Reverse to show most recent first
return entries.reverse();
}, [movements]);
// Summary stats
const summaryStats = useMemo(() => {
if (!kardexEntries.length) return { totalIn: 0, totalOut: 0, openingBalance: 0, closingBalance: 0 };
const totalIn = kardexEntries.reduce((sum, e) => sum + e.inQuantity, 0);
const totalOut = kardexEntries.reduce((sum, e) => sum + e.outQuantity, 0);
const lastEntry = kardexEntries[kardexEntries.length - 1];
const openingBalance = lastEntry
? lastEntry.runningBalance - (lastEntry.inQuantity || 0) + (lastEntry.outQuantity || 0)
: 0;
const closingBalance = kardexEntries[0]?.runningBalance || 0;
return { totalIn, totalOut, openingBalance, closingBalance };
}, [kardexEntries]);
const selectedProduct = products.find(p => p.id === selectedProductId);
const handleProductSelect = (productId: string) => {
setSelectedProductId(productId);
setSearchParams(prev => {
prev.set('productId', productId);
return prev;
});
};
const handleDateChange = (field: 'from' | 'to', value: string) => {
setDateRange(prev => ({ ...prev, [field]: value }));
setSearchParams(prev => {
if (value) {
prev.set(field === 'from' ? 'fromDate' : 'toDate', value);
} else {
prev.delete(field === 'from' ? 'fromDate' : 'toDate');
}
return prev;
});
};
const columns: Column<KardexEntry>[] = [
{
key: 'date',
header: 'Fecha',
render: (entry) => (
<span className="text-sm text-gray-600">
{formatDate(entry.createdAt, 'short')}
</span>
),
},
{
key: 'reference',
header: 'Referencia',
render: (entry) => (
<button
onClick={() => navigate(`/inventory/movements/${entry.id}`)}
className="flex items-center gap-2 text-left hover:text-blue-600"
>
<span className="font-medium text-gray-900">{entry.movementNumber}</span>
</button>
),
},
{
key: 'type',
header: 'Tipo',
render: (entry) => {
const Icon = movementTypeIcons[entry.movementType];
return (
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 text-gray-400" />
<span className="text-sm">{movementTypeLabels[entry.movementType]}</span>
</div>
);
},
},
{
key: 'in',
header: 'Entrada',
render: (entry) => (
<span className={`font-medium ${entry.inQuantity > 0 ? 'text-green-600' : 'text-gray-300'}`}>
{entry.inQuantity > 0 ? `+${formatNumber(entry.inQuantity)}` : '-'}
</span>
),
},
{
key: 'out',
header: 'Salida',
render: (entry) => (
<span className={`font-medium ${entry.outQuantity > 0 ? 'text-red-600' : 'text-gray-300'}`}>
{entry.outQuantity > 0 ? `-${formatNumber(entry.outQuantity)}` : '-'}
</span>
),
},
{
key: 'balance',
header: 'Saldo',
render: (entry) => (
<span className="font-bold text-gray-900">
{formatNumber(entry.runningBalance)}
</span>
),
},
{
key: 'warehouse',
header: 'Almacen',
render: (entry) => {
const warehouseId = entry.destWarehouseId || entry.sourceWarehouseId;
const wh = warehouses.find(w => w.id === warehouseId);
return (
<span className="text-sm text-gray-500">
{wh?.name || warehouseId || '-'}
</span>
);
},
},
];
if (error) {
return (
<div className="p-6">
<ErrorEmptyState onRetry={refresh} />
</div>
);
}
return (
<div className="space-y-6 p-6">
<Breadcrumbs
items={[
{ label: 'Inventario', href: '/inventory' },
{ label: 'Kardex' },
]}
/>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Kardex de Inventario</h1>
<p className="text-sm text-gray-500">
Historial de movimientos y saldo por producto
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={refresh} disabled={isLoading}>
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
Actualizar
</Button>
<Button variant="outline" disabled={!selectedProductId}>
<Download className="mr-2 h-4 w-4" />
Exportar
</Button>
</div>
</div>
{/* Product Selection */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Package className="h-5 w-5" />
Seleccionar Producto
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap items-end gap-4">
<div className="flex-1 min-w-[300px]">
<label className="mb-1 block text-sm font-medium text-gray-700">
Buscar producto
</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Buscar por nombre o SKU..."
value={productSearch}
onChange={(e) => setProductSearch(e.target.value)}
className="w-full rounded-md border border-gray-300 py-2 pl-10 pr-4 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
{productSearch && filteredProducts.length > 0 && (
<div className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-200 bg-white shadow-lg">
{filteredProducts.slice(0, 10).map(product => (
<button
key={product.id}
onClick={() => {
handleProductSelect(product.id);
setProductSearch('');
}}
className="flex w-full items-center gap-3 px-4 py-2 text-left hover:bg-gray-50"
>
<Package className="h-4 w-4 text-gray-400" />
<div>
<div className="font-medium text-gray-900">{product.name}</div>
<div className="text-sm text-gray-500">{product.code || '-'}</div>
</div>
</button>
))}
</div>
)}
</div>
{selectedProduct && (
<div className="flex items-center gap-3 rounded-lg border border-blue-200 bg-blue-50 px-4 py-2">
<Package className="h-5 w-5 text-blue-600" />
<div>
<div className="font-medium text-blue-900">{selectedProduct.name}</div>
<div className="text-sm text-blue-600">{selectedProduct.code || '-'}</div>
</div>
<button
onClick={() => {
setSelectedProductId('');
setSearchParams(prev => {
prev.delete('productId');
return prev;
});
}}
className="ml-2 rounded p-1 hover:bg-blue-100"
>
<span className="sr-only">Limpiar</span>
&times;
</button>
</div>
)}
</div>
</CardContent>
</Card>
{selectedProductId ? (
<>
{/* Filters */}
<Card>
<CardContent className="py-4">
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-gray-400" />
<label className="text-sm text-gray-600">Desde:</label>
<input
type="date"
value={dateRange.from}
onChange={(e) => handleDateChange('from', e.target.value)}
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600">Hasta:</label>
<input
type="date"
value={dateRange.to}
onChange={(e) => handleDateChange('to', e.target.value)}
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<select
value={selectedWarehouse}
onChange={(e) => setSelectedWarehouse(e.target.value)}
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">Todos los almacenes</option>
{warehouses.map(wh => (
<option key={wh.id} value={wh.id}>{wh.name}</option>
))}
</select>
{(dateRange.from || dateRange.to || selectedWarehouse) && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setDateRange({ from: '', to: '' });
setSelectedWarehouse('');
setSearchParams(prev => {
prev.delete('fromDate');
prev.delete('toDate');
prev.delete('warehouseId');
return prev;
});
}}
>
Limpiar filtros
</Button>
)}
</div>
</CardContent>
</Card>
{/* Summary Stats */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100">
<BarChart3 className="h-5 w-5 text-gray-600" />
</div>
<div>
<div className="text-sm text-gray-500">Saldo Inicial</div>
<div className="text-xl font-bold text-gray-900">
{formatNumber(summaryStats.openingBalance)}
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100">
<TrendingUp className="h-5 w-5 text-green-600" />
</div>
<div>
<div className="text-sm text-gray-500">Total Entradas</div>
<div className="text-xl font-bold text-green-600">
+{formatNumber(summaryStats.totalIn)}
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-red-100">
<TrendingDown className="h-5 w-5 text-red-600" />
</div>
<div>
<div className="text-sm text-gray-500">Total Salidas</div>
<div className="text-xl font-bold text-red-600">
-{formatNumber(summaryStats.totalOut)}
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
<BarChart3 className="h-5 w-5 text-blue-600" />
</div>
<div>
<div className="text-sm text-gray-500">Saldo Final</div>
<div className="text-xl font-bold text-blue-900">
{formatNumber(summaryStats.closingBalance)}
</div>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Kardex Table */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileSpreadsheet className="h-5 w-5" />
Movimientos del Periodo
</CardTitle>
</CardHeader>
<CardContent>
{kardexEntries.length === 0 && !isLoading ? (
<NoDataEmptyState
entityName="movimientos"
/>
) : (
<DataTable
data={kardexEntries}
columns={columns}
isLoading={isLoading}
pagination={{
page,
totalPages,
total,
limit: 50,
onPageChange: setPage,
}}
/>
)}
</CardContent>
</Card>
</>
) : (
<Card>
<CardContent className="py-12 text-center">
<FileSpreadsheet className="mx-auto h-12 w-12 text-gray-300" />
<h3 className="mt-4 text-lg font-medium text-gray-900">Seleccione un producto</h3>
<p className="mt-2 text-sm text-gray-500">
Busque y seleccione un producto para ver su historial de movimientos (Kardex)
</p>
</CardContent>
</Card>
)}
</div>
);
}
export default KardexPage;

View File

@ -0,0 +1,705 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
ArrowLeft,
Plus,
Edit,
Trash2,
MapPin,
ChevronRight,
ChevronDown,
Package,
Layers,
MoreVertical,
FolderTree,
} from 'lucide-react';
import { Button } from '@components/atoms/Button';
import { Spinner } from '@components/atoms/Spinner';
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
import { ConfirmModal, Modal } from '@components/organisms/Modal';
import { Dropdown, type DropdownItem } from '@components/organisms/Dropdown';
import { useToast } from '@components/organisms/Toast';
import { ErrorEmptyState, NoDataEmptyState } from '@components/templates/EmptyState';
import { useLocations } from '@features/inventory/hooks';
import { inventoryApi } from '@features/inventory/api/inventory.api';
import type { Warehouse as WarehouseType, Location, CreateLocationDto, UpdateLocationDto } from '@features/inventory/types';
type LocationTypeKey = 'internal' | 'view' | 'supplier' | 'customer' | 'inventory' | 'production' | 'transit';
const locationTypeLabels: Record<LocationTypeKey, string> = {
internal: 'Interno',
view: 'Vista',
supplier: 'Proveedor',
customer: 'Cliente',
inventory: 'Inventario',
production: 'Produccion',
transit: 'Transito',
};
const locationTypeColors: Record<LocationTypeKey, string> = {
internal: 'bg-blue-100 text-blue-700',
view: 'bg-gray-100 text-gray-700',
supplier: 'bg-purple-100 text-purple-700',
customer: 'bg-green-100 text-green-700',
inventory: 'bg-amber-100 text-amber-700',
production: 'bg-cyan-100 text-cyan-700',
transit: 'bg-orange-100 text-orange-700',
};
interface LocationNode extends Location {
children?: LocationNode[];
level?: number;
}
interface LocationFormData {
code: string;
name: string;
locationType: LocationTypeKey;
parentId: string;
}
const initialFormData: LocationFormData = {
code: '',
name: '',
locationType: 'internal',
parentId: '',
};
export function LocationsPage() {
const { warehouseId } = useParams<{ warehouseId: string }>();
const navigate = useNavigate();
const { showToast } = useToast();
const [warehouse, setWarehouse] = useState<WarehouseType | null>(null);
const [isLoadingWarehouse, setIsLoadingWarehouse] = useState(true);
const [warehouseError, setWarehouseError] = useState<string | null>(null);
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [locationToEdit, setLocationToEdit] = useState<Location | null>(null);
const [locationToDelete, setLocationToDelete] = useState<Location | null>(null);
const [formData, setFormData] = useState<LocationFormData>(initialFormData);
const [isSubmitting, setIsSubmitting] = useState(false);
const {
locations,
isLoading,
error,
refresh,
createLocation,
updateLocation,
deleteLocation,
} = useLocations(warehouseId ?? null);
const fetchWarehouse = useCallback(async () => {
if (!warehouseId) return;
setIsLoadingWarehouse(true);
setWarehouseError(null);
try {
const data = await inventoryApi.getWarehouseById(warehouseId);
setWarehouse(data);
} catch (err) {
setWarehouseError(err instanceof Error ? err.message : 'Error al cargar almacen');
} finally {
setIsLoadingWarehouse(false);
}
}, [warehouseId]);
useEffect(() => {
fetchWarehouse();
}, [fetchWarehouse]);
// Build hierarchical tree from flat locations
const locationTree = useMemo(() => {
const buildTree = (locs: Location[], parentId: string | null = null, level: number = 0): LocationNode[] => {
return locs
.filter(loc => (loc.parentId || null) === parentId)
.map(loc => ({
...loc,
level,
children: buildTree(locs, loc.id, level + 1),
}));
};
return buildTree(locations);
}, [locations]);
// Flatten tree for rendering with levels
const flattenTree = (nodes: LocationNode[], result: LocationNode[] = []): LocationNode[] => {
nodes.forEach(node => {
result.push(node);
if (node.children && expandedNodes.has(node.id)) {
flattenTree(node.children, result);
}
});
return result;
};
const flatLocations = flattenTree(locationTree);
const toggleExpand = (id: string) => {
setExpandedNodes(prev => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
};
const expandAll = () => {
const allIds = locations.map(l => l.id);
setExpandedNodes(new Set(allIds));
};
const collapseAll = () => {
setExpandedNodes(new Set());
};
const hasChildren = (locationId: string) => {
return locations.some(l => l.parentId === locationId);
};
const handleOpenCreateModal = (parentId?: string) => {
setFormData({
...initialFormData,
parentId: parentId || '',
});
setShowCreateModal(true);
};
const handleOpenEditModal = (location: Location) => {
setLocationToEdit(location);
setFormData({
code: location.code,
name: location.name,
locationType: location.locationType,
parentId: location.parentId || '',
});
setShowEditModal(true);
};
const handleCreateSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!warehouseId) return;
setIsSubmitting(true);
try {
const data: CreateLocationDto = {
warehouseId,
code: formData.code,
name: formData.name,
locationType: formData.locationType,
parentId: formData.parentId || undefined,
};
await createLocation(data);
showToast({
type: 'success',
title: 'Ubicacion creada',
message: 'La ubicacion ha sido creada exitosamente.',
});
setShowCreateModal(false);
setFormData(initialFormData);
} catch (err) {
showToast({
type: 'error',
title: 'Error',
message: err instanceof Error ? err.message : 'Error al crear ubicacion',
});
} finally {
setIsSubmitting(false);
}
};
const handleEditSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!locationToEdit) return;
setIsSubmitting(true);
try {
const data: UpdateLocationDto = {
code: formData.code,
name: formData.name,
locationType: formData.locationType,
parentId: formData.parentId || null,
};
await updateLocation(locationToEdit.id, data);
showToast({
type: 'success',
title: 'Ubicacion actualizada',
message: 'Los cambios han sido guardados.',
});
setShowEditModal(false);
setLocationToEdit(null);
setFormData(initialFormData);
} catch (err) {
showToast({
type: 'error',
title: 'Error',
message: err instanceof Error ? err.message : 'Error al actualizar ubicacion',
});
} finally {
setIsSubmitting(false);
}
};
const handleDeleteConfirm = async () => {
if (!locationToDelete) return;
try {
await deleteLocation(locationToDelete.id);
showToast({
type: 'success',
title: 'Ubicacion eliminada',
message: 'La ubicacion ha sido eliminada.',
});
setLocationToDelete(null);
} catch (err) {
showToast({
type: 'error',
title: 'Error',
message: err instanceof Error ? err.message : 'Error al eliminar ubicacion',
});
}
};
const getActionsMenu = (location: LocationNode): DropdownItem[] => {
return [
{
key: 'addChild',
label: 'Agregar sub-ubicacion',
icon: <Plus className="h-4 w-4" />,
onClick: () => handleOpenCreateModal(location.id),
},
{
key: 'edit',
label: 'Editar',
icon: <Edit className="h-4 w-4" />,
onClick: () => handleOpenEditModal(location),
},
{
key: 'delete',
label: 'Eliminar',
icon: <Trash2 className="h-4 w-4" />,
danger: true,
onClick: () => setLocationToDelete(location),
disabled: hasChildren(location.id),
},
];
};
// Get available parents (exclude self and descendants)
const getAvailableParents = (excludeId?: string): Location[] => {
if (!excludeId) return locations;
const getDescendantIds = (id: string): string[] => {
const children = locations.filter(l => l.parentId === id);
return [id, ...children.flatMap(c => getDescendantIds(c.id))];
};
const excludeIds = getDescendantIds(excludeId);
return locations.filter(l => !excludeIds.includes(l.id));
};
if (isLoadingWarehouse) {
return (
<div className="flex h-96 items-center justify-center">
<Spinner size="lg" />
</div>
);
}
if (warehouseError || !warehouse) {
return (
<div className="p-6">
<ErrorEmptyState
title="Almacen no encontrado"
description="No se pudo cargar la informacion del almacen."
onRetry={fetchWarehouse}
/>
</div>
);
}
if (error) {
return (
<div className="p-6">
<ErrorEmptyState onRetry={refresh} />
</div>
);
}
return (
<div className="space-y-6 p-6">
<Breadcrumbs
items={[
{ label: 'Inventario', href: '/inventory' },
{ label: 'Almacenes', href: '/inventory/warehouses' },
{ label: warehouse.name, href: `/inventory/warehouses/${warehouseId}` },
{ label: 'Ubicaciones' },
]}
/>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={() => navigate(`/inventory/warehouses/${warehouseId}`)}>
<ArrowLeft className="mr-2 h-4 w-4" />
Volver
</Button>
<div>
<h1 className="text-2xl font-bold text-gray-900">Ubicaciones</h1>
<p className="text-sm text-gray-500">
Gestiona las ubicaciones de {warehouse.name}
</p>
</div>
</div>
<Button onClick={() => handleOpenCreateModal()}>
<Plus className="mr-2 h-4 w-4" />
Nueva ubicacion
</Button>
</div>
{/* Stats */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
<MapPin className="h-5 w-5 text-blue-600" />
</div>
<div>
<div className="text-sm text-gray-500">Total Ubicaciones</div>
<div className="text-xl font-bold text-gray-900">{locations.length}</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100">
<Package className="h-5 w-5 text-green-600" />
</div>
<div>
<div className="text-sm text-gray-500">Activas</div>
<div className="text-xl font-bold text-gray-900">
{locations.filter(l => l.isActive).length}
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-100">
<FolderTree className="h-5 w-5 text-purple-600" />
</div>
<div>
<div className="text-sm text-gray-500">Raiz</div>
<div className="text-xl font-bold text-gray-900">
{locations.filter(l => !l.parentId).length}
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-100">
<Layers className="h-5 w-5 text-amber-600" />
</div>
<div>
<div className="text-sm text-gray-500">Niveles</div>
<div className="text-xl font-bold text-gray-900">
{Math.max(0, ...flatLocations.map(l => (l.level || 0) + 1))}
</div>
</div>
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<FolderTree className="h-5 w-5" />
Estructura de Ubicaciones
</CardTitle>
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={expandAll}>
Expandir todo
</Button>
<Button variant="ghost" size="sm" onClick={collapseAll}>
Colapsar todo
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex h-48 items-center justify-center">
<Spinner size="lg" />
</div>
) : locations.length === 0 ? (
<NoDataEmptyState
entityName="ubicaciones"
onCreateNew={() => handleOpenCreateModal()}
/>
) : (
<div className="space-y-1">
{flatLocations.map((location) => (
<div
key={location.id}
className="flex items-center justify-between rounded-lg border border-gray-200 p-3 hover:bg-gray-50"
style={{ marginLeft: `${(location.level || 0) * 24}px` }}
>
<div className="flex items-center gap-3">
{/* Expand/Collapse button */}
{hasChildren(location.id) ? (
<button
onClick={() => toggleExpand(location.id)}
className="flex h-6 w-6 items-center justify-center rounded hover:bg-gray-200"
>
{expandedNodes.has(location.id) ? (
<ChevronDown className="h-4 w-4 text-gray-500" />
) : (
<ChevronRight className="h-4 w-4 text-gray-500" />
)}
</button>
) : (
<div className="w-6" />
)}
{/* Icon */}
<div className={`flex h-8 w-8 items-center justify-center rounded-lg ${
locationTypeColors[location.locationType] || 'bg-gray-100 text-gray-700'
}`}>
<MapPin className="h-4 w-4" />
</div>
{/* Info */}
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900">{location.name}</span>
<span className="font-mono text-xs text-gray-500">({location.code})</span>
</div>
<div className="flex items-center gap-2">
<span className={`inline-flex rounded px-1.5 py-0.5 text-xs font-medium ${
locationTypeColors[location.locationType] || 'bg-gray-100 text-gray-700'
}`}>
{locationTypeLabels[location.locationType] || location.locationType}
</span>
<span className={`inline-flex rounded px-1.5 py-0.5 text-xs font-medium ${
location.isActive
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'
}`}>
{location.isActive ? 'Activo' : 'Inactivo'}
</span>
</div>
</div>
</div>
<Dropdown
trigger={
<button className="rounded p-1 hover:bg-gray-200">
<MoreVertical className="h-4 w-4 text-gray-500" />
</button>
}
items={getActionsMenu(location)}
align="right"
/>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Create Modal */}
<Modal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
title="Nueva Ubicacion"
size="md"
>
<form onSubmit={handleCreateSubmit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="create-code" className="block text-sm font-medium text-gray-700">
Codigo *
</label>
<input
type="text"
id="create-code"
value={formData.code}
onChange={(e) => setFormData(prev => ({ ...prev, code: e.target.value.toUpperCase() }))}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="LOC-001"
required
/>
</div>
<div>
<label htmlFor="create-name" className="block text-sm font-medium text-gray-700">
Nombre *
</label>
<input
type="text"
id="create-name"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="Estante A"
required
/>
</div>
</div>
<div>
<label htmlFor="create-type" className="block text-sm font-medium text-gray-700">
Tipo *
</label>
<select
id="create-type"
value={formData.locationType}
onChange={(e) => setFormData(prev => ({ ...prev, locationType: e.target.value as LocationTypeKey }))}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
{Object.entries(locationTypeLabels).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</div>
<div>
<label htmlFor="create-parent" className="block text-sm font-medium text-gray-700">
Ubicacion Padre
</label>
<select
id="create-parent"
value={formData.parentId}
onChange={(e) => setFormData(prev => ({ ...prev, parentId: e.target.value }))}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">Sin padre (raiz)</option>
{locations.map((loc) => (
<option key={loc.id} value={loc.id}>{loc.name} ({loc.code})</option>
))}
</select>
</div>
<div className="flex justify-end gap-3 pt-4">
<Button type="button" variant="outline" onClick={() => setShowCreateModal(false)}>
Cancelar
</Button>
<Button type="submit" isLoading={isSubmitting}>
Crear
</Button>
</div>
</form>
</Modal>
{/* Edit Modal */}
<Modal
isOpen={showEditModal}
onClose={() => setShowEditModal(false)}
title="Editar Ubicacion"
size="md"
>
<form onSubmit={handleEditSubmit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="edit-code" className="block text-sm font-medium text-gray-700">
Codigo *
</label>
<input
type="text"
id="edit-code"
value={formData.code}
onChange={(e) => setFormData(prev => ({ ...prev, code: e.target.value.toUpperCase() }))}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="LOC-001"
required
/>
</div>
<div>
<label htmlFor="edit-name" className="block text-sm font-medium text-gray-700">
Nombre *
</label>
<input
type="text"
id="edit-name"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="Estante A"
required
/>
</div>
</div>
<div>
<label htmlFor="edit-type" className="block text-sm font-medium text-gray-700">
Tipo *
</label>
<select
id="edit-type"
value={formData.locationType}
onChange={(e) => setFormData(prev => ({ ...prev, locationType: e.target.value as LocationTypeKey }))}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
{Object.entries(locationTypeLabels).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</div>
<div>
<label htmlFor="edit-parent" className="block text-sm font-medium text-gray-700">
Ubicacion Padre
</label>
<select
id="edit-parent"
value={formData.parentId}
onChange={(e) => setFormData(prev => ({ ...prev, parentId: e.target.value }))}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">Sin padre (raiz)</option>
{getAvailableParents(locationToEdit?.id).map((loc) => (
<option key={loc.id} value={loc.id}>{loc.name} ({loc.code})</option>
))}
</select>
</div>
<div className="flex justify-end gap-3 pt-4">
<Button type="button" variant="outline" onClick={() => setShowEditModal(false)}>
Cancelar
</Button>
<Button type="submit" isLoading={isSubmitting}>
Guardar
</Button>
</div>
</form>
</Modal>
{/* Delete Confirmation */}
<ConfirmModal
isOpen={!!locationToDelete}
onClose={() => setLocationToDelete(null)}
onConfirm={handleDeleteConfirm}
title="Eliminar ubicacion"
message={`¿Estas seguro de que deseas eliminar "${locationToDelete?.name}"? Esta accion no se puede deshacer.`}
variant="danger"
confirmText="Eliminar"
/>
</div>
);
}
export default LocationsPage;

View File

@ -0,0 +1,536 @@
import { useState, useCallback } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
ArrowLeft,
Package,
Warehouse,
Plus,
Trash2,
AlertTriangle,
Calculator,
Save,
Search,
} from 'lucide-react';
import { Button } from '@components/atoms/Button';
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
import { ConfirmModal } from '@components/organisms/Modal';
import { useToast } from '@components/organisms/Toast';
import { useWarehouses, useProducts, useStockLevels, useStockOperations } from '@features/inventory/hooks';
import type { ProductWithStock } from '@features/inventory/types';
import { formatNumber } from '@utils/formatters';
interface AdjustmentLine {
id: string;
productId: string;
productName: string;
productCode?: string;
currentQty: number;
newQty: number;
difference: number;
notes?: string;
}
export function StockAdjustmentPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { showToast } = useToast();
const initialWarehouseId = searchParams.get('warehouseId') || '';
const [selectedWarehouseId, setSelectedWarehouseId] = useState(initialWarehouseId);
const [reason, setReason] = useState('');
const [notes, setNotes] = useState('');
const [adjustmentLines, setAdjustmentLines] = useState<AdjustmentLine[]>([]);
const [productSearch, setProductSearch] = useState('');
const [showProductSearch, setShowProductSearch] = useState(false);
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const { warehouses } = useWarehouses();
const { products } = useProducts({ limit: 100 });
const { stockLevels } = useStockLevels({ warehouseId: selectedWarehouseId || undefined, limit: 500 });
const { adjustStock, isLoading: isAdjusting } = useStockOperations();
// Filter products by search
const filteredProducts = products.filter(p => {
if (!productSearch) return true;
const search = productSearch.toLowerCase();
return p.name.toLowerCase().includes(search) ||
(p.code && p.code.toLowerCase().includes(search));
});
// Get current stock for a product in selected warehouse
const getCurrentStock = useCallback((productId: string): number => {
const stock = stockLevels.find(
s => s.productId === productId && s.warehouseId === selectedWarehouseId
);
return stock?.quantityOnHand || 0;
}, [stockLevels, selectedWarehouseId]);
// Add product to adjustment
const handleAddProduct = (product: ProductWithStock) => {
if (adjustmentLines.some(l => l.productId === product.id)) {
showToast({
type: 'warning',
title: 'Producto duplicado',
message: 'Este producto ya esta en la lista de ajuste.',
});
return;
}
const currentQty = getCurrentStock(product.id);
const newLine: AdjustmentLine = {
id: `line-${Date.now()}`,
productId: product.id,
productName: product.name,
productCode: product.code || undefined,
currentQty,
newQty: currentQty,
difference: 0,
};
setAdjustmentLines(prev => [...prev, newLine]);
setProductSearch('');
setShowProductSearch(false);
};
// Update line quantity
const handleUpdateQuantity = (lineId: string, newQty: number) => {
setAdjustmentLines(prev =>
prev.map(line => {
if (line.id !== lineId) return line;
const difference = newQty - line.currentQty;
return { ...line, newQty, difference };
})
);
};
// Update line notes
const handleUpdateNotes = (lineId: string, notes: string) => {
setAdjustmentLines(prev =>
prev.map(line => (line.id === lineId ? { ...line, notes } : line))
);
};
// Remove line
const handleRemoveLine = (lineId: string) => {
setAdjustmentLines(prev => prev.filter(l => l.id !== lineId));
};
// Calculate totals
const totalIncrease = adjustmentLines.reduce((sum, l) => sum + (l.difference > 0 ? l.difference : 0), 0);
const totalDecrease = adjustmentLines.reduce((sum, l) => sum + (l.difference < 0 ? Math.abs(l.difference) : 0), 0);
const linesWithChanges = adjustmentLines.filter(l => l.difference !== 0);
// Submit adjustment
const handleSubmit = async () => {
if (!selectedWarehouseId) {
showToast({ type: 'error', title: 'Error', message: 'Seleccione un almacen.' });
return;
}
if (linesWithChanges.length === 0) {
showToast({ type: 'warning', title: 'Sin cambios', message: 'No hay diferencias para ajustar.' });
return;
}
setIsSubmitting(true);
try {
// Process each adjustment line
for (const line of linesWithChanges) {
await adjustStock({
productId: line.productId,
warehouseId: selectedWarehouseId,
newQuantity: line.newQty,
reason: reason || 'Ajuste de inventario',
notes: line.notes || notes,
});
}
showToast({
type: 'success',
title: 'Ajuste completado',
message: `Se ajustaron ${linesWithChanges.length} productos exitosamente.`,
});
navigate('/inventory/stock');
} catch {
showToast({
type: 'error',
title: 'Error',
message: 'No se pudo completar el ajuste de inventario.',
});
} finally {
setIsSubmitting(false);
setShowConfirmModal(false);
}
};
const selectedWarehouse = warehouses.find(w => w.id === selectedWarehouseId);
return (
<div className="space-y-6 p-6">
<Breadcrumbs
items={[
{ label: 'Inventario', href: '/inventory' },
{ label: 'Niveles de Stock', href: '/inventory/stock' },
{ label: 'Nuevo Ajuste' },
]}
/>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={() => navigate('/inventory/stock')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Volver
</Button>
<div>
<h1 className="text-2xl font-bold text-gray-900">Ajuste de Inventario</h1>
<p className="text-sm text-gray-500">
Ajustar cantidades de stock manualmente
</p>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => navigate('/inventory/stock')}
>
Cancelar
</Button>
<Button
onClick={() => setShowConfirmModal(true)}
disabled={linesWithChanges.length === 0 || !selectedWarehouseId}
>
<Save className="mr-2 h-4 w-4" />
Guardar Ajuste
</Button>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-3">
{/* Main Content */}
<div className="space-y-6 lg:col-span-2">
{/* Warehouse Selection */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Warehouse className="h-5 w-5" />
Almacen
</CardTitle>
</CardHeader>
<CardContent>
<select
value={selectedWarehouseId}
onChange={(e) => {
setSelectedWarehouseId(e.target.value);
setAdjustmentLines([]);
}}
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">Seleccionar almacen...</option>
{warehouses.map(wh => (
<option key={wh.id} value={wh.id}>{wh.name} ({wh.code})</option>
))}
</select>
</CardContent>
</Card>
{/* Reason */}
<Card>
<CardHeader>
<CardTitle>Razon del Ajuste</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
Razon
</label>
<select
value={reason}
onChange={(e) => setReason(e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">Seleccionar razon...</option>
<option value="inventory_count">Conteo de inventario</option>
<option value="damage">Danio / Merma</option>
<option value="theft">Robo</option>
<option value="expiration">Expiracion</option>
<option value="correction">Correccion de error</option>
<option value="other">Otro</option>
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
Notas generales (opcional)
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
placeholder="Notas adicionales sobre el ajuste..."
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
</div>
</CardContent>
</Card>
{/* Product Lines */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Package className="h-5 w-5" />
Productos a Ajustar
</CardTitle>
{selectedWarehouseId && (
<div className="relative">
<Button
variant="outline"
size="sm"
onClick={() => setShowProductSearch(!showProductSearch)}
>
<Plus className="mr-2 h-4 w-4" />
Agregar Producto
</Button>
{showProductSearch && (
<div className="absolute right-0 top-full z-10 mt-2 w-80 rounded-lg border border-gray-200 bg-white shadow-lg">
<div className="p-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Buscar producto..."
value={productSearch}
onChange={(e) => setProductSearch(e.target.value)}
className="w-full rounded-md border border-gray-300 py-2 pl-10 pr-4 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
autoFocus
/>
</div>
</div>
<div className="max-h-60 overflow-auto border-t border-gray-100">
{filteredProducts.slice(0, 10).map(product => (
<button
key={product.id}
onClick={() => handleAddProduct(product)}
className="flex w-full items-center gap-3 px-4 py-2 text-left hover:bg-gray-50"
>
<Package className="h-4 w-4 text-gray-400" />
<div className="flex-1">
<div className="font-medium text-gray-900">{product.name}</div>
<div className="text-xs text-gray-500">{product.code || '-'}</div>
</div>
<div className="text-sm text-gray-500">
Stock: {formatNumber(getCurrentStock(product.id))}
</div>
</button>
))}
{filteredProducts.length === 0 && (
<div className="px-4 py-8 text-center text-sm text-gray-500">
No se encontraron productos
</div>
)}
</div>
</div>
)}
</div>
)}
</CardHeader>
<CardContent>
{!selectedWarehouseId ? (
<div className="py-12 text-center text-gray-500">
<Warehouse className="mx-auto h-12 w-12 text-gray-300" />
<p className="mt-2">Seleccione un almacen para comenzar</p>
</div>
) : adjustmentLines.length === 0 ? (
<div className="py-12 text-center text-gray-500">
<Package className="mx-auto h-12 w-12 text-gray-300" />
<p className="mt-2">No hay productos en el ajuste</p>
<p className="text-sm">Use el boton &quot;Agregar Producto&quot; para comenzar</p>
</div>
) : (
<div className="space-y-4">
{/* Table Header */}
<div className="hidden grid-cols-12 gap-4 border-b border-gray-200 pb-2 text-sm font-medium text-gray-500 sm:grid">
<div className="col-span-4">Producto</div>
<div className="col-span-2 text-right">Actual</div>
<div className="col-span-2 text-right">Nuevo</div>
<div className="col-span-2 text-right">Diferencia</div>
<div className="col-span-2"></div>
</div>
{/* Lines */}
{adjustmentLines.map(line => (
<div
key={line.id}
className="grid grid-cols-1 gap-4 rounded-lg border border-gray-200 p-4 sm:grid-cols-12 sm:items-center"
>
{/* Product */}
<div className="sm:col-span-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100">
<Package className="h-5 w-5 text-gray-600" />
</div>
<div>
<div className="font-medium text-gray-900">{line.productName}</div>
<div className="text-sm text-gray-500">{line.productCode || '-'}</div>
</div>
</div>
</div>
{/* Current Qty */}
<div className="sm:col-span-2 sm:text-right">
<div className="text-sm text-gray-500 sm:hidden">Actual</div>
<div className="font-medium text-gray-900">{formatNumber(line.currentQty)}</div>
</div>
{/* New Qty */}
<div className="sm:col-span-2 sm:text-right">
<div className="text-sm text-gray-500 sm:hidden">Nuevo</div>
<input
type="number"
min="0"
value={line.newQty}
onChange={(e) => handleUpdateQuantity(line.id, parseFloat(e.target.value) || 0)}
className="w-24 rounded-md border border-gray-300 px-2 py-1 text-right focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
{/* Difference */}
<div className="sm:col-span-2 sm:text-right">
<div className="text-sm text-gray-500 sm:hidden">Diferencia</div>
<span className={`font-bold ${
line.difference > 0 ? 'text-green-600' :
line.difference < 0 ? 'text-red-600' :
'text-gray-400'
}`}>
{line.difference > 0 ? '+' : ''}{formatNumber(line.difference)}
</span>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-2 sm:col-span-2">
<button
onClick={() => handleRemoveLine(line.id)}
className="rounded p-1.5 text-gray-400 hover:bg-red-50 hover:text-red-600"
title="Eliminar"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
{/* Notes (full width) */}
<div className="sm:col-span-12">
<input
type="text"
placeholder="Notas para este producto (opcional)..."
value={line.notes || ''}
onChange={(e) => handleUpdateNotes(line.id, e.target.value)}
className="w-full rounded-md border border-gray-200 px-3 py-1.5 text-sm text-gray-600 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
{/* Sidebar - Summary */}
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calculator className="h-5 w-5" />
Resumen
</CardTitle>
</CardHeader>
<CardContent>
<dl className="space-y-4">
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Almacen</dt>
<dd className="font-medium text-gray-900">
{selectedWarehouse?.name || '-'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Productos</dt>
<dd className="font-medium text-gray-900">
{adjustmentLines.length}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Con cambios</dt>
<dd className="font-medium text-gray-900">
{linesWithChanges.length}
</dd>
</div>
<div className="border-t border-gray-200 pt-4">
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Total incrementos</dt>
<dd className="font-bold text-green-600">
+{formatNumber(totalIncrease)}
</dd>
</div>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Total decrementos</dt>
<dd className="font-bold text-red-600">
-{formatNumber(totalDecrease)}
</dd>
</div>
<div className="flex justify-between border-t border-gray-200 pt-4">
<dt className="font-medium text-gray-900">Cambio neto</dt>
<dd className={`text-xl font-bold ${
(totalIncrease - totalDecrease) > 0 ? 'text-green-600' :
(totalIncrease - totalDecrease) < 0 ? 'text-red-600' :
'text-gray-900'
}`}>
{(totalIncrease - totalDecrease) > 0 ? '+' : ''}
{formatNumber(totalIncrease - totalDecrease)}
</dd>
</div>
</dl>
</CardContent>
</Card>
{linesWithChanges.length > 0 && (
<Card className="border-amber-200 bg-amber-50">
<CardContent className="py-4">
<div className="flex gap-3">
<AlertTriangle className="h-5 w-5 text-amber-600" />
<div>
<p className="font-medium text-amber-800">Atencion</p>
<p className="mt-1 text-sm text-amber-700">
Este ajuste modificara {linesWithChanges.length} producto(s).
Los cambios son irreversibles.
</p>
</div>
</div>
</CardContent>
</Card>
)}
</div>
</div>
{/* Confirm Modal */}
<ConfirmModal
isOpen={showConfirmModal}
onClose={() => setShowConfirmModal(false)}
onConfirm={handleSubmit}
title="Confirmar ajuste de inventario"
message={`Se ajustaran ${linesWithChanges.length} producto(s) en ${selectedWarehouse?.name || 'el almacen seleccionado'}. Esta accion no se puede deshacer.`}
variant="warning"
confirmText="Confirmar ajuste"
isLoading={isSubmitting || isAdjusting}
/>
</div>
);
}
export default StockAdjustmentPage;

View File

@ -0,0 +1,531 @@
import { useNavigate, useParams } from 'react-router-dom';
import {
ArrowLeft,
ArrowDownToLine,
ArrowUpFromLine,
ArrowRightLeft,
Plus,
CheckCircle,
XCircle,
Package,
Warehouse,
MapPin,
User,
FileText,
Clock,
ExternalLink,
} from 'lucide-react';
import { Button } from '@components/atoms/Button';
import { Spinner } from '@components/atoms/Spinner';
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
import { ConfirmModal } from '@components/organisms/Modal';
import { useToast } from '@components/organisms/Toast';
import { ErrorEmptyState } from '@components/templates/EmptyState';
import { useStockMovement, useWarehouses } from '@features/inventory/hooks';
import type { MovementType, MovementStatus } from '@features/inventory/types';
import { formatDate, formatNumber } from '@utils/formatters';
import { useState } from 'react';
const movementTypeLabels: Record<MovementType, string> = {
receipt: 'Recepcion',
shipment: 'Envio',
transfer: 'Transferencia',
adjustment: 'Ajuste',
return: 'Devolucion',
production: 'Produccion',
consumption: 'Consumo',
};
const movementTypeIcons: Record<MovementType, typeof ArrowDownToLine> = {
receipt: ArrowDownToLine,
shipment: ArrowUpFromLine,
transfer: ArrowRightLeft,
adjustment: ArrowRightLeft,
return: ArrowDownToLine,
production: Plus,
consumption: ArrowUpFromLine,
};
const movementTypeColors: Record<MovementType, string> = {
receipt: 'bg-green-100 text-green-700',
shipment: 'bg-blue-100 text-blue-700',
transfer: 'bg-purple-100 text-purple-700',
adjustment: 'bg-amber-100 text-amber-700',
return: 'bg-teal-100 text-teal-700',
production: 'bg-indigo-100 text-indigo-700',
consumption: 'bg-orange-100 text-orange-700',
};
const statusLabels: Record<MovementStatus, string> = {
draft: 'Borrador',
confirmed: 'Confirmado',
cancelled: 'Cancelado',
};
const statusColors: Record<MovementStatus, string> = {
draft: 'bg-gray-100 text-gray-700',
confirmed: 'bg-green-100 text-green-700',
cancelled: 'bg-red-100 text-red-700',
};
const referenceTypeLabels: Record<string, string> = {
purchase_order: 'Orden de Compra',
sales_order: 'Orden de Venta',
transfer_order: 'Orden de Transferencia',
inventory_count: 'Conteo de Inventario',
production_order: 'Orden de Produccion',
manual: 'Manual',
};
export function StockMovementDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { showToast } = useToast();
const { movement, isLoading, error, refresh, confirm, cancel } = useStockMovement(id || null);
const { warehouses } = useWarehouses();
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [showCancelModal, setShowCancelModal] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const handleConfirm = async () => {
setIsProcessing(true);
try {
await confirm();
showToast({
type: 'success',
title: 'Movimiento confirmado',
message: 'El movimiento ha sido confirmado y el stock actualizado.',
});
setShowConfirmModal(false);
} catch {
showToast({
type: 'error',
title: 'Error',
message: 'No se pudo confirmar el movimiento.',
});
} finally {
setIsProcessing(false);
}
};
const handleCancel = async () => {
setIsProcessing(true);
try {
await cancel();
showToast({
type: 'success',
title: 'Movimiento cancelado',
message: 'El movimiento ha sido cancelado.',
});
setShowCancelModal(false);
} catch {
showToast({
type: 'error',
title: 'Error',
message: 'No se pudo cancelar el movimiento.',
});
} finally {
setIsProcessing(false);
}
};
const getWarehouseName = (warehouseId: string | null | undefined): string => {
if (!warehouseId) return '-';
const wh = warehouses.find(w => w.id === warehouseId);
return wh?.name || warehouseId;
};
if (isLoading) {
return (
<div className="flex h-96 items-center justify-center">
<Spinner size="lg" />
</div>
);
}
if (error || !movement) {
return (
<div className="p-6">
<ErrorEmptyState
title="Movimiento no encontrado"
description="No se pudo cargar la informacion del movimiento."
onRetry={refresh}
/>
</div>
);
}
const Icon = movementTypeIcons[movement.movementType];
const typeColor = movementTypeColors[movement.movementType];
const isPositive = ['receipt', 'return', 'production'].includes(movement.movementType);
const isDraft = movement.status === 'draft';
// Timeline events
const timelineEvents = [
{
date: movement.createdAt,
title: 'Movimiento creado',
description: movement.createdBy ? `Por ${movement.createdBy}` : undefined,
icon: FileText,
color: 'bg-gray-100 text-gray-600',
},
];
if (movement.confirmedAt) {
timelineEvents.push({
date: movement.confirmedAt,
title: 'Movimiento confirmado',
description: movement.confirmedBy ? `Por ${movement.confirmedBy}` : undefined,
icon: CheckCircle,
color: 'bg-green-100 text-green-600',
});
}
if (movement.status === 'cancelled') {
timelineEvents.push({
date: movement.updatedAt,
title: 'Movimiento cancelado',
description: undefined,
icon: XCircle,
color: 'bg-red-100 text-red-600',
});
}
return (
<div className="space-y-6 p-6">
<Breadcrumbs
items={[
{ label: 'Inventario', href: '/inventory' },
{ label: 'Movimientos', href: '/inventory/movements' },
{ label: movement.movementNumber },
]}
/>
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={() => navigate('/inventory/movements')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Volver
</Button>
<div className="flex items-center gap-3">
<div className={`flex h-12 w-12 items-center justify-center rounded-xl ${typeColor}`}>
<Icon className="h-6 w-6" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">{movement.movementNumber}</h1>
<p className="text-sm text-gray-500">{movementTypeLabels[movement.movementType]}</p>
</div>
<span className={`ml-2 inline-flex rounded-full px-3 py-1 text-sm font-medium ${statusColors[movement.status]}`}>
{statusLabels[movement.status]}
</span>
</div>
</div>
{isDraft && (
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" onClick={() => setShowCancelModal(true)}>
<XCircle className="mr-2 h-4 w-4" />
Cancelar
</Button>
<Button onClick={() => setShowConfirmModal(true)}>
<CheckCircle className="mr-2 h-4 w-4" />
Confirmar
</Button>
</div>
)}
</div>
<div className="grid gap-6 lg:grid-cols-3">
{/* Left Column - Movement Details */}
<div className="space-y-6 lg:col-span-2">
{/* Product Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Package className="h-5 w-5" />
Informacion del Producto
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<dt className="text-sm text-gray-500">Producto</dt>
<dd className="mt-1 font-medium text-gray-900">{movement.productId}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Cantidad</dt>
<dd className={`mt-1 text-2xl font-bold ${isPositive ? 'text-green-600' : 'text-red-600'}`}>
{isPositive ? '+' : '-'}{formatNumber(movement.quantity)}
</dd>
</div>
{movement.lotNumber && (
<div>
<dt className="text-sm text-gray-500">Lote</dt>
<dd className="mt-1 font-medium text-gray-900">{movement.lotNumber}</dd>
</div>
)}
{movement.serialNumber && (
<div>
<dt className="text-sm text-gray-500">Numero de Serie</dt>
<dd className="mt-1 font-medium text-gray-900">{movement.serialNumber}</dd>
</div>
)}
{movement.expiryDate && (
<div>
<dt className="text-sm text-gray-500">Fecha de Expiracion</dt>
<dd className="mt-1 font-medium text-gray-900">{formatDate(movement.expiryDate, 'short')}</dd>
</div>
)}
</div>
</CardContent>
</Card>
{/* Warehouse Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Warehouse className="h-5 w-5" />
Origen y Destino
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between gap-4">
{/* Source */}
<div className="flex-1 rounded-lg border border-gray-200 p-4">
<div className="flex items-center gap-2 text-sm text-gray-500">
<ArrowUpFromLine className="h-4 w-4" />
Origen
</div>
<div className="mt-2">
<div className="font-medium text-gray-900">
{getWarehouseName(movement.sourceWarehouseId)}
</div>
{movement.sourceLocationId && (
<div className="mt-1 flex items-center gap-1 text-sm text-gray-500">
<MapPin className="h-3 w-3" />
{movement.sourceLocationId}
</div>
)}
</div>
</div>
{/* Arrow */}
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-gray-100">
<ArrowRightLeft className="h-5 w-5 text-gray-500" />
</div>
{/* Destination */}
<div className="flex-1 rounded-lg border border-gray-200 p-4">
<div className="flex items-center gap-2 text-sm text-gray-500">
<ArrowDownToLine className="h-4 w-4" />
Destino
</div>
<div className="mt-2">
<div className="font-medium text-gray-900">
{getWarehouseName(movement.destWarehouseId)}
</div>
{movement.destLocationId && (
<div className="mt-1 flex items-center gap-1 text-sm text-gray-500">
<MapPin className="h-3 w-3" />
{movement.destLocationId}
</div>
)}
</div>
</div>
</div>
</CardContent>
</Card>
{/* Cost Info */}
{(movement.unitCost || movement.totalCost) && (
<Card>
<CardHeader>
<CardTitle>Costos</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-3">
<div>
<dt className="text-sm text-gray-500">Costo Unitario</dt>
<dd className="mt-1 font-medium text-gray-900">
${formatNumber(movement.unitCost || 0)}
</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Cantidad</dt>
<dd className="mt-1 font-medium text-gray-900">
{formatNumber(movement.quantity)}
</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Costo Total</dt>
<dd className="mt-1 text-xl font-bold text-gray-900">
${formatNumber(movement.totalCost || 0)}
</dd>
</div>
</div>
</CardContent>
</Card>
)}
{/* Notes */}
{(movement.reason || movement.notes) && (
<Card>
<CardHeader>
<CardTitle>Notas</CardTitle>
</CardHeader>
<CardContent>
{movement.reason && (
<div className="mb-4">
<dt className="text-sm text-gray-500">Razon</dt>
<dd className="mt-1 text-gray-900">{movement.reason}</dd>
</div>
)}
{movement.notes && (
<div>
<dt className="text-sm text-gray-500">Notas adicionales</dt>
<dd className="mt-1 whitespace-pre-wrap text-gray-900">{movement.notes}</dd>
</div>
)}
</CardContent>
</Card>
)}
</div>
{/* Right Column - Timeline & Reference */}
<div className="space-y-6">
{/* Reference */}
{movement.referenceType && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ExternalLink className="h-5 w-5" />
Referencia
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div>
<dt className="text-sm text-gray-500">Tipo</dt>
<dd className="mt-1 font-medium text-gray-900">
{referenceTypeLabels[movement.referenceType] || movement.referenceType}
</dd>
</div>
{movement.referenceId && (
<div>
<dt className="text-sm text-gray-500">ID de Referencia</dt>
<dd className="mt-1 font-mono text-sm text-gray-900">
{movement.referenceId}
</dd>
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* Timeline */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" />
Historial
</CardTitle>
</CardHeader>
<CardContent>
<div className="relative">
<div className="absolute left-4 top-0 h-full w-0.5 bg-gray-200" />
<div className="space-y-6">
{timelineEvents.map((event, index) => {
const EventIcon = event.icon;
return (
<div key={index} className="relative flex gap-4">
<div className={`relative z-10 flex h-8 w-8 items-center justify-center rounded-full ${event.color}`}>
<EventIcon className="h-4 w-4" />
</div>
<div className="flex-1 pt-0.5">
<div className="font-medium text-gray-900">{event.title}</div>
{event.description && (
<div className="text-sm text-gray-500">{event.description}</div>
)}
<div className="mt-1 text-xs text-gray-400">
{formatDate(event.date, 'full')}
</div>
</div>
</div>
);
})}
</div>
</div>
</CardContent>
</Card>
{/* System Info */}
<Card>
<CardHeader>
<CardTitle>Informacion del Sistema</CardTitle>
</CardHeader>
<CardContent>
<dl className="space-y-3">
<div className="flex items-center justify-between">
<dt className="text-sm text-gray-500">ID</dt>
<dd className="font-mono text-sm text-gray-900">{movement.id}</dd>
</div>
<div className="flex items-center justify-between">
<dt className="text-sm text-gray-500">Tenant</dt>
<dd className="font-mono text-sm text-gray-900">{movement.tenantId}</dd>
</div>
<div className="flex items-center justify-between">
<dt className="text-sm text-gray-500">Creado</dt>
<dd className="text-sm text-gray-900">{formatDate(movement.createdAt, 'short')}</dd>
</div>
<div className="flex items-center justify-between">
<dt className="text-sm text-gray-500">Actualizado</dt>
<dd className="text-sm text-gray-900">{formatDate(movement.updatedAt, 'short')}</dd>
</div>
{movement.createdBy && (
<div className="flex items-center justify-between">
<dt className="text-sm text-gray-500">Creado por</dt>
<dd className="flex items-center gap-1 text-sm text-gray-900">
<User className="h-3 w-3" />
{movement.createdBy}
</dd>
</div>
)}
</dl>
</CardContent>
</Card>
</div>
</div>
{/* Confirm Modal */}
<ConfirmModal
isOpen={showConfirmModal}
onClose={() => setShowConfirmModal(false)}
onConfirm={handleConfirm}
title="Confirmar movimiento"
message={`¿Confirmar el movimiento ${movement.movementNumber}? Esta accion actualizara el stock y no se puede deshacer.`}
variant="success"
confirmText="Confirmar"
isLoading={isProcessing}
/>
{/* Cancel Modal */}
<ConfirmModal
isOpen={showCancelModal}
onClose={() => setShowCancelModal(false)}
onConfirm={handleCancel}
title="Cancelar movimiento"
message={`¿Cancelar el movimiento ${movement.movementNumber}? Esta accion no se puede deshacer.`}
variant="danger"
confirmText="Cancelar movimiento"
isLoading={isProcessing}
/>
</div>
);
}
export default StockMovementDetailPage;

View File

@ -0,0 +1,477 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowLeft, Warehouse, MapPin, Phone, Mail, User, Scale, Box, Ruler } from 'lucide-react';
import { Button } from '@components/atoms/Button';
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
import { Alert } from '@components/molecules/Alert';
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
import { useToast } from '@components/organisms/Toast';
import { inventoryApi } from '@features/inventory/api/inventory.api';
import type { CreateWarehouseDto } from '@features/inventory/types';
type WarehouseTypeKey = 'main' | 'transit' | 'customer' | 'supplier' | 'virtual';
const warehouseTypes: { value: WarehouseTypeKey; label: string; description: string }[] = [
{ value: 'main', label: 'Principal', description: 'Almacen principal de operaciones' },
{ value: 'transit', label: 'Transito', description: 'Almacen temporal para transferencias' },
{ value: 'customer', label: 'Cliente', description: 'Ubicacion en instalaciones del cliente' },
{ value: 'supplier', label: 'Proveedor', description: 'Ubicacion en instalaciones del proveedor' },
{ value: 'virtual', label: 'Virtual', description: 'Almacen virtual para seguimiento' },
];
interface FormData {
code: string;
name: string;
warehouseType: WarehouseTypeKey;
address: {
street: string;
city: string;
state: string;
country: string;
postalCode: string;
};
contact: {
phone: string;
email: string;
manager: string;
};
capacity: {
maxUnits: string;
maxVolume: string;
maxWeight: string;
};
}
const initialFormData: FormData = {
code: '',
name: '',
warehouseType: 'main',
address: {
street: '',
city: '',
state: '',
country: '',
postalCode: '',
},
contact: {
phone: '',
email: '',
manager: '',
},
capacity: {
maxUnits: '',
maxVolume: '',
maxWeight: '',
},
};
export function WarehouseCreatePage() {
const navigate = useNavigate();
const { showToast } = useToast();
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState<FormData>(initialFormData);
const handleChange = (field: string, value: string) => {
const parts = field.split('.');
if (parts.length === 1) {
setFormData(prev => ({ ...prev, [field]: value }));
} else {
const [section, key] = parts as [keyof FormData, string];
setFormData(prev => ({
...prev,
[section]: {
...(prev[section] as Record<string, string>),
[key]: value,
},
}));
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setError(null);
try {
// Build the address object with all contact and capacity info
const address: Record<string, any> = {};
if (formData.address.street) address.street = formData.address.street;
if (formData.address.city) address.city = formData.address.city;
if (formData.address.state) address.state = formData.address.state;
if (formData.address.country) address.country = formData.address.country;
if (formData.address.postalCode) address.postalCode = formData.address.postalCode;
if (formData.contact.phone) address.phone = formData.contact.phone;
if (formData.contact.email) address.email = formData.contact.email;
if (formData.contact.manager) address.manager = formData.contact.manager;
if (formData.capacity.maxUnits) address.maxUnits = parseInt(formData.capacity.maxUnits, 10);
if (formData.capacity.maxVolume) address.maxVolume = parseFloat(formData.capacity.maxVolume);
if (formData.capacity.maxWeight) address.maxWeight = parseFloat(formData.capacity.maxWeight);
const createData: CreateWarehouseDto = {
code: formData.code,
name: formData.name,
warehouseType: formData.warehouseType,
address: Object.keys(address).length > 0 ? address : undefined,
};
const warehouse = await inventoryApi.createWarehouse(createData);
showToast({
type: 'success',
title: 'Almacen creado',
message: `${warehouse.name} ha sido creado exitosamente.`,
});
navigate('/inventory/warehouses');
} catch (err) {
const message = err instanceof Error ? err.message : 'Error al crear almacen';
setError(message);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="space-y-6 p-6">
<Breadcrumbs
items={[
{ label: 'Inventario', href: '/inventory' },
{ label: 'Almacenes', href: '/inventory/warehouses' },
{ label: 'Nuevo almacen' },
]}
/>
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={() => navigate('/inventory/warehouses')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Volver
</Button>
<div>
<h1 className="text-2xl font-bold text-gray-900">Nuevo Almacen</h1>
<p className="text-sm text-gray-500">
Crea un nuevo almacen para gestionar inventario
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="mx-auto max-w-3xl space-y-6">
{error && (
<Alert
variant="danger"
title="Error"
onClose={() => setError(null)}
>
{error}
</Alert>
)}
{/* Basic Information */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Warehouse className="h-5 w-5" />
Informacion Basica
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="code" className="block text-sm font-medium text-gray-700">
Codigo *
</label>
<input
type="text"
id="code"
value={formData.code}
onChange={(e) => handleChange('code', e.target.value.toUpperCase())}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="WH001"
required
/>
<p className="mt-1 text-xs text-gray-500">Codigo unico para el almacen</p>
</div>
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Nombre *
</label>
<input
type="text"
id="name"
value={formData.name}
onChange={(e) => handleChange('name', e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="Almacen Principal"
required
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Tipo de Almacen *
</label>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{warehouseTypes.map((type) => (
<label
key={type.value}
className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors ${
formData.warehouseType === type.value
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<input
type="radio"
name="warehouseType"
value={type.value}
checked={formData.warehouseType === type.value}
onChange={(e) => handleChange('warehouseType', e.target.value)}
className="mt-1"
/>
<div>
<div className="font-medium text-gray-900">{type.label}</div>
<div className="text-xs text-gray-500">{type.description}</div>
</div>
</label>
))}
</div>
</div>
</CardContent>
</Card>
{/* Address */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MapPin className="h-5 w-5" />
Direccion
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label htmlFor="street" className="block text-sm font-medium text-gray-700">
Calle / Direccion
</label>
<input
type="text"
id="street"
value={formData.address.street}
onChange={(e) => handleChange('address.street', e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="Av. Principal #123"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="city" className="block text-sm font-medium text-gray-700">
Ciudad
</label>
<input
type="text"
id="city"
value={formData.address.city}
onChange={(e) => handleChange('address.city', e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="Ciudad de Mexico"
/>
</div>
<div>
<label htmlFor="state" className="block text-sm font-medium text-gray-700">
Estado / Provincia
</label>
<input
type="text"
id="state"
value={formData.address.state}
onChange={(e) => handleChange('address.state', e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="CDMX"
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="country" className="block text-sm font-medium text-gray-700">
Pais
</label>
<input
type="text"
id="country"
value={formData.address.country}
onChange={(e) => handleChange('address.country', e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="Mexico"
/>
</div>
<div>
<label htmlFor="postalCode" className="block text-sm font-medium text-gray-700">
Codigo Postal
</label>
<input
type="text"
id="postalCode"
value={formData.address.postalCode}
onChange={(e) => handleChange('address.postalCode', e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="06600"
/>
</div>
</div>
</CardContent>
</Card>
{/* Contact */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Phone className="h-5 w-5" />
Contacto
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="phone" className="block text-sm font-medium text-gray-700">
<span className="flex items-center gap-1">
<Phone className="h-4 w-4" />
Telefono
</span>
</label>
<input
type="tel"
id="phone"
value={formData.contact.phone}
onChange={(e) => handleChange('contact.phone', e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="+52 55 1234 5678"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
<span className="flex items-center gap-1">
<Mail className="h-4 w-4" />
Email
</span>
</label>
<input
type="email"
id="email"
value={formData.contact.email}
onChange={(e) => handleChange('contact.email', e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="almacen@empresa.com"
/>
</div>
</div>
<div>
<label htmlFor="manager" className="block text-sm font-medium text-gray-700">
<span className="flex items-center gap-1">
<User className="h-4 w-4" />
Responsable / Encargado
</span>
</label>
<input
type="text"
id="manager"
value={formData.contact.manager}
onChange={(e) => handleChange('contact.manager', e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="Juan Perez"
/>
</div>
</CardContent>
</Card>
{/* Capacity */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Box className="h-5 w-5" />
Capacidad (Opcional)
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-3">
<div>
<label htmlFor="maxUnits" className="block text-sm font-medium text-gray-700">
<span className="flex items-center gap-1">
<Box className="h-4 w-4" />
Max. Unidades
</span>
</label>
<input
type="number"
id="maxUnits"
value={formData.capacity.maxUnits}
onChange={(e) => handleChange('capacity.maxUnits', e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="10000"
min="0"
/>
</div>
<div>
<label htmlFor="maxVolume" className="block text-sm font-medium text-gray-700">
<span className="flex items-center gap-1">
<Ruler className="h-4 w-4" />
Max. Volumen (m3)
</span>
</label>
<input
type="number"
id="maxVolume"
value={formData.capacity.maxVolume}
onChange={(e) => handleChange('capacity.maxVolume', e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="500"
min="0"
step="0.01"
/>
</div>
<div>
<label htmlFor="maxWeight" className="block text-sm font-medium text-gray-700">
<span className="flex items-center gap-1">
<Scale className="h-4 w-4" />
Max. Peso (kg)
</span>
</label>
<input
type="number"
id="maxWeight"
value={formData.capacity.maxWeight}
onChange={(e) => handleChange('capacity.maxWeight', e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="25000"
min="0"
step="0.01"
/>
</div>
</div>
<p className="text-xs text-gray-500">
Define limites de capacidad para alertas y control de inventario
</p>
</CardContent>
</Card>
{/* Actions */}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => navigate('/inventory/warehouses')}
>
Cancelar
</Button>
<Button type="submit" isLoading={isSubmitting}>
Crear Almacen
</Button>
</div>
</form>
</div>
);
}
export default WarehouseCreatePage;

View File

@ -0,0 +1,505 @@
import { useState, useEffect, useCallback } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
ArrowLeft,
Edit,
Trash2,
MapPin,
Package,
Warehouse,
Building2,
Truck,
Cloud,
ArrowRightLeft,
Mail,
Phone,
User,
Plus,
Eye,
Clock,
} from 'lucide-react';
import { Button } from '@components/atoms/Button';
import { Spinner } from '@components/atoms/Spinner';
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
import { ConfirmModal } from '@components/organisms/Modal';
import { useToast } from '@components/organisms/Toast';
import { ErrorEmptyState } from '@components/templates/EmptyState';
import { useLocations, useMovements, useStockLevels } from '@features/inventory/hooks';
import { inventoryApi } from '@features/inventory/api/inventory.api';
import type { Warehouse as WarehouseType, Location } from '@features/inventory/types';
import { formatDate, formatNumber, formatCurrency } from '@utils/formatters';
type WarehouseTypeKey = 'main' | 'transit' | 'customer' | 'supplier' | 'virtual';
const warehouseTypeLabels: Record<WarehouseTypeKey, string> = {
main: 'Principal',
transit: 'Transito',
customer: 'Cliente',
supplier: 'Proveedor',
virtual: 'Virtual',
};
const warehouseTypeIcons: Record<WarehouseTypeKey, typeof Warehouse> = {
main: Building2,
transit: Truck,
customer: Package,
supplier: ArrowRightLeft,
virtual: Cloud,
};
const warehouseTypeColors: Record<WarehouseTypeKey, string> = {
main: 'bg-blue-100 text-blue-700',
transit: 'bg-amber-100 text-amber-700',
customer: 'bg-green-100 text-green-700',
supplier: 'bg-purple-100 text-purple-700',
virtual: 'bg-gray-100 text-gray-700',
};
export function WarehouseDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { showToast } = useToast();
const [warehouse, setWarehouse] = useState<WarehouseType | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const { locations } = useLocations(id ?? null);
const { movements } = useMovements({ warehouseId: id, limit: 5 });
const { stockLevels } = useStockLevels({ warehouseId: id, limit: 10 });
const fetchWarehouse = useCallback(async () => {
if (!id) return;
setIsLoading(true);
setError(null);
try {
const data = await inventoryApi.getWarehouseById(id);
setWarehouse(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar almacen');
} finally {
setIsLoading(false);
}
}, [id]);
useEffect(() => {
fetchWarehouse();
}, [fetchWarehouse]);
const handleDelete = async () => {
if (!id) return;
setIsDeleting(true);
try {
await inventoryApi.deleteWarehouse(id);
showToast({
type: 'success',
title: 'Almacen eliminado',
message: 'El almacen ha sido eliminado exitosamente.',
});
navigate('/inventory/warehouses');
} catch {
showToast({
type: 'error',
title: 'Error',
message: 'No se pudo eliminar el almacen.',
});
} finally {
setIsDeleting(false);
setShowDeleteModal(false);
}
};
if (isLoading) {
return (
<div className="flex h-96 items-center justify-center">
<Spinner size="lg" />
</div>
);
}
if (error || !warehouse) {
return (
<div className="p-6">
<ErrorEmptyState
title="Almacen no encontrado"
description="No se pudo cargar la informacion del almacen."
onRetry={fetchWarehouse}
/>
</div>
);
}
const Icon = warehouseTypeIcons[warehouse.warehouseType] || Warehouse;
const typeLabel = warehouseTypeLabels[warehouse.warehouseType] || warehouse.warehouseType;
const typeColor = warehouseTypeColors[warehouse.warehouseType] || 'bg-gray-100 text-gray-700';
// Calculate totals from stock levels
const totalProducts = stockLevels.length;
const totalStockValue = stockLevels.reduce((sum, s) => sum + (s.totalCost || 0), 0);
const totalQuantity = stockLevels.reduce((sum, s) => sum + s.quantityOnHand, 0);
// Build location tree
const buildLocationTree = (locs: Location[], parentId: string | null = null): Location[] => {
return locs
.filter(loc => loc.parentId === parentId)
.map(loc => ({
...loc,
children: buildLocationTree(locs, loc.id),
}));
};
// locationTree is kept for potential future use (e.g., hierarchical display)
void buildLocationTree(locations);
return (
<div className="space-y-6 p-6">
<Breadcrumbs
items={[
{ label: 'Inventario', href: '/inventory' },
{ label: 'Almacenes', href: '/inventory/warehouses' },
{ label: warehouse.name },
]}
/>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={() => navigate('/inventory/warehouses')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Volver
</Button>
<div>
<h1 className="text-2xl font-bold text-gray-900">Detalle de Almacen</h1>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => navigate(`/inventory/warehouses/${id}/locations`)}>
<MapPin className="mr-2 h-4 w-4" />
Ubicaciones
</Button>
<Button variant="outline" onClick={() => navigate(`/inventory/warehouses/${id}/edit`)}>
<Edit className="mr-2 h-4 w-4" />
Editar
</Button>
<Button variant="danger" onClick={() => setShowDeleteModal(true)}>
<Trash2 className="mr-2 h-4 w-4" />
Eliminar
</Button>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-3">
{/* Warehouse info card */}
<div className="lg:col-span-1">
<Card>
<CardContent className="pt-6">
<div className="flex flex-col items-center text-center">
<div className={`flex h-20 w-20 items-center justify-center rounded-xl ${typeColor}`}>
<Icon className="h-10 w-10" />
</div>
<h2 className="mt-4 text-xl font-semibold text-gray-900">
{warehouse.name}
</h2>
<span className="mt-1 font-mono text-sm text-gray-500">{warehouse.code}</span>
<div className="mt-3 flex flex-wrap justify-center gap-2">
<span className={`inline-flex rounded-full px-3 py-1 text-sm font-medium ${typeColor}`}>
{typeLabel}
</span>
<span className={`inline-flex rounded-full px-3 py-1 text-sm font-medium ${
warehouse.isActive
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'
}`}>
{warehouse.isActive ? 'Activo' : 'Inactivo'}
</span>
</div>
{warehouse.address && (
<div className="mt-6 w-full space-y-3 text-left">
{warehouse.address.street && (
<div className="flex items-center gap-3 text-sm">
<MapPin className="h-4 w-4 text-gray-400" />
<span className="text-gray-600">{warehouse.address.street}</span>
</div>
)}
{warehouse.address.phone && (
<div className="flex items-center gap-3 text-sm">
<Phone className="h-4 w-4 text-gray-400" />
<span className="text-gray-600">{warehouse.address.phone}</span>
</div>
)}
{warehouse.address.email && (
<div className="flex items-center gap-3 text-sm">
<Mail className="h-4 w-4 text-gray-400" />
<a href={`mailto:${warehouse.address.email}`} className="text-primary-600 hover:underline">
{warehouse.address.email}
</a>
</div>
)}
{warehouse.address.manager && (
<div className="flex items-center gap-3 text-sm">
<User className="h-4 w-4 text-gray-400" />
<span className="text-gray-600">{warehouse.address.manager}</span>
</div>
)}
</div>
)}
</div>
</CardContent>
</Card>
{/* Quick Stats */}
<Card className="mt-6">
<CardHeader>
<CardTitle>Resumen</CardTitle>
</CardHeader>
<CardContent>
<dl className="space-y-4">
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Productos</dt>
<dd className="font-medium text-gray-900">{formatNumber(totalProducts)}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Unidades en Stock</dt>
<dd className="font-medium text-gray-900">{formatNumber(totalQuantity)}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Valor Total</dt>
<dd className="font-medium text-gray-900">{formatCurrency(totalStockValue)}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Ubicaciones</dt>
<dd className="font-medium text-gray-900">{locations.length}</dd>
</div>
</dl>
</CardContent>
</Card>
</div>
{/* Details */}
<div className="space-y-6 lg:col-span-2">
{/* Address */}
{warehouse.address && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MapPin className="h-5 w-5" />
Direccion
</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid gap-4 sm:grid-cols-2">
<div>
<dt className="text-sm text-gray-500">Calle</dt>
<dd className="font-medium text-gray-900">
{warehouse.address.street || 'No especificado'}
</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Ciudad</dt>
<dd className="font-medium text-gray-900">
{warehouse.address.city || 'No especificado'}
</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Estado/Provincia</dt>
<dd className="font-medium text-gray-900">
{warehouse.address.state || 'No especificado'}
</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Pais</dt>
<dd className="font-medium text-gray-900">
{warehouse.address.country || 'No especificado'}
</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Codigo Postal</dt>
<dd className="font-medium text-gray-900">
{warehouse.address.postalCode || 'No especificado'}
</dd>
</div>
</dl>
</CardContent>
</Card>
)}
{/* Locations Preview */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<MapPin className="h-5 w-5" />
Ubicaciones ({locations.length})
</CardTitle>
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/inventory/warehouses/${id}/locations`)}
>
<Plus className="mr-1 h-4 w-4" />
Agregar
</Button>
</CardHeader>
<CardContent>
{locations.length === 0 ? (
<div className="py-8 text-center text-gray-500">
<MapPin className="mx-auto h-12 w-12 text-gray-300" />
<p className="mt-2">No hay ubicaciones configuradas</p>
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={() => navigate(`/inventory/warehouses/${id}/locations`)}
>
Crear primera ubicacion
</Button>
</div>
) : (
<div className="space-y-2">
{locations.slice(0, 5).map((location) => (
<div
key={location.id}
className="flex items-center justify-between rounded-lg border border-gray-200 p-3"
>
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gray-100">
<MapPin className="h-4 w-4 text-gray-600" />
</div>
<div>
<div className="font-medium text-gray-900">{location.name}</div>
<div className="text-xs text-gray-500">{location.code}</div>
</div>
</div>
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${
location.isActive
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'
}`}>
{location.isActive ? 'Activo' : 'Inactivo'}
</span>
</div>
))}
{locations.length > 5 && (
<Button
variant="ghost"
className="w-full"
onClick={() => navigate(`/inventory/warehouses/${id}/locations`)}
>
Ver todas las ubicaciones ({locations.length})
</Button>
)}
</div>
)}
</CardContent>
</Card>
{/* Recent Movements */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" />
Movimientos Recientes
</CardTitle>
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/inventory/movements?warehouseId=${id}`)}
>
<Eye className="mr-1 h-4 w-4" />
Ver todos
</Button>
</CardHeader>
<CardContent>
{movements.length === 0 ? (
<div className="py-8 text-center text-gray-500">
<ArrowRightLeft className="mx-auto h-12 w-12 text-gray-300" />
<p className="mt-2">No hay movimientos recientes</p>
</div>
) : (
<div className="space-y-3">
{movements.map((movement) => (
<div
key={movement.id}
className="flex items-center justify-between rounded-lg border border-gray-200 p-3"
>
<div className="flex items-center gap-3">
<div className={`flex h-8 w-8 items-center justify-center rounded-lg ${
movement.movementType === 'receipt' ? 'bg-green-100 text-green-600' :
movement.movementType === 'shipment' ? 'bg-blue-100 text-blue-600' :
'bg-gray-100 text-gray-600'
}`}>
<ArrowRightLeft className="h-4 w-4" />
</div>
<div>
<div className="font-medium text-gray-900">{movement.movementNumber}</div>
<div className="text-xs text-gray-500">
{formatDate(movement.createdAt, 'short')}
</div>
</div>
</div>
<span className={`font-medium ${
['receipt', 'return', 'production'].includes(movement.movementType)
? 'text-green-600'
: 'text-red-600'
}`}>
{['receipt', 'return', 'production'].includes(movement.movementType) ? '+' : '-'}
{formatNumber(movement.quantity)}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* System Info */}
<Card>
<CardHeader>
<CardTitle>Informacion del Sistema</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid gap-4 sm:grid-cols-2">
<div>
<dt className="text-sm text-gray-500">ID</dt>
<dd className="font-mono text-sm text-gray-900">{warehouse.id}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Tenant ID</dt>
<dd className="font-mono text-sm text-gray-900">{warehouse.tenantId}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Creado</dt>
<dd className="text-gray-900">{formatDate(warehouse.createdAt, 'full')}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Actualizado</dt>
<dd className="text-gray-900">
{warehouse.updatedAt ? formatDate(warehouse.updatedAt, 'full') : '-'}
</dd>
</div>
</dl>
</CardContent>
</Card>
</div>
</div>
{/* Delete Modal */}
<ConfirmModal
isOpen={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
onConfirm={handleDelete}
title="Eliminar almacen"
message={`¿Estas seguro de que deseas eliminar "${warehouse.name}"? Esta accion no se puede deshacer.`}
variant="danger"
confirmText="Eliminar"
isLoading={isDeleting}
/>
</div>
);
}
export default WarehouseDetailPage;

View File

@ -0,0 +1,541 @@
import { useState, useEffect, useCallback } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ArrowLeft, Warehouse, MapPin, Phone, Mail, User, Scale, Box, Ruler } from 'lucide-react';
import { Button } from '@components/atoms/Button';
import { Spinner } from '@components/atoms/Spinner';
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
import { Alert } from '@components/molecules/Alert';
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
import { useToast } from '@components/organisms/Toast';
import { ErrorEmptyState } from '@components/templates/EmptyState';
import { inventoryApi } from '@features/inventory/api/inventory.api';
import type { Warehouse as WarehouseType, UpdateWarehouseDto } from '@features/inventory/types';
type WarehouseTypeKey = 'main' | 'transit' | 'customer' | 'supplier' | 'virtual';
const warehouseTypes: { value: WarehouseTypeKey; label: string; description: string }[] = [
{ value: 'main', label: 'Principal', description: 'Almacen principal de operaciones' },
{ value: 'transit', label: 'Transito', description: 'Almacen temporal para transferencias' },
{ value: 'customer', label: 'Cliente', description: 'Ubicacion en instalaciones del cliente' },
{ value: 'supplier', label: 'Proveedor', description: 'Ubicacion en instalaciones del proveedor' },
{ value: 'virtual', label: 'Virtual', description: 'Almacen virtual para seguimiento' },
];
interface FormData {
code: string;
name: string;
warehouseType: WarehouseTypeKey;
isActive: boolean;
address: {
street: string;
city: string;
state: string;
country: string;
postalCode: string;
};
contact: {
phone: string;
email: string;
manager: string;
};
capacity: {
maxUnits: string;
maxVolume: string;
maxWeight: string;
};
}
export function WarehouseEditPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { showToast } = useToast();
const [warehouse, setWarehouse] = useState<WarehouseType | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [formData, setFormData] = useState<FormData | null>(null);
const fetchWarehouse = useCallback(async () => {
if (!id) return;
setIsLoading(true);
setLoadError(null);
try {
const data = await inventoryApi.getWarehouseById(id);
setWarehouse(data);
setFormData({
code: data.code,
name: data.name,
warehouseType: data.warehouseType,
isActive: data.isActive,
address: {
street: data.address?.street || '',
city: data.address?.city || '',
state: data.address?.state || '',
country: data.address?.country || '',
postalCode: data.address?.postalCode || '',
},
contact: {
phone: data.address?.phone || '',
email: data.address?.email || '',
manager: data.address?.manager || '',
},
capacity: {
maxUnits: data.address?.maxUnits?.toString() || '',
maxVolume: data.address?.maxVolume?.toString() || '',
maxWeight: data.address?.maxWeight?.toString() || '',
},
});
} catch (err) {
setLoadError(err instanceof Error ? err.message : 'Error al cargar almacen');
} finally {
setIsLoading(false);
}
}, [id]);
useEffect(() => {
fetchWarehouse();
}, [fetchWarehouse]);
const handleChange = (field: string, value: string | boolean) => {
if (!formData) return;
const parts = field.split('.');
if (parts.length === 1) {
setFormData(prev => prev ? { ...prev, [field]: value } : null);
} else {
const [section, key] = parts as [keyof FormData, string];
setFormData(prev => prev ? {
...prev,
[section]: {
...(prev[section] as Record<string, string>),
[key]: value,
},
} : null);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!id || !formData) return;
setIsSubmitting(true);
setSubmitError(null);
try {
// Build the address object with all contact and capacity info
const address: Record<string, any> = {};
if (formData.address.street) address.street = formData.address.street;
if (formData.address.city) address.city = formData.address.city;
if (formData.address.state) address.state = formData.address.state;
if (formData.address.country) address.country = formData.address.country;
if (formData.address.postalCode) address.postalCode = formData.address.postalCode;
if (formData.contact.phone) address.phone = formData.contact.phone;
if (formData.contact.email) address.email = formData.contact.email;
if (formData.contact.manager) address.manager = formData.contact.manager;
if (formData.capacity.maxUnits) address.maxUnits = parseInt(formData.capacity.maxUnits, 10);
if (formData.capacity.maxVolume) address.maxVolume = parseFloat(formData.capacity.maxVolume);
if (formData.capacity.maxWeight) address.maxWeight = parseFloat(formData.capacity.maxWeight);
const updateData: UpdateWarehouseDto = {
name: formData.name,
warehouseType: formData.warehouseType,
isActive: formData.isActive,
address: Object.keys(address).length > 0 ? address : null,
};
await inventoryApi.updateWarehouse(id, updateData);
showToast({
type: 'success',
title: 'Almacen actualizado',
message: 'Los cambios han sido guardados exitosamente.',
});
navigate(`/inventory/warehouses/${id}`);
} catch (err) {
const message = err instanceof Error ? err.message : 'Error al actualizar almacen';
setSubmitError(message);
} finally {
setIsSubmitting(false);
}
};
if (isLoading) {
return (
<div className="flex h-96 items-center justify-center">
<Spinner size="lg" />
</div>
);
}
if (loadError || !warehouse || !formData) {
return (
<div className="p-6">
<ErrorEmptyState
title="Almacen no encontrado"
description="No se pudo cargar la informacion del almacen."
onRetry={fetchWarehouse}
/>
</div>
);
}
return (
<div className="space-y-6 p-6">
<Breadcrumbs
items={[
{ label: 'Inventario', href: '/inventory' },
{ label: 'Almacenes', href: '/inventory/warehouses' },
{ label: warehouse.name, href: `/inventory/warehouses/${id}` },
{ label: 'Editar' },
]}
/>
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={() => navigate(`/inventory/warehouses/${id}`)}>
<ArrowLeft className="mr-2 h-4 w-4" />
Volver
</Button>
<div>
<h1 className="text-2xl font-bold text-gray-900">Editar Almacen</h1>
<p className="text-sm text-gray-500">
Modifica la informacion de {warehouse.name}
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="mx-auto max-w-3xl space-y-6">
{submitError && (
<Alert
variant="danger"
title="Error"
onClose={() => setSubmitError(null)}
>
{submitError}
</Alert>
)}
{/* Basic Information */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Warehouse className="h-5 w-5" />
Informacion Basica
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="code" className="block text-sm font-medium text-gray-700">
Codigo
</label>
<input
type="text"
id="code"
value={formData.code}
disabled
className="mt-1 block w-full rounded-md border border-gray-300 bg-gray-50 px-3 py-2 text-gray-500 cursor-not-allowed"
/>
<p className="mt-1 text-xs text-gray-500">El codigo no puede ser modificado</p>
</div>
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Nombre *
</label>
<input
type="text"
id="name"
value={formData.name}
onChange={(e) => handleChange('name', e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="Almacen Principal"
required
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Tipo de Almacen *
</label>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{warehouseTypes.map((type) => (
<label
key={type.value}
className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors ${
formData.warehouseType === type.value
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<input
type="radio"
name="warehouseType"
value={type.value}
checked={formData.warehouseType === type.value}
onChange={(e) => handleChange('warehouseType', e.target.value)}
className="mt-1"
/>
<div>
<div className="font-medium text-gray-900">{type.label}</div>
<div className="text-xs text-gray-500">{type.description}</div>
</div>
</label>
))}
</div>
</div>
<div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.isActive}
onChange={(e) => handleChange('isActive', e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm font-medium text-gray-700">Almacen activo</span>
</label>
<p className="mt-1 ml-6 text-xs text-gray-500">
Los almacenes inactivos no pueden recibir ni enviar productos
</p>
</div>
</CardContent>
</Card>
{/* Address */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MapPin className="h-5 w-5" />
Direccion
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label htmlFor="street" className="block text-sm font-medium text-gray-700">
Calle / Direccion
</label>
<input
type="text"
id="street"
value={formData.address.street}
onChange={(e) => handleChange('address.street', e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="Av. Principal #123"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="city" className="block text-sm font-medium text-gray-700">
Ciudad
</label>
<input
type="text"
id="city"
value={formData.address.city}
onChange={(e) => handleChange('address.city', e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="Ciudad de Mexico"
/>
</div>
<div>
<label htmlFor="state" className="block text-sm font-medium text-gray-700">
Estado / Provincia
</label>
<input
type="text"
id="state"
value={formData.address.state}
onChange={(e) => handleChange('address.state', e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="CDMX"
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="country" className="block text-sm font-medium text-gray-700">
Pais
</label>
<input
type="text"
id="country"
value={formData.address.country}
onChange={(e) => handleChange('address.country', e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="Mexico"
/>
</div>
<div>
<label htmlFor="postalCode" className="block text-sm font-medium text-gray-700">
Codigo Postal
</label>
<input
type="text"
id="postalCode"
value={formData.address.postalCode}
onChange={(e) => handleChange('address.postalCode', e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="06600"
/>
</div>
</div>
</CardContent>
</Card>
{/* Contact */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Phone className="h-5 w-5" />
Contacto
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="phone" className="block text-sm font-medium text-gray-700">
<span className="flex items-center gap-1">
<Phone className="h-4 w-4" />
Telefono
</span>
</label>
<input
type="tel"
id="phone"
value={formData.contact.phone}
onChange={(e) => handleChange('contact.phone', e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="+52 55 1234 5678"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
<span className="flex items-center gap-1">
<Mail className="h-4 w-4" />
Email
</span>
</label>
<input
type="email"
id="email"
value={formData.contact.email}
onChange={(e) => handleChange('contact.email', e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="almacen@empresa.com"
/>
</div>
</div>
<div>
<label htmlFor="manager" className="block text-sm font-medium text-gray-700">
<span className="flex items-center gap-1">
<User className="h-4 w-4" />
Responsable / Encargado
</span>
</label>
<input
type="text"
id="manager"
value={formData.contact.manager}
onChange={(e) => handleChange('contact.manager', e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="Juan Perez"
/>
</div>
</CardContent>
</Card>
{/* Capacity */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Box className="h-5 w-5" />
Capacidad (Opcional)
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-3">
<div>
<label htmlFor="maxUnits" className="block text-sm font-medium text-gray-700">
<span className="flex items-center gap-1">
<Box className="h-4 w-4" />
Max. Unidades
</span>
</label>
<input
type="number"
id="maxUnits"
value={formData.capacity.maxUnits}
onChange={(e) => handleChange('capacity.maxUnits', e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="10000"
min="0"
/>
</div>
<div>
<label htmlFor="maxVolume" className="block text-sm font-medium text-gray-700">
<span className="flex items-center gap-1">
<Ruler className="h-4 w-4" />
Max. Volumen (m3)
</span>
</label>
<input
type="number"
id="maxVolume"
value={formData.capacity.maxVolume}
onChange={(e) => handleChange('capacity.maxVolume', e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="500"
min="0"
step="0.01"
/>
</div>
<div>
<label htmlFor="maxWeight" className="block text-sm font-medium text-gray-700">
<span className="flex items-center gap-1">
<Scale className="h-4 w-4" />
Max. Peso (kg)
</span>
</label>
<input
type="number"
id="maxWeight"
value={formData.capacity.maxWeight}
onChange={(e) => handleChange('capacity.maxWeight', e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="25000"
min="0"
step="0.01"
/>
</div>
</div>
<p className="text-xs text-gray-500">
Define limites de capacidad para alertas y control de inventario
</p>
</CardContent>
</Card>
{/* Actions */}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => navigate(`/inventory/warehouses/${id}`)}
>
Cancelar
</Button>
<Button type="submit" isLoading={isSubmitting}>
Guardar Cambios
</Button>
</div>
</form>
</div>
);
}
export default WarehouseEditPage;

View File

@ -0,0 +1,329 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Plus,
MoreVertical,
Eye,
Edit,
Trash2,
Warehouse,
MapPin,
Package,
Building2,
ArrowRightLeft,
Truck,
Cloud,
} from 'lucide-react';
import { Button } from '@components/atoms/Button';
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
import { DataTable, type Column } from '@components/organisms/DataTable';
import { Dropdown, type DropdownItem } from '@components/organisms/Dropdown';
import { ConfirmModal } from '@components/organisms/Modal';
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
import { NoDataEmptyState, ErrorEmptyState } from '@components/templates/EmptyState';
import { useWarehouses } from '@features/inventory/hooks';
import type { Warehouse as WarehouseType } from '@features/inventory/types';
type WarehouseTypeKey = 'main' | 'transit' | 'customer' | 'supplier' | 'virtual';
const warehouseTypeLabels: Record<WarehouseTypeKey, string> = {
main: 'Principal',
transit: 'Tránsito',
customer: 'Cliente',
supplier: 'Proveedor',
virtual: 'Virtual',
};
const warehouseTypeIcons: Record<WarehouseTypeKey, typeof Warehouse> = {
main: Building2,
transit: Truck,
customer: Package,
supplier: ArrowRightLeft,
virtual: Cloud,
};
const warehouseTypeColors: Record<WarehouseTypeKey, string> = {
main: 'bg-blue-100 text-blue-700',
transit: 'bg-amber-100 text-amber-700',
customer: 'bg-green-100 text-green-700',
supplier: 'bg-purple-100 text-purple-700',
virtual: 'bg-gray-100 text-gray-700',
};
export function WarehousesListPage() {
const navigate = useNavigate();
const [warehouseToDelete, setWarehouseToDelete] = useState<WarehouseType | null>(null);
const {
warehouses,
total,
page,
totalPages,
isLoading,
error,
setPage,
refresh,
deleteWarehouse,
} = useWarehouses();
const getActionsMenu = (warehouse: WarehouseType): DropdownItem[] => {
return [
{
key: 'view',
label: 'Ver detalle',
icon: <Eye className="h-4 w-4" />,
onClick: () => navigate(`/inventory/warehouses/${warehouse.id}`),
},
{
key: 'edit',
label: 'Editar',
icon: <Edit className="h-4 w-4" />,
onClick: () => navigate(`/inventory/warehouses/${warehouse.id}/edit`),
},
{
key: 'locations',
label: 'Gestionar ubicaciones',
icon: <MapPin className="h-4 w-4" />,
onClick: () => navigate(`/inventory/warehouses/${warehouse.id}/locations`),
},
{
key: 'delete',
label: 'Eliminar',
icon: <Trash2 className="h-4 w-4" />,
danger: true,
onClick: () => setWarehouseToDelete(warehouse),
},
];
};
const columns: Column<WarehouseType>[] = [
{
key: 'code',
header: 'Codigo',
sortable: true,
render: (warehouse) => (
<span className="font-mono text-sm font-medium text-gray-900">
{warehouse.code}
</span>
),
},
{
key: 'name',
header: 'Nombre',
render: (warehouse) => {
const Icon = warehouseTypeIcons[warehouse.warehouseType] || Warehouse;
return (
<div className="flex items-center gap-3">
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${
warehouseTypeColors[warehouse.warehouseType] || 'bg-gray-100 text-gray-700'
}`}>
<Icon className="h-5 w-5" />
</div>
<div>
<div className="font-medium text-gray-900">{warehouse.name}</div>
{warehouse.address?.city && (
<div className="text-sm text-gray-500">{warehouse.address.city}</div>
)}
</div>
</div>
);
},
},
{
key: 'type',
header: 'Tipo',
render: (warehouse) => (
<span className={`inline-flex rounded-full px-2.5 py-1 text-xs font-medium ${
warehouseTypeColors[warehouse.warehouseType] || 'bg-gray-100 text-gray-700'
}`}>
{warehouseTypeLabels[warehouse.warehouseType] || warehouse.warehouseType}
</span>
),
},
{
key: 'address',
header: 'Direccion',
render: (warehouse) => {
if (!warehouse.address) return <span className="text-gray-400">-</span>;
const parts = [
warehouse.address.street,
warehouse.address.city,
warehouse.address.state,
].filter(Boolean);
return (
<span className="text-sm text-gray-600">
{parts.length > 0 ? parts.join(', ') : '-'}
</span>
);
},
},
{
key: 'status',
header: 'Estado',
render: (warehouse) => (
<span className={`inline-flex rounded-full px-2.5 py-1 text-xs font-medium ${
warehouse.isActive
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'
}`}>
{warehouse.isActive ? 'Activo' : 'Inactivo'}
</span>
),
},
{
key: 'actions',
header: '',
render: (warehouse) => (
<Dropdown
trigger={
<button className="rounded p-1 hover:bg-gray-100">
<MoreVertical className="h-4 w-4 text-gray-500" />
</button>
}
items={getActionsMenu(warehouse)}
align="right"
/>
),
},
];
const handleDeleteConfirm = async () => {
if (warehouseToDelete) {
await deleteWarehouse(warehouseToDelete.id);
setWarehouseToDelete(null);
}
};
const activeWarehouses = warehouses.filter(w => w.isActive).length;
const mainWarehouses = warehouses.filter(w => w.warehouseType === 'main').length;
const transitWarehouses = warehouses.filter(w => w.warehouseType === 'transit').length;
if (error) {
return (
<div className="p-6">
<ErrorEmptyState onRetry={refresh} />
</div>
);
}
return (
<div className="space-y-6 p-6">
<Breadcrumbs items={[
{ label: 'Inventario', href: '/inventory' },
{ label: 'Almacenes' },
]} />
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Almacenes</h1>
<p className="text-sm text-gray-500">
Gestiona los almacenes y ubicaciones de inventario
</p>
</div>
<Button onClick={() => navigate('/inventory/warehouses/new')}>
<Plus className="mr-2 h-4 w-4" />
Nuevo almacen
</Button>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
<Warehouse className="h-5 w-5 text-blue-600" />
</div>
<div>
<div className="text-sm text-gray-500">Total Almacenes</div>
<div className="text-xl font-bold text-gray-900">{total}</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100">
<Package className="h-5 w-5 text-green-600" />
</div>
<div>
<div className="text-sm text-gray-500">Activos</div>
<div className="text-xl font-bold text-gray-900">{activeWarehouses}</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-100">
<Building2 className="h-5 w-5 text-purple-600" />
</div>
<div>
<div className="text-sm text-gray-500">Principales</div>
<div className="text-xl font-bold text-gray-900">{mainWarehouses}</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-100">
<Truck className="h-5 w-5 text-amber-600" />
</div>
<div>
<div className="text-sm text-gray-500">En Transito</div>
<div className="text-xl font-bold text-gray-900">{transitWarehouses}</div>
</div>
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Lista de Almacenes</CardTitle>
</CardHeader>
<CardContent>
{warehouses.length === 0 && !isLoading ? (
<NoDataEmptyState
entityName="almacenes"
onCreateNew={() => navigate('/inventory/warehouses/new')}
/>
) : (
<DataTable
data={warehouses}
columns={columns}
isLoading={isLoading}
pagination={{
page,
totalPages,
total,
limit: 20,
onPageChange: setPage,
}}
/>
)}
</CardContent>
</Card>
{/* Delete confirmation modal */}
<ConfirmModal
isOpen={!!warehouseToDelete}
onClose={() => setWarehouseToDelete(null)}
onConfirm={handleDeleteConfirm}
title="Eliminar almacen"
message={`¿Estas seguro de que deseas eliminar "${warehouseToDelete?.name}"? Esta accion no se puede deshacer y eliminara todas las ubicaciones asociadas.`}
variant="danger"
confirmText="Eliminar"
/>
</div>
);
}
export default WarehousesListPage;

View File

@ -1,5 +1,22 @@
// Product pages
export { ProductsListPage, default as ProductsListPageDefault } from './products/ProductsListPage';
export { ProductDetailPage, default as ProductDetailPageDefault } from './products/ProductDetailPage';
export { ProductCreatePage, default as ProductCreatePageDefault } from './products/ProductCreatePage';
export { ProductEditPage, default as ProductEditPageDefault } from './products/ProductEditPage';
// Stock & Movements pages
export { StockLevelsPage, default as StockLevelsPageDefault } from './StockLevelsPage';
export { MovementsPage, default as MovementsPageDefault } from './MovementsPage';
export { StockMovementDetailPage, default as StockMovementDetailPageDefault } from './StockMovementDetailPage';
export { KardexPage, default as KardexPageDefault } from './KardexPage';
export { StockAdjustmentPage, default as StockAdjustmentPageDefault } from './StockAdjustmentPage';
export { InventoryCountsPage, default as InventoryCountsPageDefault } from './InventoryCountsPage';
export { ReorderAlertsPage, default as ReorderAlertsPageDefault } from './ReorderAlertsPage';
export { ValuationReportsPage, default as ValuationReportsPageDefault } from './ValuationReportsPage';
// Warehouse pages
export { WarehousesListPage, default as WarehousesListPageDefault } from './WarehousesListPage';
export { WarehouseDetailPage, default as WarehouseDetailPageDefault } from './WarehouseDetailPage';
export { WarehouseCreatePage, default as WarehouseCreatePageDefault } from './WarehouseCreatePage';
export { WarehouseEditPage, default as WarehouseEditPageDefault } from './WarehouseEditPage';
export { LocationsPage, default as LocationsPageDefault } from './LocationsPage';

View File

@ -0,0 +1,98 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowLeft } from 'lucide-react';
import { Button } from '@components/atoms/Button';
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
import { Alert } from '@components/molecules/Alert';
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
import { useToast } from '@components/organisms/Toast';
import { ProductForm } from '@features/inventory/components/ProductForm';
import { productsApi } from '@services/api/products.api';
import type { CreateProductDto, UpdateProductDto } from '@services/api/products.api';
export function ProductCreatePage() {
const navigate = useNavigate();
const { showToast } = useToast();
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (data: CreateProductDto | UpdateProductDto, saveAsDraft?: boolean) => {
setIsSubmitting(true);
setError(null);
try {
const productData = {
...data,
isActive: saveAsDraft ? false : (data.isActive ?? true),
};
const product = await productsApi.create(productData as CreateProductDto);
showToast({
type: 'success',
title: 'Producto creado',
message: `${product.name} ha sido creado exitosamente.`,
});
navigate(`/inventory/products/${product.id}`);
} catch (err) {
const message = err instanceof Error ? err.message : 'Error al crear producto';
setError(message);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="space-y-6 p-6">
<Breadcrumbs
items={[
{ label: 'Inventario', href: '/inventory' },
{ label: 'Productos', href: '/inventory/products' },
{ label: 'Nuevo producto' },
]}
/>
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={() => navigate('/inventory/products')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Volver
</Button>
<div>
<h1 className="text-2xl font-bold text-gray-900">Nuevo producto</h1>
<p className="text-sm text-gray-500">
Crea un nuevo producto para el inventario
</p>
</div>
</div>
<div className="mx-auto max-w-4xl">
{error && (
<Alert
variant="danger"
title="Error"
className="mb-6"
onClose={() => setError(null)}
>
{error}
</Alert>
)}
{/* Basic Info */}
<Card className="mb-6">
<CardHeader>
<CardTitle>Informacion Basica</CardTitle>
</CardHeader>
<CardContent>
<ProductForm
onSubmit={handleSubmit}
onCancel={() => navigate('/inventory/products')}
isLoading={isSubmitting}
showDraftButton
/>
</CardContent>
</Card>
</div>
</div>
);
}
export default ProductCreatePage;

View File

@ -0,0 +1,532 @@
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
ArrowLeft,
Edit,
Package,
Warehouse,
AlertTriangle,
RefreshCw,
ArrowRightLeft,
Boxes,
TrendingUp,
TrendingDown,
DollarSign,
BarChart3,
Clock,
} from 'lucide-react';
import { Button } from '@components/atoms/Button';
import { Spinner } from '@components/atoms/Spinner';
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
import { DataTable, type Column } from '@components/organisms/DataTable';
import { ErrorEmptyState } from '@components/templates/EmptyState';
import { useProduct } from '@features/inventory/hooks';
import type { StockByWarehouse, StockMovement, StockStatus } from '@features/inventory/types';
import { formatDate, formatNumber } from '@utils/formatters';
const stockStatusLabels: Record<StockStatus, string> = {
normal: 'Normal',
low: 'Stock Bajo',
out: 'Sin Stock',
overstock: 'Exceso',
};
const stockStatusColors: Record<StockStatus, string> = {
normal: 'bg-green-100 text-green-700',
low: 'bg-amber-100 text-amber-700',
out: 'bg-red-100 text-red-700',
overstock: 'bg-blue-100 text-blue-700',
};
const movementTypeLabels: Record<string, string> = {
receipt: 'Recepcion',
shipment: 'Envio',
transfer: 'Transferencia',
adjustment: 'Ajuste',
return: 'Devolucion',
production: 'Produccion',
consumption: 'Consumo',
};
const formatCurrency = (value: number): string => {
return formatNumber(value, 'es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
};
export function ProductDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { product, stockByWarehouse, recentMovements, isLoading, error, refresh } = useProduct(id || null);
const [isRefreshing, setIsRefreshing] = useState(false);
const handleRefresh = async () => {
setIsRefreshing(true);
await refresh();
setIsRefreshing(false);
};
const warehouseColumns: Column<StockByWarehouse>[] = [
{
key: 'warehouse',
header: 'Almacen',
render: (stock) => (
<div className="flex items-center gap-2">
<Warehouse className="h-4 w-4 text-gray-400" />
<span className="font-medium text-gray-900">{stock.warehouseName}</span>
</div>
),
},
{
key: 'onHand',
header: 'En Stock',
render: (stock) => (
<div className="text-right font-medium">
{formatNumber(stock.quantityOnHand)}
</div>
),
},
{
key: 'reserved',
header: 'Reservado',
render: (stock) => (
<div className="text-right text-gray-600">
{formatNumber(stock.quantityReserved)}
</div>
),
},
{
key: 'available',
header: 'Disponible',
render: (stock) => (
<div className="text-right font-medium text-green-600">
{formatNumber(stock.quantityAvailable)}
</div>
),
},
{
key: 'incoming',
header: 'Entrante',
render: (stock) => (
<div className="text-right text-blue-600">
{stock.quantityIncoming > 0 ? `+${formatNumber(stock.quantityIncoming)}` : '-'}
</div>
),
},
{
key: 'outgoing',
header: 'Saliente',
render: (stock) => (
<div className="text-right text-amber-600">
{stock.quantityOutgoing > 0 ? `-${formatNumber(stock.quantityOutgoing)}` : '-'}
</div>
),
},
];
const movementColumns: Column<StockMovement>[] = [
{
key: 'date',
header: 'Fecha',
render: (movement) => (
<span className="text-sm text-gray-600">
{formatDate(movement.createdAt, 'short')}
</span>
),
},
{
key: 'type',
header: 'Tipo',
render: (movement) => (
<span className="text-sm font-medium">
{movementTypeLabels[movement.movementType] || movement.movementType}
</span>
),
},
{
key: 'quantity',
header: 'Cantidad',
render: (movement) => {
const isPositive = ['receipt', 'return', 'production'].includes(movement.movementType);
return (
<div className={`text-right font-medium ${isPositive ? 'text-green-600' : 'text-red-600'}`}>
{isPositive ? '+' : '-'}{formatNumber(movement.quantity)}
</div>
);
},
},
{
key: 'warehouse',
header: 'Almacen',
render: (movement) => (
<span className="text-sm text-gray-600">
{movement.destWarehouseId || movement.sourceWarehouseId || '-'}
</span>
),
},
{
key: 'reference',
header: 'Referencia',
render: (movement) => (
<span className="text-sm text-gray-500">
{movement.movementNumber}
</span>
),
},
];
if (isLoading) {
return (
<div className="flex h-96 items-center justify-center">
<Spinner size="lg" />
</div>
);
}
if (error || !product) {
return (
<div className="p-6">
<ErrorEmptyState
title="Producto no encontrado"
description="No se pudo cargar la informacion del producto."
onRetry={refresh}
/>
</div>
);
}
const stockStatus: StockStatus = product.stockStatus || 'normal';
const isLowStock = stockStatus === 'low' || stockStatus === 'out';
const totalOnHand = product.quantityOnHand ?? 0;
const totalReserved = product.quantityReserved ?? 0;
const totalAvailable = product.quantityAvailable ?? 0;
return (
<div className="space-y-6 p-6">
<Breadcrumbs
items={[
{ label: 'Inventario', href: '/inventory' },
{ label: 'Productos', href: '/inventory/products' },
{ label: product.name },
]}
/>
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={() => navigate('/inventory/products')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Volver
</Button>
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-100">
<Package className="h-6 w-6 text-purple-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">{product.name}</h1>
<p className="text-sm text-gray-500">SKU: {product.code || '-'}</p>
</div>
<span className={`ml-2 inline-flex rounded-full px-3 py-1 text-sm font-medium ${stockStatusColors[stockStatus]}`}>
{stockStatusLabels[stockStatus]}
</span>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" onClick={handleRefresh} disabled={isRefreshing}>
<RefreshCw className={`mr-2 h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
Actualizar
</Button>
<Button variant="outline" onClick={() => navigate(`/inventory/products/${id}/edit`)}>
<Edit className="mr-2 h-4 w-4" />
Editar
</Button>
<Button variant="outline" onClick={() => navigate(`/inventory/stock/adjust?productId=${id}`)}>
<Boxes className="mr-2 h-4 w-4" />
Ajustar Stock
</Button>
<Button onClick={() => navigate(`/inventory/stock/transfer?productId=${id}`)}>
<ArrowRightLeft className="mr-2 h-4 w-4" />
Transferir
</Button>
</div>
</div>
{/* Low Stock Alert */}
{isLowStock && (
<div className="flex items-center gap-3 rounded-lg bg-amber-50 border border-amber-200 p-4">
<AlertTriangle className="h-5 w-5 text-amber-600" />
<div>
<p className="font-medium text-amber-800">
{stockStatus === 'out' ? 'Producto sin stock' : 'Stock bajo'}
</p>
<p className="text-sm text-amber-600">
{stockStatus === 'out'
? 'Este producto no tiene existencias disponibles. Considere crear una orden de compra.'
: `El stock disponible (${totalAvailable}) esta por debajo del punto de reorden (${product.reorderPoint || 0}).`}
</p>
</div>
<Button variant="outline" size="sm" className="ml-auto" onClick={() => navigate('/purchases/orders/new')}>
Crear orden de compra
</Button>
</div>
)}
{/* Stock Summary Cards */}
<div className="grid gap-4 sm:grid-cols-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
<BarChart3 className="h-5 w-5 text-blue-600" />
</div>
<div>
<div className="text-sm text-gray-500">En Stock</div>
<div className="text-xl font-bold text-gray-900">{formatNumber(totalOnHand)}</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-100">
<TrendingDown className="h-5 w-5 text-amber-600" />
</div>
<div>
<div className="text-sm text-gray-500">Reservado</div>
<div className="text-xl font-bold text-amber-600">{formatNumber(totalReserved)}</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100">
<TrendingUp className="h-5 w-5 text-green-600" />
</div>
<div>
<div className="text-sm text-gray-500">Disponible</div>
<div className="text-xl font-bold text-green-600">{formatNumber(totalAvailable)}</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-100">
<DollarSign className="h-5 w-5 text-purple-600" />
</div>
<div>
<div className="text-sm text-gray-500">Valor en Stock</div>
<div className="text-xl font-bold text-purple-600">
${formatCurrency((product.cost ?? 0) * totalOnHand)}
</div>
</div>
</div>
</CardContent>
</Card>
</div>
<div className="grid gap-6 lg:grid-cols-3">
{/* Left Column - Product Info */}
<div className="space-y-6 lg:col-span-1">
{/* Basic Info */}
<Card>
<CardHeader>
<CardTitle>Informacion del Producto</CardTitle>
</CardHeader>
<CardContent>
<dl className="space-y-3">
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Nombre</dt>
<dd className="text-sm font-medium text-gray-900">{product.name}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">SKU</dt>
<dd className="text-sm font-medium text-gray-900">{product.code || '-'}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Codigo de Barras</dt>
<dd className="text-sm font-medium text-gray-900">{product.barcode || '-'}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Tipo</dt>
<dd className="text-sm font-medium text-gray-900 capitalize">{product.type}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Categoria</dt>
<dd className="text-sm font-medium text-gray-900">{product.categoryName || '-'}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Estado</dt>
<dd>
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${product.isActive ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-700'}`}>
{product.isActive ? 'Activo' : 'Inactivo'}
</span>
</dd>
</div>
</dl>
</CardContent>
</Card>
{/* Inventory Settings */}
<Card>
<CardHeader>
<CardTitle>Configuracion de Inventario</CardTitle>
</CardHeader>
<CardContent>
<dl className="space-y-3">
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Punto de Reorden</dt>
<dd className="text-sm font-medium text-gray-900">
{product.reorderPoint ? formatNumber(product.reorderPoint) : '-'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Stock Minimo</dt>
<dd className="text-sm font-medium text-gray-900">
{product.minStock ? formatNumber(product.minStock) : '-'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Stock Maximo</dt>
<dd className="text-sm font-medium text-gray-900">
{product.maxStock ? formatNumber(product.maxStock) : '-'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Unidad de Medida</dt>
<dd className="text-sm font-medium text-gray-900">{product.uomName || '-'}</dd>
</div>
</dl>
</CardContent>
</Card>
{/* Pricing */}
<Card>
<CardHeader>
<CardTitle>Precios</CardTitle>
</CardHeader>
<CardContent>
<dl className="space-y-3">
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Costo</dt>
<dd className="text-sm font-medium text-gray-900">
${formatCurrency(product.cost ?? 0)}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Precio de Venta</dt>
<dd className="text-sm font-medium text-gray-900">
${formatCurrency(product.salePrice ?? 0)}
</dd>
</div>
{product.cost && product.salePrice && product.cost > 0 && (
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Margen</dt>
<dd className="text-sm font-medium text-green-600">
{formatNumber(((product.salePrice - product.cost) / product.cost) * 100, 'es-MX', { maximumFractionDigits: 1 })}%
</dd>
</div>
)}
</dl>
</CardContent>
</Card>
</div>
{/* Right Column - Stock & Movements */}
<div className="space-y-6 lg:col-span-2">
{/* Stock by Warehouse */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Warehouse className="h-5 w-5" />
Stock por Almacen
</CardTitle>
</CardHeader>
<CardContent>
{stockByWarehouse && stockByWarehouse.length > 0 ? (
<DataTable
data={stockByWarehouse}
columns={warehouseColumns}
isLoading={false}
/>
) : (
<div className="py-8 text-center text-gray-500">
No hay stock registrado en ningun almacen
</div>
)}
</CardContent>
</Card>
{/* Recent Movements (Kardex Preview) */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" />
Movimientos Recientes
</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() => navigate(`/inventory/movements?productId=${id}`)}
>
Ver Kardex completo
</Button>
</CardHeader>
<CardContent>
{recentMovements && recentMovements.length > 0 ? (
<DataTable
data={recentMovements.slice(0, 5)}
columns={movementColumns}
isLoading={false}
/>
) : (
<div className="py-8 text-center text-gray-500">
No hay movimientos registrados para este producto
</div>
)}
</CardContent>
</Card>
{/* System Info */}
<Card>
<CardHeader>
<CardTitle>Informacion del Sistema</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid gap-4 sm:grid-cols-2">
<div>
<dt className="text-sm text-gray-500">ID</dt>
<dd className="font-mono text-sm text-gray-900">{product.id}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Tenant ID</dt>
<dd className="font-mono text-sm text-gray-900">{product.tenantId}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Creado</dt>
<dd className="text-sm text-gray-900">{formatDate(product.createdAt, 'full')}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Actualizado</dt>
<dd className="text-sm text-gray-900">
{product.updatedAt ? formatDate(product.updatedAt, 'full') : '-'}
</dd>
</div>
</dl>
</CardContent>
</Card>
</div>
</div>
</div>
);
}
export default ProductDetailPage;

View File

@ -0,0 +1,120 @@
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ArrowLeft } from 'lucide-react';
import { Button } from '@components/atoms/Button';
import { Spinner } from '@components/atoms/Spinner';
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
import { Alert } from '@components/molecules/Alert';
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
import { useToast } from '@components/organisms/Toast';
import { ErrorEmptyState } from '@components/templates/EmptyState';
import { ProductForm } from '@features/inventory/components/ProductForm';
import { useProduct } from '@features/inventory/hooks';
import { productsApi } from '@services/api/products.api';
import type { CreateProductDto, UpdateProductDto } from '@services/api/products.api';
export function ProductEditPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { showToast } = useToast();
const { product, isLoading, error: fetchError, refresh } = useProduct(id || null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (data: CreateProductDto | UpdateProductDto) => {
if (!id) return;
setIsSubmitting(true);
setError(null);
try {
await productsApi.update(id, data);
showToast({
type: 'success',
title: 'Producto actualizado',
message: 'Los cambios han sido guardados exitosamente.',
});
navigate(`/inventory/products/${id}`);
} catch (err) {
const message = err instanceof Error ? err.message : 'Error al actualizar producto';
setError(message);
} finally {
setIsSubmitting(false);
}
};
if (isLoading) {
return (
<div className="flex h-96 items-center justify-center">
<Spinner size="lg" />
</div>
);
}
if (fetchError || !product) {
return (
<div className="p-6">
<ErrorEmptyState
title="Producto no encontrado"
description="No se pudo cargar la informacion del producto."
onRetry={refresh}
/>
</div>
);
}
return (
<div className="space-y-6 p-6">
<Breadcrumbs
items={[
{ label: 'Inventario', href: '/inventory' },
{ label: 'Productos', href: '/inventory/products' },
{ label: product.name, href: `/inventory/products/${id}` },
{ label: 'Editar' },
]}
/>
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={() => navigate(`/inventory/products/${id}`)}>
<ArrowLeft className="mr-2 h-4 w-4" />
Volver
</Button>
<div>
<h1 className="text-2xl font-bold text-gray-900">Editar producto</h1>
<p className="text-sm text-gray-500">
Modifica la informacion de {product.name}
</p>
</div>
</div>
<div className="mx-auto max-w-4xl">
{error && (
<Alert
variant="danger"
title="Error"
className="mb-6"
onClose={() => setError(null)}
>
{error}
</Alert>
)}
<Card>
<CardHeader>
<CardTitle>Informacion del Producto</CardTitle>
</CardHeader>
<CardContent>
<ProductForm
product={product}
onSubmit={handleSubmit}
onCancel={() => navigate(`/inventory/products/${id}`)}
isLoading={isSubmitting}
/>
</CardContent>
</Card>
</div>
</div>
);
}
export default ProductEditPage;

View File

@ -0,0 +1,388 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Package,
Plus,
MoreVertical,
Eye,
Edit,
AlertTriangle,
RefreshCw,
Search,
DollarSign,
TrendingDown,
XCircle,
Boxes,
} from 'lucide-react';
import { Button } from '@components/atoms/Button';
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
import { DataTable, type Column } from '@components/organisms/DataTable';
import { Dropdown, type DropdownItem } from '@components/organisms/Dropdown';
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
import { NoDataEmptyState, ErrorEmptyState } from '@components/templates/EmptyState';
import { useProducts } from '@features/inventory/hooks';
import type { ProductWithStock, StockStatus } from '@features/inventory/types';
import { formatNumber } from '@utils/formatters';
const stockStatusLabels: Record<StockStatus, string> = {
normal: 'Normal',
low: 'Stock Bajo',
out: 'Sin Stock',
overstock: 'Exceso',
};
const stockStatusColors: Record<StockStatus, string> = {
normal: 'bg-green-100 text-green-700',
low: 'bg-amber-100 text-amber-700',
out: 'bg-red-100 text-red-700',
overstock: 'bg-blue-100 text-blue-700',
};
const formatCurrency = (value: number): string => {
return formatNumber(value, 'es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
};
export function ProductsListPage() {
const navigate = useNavigate();
const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string>('');
const [selectedStockStatus, setSelectedStockStatus] = useState<StockStatus | ''>('');
const {
products,
total,
page,
totalPages,
isLoading,
error,
setPage,
refresh,
stats,
} = useProducts({
search: searchTerm || undefined,
categoryId: selectedCategory || undefined,
stockStatus: selectedStockStatus || undefined,
limit: 20,
});
const getActionsMenu = (product: ProductWithStock): DropdownItem[] => {
return [
{
key: 'view',
label: 'Ver detalle',
icon: <Eye className="h-4 w-4" />,
onClick: () => navigate(`/inventory/products/${product.id}`),
},
{
key: 'edit',
label: 'Editar producto',
icon: <Edit className="h-4 w-4" />,
onClick: () => navigate(`/inventory/products/${product.id}/edit`),
},
{
key: 'adjust',
label: 'Ajustar stock',
icon: <Boxes className="h-4 w-4" />,
onClick: () => navigate(`/inventory/stock/adjust?productId=${product.id}`),
},
];
};
const columns: Column<ProductWithStock>[] = [
{
key: 'product',
header: 'Producto',
render: (product) => (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-50">
<Package className="h-5 w-5 text-purple-600" />
</div>
<div>
<div className="font-medium text-gray-900">{product.name}</div>
<div className="text-sm text-gray-500">SKU: {product.code || '-'}</div>
</div>
</div>
),
},
{
key: 'category',
header: 'Categoria',
render: (product) => (
<span className="text-sm text-gray-600">
{product.categoryName || '-'}
</span>
),
},
{
key: 'onHand',
header: 'En Stock',
sortable: true,
render: (product) => (
<div className="text-right font-medium text-gray-900">
{formatNumber(product.quantityOnHand || 0)}
</div>
),
},
{
key: 'reserved',
header: 'Reservado',
render: (product) => (
<div className="text-right text-gray-600">
{formatNumber(product.quantityReserved || 0)}
</div>
),
},
{
key: 'available',
header: 'Disponible',
sortable: true,
render: (product) => (
<div className="text-right font-medium text-green-600">
{formatNumber(product.quantityAvailable || 0)}
</div>
),
},
{
key: 'reorderPoint',
header: 'Pto. Reorden',
render: (product) => (
<div className="text-right text-gray-600">
{product.reorderPoint ? formatNumber(product.reorderPoint) : '-'}
</div>
),
},
{
key: 'stockStatus',
header: 'Estado',
render: (product) => {
const status = product.stockStatus || 'normal';
return (
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${stockStatusColors[status]}`}>
{stockStatusLabels[status]}
</span>
);
},
},
{
key: 'actions',
header: '',
render: (product) => (
<Dropdown
trigger={
<button className="rounded p-1 hover:bg-gray-100">
<MoreVertical className="h-4 w-4 text-gray-500" />
</button>
}
items={getActionsMenu(product)}
align="right"
/>
),
},
];
if (error) {
return (
<div className="p-6">
<ErrorEmptyState onRetry={refresh} />
</div>
);
}
return (
<div className="space-y-6 p-6">
<Breadcrumbs items={[
{ label: 'Inventario', href: '/inventory' },
{ label: 'Productos' },
]} />
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Productos</h1>
<p className="text-sm text-gray-500">
Gestiona productos y niveles de inventario
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={refresh} disabled={isLoading}>
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
Actualizar
</Button>
<Button onClick={() => navigate('/inventory/products/new')}>
<Plus className="mr-2 h-4 w-4" />
Nuevo producto
</Button>
</div>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-100">
<Package className="h-5 w-5 text-purple-600" />
</div>
<div>
<div className="text-sm text-gray-500">Total Productos</div>
<div className="text-xl font-bold text-gray-900">
{stats?.totalProducts ?? total}
</div>
</div>
</div>
</CardContent>
</Card>
<Card
className="cursor-pointer hover:shadow-md transition-shadow"
onClick={() => setSelectedStockStatus('low')}
>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-100">
<TrendingDown className="h-5 w-5 text-amber-600" />
</div>
<div>
<div className="text-sm text-gray-500">Stock Bajo</div>
<div className="text-xl font-bold text-amber-600">
{stats?.lowStockCount ?? 0}
</div>
</div>
</div>
</CardContent>
</Card>
<Card
className="cursor-pointer hover:shadow-md transition-shadow"
onClick={() => setSelectedStockStatus('out')}
>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-red-100">
<XCircle className="h-5 w-5 text-red-600" />
</div>
<div>
<div className="text-sm text-gray-500">Sin Stock</div>
<div className="text-xl font-bold text-red-600">
{stats?.outOfStockCount ?? 0}
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100">
<DollarSign className="h-5 w-5 text-green-600" />
</div>
<div>
<div className="text-sm text-gray-500">Valor Total</div>
<div className="text-xl font-bold text-green-600">
${formatCurrency(stats?.totalValue ?? 0)}
</div>
</div>
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Lista de Productos</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Filters */}
<div className="flex flex-wrap items-center gap-4">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Buscar por nombre, SKU o codigo de barras..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full rounded-md border border-gray-300 py-2 pl-10 pr-4 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">Todas las categorias</option>
{/* Categories would be loaded from API */}
</select>
<select
value={selectedStockStatus}
onChange={(e) => setSelectedStockStatus(e.target.value as StockStatus | '')}
className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">Todos los estados</option>
{Object.entries(stockStatusLabels).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
{(selectedStockStatus || searchTerm || selectedCategory) && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedStockStatus('');
setSearchTerm('');
setSelectedCategory('');
}}
>
Limpiar filtros
</Button>
)}
</div>
{/* Low Stock Warning */}
{(stats?.lowStockCount ?? 0) > 0 && !selectedStockStatus && (
<div className="flex items-center gap-2 rounded-lg bg-amber-50 p-3 text-sm text-amber-700">
<AlertTriangle className="h-4 w-4" />
<span>
Hay {stats?.lowStockCount} producto(s) con stock bajo que requieren atencion.
</span>
<Button
variant="ghost"
size="sm"
className="ml-auto text-amber-700"
onClick={() => setSelectedStockStatus('low')}
>
Ver productos
</Button>
</div>
)}
{/* Table */}
{products.length === 0 && !isLoading ? (
<NoDataEmptyState
entityName="productos"
/>
) : (
<DataTable
data={products}
columns={columns}
isLoading={isLoading}
pagination={{
page,
totalPages,
total,
limit: 20,
onPageChange: setPage,
}}
/>
)}
</div>
</CardContent>
</Card>
</div>
);
}
export default ProductsListPage;

View File

@ -0,0 +1,4 @@
export { ProductsListPage, default as ProductsListPageDefault } from './ProductsListPage';
export { ProductDetailPage, default as ProductDetailPageDefault } from './ProductDetailPage';
export { ProductCreatePage, default as ProductCreatePageDefault } from './ProductCreatePage';
export { ProductEditPage, default as ProductEditPageDefault } from './ProductEditPage';

View File

@ -45,11 +45,22 @@ export interface Product extends BaseEntity {
description?: string;
type: 'goods' | 'service' | 'consumable';
categoryId?: string;
categoryName?: string;
uomId?: string;
uomName?: string;
salePrice?: number;
cost?: number;
isActive: boolean;
tenantId: string;
// Inventory settings
minStock?: number | null;
maxStock?: number | null;
reorderPoint?: number | null;
// Physical dimensions
weight?: number | null;
length?: number | null;
width?: number | null;
height?: number | null;
}
// Auth