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:
parent
36ba16e2a2
commit
b6dd94abcb
@ -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 LeadsPage = lazy(() => import('@pages/crm/LeadsPage').then(m => ({ default: m.LeadsPage })));
|
||||||
const OpportunitiesPage = lazy(() => import('@pages/crm/OpportunitiesPage').then(m => ({ default: m.OpportunitiesPage })));
|
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 }) {
|
function LazyWrapper({ children }: { children: React.ReactNode }) {
|
||||||
return <Suspense fallback={<FullPageSpinner />}>{children}</Suspense>;
|
return <Suspense fallback={<FullPageSpinner />}>{children}</Suspense>;
|
||||||
}
|
}
|
||||||
@ -216,11 +233,117 @@ export const router = createBrowserRouter([
|
|||||||
</DashboardWrapper>
|
</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/*',
|
path: '/inventory/*',
|
||||||
element: (
|
element: (
|
||||||
<DashboardWrapper>
|
<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>
|
</DashboardWrapper>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
145
src/features/inventory/components/InventoryStatsCard.tsx
Normal file
145
src/features/inventory/components/InventoryStatsCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
src/features/inventory/components/LocationSelector.tsx
Normal file
126
src/features/inventory/components/LocationSelector.tsx
Normal 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)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/features/inventory/components/MovementStatusBadge.tsx
Normal file
25
src/features/inventory/components/MovementStatusBadge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
src/features/inventory/components/MovementTypeIcon.tsx
Normal file
121
src/features/inventory/components/MovementTypeIcon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
452
src/features/inventory/components/ProductForm.tsx
Normal file
452
src/features/inventory/components/ProductForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
244
src/features/inventory/components/ProductInventoryForm.tsx
Normal file
244
src/features/inventory/components/ProductInventoryForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
147
src/features/inventory/components/ProductStockCard.tsx
Normal file
147
src/features/inventory/components/ProductStockCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
src/features/inventory/components/StockLevelBadge.tsx
Normal file
51
src/features/inventory/components/StockLevelBadge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
src/features/inventory/components/StockLevelIndicator.tsx
Normal file
124
src/features/inventory/components/StockLevelIndicator.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
src/features/inventory/components/StockMovementRow.tsx
Normal file
105
src/features/inventory/components/StockMovementRow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
268
src/features/inventory/components/WarehouseForm.tsx
Normal file
268
src/features/inventory/components/WarehouseForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
src/features/inventory/components/WarehouseSelector.tsx
Normal file
62
src/features/inventory/components/WarehouseSelector.tsx
Normal 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)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/features/inventory/components/index.ts
Normal file
23
src/features/inventory/components/index.ts
Normal 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';
|
||||||
@ -1,16 +1,33 @@
|
|||||||
|
// Main inventory hook with overview and re-exports
|
||||||
export {
|
export {
|
||||||
useStockLevels,
|
useInventoryOverview,
|
||||||
useMovements,
|
|
||||||
useWarehouses,
|
|
||||||
useLocations,
|
|
||||||
useStockOperations,
|
|
||||||
useInventoryCounts,
|
useInventoryCounts,
|
||||||
useInventoryAdjustments,
|
useInventoryAdjustments,
|
||||||
|
useStockValuations,
|
||||||
} from './useInventory';
|
} from './useInventory';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
UseStockLevelsOptions,
|
InventoryOverview,
|
||||||
UseMovementsOptions,
|
|
||||||
UseInventoryCountsOptions,
|
UseInventoryCountsOptions,
|
||||||
UseInventoryAdjustmentsOptions,
|
UseInventoryAdjustmentsOptions,
|
||||||
|
UseStockValuationsOptions,
|
||||||
} from './useInventory';
|
} 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';
|
||||||
|
|||||||
@ -1,412 +1,84 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { inventoryApi } from '../api/inventory.api';
|
import { inventoryApi } from '../api/inventory.api';
|
||||||
import type {
|
import type {
|
||||||
StockLevel,
|
|
||||||
StockMovement,
|
|
||||||
StockSearchParams,
|
|
||||||
MovementSearchParams,
|
|
||||||
CreateStockMovementDto,
|
|
||||||
AdjustStockDto,
|
|
||||||
TransferStockDto,
|
|
||||||
Warehouse,
|
|
||||||
CreateWarehouseDto,
|
|
||||||
UpdateWarehouseDto,
|
|
||||||
Location,
|
|
||||||
CreateLocationDto,
|
|
||||||
UpdateLocationDto,
|
|
||||||
InventoryCount,
|
InventoryCount,
|
||||||
CreateInventoryCountDto,
|
InventoryAdjustment,
|
||||||
|
ValuationSummary,
|
||||||
CountType,
|
CountType,
|
||||||
CountStatus,
|
CountStatus,
|
||||||
InventoryAdjustment,
|
|
||||||
CreateInventoryAdjustmentDto,
|
|
||||||
AdjustmentStatus,
|
AdjustmentStatus,
|
||||||
|
CreateInventoryCountDto,
|
||||||
|
CreateInventoryAdjustmentDto,
|
||||||
} from '../types';
|
} 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 {
|
export { useWarehouses, useWarehouse, useWarehouseStock } from './useWarehouses';
|
||||||
autoFetch?: boolean;
|
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 = {}) {
|
export function useInventoryOverview() {
|
||||||
const { autoFetch = true, ...params } = options;
|
const [overview, setOverview] = useState<InventoryOverview | null>(null);
|
||||||
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 [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const fetchStockLevels = useCallback(async () => {
|
const fetchOverview = useCallback(async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await inventoryApi.getStockLevels({ ...params, page });
|
const [stockResponse, warehousesResponse, lowStockResponse, valuationResponse] = await Promise.all([
|
||||||
setStockLevels(response.data);
|
inventoryApi.getStockLevels({ limit: 1 }),
|
||||||
setTotal(response.meta.total);
|
inventoryApi.getWarehouses(1, 1),
|
||||||
setTotalPages(response.meta.totalPages);
|
inventoryApi.getStockLevels({ lowStock: true, limit: 1 }),
|
||||||
} catch (err) {
|
inventoryApi.getValuationSummary(),
|
||||||
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(() => {
|
setOverview({
|
||||||
if (autoFetch) {
|
totalProducts: stockResponse.meta.total,
|
||||||
fetchStockLevels();
|
totalWarehouses: warehousesResponse.meta.total,
|
||||||
}
|
lowStockCount: lowStockResponse.meta.total,
|
||||||
}, [fetchStockLevels, autoFetch]);
|
totalValue: valuationResponse.totalValue,
|
||||||
|
recentMovements: 0,
|
||||||
return {
|
pendingCounts: 0,
|
||||||
stockLevels,
|
pendingAdjustments: 0,
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
return result;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Error al reservar stock';
|
setError(err instanceof Error ? err.message : 'Error al cargar resumen de inventario');
|
||||||
setError(message);
|
|
||||||
throw err;
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const releaseReservation = async (productId: string, warehouseId: string, quantity: number) => {
|
useEffect(() => {
|
||||||
setIsLoading(true);
|
fetchOverview();
|
||||||
setError(null);
|
}, [fetchOverview]);
|
||||||
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 {
|
return {
|
||||||
|
overview,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
adjustStock,
|
refresh: fetchOverview,
|
||||||
transferStock,
|
|
||||||
reserveStock,
|
|
||||||
releaseReservation,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -630,3 +302,60 @@ export function useInventoryAdjustments(options: UseInventoryAdjustmentsOptions
|
|||||||
cancelAdjustment,
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
145
src/features/inventory/hooks/useLocations.ts
Normal file
145
src/features/inventory/hooks/useLocations.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
283
src/features/inventory/hooks/useProducts.ts
Normal file
283
src/features/inventory/hooks/useProducts.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
146
src/features/inventory/hooks/useStockLevels.ts
Normal file
146
src/features/inventory/hooks/useStockLevels.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
298
src/features/inventory/hooks/useStockMovements.ts
Normal file
298
src/features/inventory/hooks/useStockMovements.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
214
src/features/inventory/hooks/useWarehouses.ts
Normal file
214
src/features/inventory/hooks/useWarehouses.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,2 +1,4 @@
|
|||||||
export * from './api/inventory.api';
|
export * from './api/inventory.api';
|
||||||
|
export * from './components';
|
||||||
|
export * from './hooks';
|
||||||
export * from './types';
|
export * from './types';
|
||||||
|
|||||||
@ -6,6 +6,8 @@ export interface StockLevel {
|
|||||||
tenantId: string;
|
tenantId: string;
|
||||||
productId: string;
|
productId: string;
|
||||||
warehouseId: string;
|
warehouseId: string;
|
||||||
|
warehouseName?: string;
|
||||||
|
warehouseCode?: string;
|
||||||
locationId?: string | null;
|
locationId?: string | null;
|
||||||
lotNumber?: string | null;
|
lotNumber?: string | null;
|
||||||
serialNumber?: 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
|
// Stock Valuation types
|
||||||
export interface StockValuationLayer {
|
export interface StockValuationLayer {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
509
src/pages/inventory/KardexPage.tsx
Normal file
509
src/pages/inventory/KardexPage.tsx
Normal 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>
|
||||||
|
×
|
||||||
|
</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;
|
||||||
705
src/pages/inventory/LocationsPage.tsx
Normal file
705
src/pages/inventory/LocationsPage.tsx
Normal 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;
|
||||||
536
src/pages/inventory/StockAdjustmentPage.tsx
Normal file
536
src/pages/inventory/StockAdjustmentPage.tsx
Normal 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 "Agregar Producto" 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;
|
||||||
531
src/pages/inventory/StockMovementDetailPage.tsx
Normal file
531
src/pages/inventory/StockMovementDetailPage.tsx
Normal 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;
|
||||||
477
src/pages/inventory/WarehouseCreatePage.tsx
Normal file
477
src/pages/inventory/WarehouseCreatePage.tsx
Normal 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;
|
||||||
505
src/pages/inventory/WarehouseDetailPage.tsx
Normal file
505
src/pages/inventory/WarehouseDetailPage.tsx
Normal 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;
|
||||||
541
src/pages/inventory/WarehouseEditPage.tsx
Normal file
541
src/pages/inventory/WarehouseEditPage.tsx
Normal 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;
|
||||||
329
src/pages/inventory/WarehousesListPage.tsx
Normal file
329
src/pages/inventory/WarehousesListPage.tsx
Normal 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;
|
||||||
@ -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 { StockLevelsPage, default as StockLevelsPageDefault } from './StockLevelsPage';
|
||||||
export { MovementsPage, default as MovementsPageDefault } from './MovementsPage';
|
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 { InventoryCountsPage, default as InventoryCountsPageDefault } from './InventoryCountsPage';
|
||||||
export { ReorderAlertsPage, default as ReorderAlertsPageDefault } from './ReorderAlertsPage';
|
export { ReorderAlertsPage, default as ReorderAlertsPageDefault } from './ReorderAlertsPage';
|
||||||
export { ValuationReportsPage, default as ValuationReportsPageDefault } from './ValuationReportsPage';
|
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';
|
||||||
|
|||||||
98
src/pages/inventory/products/ProductCreatePage.tsx
Normal file
98
src/pages/inventory/products/ProductCreatePage.tsx
Normal 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;
|
||||||
532
src/pages/inventory/products/ProductDetailPage.tsx
Normal file
532
src/pages/inventory/products/ProductDetailPage.tsx
Normal 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;
|
||||||
120
src/pages/inventory/products/ProductEditPage.tsx
Normal file
120
src/pages/inventory/products/ProductEditPage.tsx
Normal 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;
|
||||||
388
src/pages/inventory/products/ProductsListPage.tsx
Normal file
388
src/pages/inventory/products/ProductsListPage.tsx
Normal 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;
|
||||||
4
src/pages/inventory/products/index.ts
Normal file
4
src/pages/inventory/products/index.ts
Normal 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';
|
||||||
@ -45,11 +45,22 @@ export interface Product extends BaseEntity {
|
|||||||
description?: string;
|
description?: string;
|
||||||
type: 'goods' | 'service' | 'consumable';
|
type: 'goods' | 'service' | 'consumable';
|
||||||
categoryId?: string;
|
categoryId?: string;
|
||||||
|
categoryName?: string;
|
||||||
uomId?: string;
|
uomId?: string;
|
||||||
|
uomName?: string;
|
||||||
salePrice?: number;
|
salePrice?: number;
|
||||||
cost?: number;
|
cost?: number;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
tenantId: string;
|
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
|
// Auth
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user