feat(inventory): Add complete inventory frontend pages and hooks (MGN-011)
New pages: - StockLevelsPage: View and manage stock levels by warehouse/product - MovementsPage: Stock movements history with filters and actions - InventoryCountsPage: Physical inventory counts management - ReorderAlertsPage: Low stock alerts and reorder suggestions - ValuationReportsPage: Inventory valuation by FIFO/average cost New hooks: - useStockLevels: Fetch and manage stock levels - useMovements: Fetch stock movements with CRUD operations - useWarehouses: Manage warehouses - useLocations: Manage warehouse locations - useStockOperations: Stock adjust, transfer, reserve operations - useInventoryCounts: Inventory count lifecycle management - useInventoryAdjustments: Inventory adjustments management Extended API client with: - Inventory counts endpoints - Inventory adjustments endpoints - Stock valuation endpoints Extended types with: - InventoryCount, InventoryCountLine, CreateInventoryCountDto - InventoryAdjustment, InventoryAdjustmentLine, CreateInventoryAdjustmentDto - StockValuationLayer, ValuationSummary Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
c9e1c3fb06
commit
28c8bbb768
@ -18,12 +18,26 @@ import type {
|
|||||||
CreateLocationDto,
|
CreateLocationDto,
|
||||||
UpdateLocationDto,
|
UpdateLocationDto,
|
||||||
LocationsResponse,
|
LocationsResponse,
|
||||||
|
InventoryCount,
|
||||||
|
InventoryCountLine,
|
||||||
|
CreateInventoryCountDto,
|
||||||
|
InventoryCountsResponse,
|
||||||
|
InventoryAdjustment,
|
||||||
|
InventoryAdjustmentLine,
|
||||||
|
CreateInventoryAdjustmentDto,
|
||||||
|
InventoryAdjustmentsResponse,
|
||||||
|
StockValuationLayer,
|
||||||
|
StockValuationsResponse,
|
||||||
|
ValuationSummaryResponse,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
const STOCK_URL = '/api/v1/inventory/stock';
|
const STOCK_URL = '/api/v1/inventory/stock';
|
||||||
const MOVEMENTS_URL = '/api/v1/inventory/movements';
|
const MOVEMENTS_URL = '/api/v1/inventory/movements';
|
||||||
const WAREHOUSES_URL = '/api/v1/warehouses';
|
const WAREHOUSES_URL = '/api/v1/warehouses';
|
||||||
const LOCATIONS_URL = '/api/v1/inventory/locations';
|
const LOCATIONS_URL = '/api/v1/inventory/locations';
|
||||||
|
const COUNTS_URL = '/api/v1/inventory/counts';
|
||||||
|
const ADJUSTMENTS_URL = '/api/v1/inventory/adjustments';
|
||||||
|
const VALUATIONS_URL = '/api/v1/inventory/valuations';
|
||||||
|
|
||||||
export const inventoryApi = {
|
export const inventoryApi = {
|
||||||
// ==================== Stock Levels ====================
|
// ==================== Stock Levels ====================
|
||||||
@ -201,4 +215,180 @@ export const inventoryApi = {
|
|||||||
deleteLocation: async (id: string): Promise<void> => {
|
deleteLocation: async (id: string): Promise<void> => {
|
||||||
await api.delete(`${LOCATIONS_URL}/${id}`);
|
await api.delete(`${LOCATIONS_URL}/${id}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ==================== Inventory Counts ====================
|
||||||
|
|
||||||
|
// Get inventory counts
|
||||||
|
getInventoryCounts: async (params?: {
|
||||||
|
warehouseId?: string;
|
||||||
|
status?: string;
|
||||||
|
countType?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<InventoryCountsResponse> => {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (params?.warehouseId) searchParams.append('warehouseId', params.warehouseId);
|
||||||
|
if (params?.status) searchParams.append('status', params.status);
|
||||||
|
if (params?.countType) searchParams.append('countType', params.countType);
|
||||||
|
if (params?.page) searchParams.append('page', String(params.page));
|
||||||
|
if (params?.limit) searchParams.append('limit', String(params.limit));
|
||||||
|
|
||||||
|
const response = await api.get<InventoryCountsResponse>(`${COUNTS_URL}?${searchParams.toString()}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get inventory count by ID
|
||||||
|
getInventoryCountById: async (id: string): Promise<InventoryCount> => {
|
||||||
|
const response = await api.get<InventoryCount>(`${COUNTS_URL}/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get count lines
|
||||||
|
getInventoryCountLines: async (countId: string): Promise<InventoryCountLine[]> => {
|
||||||
|
const response = await api.get<InventoryCountLine[]>(`${COUNTS_URL}/${countId}/lines`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create inventory count
|
||||||
|
createInventoryCount: async (data: CreateInventoryCountDto): Promise<InventoryCount> => {
|
||||||
|
const response = await api.post<InventoryCount>(COUNTS_URL, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Start inventory count
|
||||||
|
startInventoryCount: async (id: string): Promise<InventoryCount> => {
|
||||||
|
const response = await api.post<InventoryCount>(`${COUNTS_URL}/${id}/start`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update count line
|
||||||
|
updateCountLine: async (countId: string, lineId: string, data: { countedQuantity: number; notes?: string }): Promise<InventoryCountLine> => {
|
||||||
|
const response = await api.patch<InventoryCountLine>(`${COUNTS_URL}/${countId}/lines/${lineId}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Complete inventory count
|
||||||
|
completeInventoryCount: async (id: string): Promise<InventoryCount> => {
|
||||||
|
const response = await api.post<InventoryCount>(`${COUNTS_URL}/${id}/complete`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Cancel inventory count
|
||||||
|
cancelInventoryCount: async (id: string): Promise<InventoryCount> => {
|
||||||
|
const response = await api.post<InventoryCount>(`${COUNTS_URL}/${id}/cancel`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== Inventory Adjustments ====================
|
||||||
|
|
||||||
|
// Get inventory adjustments
|
||||||
|
getInventoryAdjustments: async (params?: {
|
||||||
|
locationId?: string;
|
||||||
|
status?: string;
|
||||||
|
fromDate?: string;
|
||||||
|
toDate?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<InventoryAdjustmentsResponse> => {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (params?.locationId) searchParams.append('locationId', params.locationId);
|
||||||
|
if (params?.status) searchParams.append('status', params.status);
|
||||||
|
if (params?.fromDate) searchParams.append('fromDate', params.fromDate);
|
||||||
|
if (params?.toDate) searchParams.append('toDate', params.toDate);
|
||||||
|
if (params?.page) searchParams.append('page', String(params.page));
|
||||||
|
if (params?.limit) searchParams.append('limit', String(params.limit));
|
||||||
|
|
||||||
|
const response = await api.get<InventoryAdjustmentsResponse>(`${ADJUSTMENTS_URL}?${searchParams.toString()}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get adjustment by ID
|
||||||
|
getInventoryAdjustmentById: async (id: string): Promise<InventoryAdjustment> => {
|
||||||
|
const response = await api.get<InventoryAdjustment>(`${ADJUSTMENTS_URL}/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get adjustment lines
|
||||||
|
getInventoryAdjustmentLines: async (adjustmentId: string): Promise<InventoryAdjustmentLine[]> => {
|
||||||
|
const response = await api.get<InventoryAdjustmentLine[]>(`${ADJUSTMENTS_URL}/${adjustmentId}/lines`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create inventory adjustment
|
||||||
|
createInventoryAdjustment: async (data: CreateInventoryAdjustmentDto): Promise<InventoryAdjustment> => {
|
||||||
|
const response = await api.post<InventoryAdjustment>(ADJUSTMENTS_URL, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Confirm adjustment
|
||||||
|
confirmInventoryAdjustment: async (id: string): Promise<InventoryAdjustment> => {
|
||||||
|
const response = await api.post<InventoryAdjustment>(`${ADJUSTMENTS_URL}/${id}/confirm`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Apply adjustment (done)
|
||||||
|
applyInventoryAdjustment: async (id: string): Promise<InventoryAdjustment> => {
|
||||||
|
const response = await api.post<InventoryAdjustment>(`${ADJUSTMENTS_URL}/${id}/apply`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Cancel adjustment
|
||||||
|
cancelInventoryAdjustment: async (id: string): Promise<InventoryAdjustment> => {
|
||||||
|
const response = await api.post<InventoryAdjustment>(`${ADJUSTMENTS_URL}/${id}/cancel`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== Stock Valuations ====================
|
||||||
|
|
||||||
|
// Get valuation layers
|
||||||
|
getValuationLayers: async (params?: {
|
||||||
|
productId?: string;
|
||||||
|
warehouseId?: string;
|
||||||
|
fromDate?: string;
|
||||||
|
toDate?: string;
|
||||||
|
hasRemaining?: boolean;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<StockValuationsResponse> => {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (params?.productId) searchParams.append('productId', params.productId);
|
||||||
|
if (params?.warehouseId) searchParams.append('warehouseId', params.warehouseId);
|
||||||
|
if (params?.fromDate) searchParams.append('fromDate', params.fromDate);
|
||||||
|
if (params?.toDate) searchParams.append('toDate', params.toDate);
|
||||||
|
if (params?.hasRemaining !== undefined) searchParams.append('hasRemaining', String(params.hasRemaining));
|
||||||
|
if (params?.page) searchParams.append('page', String(params.page));
|
||||||
|
if (params?.limit) searchParams.append('limit', String(params.limit));
|
||||||
|
|
||||||
|
const response = await api.get<StockValuationsResponse>(`${VALUATIONS_URL}?${searchParams.toString()}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get valuation summary by product
|
||||||
|
getValuationSummary: async (params?: {
|
||||||
|
warehouseId?: string;
|
||||||
|
asOfDate?: string;
|
||||||
|
}): Promise<ValuationSummaryResponse> => {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (params?.warehouseId) searchParams.append('warehouseId', params.warehouseId);
|
||||||
|
if (params?.asOfDate) searchParams.append('asOfDate', params.asOfDate);
|
||||||
|
|
||||||
|
const response = await api.get<ValuationSummaryResponse>(`${VALUATIONS_URL}/summary?${searchParams.toString()}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get valuation for specific product
|
||||||
|
getProductValuation: async (productId: string): Promise<{
|
||||||
|
totalQuantity: number;
|
||||||
|
totalValue: number;
|
||||||
|
averageCost: number;
|
||||||
|
layers: StockValuationLayer[];
|
||||||
|
}> => {
|
||||||
|
const response = await api.get<{
|
||||||
|
totalQuantity: number;
|
||||||
|
totalValue: number;
|
||||||
|
averageCost: number;
|
||||||
|
layers: StockValuationLayer[];
|
||||||
|
}>(`${VALUATIONS_URL}/product/${productId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
16
src/features/inventory/hooks/index.ts
Normal file
16
src/features/inventory/hooks/index.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
export {
|
||||||
|
useStockLevels,
|
||||||
|
useMovements,
|
||||||
|
useWarehouses,
|
||||||
|
useLocations,
|
||||||
|
useStockOperations,
|
||||||
|
useInventoryCounts,
|
||||||
|
useInventoryAdjustments,
|
||||||
|
} from './useInventory';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
UseStockLevelsOptions,
|
||||||
|
UseMovementsOptions,
|
||||||
|
UseInventoryCountsOptions,
|
||||||
|
UseInventoryAdjustmentsOptions,
|
||||||
|
} from './useInventory';
|
||||||
632
src/features/inventory/hooks/useInventory.ts
Normal file
632
src/features/inventory/hooks/useInventory.ts
Normal file
@ -0,0 +1,632 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { inventoryApi } from '../api/inventory.api';
|
||||||
|
import type {
|
||||||
|
StockLevel,
|
||||||
|
StockMovement,
|
||||||
|
StockSearchParams,
|
||||||
|
MovementSearchParams,
|
||||||
|
CreateStockMovementDto,
|
||||||
|
AdjustStockDto,
|
||||||
|
TransferStockDto,
|
||||||
|
Warehouse,
|
||||||
|
CreateWarehouseDto,
|
||||||
|
UpdateWarehouseDto,
|
||||||
|
Location,
|
||||||
|
CreateLocationDto,
|
||||||
|
UpdateLocationDto,
|
||||||
|
InventoryCount,
|
||||||
|
CreateInventoryCountDto,
|
||||||
|
CountType,
|
||||||
|
CountStatus,
|
||||||
|
InventoryAdjustment,
|
||||||
|
CreateInventoryAdjustmentDto,
|
||||||
|
AdjustmentStatus,
|
||||||
|
} 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 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) {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Inventory Counts Hook ====================
|
||||||
|
|
||||||
|
export interface UseInventoryCountsOptions {
|
||||||
|
warehouseId?: string;
|
||||||
|
status?: CountStatus;
|
||||||
|
countType?: CountType;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
autoFetch?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInventoryCounts(options: UseInventoryCountsOptions = {}) {
|
||||||
|
const { autoFetch = true, ...params } = options;
|
||||||
|
const [counts, setCounts] = useState<InventoryCount[]>([]);
|
||||||
|
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 fetchCounts = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await inventoryApi.getInventoryCounts({ ...params, page });
|
||||||
|
setCounts(response.data);
|
||||||
|
setTotal(response.meta.total);
|
||||||
|
setTotalPages(response.meta.totalPages);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Error al cargar conteos de inventario');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [params.warehouseId, params.status, params.countType, params.limit, page]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoFetch) {
|
||||||
|
fetchCounts();
|
||||||
|
}
|
||||||
|
}, [fetchCounts, autoFetch]);
|
||||||
|
|
||||||
|
const createCount = async (data: CreateInventoryCountDto) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const newCount = await inventoryApi.createInventoryCount(data);
|
||||||
|
await fetchCounts();
|
||||||
|
return newCount;
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Error al crear conteo');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startCount = async (id: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await inventoryApi.startInventoryCount(id);
|
||||||
|
await fetchCounts();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Error al iniciar conteo');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const completeCount = async (id: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await inventoryApi.completeInventoryCount(id);
|
||||||
|
await fetchCounts();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Error al completar conteo');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelCount = async (id: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await inventoryApi.cancelInventoryCount(id);
|
||||||
|
await fetchCounts();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Error al cancelar conteo');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
counts,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
setPage,
|
||||||
|
refresh: fetchCounts,
|
||||||
|
createCount,
|
||||||
|
startCount,
|
||||||
|
completeCount,
|
||||||
|
cancelCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Inventory Adjustments Hook ====================
|
||||||
|
|
||||||
|
export interface UseInventoryAdjustmentsOptions {
|
||||||
|
locationId?: string;
|
||||||
|
status?: AdjustmentStatus;
|
||||||
|
fromDate?: string;
|
||||||
|
toDate?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
autoFetch?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInventoryAdjustments(options: UseInventoryAdjustmentsOptions = {}) {
|
||||||
|
const { autoFetch = true, ...params } = options;
|
||||||
|
const [adjustments, setAdjustments] = useState<InventoryAdjustment[]>([]);
|
||||||
|
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 fetchAdjustments = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await inventoryApi.getInventoryAdjustments({ ...params, page });
|
||||||
|
setAdjustments(response.data);
|
||||||
|
setTotal(response.meta.total);
|
||||||
|
setTotalPages(response.meta.totalPages);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Error al cargar ajustes de inventario');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [params.locationId, params.status, params.fromDate, params.toDate, params.limit, page]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoFetch) {
|
||||||
|
fetchAdjustments();
|
||||||
|
}
|
||||||
|
}, [fetchAdjustments, autoFetch]);
|
||||||
|
|
||||||
|
const createAdjustment = async (data: CreateInventoryAdjustmentDto) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const newAdjustment = await inventoryApi.createInventoryAdjustment(data);
|
||||||
|
await fetchAdjustments();
|
||||||
|
return newAdjustment;
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Error al crear ajuste');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmAdjustment = async (id: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await inventoryApi.confirmInventoryAdjustment(id);
|
||||||
|
await fetchAdjustments();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Error al confirmar ajuste');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyAdjustment = async (id: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await inventoryApi.applyInventoryAdjustment(id);
|
||||||
|
await fetchAdjustments();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Error al aplicar ajuste');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelAdjustment = async (id: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await inventoryApi.cancelInventoryAdjustment(id);
|
||||||
|
await fetchAdjustments();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Error al cancelar ajuste');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
adjustments,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
setPage,
|
||||||
|
refresh: fetchAdjustments,
|
||||||
|
createAdjustment,
|
||||||
|
confirmAdjustment,
|
||||||
|
applyAdjustment,
|
||||||
|
cancelAdjustment,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -221,3 +221,157 @@ export interface LocationsResponse {
|
|||||||
totalPages: number;
|
totalPages: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inventory Count types
|
||||||
|
export type CountType = 'full' | 'partial' | 'cycle' | 'spot';
|
||||||
|
export type CountStatus = 'draft' | 'in_progress' | 'completed' | 'cancelled';
|
||||||
|
|
||||||
|
export interface InventoryCount {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
warehouseId: string;
|
||||||
|
countNumber: string;
|
||||||
|
name?: string | null;
|
||||||
|
countType: CountType;
|
||||||
|
scheduledDate?: string | null;
|
||||||
|
startedAt?: string | null;
|
||||||
|
completedAt?: string | null;
|
||||||
|
status: CountStatus;
|
||||||
|
assignedTo?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
createdBy?: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryCountLine {
|
||||||
|
id: string;
|
||||||
|
countId: string;
|
||||||
|
productId: string;
|
||||||
|
locationId?: string | null;
|
||||||
|
systemQuantity?: number | null;
|
||||||
|
countedQuantity?: number | null;
|
||||||
|
lotNumber?: string | null;
|
||||||
|
serialNumber?: string | null;
|
||||||
|
isCounted: boolean;
|
||||||
|
countedAt?: string | null;
|
||||||
|
countedBy?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateInventoryCountDto {
|
||||||
|
warehouseId: string;
|
||||||
|
name?: string;
|
||||||
|
countType?: CountType;
|
||||||
|
scheduledDate?: string;
|
||||||
|
assignedTo?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryCountsResponse {
|
||||||
|
data: InventoryCount[];
|
||||||
|
meta: {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inventory Adjustment types
|
||||||
|
export type AdjustmentStatus = 'draft' | 'confirmed' | 'done' | 'cancelled';
|
||||||
|
|
||||||
|
export interface InventoryAdjustment {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
companyId: string;
|
||||||
|
name: string;
|
||||||
|
locationId: string;
|
||||||
|
date: string;
|
||||||
|
status: AdjustmentStatus;
|
||||||
|
notes?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
createdBy?: string | null;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
updatedBy?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryAdjustmentLine {
|
||||||
|
id: string;
|
||||||
|
adjustmentId: string;
|
||||||
|
tenantId: string;
|
||||||
|
productId: string;
|
||||||
|
locationId: string;
|
||||||
|
lotId?: string | null;
|
||||||
|
theoreticalQty: number;
|
||||||
|
countedQty: number;
|
||||||
|
differenceQty: number;
|
||||||
|
uomId?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateInventoryAdjustmentDto {
|
||||||
|
name: string;
|
||||||
|
locationId: string;
|
||||||
|
date?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryAdjustmentsResponse {
|
||||||
|
data: InventoryAdjustment[];
|
||||||
|
meta: {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stock Valuation types
|
||||||
|
export interface StockValuationLayer {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
productId: string;
|
||||||
|
companyId: string;
|
||||||
|
quantity: number;
|
||||||
|
unitCost: number;
|
||||||
|
value: number;
|
||||||
|
remainingQty: number;
|
||||||
|
remainingValue: number;
|
||||||
|
stockMoveId?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
accountMoveId?: string | null;
|
||||||
|
journalEntryId?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
createdBy?: string | null;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
updatedBy?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValuationSummary {
|
||||||
|
productId: string;
|
||||||
|
productName?: string;
|
||||||
|
totalQuantity: number;
|
||||||
|
totalValue: number;
|
||||||
|
averageCost: number;
|
||||||
|
layerCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StockValuationsResponse {
|
||||||
|
data: StockValuationLayer[];
|
||||||
|
meta: {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValuationSummaryResponse {
|
||||||
|
data: ValuationSummary[];
|
||||||
|
totalValue: number;
|
||||||
|
totalProducts: number;
|
||||||
|
}
|
||||||
|
|||||||
448
src/pages/inventory/InventoryCountsPage.tsx
Normal file
448
src/pages/inventory/InventoryCountsPage.tsx
Normal file
@ -0,0 +1,448 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
ClipboardList,
|
||||||
|
Plus,
|
||||||
|
MoreVertical,
|
||||||
|
Eye,
|
||||||
|
Play,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
Package,
|
||||||
|
RefreshCw,
|
||||||
|
} 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 { ConfirmModal } from '@components/organisms/Modal';
|
||||||
|
import { NoDataEmptyState, ErrorEmptyState } from '@components/templates/EmptyState';
|
||||||
|
import { useInventoryCounts, useWarehouses } from '@features/inventory/hooks';
|
||||||
|
import type { InventoryCount, CountType, CountStatus } from '@features/inventory/types';
|
||||||
|
import { formatDate } from '@utils/formatters';
|
||||||
|
|
||||||
|
const countTypeLabels: Record<CountType, string> = {
|
||||||
|
full: 'Completo',
|
||||||
|
partial: 'Parcial',
|
||||||
|
cycle: 'Cíclico',
|
||||||
|
spot: 'Puntual',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabels: Record<CountStatus, string> = {
|
||||||
|
draft: 'Borrador',
|
||||||
|
in_progress: 'En Progreso',
|
||||||
|
completed: 'Completado',
|
||||||
|
cancelled: 'Cancelado',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColors: Record<CountStatus, string> = {
|
||||||
|
draft: 'bg-gray-100 text-gray-700',
|
||||||
|
in_progress: 'bg-blue-100 text-blue-700',
|
||||||
|
completed: 'bg-green-100 text-green-700',
|
||||||
|
cancelled: 'bg-red-100 text-red-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InventoryCountsPage() {
|
||||||
|
const [selectedWarehouse, setSelectedWarehouse] = useState<string>('');
|
||||||
|
const [selectedStatus, setSelectedStatus] = useState<CountStatus | ''>('');
|
||||||
|
const [selectedType, setSelectedType] = useState<CountType | ''>('');
|
||||||
|
const [countToStart, setCountToStart] = useState<InventoryCount | null>(null);
|
||||||
|
const [countToComplete, setCountToComplete] = useState<InventoryCount | null>(null);
|
||||||
|
const [countToCancel, setCountToCancel] = useState<InventoryCount | null>(null);
|
||||||
|
|
||||||
|
const { warehouses } = useWarehouses();
|
||||||
|
const {
|
||||||
|
counts,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
setPage,
|
||||||
|
refresh,
|
||||||
|
startCount,
|
||||||
|
completeCount,
|
||||||
|
cancelCount,
|
||||||
|
} = useInventoryCounts({
|
||||||
|
warehouseId: selectedWarehouse || undefined,
|
||||||
|
status: selectedStatus || undefined,
|
||||||
|
countType: selectedType || undefined,
|
||||||
|
limit: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getActionsMenu = (count: InventoryCount): DropdownItem[] => {
|
||||||
|
const items: DropdownItem[] = [
|
||||||
|
{
|
||||||
|
key: 'view',
|
||||||
|
label: 'Ver detalle',
|
||||||
|
icon: <Eye className="h-4 w-4" />,
|
||||||
|
onClick: () => console.log('View', count.id),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (count.status === 'draft') {
|
||||||
|
items.push({
|
||||||
|
key: 'start',
|
||||||
|
label: 'Iniciar conteo',
|
||||||
|
icon: <Play className="h-4 w-4" />,
|
||||||
|
onClick: () => setCountToStart(count),
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
|
key: 'cancel',
|
||||||
|
label: 'Cancelar',
|
||||||
|
icon: <XCircle className="h-4 w-4" />,
|
||||||
|
danger: true,
|
||||||
|
onClick: () => setCountToCancel(count),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count.status === 'in_progress') {
|
||||||
|
items.push({
|
||||||
|
key: 'complete',
|
||||||
|
label: 'Completar',
|
||||||
|
icon: <CheckCircle className="h-4 w-4" />,
|
||||||
|
onClick: () => setCountToComplete(count),
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
|
key: 'cancel',
|
||||||
|
label: 'Cancelar',
|
||||||
|
icon: <XCircle className="h-4 w-4" />,
|
||||||
|
danger: true,
|
||||||
|
onClick: () => setCountToCancel(count),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: Column<InventoryCount>[] = [
|
||||||
|
{
|
||||||
|
key: 'number',
|
||||||
|
header: 'Conteo',
|
||||||
|
render: (count) => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-50">
|
||||||
|
<ClipboardList className="h-5 w-5 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900">{count.countNumber}</div>
|
||||||
|
{count.name && (
|
||||||
|
<div className="text-sm text-gray-500">{count.name}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'warehouse',
|
||||||
|
header: 'Almacén',
|
||||||
|
render: (count) => {
|
||||||
|
const wh = warehouses.find(w => w.id === count.warehouseId);
|
||||||
|
return <span className="text-sm text-gray-600">{wh?.name || count.warehouseId}</span>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'type',
|
||||||
|
header: 'Tipo',
|
||||||
|
render: (count) => (
|
||||||
|
<span className="text-sm text-gray-600">{countTypeLabels[count.countType]}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'scheduledDate',
|
||||||
|
header: 'Fecha Programada',
|
||||||
|
render: (count) => (
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{count.scheduledDate ? formatDate(count.scheduledDate, 'short') : '-'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: 'Estado',
|
||||||
|
render: (count) => (
|
||||||
|
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${statusColors[count.status]}`}>
|
||||||
|
{statusLabels[count.status]}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'dates',
|
||||||
|
header: 'Progreso',
|
||||||
|
render: (count) => (
|
||||||
|
<div className="text-sm">
|
||||||
|
{count.startedAt && (
|
||||||
|
<div className="text-gray-500">Inicio: {formatDate(count.startedAt, 'short')}</div>
|
||||||
|
)}
|
||||||
|
{count.completedAt && (
|
||||||
|
<div className="text-green-600">Fin: {formatDate(count.completedAt, 'short')}</div>
|
||||||
|
)}
|
||||||
|
{!count.startedAt && !count.completedAt && (
|
||||||
|
<span className="text-gray-400">-</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'createdAt',
|
||||||
|
header: 'Creado',
|
||||||
|
sortable: true,
|
||||||
|
render: (count) => (
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{formatDate(count.createdAt, 'short')}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
render: (count) => (
|
||||||
|
<Dropdown
|
||||||
|
trigger={
|
||||||
|
<button className="rounded p-1 hover:bg-gray-100">
|
||||||
|
<MoreVertical className="h-4 w-4 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
items={getActionsMenu(count)}
|
||||||
|
align="right"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleStart = async () => {
|
||||||
|
if (countToStart) {
|
||||||
|
await startCount(countToStart.id);
|
||||||
|
setCountToStart(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleComplete = async () => {
|
||||||
|
if (countToComplete) {
|
||||||
|
await completeCount(countToComplete.id);
|
||||||
|
setCountToComplete(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = async () => {
|
||||||
|
if (countToCancel) {
|
||||||
|
await cancelCount(countToCancel.id);
|
||||||
|
setCountToCancel(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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: 'Conteos de Inventario' },
|
||||||
|
]} />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Conteos de Inventario</h1>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Gestiona los conteos físicos y verificaciones 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>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Nuevo conteo
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Stats */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||||
|
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedStatus('draft')}>
|
||||||
|
<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">
|
||||||
|
<ClipboardList className="h-5 w-5 text-gray-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-500">Borradores</div>
|
||||||
|
<div className="text-xl font-bold text-gray-900">
|
||||||
|
{counts.filter(c => c.status === 'draft').length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedStatus('in_progress')}>
|
||||||
|
<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">
|
||||||
|
<Play className="h-5 w-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-500">En Progreso</div>
|
||||||
|
<div className="text-xl font-bold text-gray-900">
|
||||||
|
{counts.filter(c => c.status === 'in_progress').length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedStatus('completed')}>
|
||||||
|
<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">
|
||||||
|
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-500">Completados</div>
|
||||||
|
<div className="text-xl font-bold text-gray-900">
|
||||||
|
{counts.filter(c => c.status === 'completed').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">
|
||||||
|
<Package className="h-5 w-5 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-500">Total</div>
|
||||||
|
<div className="text-xl font-bold text-gray-900">{total}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Lista de Conteos</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<select
|
||||||
|
value={selectedWarehouse}
|
||||||
|
onChange={(e) => setSelectedWarehouse(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="">Todos los almacenes</option>
|
||||||
|
{warehouses.map((wh) => (
|
||||||
|
<option key={wh.id} value={wh.id}>{wh.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={selectedType}
|
||||||
|
onChange={(e) => setSelectedType(e.target.value as CountType | '')}
|
||||||
|
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 tipos</option>
|
||||||
|
{Object.entries(countTypeLabels).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={selectedStatus}
|
||||||
|
onChange={(e) => setSelectedStatus(e.target.value as CountStatus | '')}
|
||||||
|
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(statusLabels).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{(selectedWarehouse || selectedType || selectedStatus) && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedWarehouse('');
|
||||||
|
setSelectedType('');
|
||||||
|
setSelectedStatus('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Limpiar filtros
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{counts.length === 0 && !isLoading ? (
|
||||||
|
<NoDataEmptyState
|
||||||
|
entityName="conteos de inventario"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
data={counts}
|
||||||
|
columns={columns}
|
||||||
|
isLoading={isLoading}
|
||||||
|
pagination={{
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
total,
|
||||||
|
limit: 20,
|
||||||
|
onPageChange: setPage,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Start Count Modal */}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={!!countToStart}
|
||||||
|
onClose={() => setCountToStart(null)}
|
||||||
|
onConfirm={handleStart}
|
||||||
|
title="Iniciar conteo"
|
||||||
|
message={`¿Iniciar el conteo ${countToStart?.countNumber}? El sistema generará las líneas con las cantidades actuales del sistema.`}
|
||||||
|
variant="info"
|
||||||
|
confirmText="Iniciar"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Complete Count Modal */}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={!!countToComplete}
|
||||||
|
onClose={() => setCountToComplete(null)}
|
||||||
|
onConfirm={handleComplete}
|
||||||
|
title="Completar conteo"
|
||||||
|
message={`¿Completar el conteo ${countToComplete?.countNumber}? Las diferencias generarán ajustes de inventario.`}
|
||||||
|
variant="success"
|
||||||
|
confirmText="Completar"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Cancel Count Modal */}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={!!countToCancel}
|
||||||
|
onClose={() => setCountToCancel(null)}
|
||||||
|
onConfirm={handleCancel}
|
||||||
|
title="Cancelar conteo"
|
||||||
|
message={`¿Cancelar el conteo ${countToCancel?.countNumber}? Esta acción no se puede deshacer.`}
|
||||||
|
variant="danger"
|
||||||
|
confirmText="Cancelar conteo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InventoryCountsPage;
|
||||||
452
src/pages/inventory/MovementsPage.tsx
Normal file
452
src/pages/inventory/MovementsPage.tsx
Normal file
@ -0,0 +1,452 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
ArrowDownToLine,
|
||||||
|
ArrowUpFromLine,
|
||||||
|
ArrowRightLeft,
|
||||||
|
MoreVertical,
|
||||||
|
Eye,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
Plus,
|
||||||
|
Filter,
|
||||||
|
Calendar,
|
||||||
|
} 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 { ConfirmModal } from '@components/organisms/Modal';
|
||||||
|
import { NoDataEmptyState, ErrorEmptyState } from '@components/templates/EmptyState';
|
||||||
|
import { useMovements, useWarehouses } from '@features/inventory/hooks';
|
||||||
|
import type { StockMovement, MovementType, MovementStatus } from '@features/inventory/types';
|
||||||
|
import { formatDate, formatNumber } from '@utils/formatters';
|
||||||
|
|
||||||
|
const movementTypeLabels: Record<MovementType, string> = {
|
||||||
|
receipt: 'Recepción',
|
||||||
|
shipment: 'Envío',
|
||||||
|
transfer: 'Transferencia',
|
||||||
|
adjustment: 'Ajuste',
|
||||||
|
return: 'Devolución',
|
||||||
|
production: 'Producción',
|
||||||
|
consumption: 'Consumo',
|
||||||
|
};
|
||||||
|
|
||||||
|
const movementTypeIcons: Record<MovementType, typeof ArrowDownToLine> = {
|
||||||
|
receipt: ArrowDownToLine,
|
||||||
|
shipment: ArrowUpFromLine,
|
||||||
|
transfer: ArrowRightLeft,
|
||||||
|
adjustment: ArrowRightLeft,
|
||||||
|
return: ArrowDownToLine,
|
||||||
|
production: Plus,
|
||||||
|
consumption: ArrowUpFromLine,
|
||||||
|
};
|
||||||
|
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MovementsPage() {
|
||||||
|
const [selectedType, setSelectedType] = useState<MovementType | ''>('');
|
||||||
|
const [selectedStatus, setSelectedStatus] = useState<MovementStatus | ''>('');
|
||||||
|
const [selectedWarehouse, setSelectedWarehouse] = useState<string>('');
|
||||||
|
const [dateRange, setDateRange] = useState<{ from: string; to: string }>({ from: '', to: '' });
|
||||||
|
const [movementToConfirm, setMovementToConfirm] = useState<StockMovement | null>(null);
|
||||||
|
const [movementToCancel, setMovementToCancel] = useState<StockMovement | null>(null);
|
||||||
|
|
||||||
|
const { warehouses } = useWarehouses();
|
||||||
|
const {
|
||||||
|
movements,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
setPage,
|
||||||
|
refresh,
|
||||||
|
confirmMovement,
|
||||||
|
cancelMovement,
|
||||||
|
} = useMovements({
|
||||||
|
movementType: selectedType || undefined,
|
||||||
|
status: selectedStatus || undefined,
|
||||||
|
warehouseId: selectedWarehouse || undefined,
|
||||||
|
fromDate: dateRange.from || undefined,
|
||||||
|
toDate: dateRange.to || undefined,
|
||||||
|
limit: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getActionsMenu = (movement: StockMovement): DropdownItem[] => {
|
||||||
|
const items: DropdownItem[] = [
|
||||||
|
{
|
||||||
|
key: 'view',
|
||||||
|
label: 'Ver detalle',
|
||||||
|
icon: <Eye className="h-4 w-4" />,
|
||||||
|
onClick: () => console.log('View', movement.id),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (movement.status === 'draft') {
|
||||||
|
items.push({
|
||||||
|
key: 'confirm',
|
||||||
|
label: 'Confirmar',
|
||||||
|
icon: <CheckCircle className="h-4 w-4" />,
|
||||||
|
onClick: () => setMovementToConfirm(movement),
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
|
key: 'cancel',
|
||||||
|
label: 'Cancelar',
|
||||||
|
icon: <XCircle className="h-4 w-4" />,
|
||||||
|
danger: true,
|
||||||
|
onClick: () => setMovementToCancel(movement),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: Column<StockMovement>[] = [
|
||||||
|
{
|
||||||
|
key: 'number',
|
||||||
|
header: 'Número',
|
||||||
|
render: (movement) => {
|
||||||
|
const Icon = movementTypeIcons[movement.movementType];
|
||||||
|
return (
|
||||||
|
<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-50 text-green-600' :
|
||||||
|
movement.movementType === 'shipment' ? 'bg-blue-50 text-blue-600' :
|
||||||
|
'bg-gray-50 text-gray-600'
|
||||||
|
}`}>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900">{movement.movementNumber}</div>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{movementTypeLabels[movement.movementType]}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'product',
|
||||||
|
header: 'Producto',
|
||||||
|
render: (movement) => (
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-900">{movement.productId}</div>
|
||||||
|
{movement.lotNumber && (
|
||||||
|
<div className="text-xs text-gray-500">Lote: {movement.lotNumber}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'quantity',
|
||||||
|
header: 'Cantidad',
|
||||||
|
sortable: true,
|
||||||
|
render: (movement) => (
|
||||||
|
<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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'origin',
|
||||||
|
header: 'Origen',
|
||||||
|
render: (movement) => {
|
||||||
|
if (!movement.sourceWarehouseId) return <span className="text-gray-400">-</span>;
|
||||||
|
const wh = warehouses.find(w => w.id === movement.sourceWarehouseId);
|
||||||
|
return <span className="text-sm text-gray-600">{wh?.name || movement.sourceWarehouseId}</span>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'destination',
|
||||||
|
header: 'Destino',
|
||||||
|
render: (movement) => {
|
||||||
|
if (!movement.destWarehouseId) return <span className="text-gray-400">-</span>;
|
||||||
|
const wh = warehouses.find(w => w.id === movement.destWarehouseId);
|
||||||
|
return <span className="text-sm text-gray-600">{wh?.name || movement.destWarehouseId}</span>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: 'Estado',
|
||||||
|
render: (movement) => (
|
||||||
|
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${statusColors[movement.status]}`}>
|
||||||
|
{statusLabels[movement.status]}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'date',
|
||||||
|
header: 'Fecha',
|
||||||
|
sortable: true,
|
||||||
|
render: (movement) => (
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{formatDate(movement.createdAt, 'short')}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
render: (movement) => (
|
||||||
|
<Dropdown
|
||||||
|
trigger={
|
||||||
|
<button className="rounded p-1 hover:bg-gray-100">
|
||||||
|
<MoreVertical className="h-4 w-4 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
items={getActionsMenu(movement)}
|
||||||
|
align="right"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
if (movementToConfirm) {
|
||||||
|
await confirmMovement(movementToConfirm.id);
|
||||||
|
setMovementToConfirm(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = async () => {
|
||||||
|
if (movementToCancel) {
|
||||||
|
await cancelMovement(movementToCancel.id);
|
||||||
|
setMovementToCancel(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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: 'Movimientos' },
|
||||||
|
]} />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Movimientos de Stock</h1>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Historial de entradas, salidas y transferencias de inventario
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Nuevo movimiento
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Stats */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||||
|
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedType('receipt')}>
|
||||||
|
<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">
|
||||||
|
<ArrowDownToLine className="h-5 w-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-500">Recepciones</div>
|
||||||
|
<div className="text-xl font-bold text-gray-900">
|
||||||
|
{movements.filter(m => m.movementType === 'receipt').length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedType('shipment')}>
|
||||||
|
<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">
|
||||||
|
<ArrowUpFromLine className="h-5 w-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-500">Envíos</div>
|
||||||
|
<div className="text-xl font-bold text-gray-900">
|
||||||
|
{movements.filter(m => m.movementType === 'shipment').length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedType('transfer')}>
|
||||||
|
<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">
|
||||||
|
<ArrowRightLeft className="h-5 w-5 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-500">Transferencias</div>
|
||||||
|
<div className="text-xl font-bold text-gray-900">
|
||||||
|
{movements.filter(m => m.movementType === 'transfer').length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedStatus('draft')}>
|
||||||
|
<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">
|
||||||
|
<Filter className="h-5 w-5 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-500">Pendientes</div>
|
||||||
|
<div className="text-xl font-bold text-gray-900">
|
||||||
|
{movements.filter(m => m.status === 'draft').length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Historial de Movimientos</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<select
|
||||||
|
value={selectedType}
|
||||||
|
onChange={(e) => setSelectedType(e.target.value as MovementType | '')}
|
||||||
|
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 tipos</option>
|
||||||
|
{Object.entries(movementTypeLabels).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={selectedStatus}
|
||||||
|
onChange={(e) => setSelectedStatus(e.target.value as MovementStatus | '')}
|
||||||
|
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(statusLabels).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={selectedWarehouse}
|
||||||
|
onChange={(e) => setSelectedWarehouse(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="">Todos los almacenes</option>
|
||||||
|
{warehouses.map((wh) => (
|
||||||
|
<option key={wh.id} value={wh.id}>{wh.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="h-4 w-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateRange.from}
|
||||||
|
onChange={(e) => setDateRange({ ...dateRange, from: 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"
|
||||||
|
/>
|
||||||
|
<span className="text-gray-400">-</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateRange.to}
|
||||||
|
onChange={(e) => setDateRange({ ...dateRange, to: 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(selectedType || selectedStatus || selectedWarehouse || dateRange.from || dateRange.to) && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedType('');
|
||||||
|
setSelectedStatus('');
|
||||||
|
setSelectedWarehouse('');
|
||||||
|
setDateRange({ from: '', to: '' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Limpiar filtros
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{movements.length === 0 && !isLoading ? (
|
||||||
|
<NoDataEmptyState
|
||||||
|
entityName="movimientos"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
data={movements}
|
||||||
|
columns={columns}
|
||||||
|
isLoading={isLoading}
|
||||||
|
pagination={{
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
total,
|
||||||
|
limit: 20,
|
||||||
|
onPageChange: setPage,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Confirm Movement Modal */}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={!!movementToConfirm}
|
||||||
|
onClose={() => setMovementToConfirm(null)}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
title="Confirmar movimiento"
|
||||||
|
message={`¿Confirmar el movimiento ${movementToConfirm?.movementNumber}? Esta acción actualizará el stock.`}
|
||||||
|
variant="success"
|
||||||
|
confirmText="Confirmar"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Cancel Movement Modal */}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={!!movementToCancel}
|
||||||
|
onClose={() => setMovementToCancel(null)}
|
||||||
|
onConfirm={handleCancel}
|
||||||
|
title="Cancelar movimiento"
|
||||||
|
message={`¿Cancelar el movimiento ${movementToCancel?.movementNumber}? Esta acción no se puede deshacer.`}
|
||||||
|
variant="danger"
|
||||||
|
confirmText="Cancelar movimiento"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MovementsPage;
|
||||||
400
src/pages/inventory/ReorderAlertsPage.tsx
Normal file
400
src/pages/inventory/ReorderAlertsPage.tsx
Normal file
@ -0,0 +1,400 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Package,
|
||||||
|
TrendingDown,
|
||||||
|
ShoppingCart,
|
||||||
|
RefreshCw,
|
||||||
|
Bell,
|
||||||
|
Eye,
|
||||||
|
ExternalLink,
|
||||||
|
} 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 { useStockLevels, useWarehouses } from '@features/inventory/hooks';
|
||||||
|
import type { StockLevel } from '@features/inventory/types';
|
||||||
|
import { formatNumber } from '@utils/formatters';
|
||||||
|
|
||||||
|
type AlertLevel = 'critical' | 'warning' | 'info';
|
||||||
|
|
||||||
|
interface StockAlert extends StockLevel {
|
||||||
|
alertLevel: AlertLevel;
|
||||||
|
daysToStockout?: number;
|
||||||
|
suggestedReorderQty?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const alertLevelColors: Record<AlertLevel, string> = {
|
||||||
|
critical: 'bg-red-100 text-red-700 border-red-200',
|
||||||
|
warning: 'bg-amber-100 text-amber-700 border-amber-200',
|
||||||
|
info: 'bg-blue-100 text-blue-700 border-blue-200',
|
||||||
|
};
|
||||||
|
|
||||||
|
const alertLevelLabels: Record<AlertLevel, string> = {
|
||||||
|
critical: 'Crítico',
|
||||||
|
warning: 'Advertencia',
|
||||||
|
info: 'Información',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ReorderAlertsPage() {
|
||||||
|
const [selectedWarehouse, setSelectedWarehouse] = useState<string>('');
|
||||||
|
const [selectedAlertLevel, setSelectedAlertLevel] = useState<AlertLevel | ''>('');
|
||||||
|
|
||||||
|
const { warehouses } = useWarehouses();
|
||||||
|
const {
|
||||||
|
stockLevels,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
setPage,
|
||||||
|
refresh,
|
||||||
|
} = useStockLevels({
|
||||||
|
warehouseId: selectedWarehouse || undefined,
|
||||||
|
lowStock: true,
|
||||||
|
limit: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transform stock levels to alerts with levels
|
||||||
|
const getAlertLevel = (item: StockLevel): AlertLevel => {
|
||||||
|
if (item.quantityAvailable <= 0) return 'critical';
|
||||||
|
if (item.quantityAvailable < 10) return 'warning';
|
||||||
|
return 'info';
|
||||||
|
};
|
||||||
|
|
||||||
|
const alerts: StockAlert[] = stockLevels.map(item => ({
|
||||||
|
...item,
|
||||||
|
alertLevel: getAlertLevel(item),
|
||||||
|
daysToStockout: item.quantityAvailable > 0 ? Math.floor(item.quantityAvailable / 5) : 0,
|
||||||
|
suggestedReorderQty: Math.max(100 - item.quantityAvailable, 0),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const filteredAlerts = selectedAlertLevel
|
||||||
|
? alerts.filter(a => a.alertLevel === selectedAlertLevel)
|
||||||
|
: alerts;
|
||||||
|
|
||||||
|
const getActionsMenu = (alert: StockAlert): DropdownItem[] => [
|
||||||
|
{
|
||||||
|
key: 'view',
|
||||||
|
label: 'Ver producto',
|
||||||
|
icon: <Eye className="h-4 w-4" />,
|
||||||
|
onClick: () => console.log('View product', alert.productId),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'history',
|
||||||
|
label: 'Ver historial',
|
||||||
|
icon: <TrendingDown className="h-4 w-4" />,
|
||||||
|
onClick: () => console.log('View history', alert.productId),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'order',
|
||||||
|
label: 'Crear orden de compra',
|
||||||
|
icon: <ShoppingCart className="h-4 w-4" />,
|
||||||
|
onClick: () => console.log('Create PO', alert.productId),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const columns: Column<StockAlert>[] = [
|
||||||
|
{
|
||||||
|
key: 'product',
|
||||||
|
header: 'Producto',
|
||||||
|
render: (alert) => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${
|
||||||
|
alert.alertLevel === 'critical' ? 'bg-red-50' :
|
||||||
|
alert.alertLevel === 'warning' ? 'bg-amber-50' : 'bg-blue-50'
|
||||||
|
}`}>
|
||||||
|
<Package className={`h-5 w-5 ${
|
||||||
|
alert.alertLevel === 'critical' ? 'text-red-600' :
|
||||||
|
alert.alertLevel === 'warning' ? 'text-amber-600' : 'text-blue-600'
|
||||||
|
}`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900">{alert.productId}</div>
|
||||||
|
{alert.lotNumber && (
|
||||||
|
<div className="text-sm text-gray-500">Lote: {alert.lotNumber}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'warehouse',
|
||||||
|
header: 'Almacén',
|
||||||
|
render: (alert) => {
|
||||||
|
const wh = warehouses.find(w => w.id === alert.warehouseId);
|
||||||
|
return <span className="text-sm text-gray-600">{wh?.name || alert.warehouseId}</span>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'alertLevel',
|
||||||
|
header: 'Nivel',
|
||||||
|
render: (alert) => (
|
||||||
|
<span className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-1 text-xs font-medium ${alertLevelColors[alert.alertLevel]}`}>
|
||||||
|
<AlertTriangle className="h-3 w-3" />
|
||||||
|
{alertLevelLabels[alert.alertLevel]}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'available',
|
||||||
|
header: 'Disponible',
|
||||||
|
sortable: true,
|
||||||
|
render: (alert) => (
|
||||||
|
<span className={`font-medium ${
|
||||||
|
alert.quantityAvailable <= 0 ? 'text-red-600' :
|
||||||
|
alert.quantityAvailable < 10 ? 'text-amber-600' : 'text-gray-900'
|
||||||
|
}`}>
|
||||||
|
{formatNumber(alert.quantityAvailable)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'incoming',
|
||||||
|
header: 'Por Recibir',
|
||||||
|
render: (alert) => (
|
||||||
|
<span className="text-blue-600">
|
||||||
|
{alert.quantityIncoming > 0 ? `+${formatNumber(alert.quantityIncoming)}` : '-'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'daysToStockout',
|
||||||
|
header: 'Días a Agotarse',
|
||||||
|
render: (alert) => (
|
||||||
|
<span className={`font-medium ${
|
||||||
|
alert.daysToStockout === 0 ? 'text-red-600' :
|
||||||
|
(alert.daysToStockout || 0) < 7 ? 'text-amber-600' : 'text-gray-600'
|
||||||
|
}`}>
|
||||||
|
{alert.daysToStockout === 0 ? 'Agotado' : `${alert.daysToStockout} días`}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'suggestedQty',
|
||||||
|
header: 'Cant. Sugerida',
|
||||||
|
render: (alert) => (
|
||||||
|
<span className="font-medium text-green-600">
|
||||||
|
+{formatNumber(alert.suggestedReorderQty || 0)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
render: (alert) => (
|
||||||
|
<Dropdown
|
||||||
|
trigger={
|
||||||
|
<button className="rounded p-1.5 text-gray-500 hover:bg-gray-100 hover:text-blue-600">
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
items={getActionsMenu(alert)}
|
||||||
|
align="right"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const criticalCount = alerts.filter(a => a.alertLevel === 'critical').length;
|
||||||
|
const warningCount = alerts.filter(a => a.alertLevel === 'warning').length;
|
||||||
|
const infoCount = alerts.filter(a => a.alertLevel === 'info').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: 'Alertas de Reorden' },
|
||||||
|
]} />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Alertas de Reorden</h1>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Productos con stock bajo que requieren reabastecimiento
|
||||||
|
</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">
|
||||||
|
<Bell className="mr-2 h-4 w-4" />
|
||||||
|
Configurar alertas
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Alert Summary */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||||
|
<Card
|
||||||
|
className={`cursor-pointer hover:shadow-md transition-shadow ${selectedAlertLevel === 'critical' ? 'ring-2 ring-red-500' : ''}`}
|
||||||
|
onClick={() => setSelectedAlertLevel(selectedAlertLevel === 'critical' ? '' : 'critical')}
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-500">Críticos</div>
|
||||||
|
<div className="text-xl font-bold text-red-600">{criticalCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
className={`cursor-pointer hover:shadow-md transition-shadow ${selectedAlertLevel === 'warning' ? 'ring-2 ring-amber-500' : ''}`}
|
||||||
|
onClick={() => setSelectedAlertLevel(selectedAlertLevel === 'warning' ? '' : 'warning')}
|
||||||
|
>
|
||||||
|
<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">Advertencias</div>
|
||||||
|
<div className="text-xl font-bold text-amber-600">{warningCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
className={`cursor-pointer hover:shadow-md transition-shadow ${selectedAlertLevel === 'info' ? 'ring-2 ring-blue-500' : ''}`}
|
||||||
|
onClick={() => setSelectedAlertLevel(selectedAlertLevel === 'info' ? '' : 'info')}
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
<Package className="h-5 w-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-500">Informativos</div>
|
||||||
|
<div className="text-xl font-bold text-blue-600">{infoCount}</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-gray-100">
|
||||||
|
<Bell className="h-5 w-5 text-gray-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-500">Total Alertas</div>
|
||||||
|
<div className="text-xl font-bold text-gray-900">{alerts.length}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Critical Alerts Banner */}
|
||||||
|
{criticalCount > 0 && (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-red-600" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-red-800">
|
||||||
|
{criticalCount} productos sin stock disponible
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-red-600">
|
||||||
|
Estos productos requieren atención inmediata para evitar rupturas de stock.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" className="ml-auto bg-red-600 hover:bg-red-700">
|
||||||
|
<ShoppingCart className="mr-2 h-4 w-4" />
|
||||||
|
Crear órdenes de compra
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Productos con Stock Bajo</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<select
|
||||||
|
value={selectedWarehouse}
|
||||||
|
onChange={(e) => setSelectedWarehouse(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="">Todos los almacenes</option>
|
||||||
|
{warehouses.map((wh) => (
|
||||||
|
<option key={wh.id} value={wh.id}>{wh.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={selectedAlertLevel}
|
||||||
|
onChange={(e) => setSelectedAlertLevel(e.target.value as AlertLevel | '')}
|
||||||
|
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 niveles</option>
|
||||||
|
{Object.entries(alertLevelLabels).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{(selectedWarehouse || selectedAlertLevel) && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedWarehouse('');
|
||||||
|
setSelectedAlertLevel('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Limpiar filtros
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{filteredAlerts.length === 0 && !isLoading ? (
|
||||||
|
<NoDataEmptyState
|
||||||
|
entityName="alertas"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
data={filteredAlerts}
|
||||||
|
columns={columns}
|
||||||
|
isLoading={isLoading}
|
||||||
|
pagination={{
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
total,
|
||||||
|
limit: 20,
|
||||||
|
onPageChange: setPage,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReorderAlertsPage;
|
||||||
331
src/pages/inventory/StockLevelsPage.tsx
Normal file
331
src/pages/inventory/StockLevelsPage.tsx
Normal file
@ -0,0 +1,331 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Package,
|
||||||
|
Search,
|
||||||
|
Download,
|
||||||
|
ArrowRightLeft,
|
||||||
|
Minus,
|
||||||
|
RefreshCw,
|
||||||
|
AlertTriangle,
|
||||||
|
} 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 { useStockLevels, useWarehouses } from '@features/inventory/hooks';
|
||||||
|
import type { StockLevel } from '@features/inventory/types';
|
||||||
|
import { formatNumber } from '@utils/formatters';
|
||||||
|
|
||||||
|
export function StockLevelsPage() {
|
||||||
|
const [selectedWarehouse, setSelectedWarehouse] = useState<string>('');
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [showLowStock, setShowLowStock] = useState(false);
|
||||||
|
const [showTransferModal, setShowTransferModal] = useState(false);
|
||||||
|
const [showAdjustModal, setShowAdjustModal] = useState(false);
|
||||||
|
const [selectedItem, setSelectedItem] = useState<StockLevel | null>(null);
|
||||||
|
|
||||||
|
const { warehouses } = useWarehouses();
|
||||||
|
const {
|
||||||
|
stockLevels,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
setPage,
|
||||||
|
refresh,
|
||||||
|
} = useStockLevels({
|
||||||
|
warehouseId: selectedWarehouse || undefined,
|
||||||
|
lowStock: showLowStock || undefined,
|
||||||
|
limit: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: Column<StockLevel>[] = [
|
||||||
|
{
|
||||||
|
key: 'product',
|
||||||
|
header: 'Producto',
|
||||||
|
render: (item) => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-50">
|
||||||
|
<Package className="h-5 w-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900">{item.productId}</div>
|
||||||
|
{item.lotNumber && (
|
||||||
|
<div className="text-sm text-gray-500">Lote: {item.lotNumber}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'warehouse',
|
||||||
|
header: 'Almacén',
|
||||||
|
render: (item) => {
|
||||||
|
const wh = warehouses.find(w => w.id === item.warehouseId);
|
||||||
|
return (
|
||||||
|
<span className="text-sm text-gray-600">{wh?.name || item.warehouseId}</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'onHand',
|
||||||
|
header: 'En Existencia',
|
||||||
|
sortable: true,
|
||||||
|
render: (item) => (
|
||||||
|
<span className="font-medium text-gray-900">
|
||||||
|
{formatNumber(item.quantityOnHand)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'reserved',
|
||||||
|
header: 'Reservado',
|
||||||
|
render: (item) => (
|
||||||
|
<span className="text-amber-600">
|
||||||
|
{formatNumber(item.quantityReserved)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'available',
|
||||||
|
header: 'Disponible',
|
||||||
|
sortable: true,
|
||||||
|
render: (item) => (
|
||||||
|
<span className={`font-medium ${item.quantityAvailable > 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
|
{formatNumber(item.quantityAvailable)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'incoming',
|
||||||
|
header: 'Por Recibir',
|
||||||
|
render: (item) => (
|
||||||
|
<span className="text-blue-600">
|
||||||
|
{item.quantityIncoming > 0 ? `+${formatNumber(item.quantityIncoming)}` : '-'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'outgoing',
|
||||||
|
header: 'Por Enviar',
|
||||||
|
render: (item) => (
|
||||||
|
<span className="text-orange-600">
|
||||||
|
{item.quantityOutgoing > 0 ? `-${formatNumber(item.quantityOutgoing)}` : '-'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'value',
|
||||||
|
header: 'Valor',
|
||||||
|
render: (item) => (
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
{item.totalCost ? `$${formatNumber(item.totalCost)}` : '-'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
render: (item) => (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => { setSelectedItem(item); setShowTransferModal(true); }}
|
||||||
|
className="rounded p-1.5 text-gray-500 hover:bg-gray-100 hover:text-blue-600"
|
||||||
|
title="Transferir"
|
||||||
|
>
|
||||||
|
<ArrowRightLeft className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setSelectedItem(item); setShowAdjustModal(true); }}
|
||||||
|
className="rounded p-1.5 text-gray-500 hover:bg-gray-100 hover:text-amber-600"
|
||||||
|
title="Ajustar"
|
||||||
|
>
|
||||||
|
<Minus className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handlePageChange = (newPage: number) => {
|
||||||
|
setPage(newPage);
|
||||||
|
};
|
||||||
|
|
||||||
|
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: 'Niveles de Stock' },
|
||||||
|
]} />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Niveles de Stock</h1>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Visualiza y gestiona el inventario por almacén y ubicación
|
||||||
|
</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">
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Exportar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Inventario Actual</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 producto..."
|
||||||
|
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={selectedWarehouse}
|
||||||
|
onChange={(e) => setSelectedWarehouse(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="">Todos los almacenes</option>
|
||||||
|
{warehouses.map((wh) => (
|
||||||
|
<option key={wh.id} value={wh.id}>{wh.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLowStock(!showLowStock)}
|
||||||
|
className={`flex items-center gap-2 rounded-md border px-3 py-2 transition-colors ${
|
||||||
|
showLowStock
|
||||||
|
? 'border-amber-500 bg-amber-50 text-amber-700'
|
||||||
|
: 'border-gray-300 text-gray-600 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
Stock bajo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||||
|
<div className="rounded-lg bg-blue-50 p-4">
|
||||||
|
<div className="text-sm font-medium text-blue-600">Total Productos</div>
|
||||||
|
<div className="mt-1 text-2xl font-bold text-blue-900">{formatNumber(total)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-green-50 p-4">
|
||||||
|
<div className="text-sm font-medium text-green-600">Con Stock</div>
|
||||||
|
<div className="mt-1 text-2xl font-bold text-green-900">
|
||||||
|
{formatNumber(stockLevels.filter(s => s.quantityAvailable > 0).length)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-amber-50 p-4">
|
||||||
|
<div className="text-sm font-medium text-amber-600">Stock Bajo</div>
|
||||||
|
<div className="mt-1 text-2xl font-bold text-amber-900">
|
||||||
|
{formatNumber(stockLevels.filter(s => s.quantityAvailable <= 0 && s.quantityOnHand > 0).length)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-red-50 p-4">
|
||||||
|
<div className="text-sm font-medium text-red-600">Sin Stock</div>
|
||||||
|
<div className="mt-1 text-2xl font-bold text-red-900">
|
||||||
|
{formatNumber(stockLevels.filter(s => s.quantityOnHand <= 0).length)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{stockLevels.length === 0 && !isLoading ? (
|
||||||
|
<NoDataEmptyState
|
||||||
|
entityName="niveles de stock"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
data={stockLevels}
|
||||||
|
columns={columns}
|
||||||
|
isLoading={isLoading}
|
||||||
|
pagination={{
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
total,
|
||||||
|
limit: 20,
|
||||||
|
onPageChange: handlePageChange,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Transfer Modal - Simplified */}
|
||||||
|
{showTransferModal && selectedItem && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
||||||
|
<h3 className="text-lg font-semibold">Transferir Stock</h3>
|
||||||
|
<p className="mt-2 text-sm text-gray-500">
|
||||||
|
Producto: {selectedItem.productId}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Disponible: {formatNumber(selectedItem.quantityAvailable)}
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setShowTransferModal(false)}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setShowTransferModal(false)}>
|
||||||
|
Transferir
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Adjust Modal - Simplified */}
|
||||||
|
{showAdjustModal && selectedItem && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
||||||
|
<h3 className="text-lg font-semibold">Ajustar Stock</h3>
|
||||||
|
<p className="mt-2 text-sm text-gray-500">
|
||||||
|
Producto: {selectedItem.productId}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Actual: {formatNumber(selectedItem.quantityOnHand)}
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setShowAdjustModal(false)}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setShowAdjustModal(false)}>
|
||||||
|
Ajustar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StockLevelsPage;
|
||||||
431
src/pages/inventory/ValuationReportsPage.tsx
Normal file
431
src/pages/inventory/ValuationReportsPage.tsx
Normal file
@ -0,0 +1,431 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
DollarSign,
|
||||||
|
Package,
|
||||||
|
Layers,
|
||||||
|
TrendingUp,
|
||||||
|
RefreshCw,
|
||||||
|
Download,
|
||||||
|
Calendar,
|
||||||
|
Eye,
|
||||||
|
} 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 { useWarehouses } from '@features/inventory/hooks';
|
||||||
|
import { inventoryApi } from '@features/inventory/api/inventory.api';
|
||||||
|
import type { ValuationSummary, StockValuationLayer } from '@features/inventory/types';
|
||||||
|
import { formatNumber } from '@utils/formatters';
|
||||||
|
|
||||||
|
// Helper function to format currency with 2 decimals
|
||||||
|
const formatCurrency = (value: number): string => {
|
||||||
|
return formatNumber(value, 'es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getToday = (): string => getToday() || '';
|
||||||
|
|
||||||
|
export function ValuationReportsPage() {
|
||||||
|
const [selectedWarehouse, setSelectedWarehouse] = useState<string>('');
|
||||||
|
const [asOfDate, setAsOfDate] = useState<string>(getToday());
|
||||||
|
const [valuationData, setValuationData] = 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 [selectedProduct, setSelectedProduct] = useState<ValuationSummary | null>(null);
|
||||||
|
const [productLayers, setProductLayers] = useState<StockValuationLayer[]>([]);
|
||||||
|
const [showLayersModal, setShowLayersModal] = useState(false);
|
||||||
|
|
||||||
|
const { warehouses } = useWarehouses();
|
||||||
|
|
||||||
|
const fetchValuation = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await inventoryApi.getValuationSummary({
|
||||||
|
warehouseId: selectedWarehouse || undefined,
|
||||||
|
asOfDate: asOfDate || undefined,
|
||||||
|
});
|
||||||
|
setValuationData(response.data);
|
||||||
|
setTotalValue(response.totalValue);
|
||||||
|
setTotalProducts(response.totalProducts);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Error al cargar valoracion de inventario');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [selectedWarehouse, asOfDate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchValuation();
|
||||||
|
}, [fetchValuation]);
|
||||||
|
|
||||||
|
const handleViewLayers = async (product: ValuationSummary) => {
|
||||||
|
setSelectedProduct(product);
|
||||||
|
try {
|
||||||
|
const response = await inventoryApi.getProductValuation(product.productId);
|
||||||
|
setProductLayers(response.layers);
|
||||||
|
setShowLayersModal(true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading layers', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: Column<ValuationSummary>[] = [
|
||||||
|
{
|
||||||
|
key: 'product',
|
||||||
|
header: 'Producto',
|
||||||
|
render: (item) => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-50">
|
||||||
|
<Package className="h-5 w-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900">{item.productId}</div>
|
||||||
|
{item.productName && (
|
||||||
|
<div className="text-sm text-gray-500">{item.productName}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'quantity',
|
||||||
|
header: 'Cantidad',
|
||||||
|
sortable: true,
|
||||||
|
render: (item) => (
|
||||||
|
<span className="font-medium text-gray-900">
|
||||||
|
{formatNumber(item.totalQuantity)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'averageCost',
|
||||||
|
header: 'Costo Promedio',
|
||||||
|
sortable: true,
|
||||||
|
render: (item) => (
|
||||||
|
<span className="text-gray-600">
|
||||||
|
${formatCurrency(item.averageCost)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'totalValue',
|
||||||
|
header: 'Valor Total',
|
||||||
|
sortable: true,
|
||||||
|
render: (item) => (
|
||||||
|
<span className="font-medium text-green-600">
|
||||||
|
${formatCurrency(item.totalValue)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'layers',
|
||||||
|
header: 'Capas',
|
||||||
|
render: (item) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-gray-600">{item.layerCount}</span>
|
||||||
|
<Layers className="h-4 w-4 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'percentage',
|
||||||
|
header: '% del Total',
|
||||||
|
render: (item) => {
|
||||||
|
const percentage = totalValue > 0 ? (item.totalValue / totalValue) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-2 w-16 rounded-full bg-gray-200">
|
||||||
|
<div
|
||||||
|
className="h-2 rounded-full bg-blue-500"
|
||||||
|
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-500">{percentage.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
render: (item) => (
|
||||||
|
<button
|
||||||
|
onClick={() => handleViewLayers(item)}
|
||||||
|
className="rounded p-1.5 text-gray-500 hover:bg-gray-100 hover:text-blue-600"
|
||||||
|
title="Ver capas de valoracion"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const layerColumns: Column<StockValuationLayer>[] = [
|
||||||
|
{
|
||||||
|
key: 'date',
|
||||||
|
header: 'Fecha',
|
||||||
|
render: (layer) => (
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
{new Date(layer.createdAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'quantity',
|
||||||
|
header: 'Cantidad Original',
|
||||||
|
render: (layer) => (
|
||||||
|
<span className="text-gray-900">{formatNumber(layer.quantity)}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'remaining',
|
||||||
|
header: 'Cantidad Restante',
|
||||||
|
render: (layer) => (
|
||||||
|
<span className={layer.remainingQty > 0 ? 'text-green-600' : 'text-gray-400'}>
|
||||||
|
{formatNumber(layer.remainingQty)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'unitCost',
|
||||||
|
header: 'Costo Unitario',
|
||||||
|
render: (layer) => (
|
||||||
|
<span className="text-gray-600">${formatCurrency(layer.unitCost)}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'value',
|
||||||
|
header: 'Valor Restante',
|
||||||
|
render: (layer) => (
|
||||||
|
<span className="font-medium text-green-600">
|
||||||
|
${formatCurrency(layer.remainingValue)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Calculate summary statistics
|
||||||
|
const avgCost = valuationData.length > 0
|
||||||
|
? valuationData.reduce((sum, item) => sum + item.totalValue, 0) /
|
||||||
|
valuationData.reduce((sum, item) => sum + item.totalQuantity, 0)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const totalLayers = valuationData.reduce((sum, item) => sum + item.layerCount, 0);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<ErrorEmptyState onRetry={fetchValuation} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
<Breadcrumbs items={[
|
||||||
|
{ label: 'Inventario', href: '/inventory' },
|
||||||
|
{ label: 'Reportes de Valoracion' },
|
||||||
|
]} />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Valoracion de Inventario</h1>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Analisis del valor del inventario por metodo FIFO/Costo Promedio
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={fetchValuation} disabled={isLoading}>
|
||||||
|
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||||
|
Actualizar
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline">
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Exportar
|
||||||
|
</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-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(totalValue)}
|
||||||
|
</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">
|
||||||
|
<Package className="h-5 w-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-500">Productos</div>
|
||||||
|
<div className="text-xl font-bold text-gray-900">
|
||||||
|
{formatNumber(totalProducts)}
|
||||||
|
</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">
|
||||||
|
<Layers className="h-5 w-5 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-500">Capas de Valoracion</div>
|
||||||
|
<div className="text-xl font-bold text-gray-900">
|
||||||
|
{formatNumber(totalLayers)}
|
||||||
|
</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">
|
||||||
|
<TrendingUp className="h-5 w-5 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-500">Costo Promedio</div>
|
||||||
|
<div className="text-xl font-bold text-gray-900">
|
||||||
|
${formatCurrency(avgCost)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Detalle de Valoracion por Producto</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<select
|
||||||
|
value={selectedWarehouse}
|
||||||
|
onChange={(e) => setSelectedWarehouse(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="">Todos los almacenes</option>
|
||||||
|
{warehouses.map((wh) => (
|
||||||
|
<option key={wh.id} value={wh.id}>{wh.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="h-4 w-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={asOfDate}
|
||||||
|
onChange={(e) => setAsOfDate(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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(selectedWarehouse || asOfDate !== getToday()) && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedWarehouse('');
|
||||||
|
setAsOfDate(getToday());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Limpiar filtros
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{valuationData.length === 0 && !isLoading ? (
|
||||||
|
<NoDataEmptyState
|
||||||
|
entityName="datos de valoracion"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
data={valuationData}
|
||||||
|
columns={columns}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Layers Modal */}
|
||||||
|
{showLayersModal && selectedProduct && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="w-full max-w-4xl rounded-lg bg-white p-6 shadow-xl">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">Capas de Valoracion</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Producto: {selectedProduct.productId}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLayersModal(false)}
|
||||||
|
className="rounded p-1 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<span className="text-2xl text-gray-400">×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 grid grid-cols-3 gap-4">
|
||||||
|
<div className="rounded-lg bg-gray-50 p-3">
|
||||||
|
<div className="text-sm text-gray-500">Cantidad Total</div>
|
||||||
|
<div className="text-lg font-bold">{formatNumber(selectedProduct.totalQuantity)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-gray-50 p-3">
|
||||||
|
<div className="text-sm text-gray-500">Valor Total</div>
|
||||||
|
<div className="text-lg font-bold text-green-600">${formatCurrency(selectedProduct.totalValue)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-gray-50 p-3">
|
||||||
|
<div className="text-sm text-gray-500">Costo Promedio</div>
|
||||||
|
<div className="text-lg font-bold">${formatCurrency(selectedProduct.averageCost)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
data={productLayers}
|
||||||
|
columns={layerColumns}
|
||||||
|
isLoading={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<Button variant="outline" onClick={() => setShowLayersModal(false)}>
|
||||||
|
Cerrar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ValuationReportsPage;
|
||||||
5
src/pages/inventory/index.ts
Normal file
5
src/pages/inventory/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export { StockLevelsPage, default as StockLevelsPageDefault } from './StockLevelsPage';
|
||||||
|
export { MovementsPage, default as MovementsPageDefault } from './MovementsPage';
|
||||||
|
export { InventoryCountsPage, default as InventoryCountsPageDefault } from './InventoryCountsPage';
|
||||||
|
export { ReorderAlertsPage, default as ReorderAlertsPageDefault } from './ReorderAlertsPage';
|
||||||
|
export { ValuationReportsPage, default as ValuationReportsPageDefault } from './ValuationReportsPage';
|
||||||
Loading…
Reference in New Issue
Block a user