From 28c8bbb76820699522d567a99cdbae282a76bddb Mon Sep 17 00:00:00 2001 From: rckrdmrd Date: Sun, 18 Jan 2026 09:54:51 -0600 Subject: [PATCH] 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 --- src/features/inventory/api/inventory.api.ts | 190 ++++++ src/features/inventory/hooks/index.ts | 16 + src/features/inventory/hooks/useInventory.ts | 632 ++++++++++++++++++ .../inventory/types/inventory.types.ts | 154 +++++ src/pages/inventory/InventoryCountsPage.tsx | 448 +++++++++++++ src/pages/inventory/MovementsPage.tsx | 452 +++++++++++++ src/pages/inventory/ReorderAlertsPage.tsx | 400 +++++++++++ src/pages/inventory/StockLevelsPage.tsx | 331 +++++++++ src/pages/inventory/ValuationReportsPage.tsx | 431 ++++++++++++ src/pages/inventory/index.ts | 5 + 10 files changed, 3059 insertions(+) create mode 100644 src/features/inventory/hooks/index.ts create mode 100644 src/features/inventory/hooks/useInventory.ts create mode 100644 src/pages/inventory/InventoryCountsPage.tsx create mode 100644 src/pages/inventory/MovementsPage.tsx create mode 100644 src/pages/inventory/ReorderAlertsPage.tsx create mode 100644 src/pages/inventory/StockLevelsPage.tsx create mode 100644 src/pages/inventory/ValuationReportsPage.tsx create mode 100644 src/pages/inventory/index.ts diff --git a/src/features/inventory/api/inventory.api.ts b/src/features/inventory/api/inventory.api.ts index a8aa8c2..bf69a21 100644 --- a/src/features/inventory/api/inventory.api.ts +++ b/src/features/inventory/api/inventory.api.ts @@ -18,12 +18,26 @@ import type { CreateLocationDto, UpdateLocationDto, LocationsResponse, + InventoryCount, + InventoryCountLine, + CreateInventoryCountDto, + InventoryCountsResponse, + InventoryAdjustment, + InventoryAdjustmentLine, + CreateInventoryAdjustmentDto, + InventoryAdjustmentsResponse, + StockValuationLayer, + StockValuationsResponse, + ValuationSummaryResponse, } from '../types'; const STOCK_URL = '/api/v1/inventory/stock'; const MOVEMENTS_URL = '/api/v1/inventory/movements'; const WAREHOUSES_URL = '/api/v1/warehouses'; 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 = { // ==================== Stock Levels ==================== @@ -201,4 +215,180 @@ export const inventoryApi = { deleteLocation: async (id: string): Promise => { 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 => { + 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(`${COUNTS_URL}?${searchParams.toString()}`); + return response.data; + }, + + // Get inventory count by ID + getInventoryCountById: async (id: string): Promise => { + const response = await api.get(`${COUNTS_URL}/${id}`); + return response.data; + }, + + // Get count lines + getInventoryCountLines: async (countId: string): Promise => { + const response = await api.get(`${COUNTS_URL}/${countId}/lines`); + return response.data; + }, + + // Create inventory count + createInventoryCount: async (data: CreateInventoryCountDto): Promise => { + const response = await api.post(COUNTS_URL, data); + return response.data; + }, + + // Start inventory count + startInventoryCount: async (id: string): Promise => { + const response = await api.post(`${COUNTS_URL}/${id}/start`); + return response.data; + }, + + // Update count line + updateCountLine: async (countId: string, lineId: string, data: { countedQuantity: number; notes?: string }): Promise => { + const response = await api.patch(`${COUNTS_URL}/${countId}/lines/${lineId}`, data); + return response.data; + }, + + // Complete inventory count + completeInventoryCount: async (id: string): Promise => { + const response = await api.post(`${COUNTS_URL}/${id}/complete`); + return response.data; + }, + + // Cancel inventory count + cancelInventoryCount: async (id: string): Promise => { + const response = await api.post(`${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 => { + 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(`${ADJUSTMENTS_URL}?${searchParams.toString()}`); + return response.data; + }, + + // Get adjustment by ID + getInventoryAdjustmentById: async (id: string): Promise => { + const response = await api.get(`${ADJUSTMENTS_URL}/${id}`); + return response.data; + }, + + // Get adjustment lines + getInventoryAdjustmentLines: async (adjustmentId: string): Promise => { + const response = await api.get(`${ADJUSTMENTS_URL}/${adjustmentId}/lines`); + return response.data; + }, + + // Create inventory adjustment + createInventoryAdjustment: async (data: CreateInventoryAdjustmentDto): Promise => { + const response = await api.post(ADJUSTMENTS_URL, data); + return response.data; + }, + + // Confirm adjustment + confirmInventoryAdjustment: async (id: string): Promise => { + const response = await api.post(`${ADJUSTMENTS_URL}/${id}/confirm`); + return response.data; + }, + + // Apply adjustment (done) + applyInventoryAdjustment: async (id: string): Promise => { + const response = await api.post(`${ADJUSTMENTS_URL}/${id}/apply`); + return response.data; + }, + + // Cancel adjustment + cancelInventoryAdjustment: async (id: string): Promise => { + const response = await api.post(`${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 => { + 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(`${VALUATIONS_URL}?${searchParams.toString()}`); + return response.data; + }, + + // Get valuation summary by product + getValuationSummary: async (params?: { + warehouseId?: string; + asOfDate?: string; + }): Promise => { + 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(`${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; + }, }; diff --git a/src/features/inventory/hooks/index.ts b/src/features/inventory/hooks/index.ts new file mode 100644 index 0000000..6125eb6 --- /dev/null +++ b/src/features/inventory/hooks/index.ts @@ -0,0 +1,16 @@ +export { + useStockLevels, + useMovements, + useWarehouses, + useLocations, + useStockOperations, + useInventoryCounts, + useInventoryAdjustments, +} from './useInventory'; + +export type { + UseStockLevelsOptions, + UseMovementsOptions, + UseInventoryCountsOptions, + UseInventoryAdjustmentsOptions, +} from './useInventory'; diff --git a/src/features/inventory/hooks/useInventory.ts b/src/features/inventory/hooks/useInventory.ts new file mode 100644 index 0000000..154d9a6 --- /dev/null +++ b/src/features/inventory/hooks/useInventory.ts @@ -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([]); + 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(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([]); + 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(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([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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(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([]); + 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(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([]); + 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(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, + }; +} diff --git a/src/features/inventory/types/inventory.types.ts b/src/features/inventory/types/inventory.types.ts index d8b19e2..69e70f0 100644 --- a/src/features/inventory/types/inventory.types.ts +++ b/src/features/inventory/types/inventory.types.ts @@ -221,3 +221,157 @@ export interface LocationsResponse { 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; +} diff --git a/src/pages/inventory/InventoryCountsPage.tsx b/src/pages/inventory/InventoryCountsPage.tsx new file mode 100644 index 0000000..bc70257 --- /dev/null +++ b/src/pages/inventory/InventoryCountsPage.tsx @@ -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 = { + full: 'Completo', + partial: 'Parcial', + cycle: 'Cíclico', + spot: 'Puntual', +}; + +const statusLabels: Record = { + draft: 'Borrador', + in_progress: 'En Progreso', + completed: 'Completado', + cancelled: 'Cancelado', +}; + +const statusColors: Record = { + 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(''); + const [selectedStatus, setSelectedStatus] = useState(''); + const [selectedType, setSelectedType] = useState(''); + const [countToStart, setCountToStart] = useState(null); + const [countToComplete, setCountToComplete] = useState(null); + const [countToCancel, setCountToCancel] = useState(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: , + onClick: () => console.log('View', count.id), + }, + ]; + + if (count.status === 'draft') { + items.push({ + key: 'start', + label: 'Iniciar conteo', + icon: , + onClick: () => setCountToStart(count), + }); + items.push({ + key: 'cancel', + label: 'Cancelar', + icon: , + danger: true, + onClick: () => setCountToCancel(count), + }); + } + + if (count.status === 'in_progress') { + items.push({ + key: 'complete', + label: 'Completar', + icon: , + onClick: () => setCountToComplete(count), + }); + items.push({ + key: 'cancel', + label: 'Cancelar', + icon: , + danger: true, + onClick: () => setCountToCancel(count), + }); + } + + return items; + }; + + const columns: Column[] = [ + { + key: 'number', + header: 'Conteo', + render: (count) => ( +
+
+ +
+
+
{count.countNumber}
+ {count.name && ( +
{count.name}
+ )} +
+
+ ), + }, + { + key: 'warehouse', + header: 'Almacén', + render: (count) => { + const wh = warehouses.find(w => w.id === count.warehouseId); + return {wh?.name || count.warehouseId}; + }, + }, + { + key: 'type', + header: 'Tipo', + render: (count) => ( + {countTypeLabels[count.countType]} + ), + }, + { + key: 'scheduledDate', + header: 'Fecha Programada', + render: (count) => ( + + {count.scheduledDate ? formatDate(count.scheduledDate, 'short') : '-'} + + ), + }, + { + key: 'status', + header: 'Estado', + render: (count) => ( + + {statusLabels[count.status]} + + ), + }, + { + key: 'dates', + header: 'Progreso', + render: (count) => ( +
+ {count.startedAt && ( +
Inicio: {formatDate(count.startedAt, 'short')}
+ )} + {count.completedAt && ( +
Fin: {formatDate(count.completedAt, 'short')}
+ )} + {!count.startedAt && !count.completedAt && ( + - + )} +
+ ), + }, + { + key: 'createdAt', + header: 'Creado', + sortable: true, + render: (count) => ( + + {formatDate(count.createdAt, 'short')} + + ), + }, + { + key: 'actions', + header: '', + render: (count) => ( + + + + } + 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 ( +
+ +
+ ); + } + + return ( +
+ + +
+
+

Conteos de Inventario

+

+ Gestiona los conteos físicos y verificaciones de inventario +

+
+
+ + +
+
+ + {/* Summary Stats */} +
+ setSelectedStatus('draft')}> + +
+
+ +
+
+
Borradores
+
+ {counts.filter(c => c.status === 'draft').length} +
+
+
+
+
+ + setSelectedStatus('in_progress')}> + +
+
+ +
+
+
En Progreso
+
+ {counts.filter(c => c.status === 'in_progress').length} +
+
+
+
+
+ + setSelectedStatus('completed')}> + +
+
+ +
+
+
Completados
+
+ {counts.filter(c => c.status === 'completed').length} +
+
+
+
+
+ + + +
+
+ +
+
+
Total
+
{total}
+
+
+
+
+
+ + + + Lista de Conteos + + +
+ {/* Filters */} +
+ + + + + + + {(selectedWarehouse || selectedType || selectedStatus) && ( + + )} +
+ + {/* Table */} + {counts.length === 0 && !isLoading ? ( + + ) : ( + + )} +
+
+
+ + {/* Start Count Modal */} + 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 */} + 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 */} + 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" + /> +
+ ); +} + +export default InventoryCountsPage; diff --git a/src/pages/inventory/MovementsPage.tsx b/src/pages/inventory/MovementsPage.tsx new file mode 100644 index 0000000..aac9e53 --- /dev/null +++ b/src/pages/inventory/MovementsPage.tsx @@ -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 = { + receipt: 'Recepción', + shipment: 'Envío', + transfer: 'Transferencia', + adjustment: 'Ajuste', + return: 'Devolución', + production: 'Producción', + consumption: 'Consumo', +}; + +const movementTypeIcons: Record = { + receipt: ArrowDownToLine, + shipment: ArrowUpFromLine, + transfer: ArrowRightLeft, + adjustment: ArrowRightLeft, + return: ArrowDownToLine, + production: Plus, + consumption: ArrowUpFromLine, +}; + +const statusLabels: Record = { + draft: 'Borrador', + confirmed: 'Confirmado', + cancelled: 'Cancelado', +}; + +const statusColors: Record = { + 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(''); + const [selectedStatus, setSelectedStatus] = useState(''); + const [selectedWarehouse, setSelectedWarehouse] = useState(''); + const [dateRange, setDateRange] = useState<{ from: string; to: string }>({ from: '', to: '' }); + const [movementToConfirm, setMovementToConfirm] = useState(null); + const [movementToCancel, setMovementToCancel] = useState(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: , + onClick: () => console.log('View', movement.id), + }, + ]; + + if (movement.status === 'draft') { + items.push({ + key: 'confirm', + label: 'Confirmar', + icon: , + onClick: () => setMovementToConfirm(movement), + }); + items.push({ + key: 'cancel', + label: 'Cancelar', + icon: , + danger: true, + onClick: () => setMovementToCancel(movement), + }); + } + + return items; + }; + + const columns: Column[] = [ + { + key: 'number', + header: 'Número', + render: (movement) => { + const Icon = movementTypeIcons[movement.movementType]; + return ( +
+
+ +
+
+
{movement.movementNumber}
+
+ {movementTypeLabels[movement.movementType]} +
+
+
+ ); + }, + }, + { + key: 'product', + header: 'Producto', + render: (movement) => ( +
+
{movement.productId}
+ {movement.lotNumber && ( +
Lote: {movement.lotNumber}
+ )} +
+ ), + }, + { + key: 'quantity', + header: 'Cantidad', + sortable: true, + render: (movement) => ( + + {['receipt', 'return', 'production'].includes(movement.movementType) ? '+' : '-'} + {formatNumber(movement.quantity)} + + ), + }, + { + key: 'origin', + header: 'Origen', + render: (movement) => { + if (!movement.sourceWarehouseId) return -; + const wh = warehouses.find(w => w.id === movement.sourceWarehouseId); + return {wh?.name || movement.sourceWarehouseId}; + }, + }, + { + key: 'destination', + header: 'Destino', + render: (movement) => { + if (!movement.destWarehouseId) return -; + const wh = warehouses.find(w => w.id === movement.destWarehouseId); + return {wh?.name || movement.destWarehouseId}; + }, + }, + { + key: 'status', + header: 'Estado', + render: (movement) => ( + + {statusLabels[movement.status]} + + ), + }, + { + key: 'date', + header: 'Fecha', + sortable: true, + render: (movement) => ( + + {formatDate(movement.createdAt, 'short')} + + ), + }, + { + key: 'actions', + header: '', + render: (movement) => ( + + + + } + 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 ( +
+ +
+ ); + } + + return ( +
+ + +
+
+

Movimientos de Stock

+

+ Historial de entradas, salidas y transferencias de inventario +

+
+ +
+ + {/* Quick Stats */} +
+ setSelectedType('receipt')}> + +
+
+ +
+
+
Recepciones
+
+ {movements.filter(m => m.movementType === 'receipt').length} +
+
+
+
+
+ + setSelectedType('shipment')}> + +
+
+ +
+
+
Envíos
+
+ {movements.filter(m => m.movementType === 'shipment').length} +
+
+
+
+
+ + setSelectedType('transfer')}> + +
+
+ +
+
+
Transferencias
+
+ {movements.filter(m => m.movementType === 'transfer').length} +
+
+
+
+
+ + setSelectedStatus('draft')}> + +
+
+ +
+
+
Pendientes
+
+ {movements.filter(m => m.status === 'draft').length} +
+
+
+
+
+
+ + + + Historial de Movimientos + + +
+ {/* Filters */} +
+ + + + + + +
+ + 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" + /> + - + 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" + /> +
+ + {(selectedType || selectedStatus || selectedWarehouse || dateRange.from || dateRange.to) && ( + + )} +
+ + {/* Table */} + {movements.length === 0 && !isLoading ? ( + + ) : ( + + )} +
+
+
+ + {/* Confirm Movement Modal */} + 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 */} + 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" + /> +
+ ); +} + +export default MovementsPage; diff --git a/src/pages/inventory/ReorderAlertsPage.tsx b/src/pages/inventory/ReorderAlertsPage.tsx new file mode 100644 index 0000000..72de5a5 --- /dev/null +++ b/src/pages/inventory/ReorderAlertsPage.tsx @@ -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 = { + 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 = { + critical: 'Crítico', + warning: 'Advertencia', + info: 'Información', +}; + +export function ReorderAlertsPage() { + const [selectedWarehouse, setSelectedWarehouse] = useState(''); + const [selectedAlertLevel, setSelectedAlertLevel] = useState(''); + + 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: , + onClick: () => console.log('View product', alert.productId), + }, + { + key: 'history', + label: 'Ver historial', + icon: , + onClick: () => console.log('View history', alert.productId), + }, + { + key: 'order', + label: 'Crear orden de compra', + icon: , + onClick: () => console.log('Create PO', alert.productId), + }, + ]; + + const columns: Column[] = [ + { + key: 'product', + header: 'Producto', + render: (alert) => ( +
+
+ +
+
+
{alert.productId}
+ {alert.lotNumber && ( +
Lote: {alert.lotNumber}
+ )} +
+
+ ), + }, + { + key: 'warehouse', + header: 'Almacén', + render: (alert) => { + const wh = warehouses.find(w => w.id === alert.warehouseId); + return {wh?.name || alert.warehouseId}; + }, + }, + { + key: 'alertLevel', + header: 'Nivel', + render: (alert) => ( + + + {alertLevelLabels[alert.alertLevel]} + + ), + }, + { + key: 'available', + header: 'Disponible', + sortable: true, + render: (alert) => ( + + {formatNumber(alert.quantityAvailable)} + + ), + }, + { + key: 'incoming', + header: 'Por Recibir', + render: (alert) => ( + + {alert.quantityIncoming > 0 ? `+${formatNumber(alert.quantityIncoming)}` : '-'} + + ), + }, + { + key: 'daysToStockout', + header: 'Días a Agotarse', + render: (alert) => ( + + {alert.daysToStockout === 0 ? 'Agotado' : `${alert.daysToStockout} días`} + + ), + }, + { + key: 'suggestedQty', + header: 'Cant. Sugerida', + render: (alert) => ( + + +{formatNumber(alert.suggestedReorderQty || 0)} + + ), + }, + { + key: 'actions', + header: '', + render: (alert) => ( + + + + } + 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 ( +
+ +
+ ); + } + + return ( +
+ + +
+
+

Alertas de Reorden

+

+ Productos con stock bajo que requieren reabastecimiento +

+
+
+ + +
+
+ + {/* Alert Summary */} +
+ setSelectedAlertLevel(selectedAlertLevel === 'critical' ? '' : 'critical')} + > + +
+
+ +
+
+
Críticos
+
{criticalCount}
+
+
+
+
+ + setSelectedAlertLevel(selectedAlertLevel === 'warning' ? '' : 'warning')} + > + +
+
+ +
+
+
Advertencias
+
{warningCount}
+
+
+
+
+ + setSelectedAlertLevel(selectedAlertLevel === 'info' ? '' : 'info')} + > + +
+
+ +
+
+
Informativos
+
{infoCount}
+
+
+
+
+ + + +
+
+ +
+
+
Total Alertas
+
{alerts.length}
+
+
+
+
+
+ + {/* Critical Alerts Banner */} + {criticalCount > 0 && ( +
+
+ +
+
+ {criticalCount} productos sin stock disponible +
+
+ Estos productos requieren atención inmediata para evitar rupturas de stock. +
+
+ +
+
+ )} + + + + Productos con Stock Bajo + + +
+ {/* Filters */} +
+ + + + + {(selectedWarehouse || selectedAlertLevel) && ( + + )} +
+ + {/* Table */} + {filteredAlerts.length === 0 && !isLoading ? ( + + ) : ( + + )} +
+
+
+
+ ); +} + +export default ReorderAlertsPage; diff --git a/src/pages/inventory/StockLevelsPage.tsx b/src/pages/inventory/StockLevelsPage.tsx new file mode 100644 index 0000000..bc07797 --- /dev/null +++ b/src/pages/inventory/StockLevelsPage.tsx @@ -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(''); + const [searchTerm, setSearchTerm] = useState(''); + const [showLowStock, setShowLowStock] = useState(false); + const [showTransferModal, setShowTransferModal] = useState(false); + const [showAdjustModal, setShowAdjustModal] = useState(false); + const [selectedItem, setSelectedItem] = useState(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[] = [ + { + key: 'product', + header: 'Producto', + render: (item) => ( +
+
+ +
+
+
{item.productId}
+ {item.lotNumber && ( +
Lote: {item.lotNumber}
+ )} +
+
+ ), + }, + { + key: 'warehouse', + header: 'Almacén', + render: (item) => { + const wh = warehouses.find(w => w.id === item.warehouseId); + return ( + {wh?.name || item.warehouseId} + ); + }, + }, + { + key: 'onHand', + header: 'En Existencia', + sortable: true, + render: (item) => ( + + {formatNumber(item.quantityOnHand)} + + ), + }, + { + key: 'reserved', + header: 'Reservado', + render: (item) => ( + + {formatNumber(item.quantityReserved)} + + ), + }, + { + key: 'available', + header: 'Disponible', + sortable: true, + render: (item) => ( + 0 ? 'text-green-600' : 'text-red-600'}`}> + {formatNumber(item.quantityAvailable)} + + ), + }, + { + key: 'incoming', + header: 'Por Recibir', + render: (item) => ( + + {item.quantityIncoming > 0 ? `+${formatNumber(item.quantityIncoming)}` : '-'} + + ), + }, + { + key: 'outgoing', + header: 'Por Enviar', + render: (item) => ( + + {item.quantityOutgoing > 0 ? `-${formatNumber(item.quantityOutgoing)}` : '-'} + + ), + }, + { + key: 'value', + header: 'Valor', + render: (item) => ( + + {item.totalCost ? `$${formatNumber(item.totalCost)}` : '-'} + + ), + }, + { + key: 'actions', + header: '', + render: (item) => ( +
+ + +
+ ), + }, + ]; + + const handlePageChange = (newPage: number) => { + setPage(newPage); + }; + + if (error) { + return ( +
+ +
+ ); + } + + return ( +
+ + +
+
+

Niveles de Stock

+

+ Visualiza y gestiona el inventario por almacén y ubicación +

+
+
+ + +
+
+ + + + Inventario Actual + + +
+ {/* Filters */} +
+
+ + 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" + /> +
+ + + + +
+ + {/* Summary */} +
+
+
Total Productos
+
{formatNumber(total)}
+
+
+
Con Stock
+
+ {formatNumber(stockLevels.filter(s => s.quantityAvailable > 0).length)} +
+
+
+
Stock Bajo
+
+ {formatNumber(stockLevels.filter(s => s.quantityAvailable <= 0 && s.quantityOnHand > 0).length)} +
+
+
+
Sin Stock
+
+ {formatNumber(stockLevels.filter(s => s.quantityOnHand <= 0).length)} +
+
+
+ + {/* Table */} + {stockLevels.length === 0 && !isLoading ? ( + + ) : ( + + )} +
+
+
+ + {/* Transfer Modal - Simplified */} + {showTransferModal && selectedItem && ( +
+
+

Transferir Stock

+

+ Producto: {selectedItem.productId} +

+

+ Disponible: {formatNumber(selectedItem.quantityAvailable)} +

+
+ + +
+
+
+ )} + + {/* Adjust Modal - Simplified */} + {showAdjustModal && selectedItem && ( +
+
+

Ajustar Stock

+

+ Producto: {selectedItem.productId} +

+

+ Actual: {formatNumber(selectedItem.quantityOnHand)} +

+
+ + +
+
+
+ )} +
+ ); +} + +export default StockLevelsPage; diff --git a/src/pages/inventory/ValuationReportsPage.tsx b/src/pages/inventory/ValuationReportsPage.tsx new file mode 100644 index 0000000..dacc0a2 --- /dev/null +++ b/src/pages/inventory/ValuationReportsPage.tsx @@ -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(''); + const [asOfDate, setAsOfDate] = useState(getToday()); + const [valuationData, setValuationData] = useState([]); + const [totalValue, setTotalValue] = useState(0); + const [totalProducts, setTotalProducts] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [selectedProduct, setSelectedProduct] = useState(null); + const [productLayers, setProductLayers] = useState([]); + 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[] = [ + { + key: 'product', + header: 'Producto', + render: (item) => ( +
+
+ +
+
+
{item.productId}
+ {item.productName && ( +
{item.productName}
+ )} +
+
+ ), + }, + { + key: 'quantity', + header: 'Cantidad', + sortable: true, + render: (item) => ( + + {formatNumber(item.totalQuantity)} + + ), + }, + { + key: 'averageCost', + header: 'Costo Promedio', + sortable: true, + render: (item) => ( + + ${formatCurrency(item.averageCost)} + + ), + }, + { + key: 'totalValue', + header: 'Valor Total', + sortable: true, + render: (item) => ( + + ${formatCurrency(item.totalValue)} + + ), + }, + { + key: 'layers', + header: 'Capas', + render: (item) => ( +
+ {item.layerCount} + +
+ ), + }, + { + key: 'percentage', + header: '% del Total', + render: (item) => { + const percentage = totalValue > 0 ? (item.totalValue / totalValue) * 100 : 0; + return ( +
+
+
+
+ {percentage.toFixed(1)}% +
+ ); + }, + }, + { + key: 'actions', + header: '', + render: (item) => ( + + ), + }, + ]; + + const layerColumns: Column[] = [ + { + key: 'date', + header: 'Fecha', + render: (layer) => ( + + {new Date(layer.createdAt).toLocaleDateString()} + + ), + }, + { + key: 'quantity', + header: 'Cantidad Original', + render: (layer) => ( + {formatNumber(layer.quantity)} + ), + }, + { + key: 'remaining', + header: 'Cantidad Restante', + render: (layer) => ( + 0 ? 'text-green-600' : 'text-gray-400'}> + {formatNumber(layer.remainingQty)} + + ), + }, + { + key: 'unitCost', + header: 'Costo Unitario', + render: (layer) => ( + ${formatCurrency(layer.unitCost)} + ), + }, + { + key: 'value', + header: 'Valor Restante', + render: (layer) => ( + + ${formatCurrency(layer.remainingValue)} + + ), + }, + ]; + + // 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 ( +
+ +
+ ); + } + + return ( +
+ + +
+
+

Valoracion de Inventario

+

+ Analisis del valor del inventario por metodo FIFO/Costo Promedio +

+
+
+ + +
+
+ + {/* Summary Stats */} +
+ + +
+
+ +
+
+
Valor Total
+
+ ${formatCurrency(totalValue)} +
+
+
+
+
+ + + +
+
+ +
+
+
Productos
+
+ {formatNumber(totalProducts)} +
+
+
+
+
+ + + +
+
+ +
+
+
Capas de Valoracion
+
+ {formatNumber(totalLayers)} +
+
+
+
+
+ + + +
+
+ +
+
+
Costo Promedio
+
+ ${formatCurrency(avgCost)} +
+
+
+
+
+
+ + + + Detalle de Valoracion por Producto + + +
+ {/* Filters */} +
+ + +
+ + 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" + /> +
+ + {(selectedWarehouse || asOfDate !== getToday()) && ( + + )} +
+ + {/* Table */} + {valuationData.length === 0 && !isLoading ? ( + + ) : ( + + )} +
+
+
+ + {/* Layers Modal */} + {showLayersModal && selectedProduct && ( +
+
+
+
+

Capas de Valoracion

+

+ Producto: {selectedProduct.productId} +

+
+ +
+ +
+
+
Cantidad Total
+
{formatNumber(selectedProduct.totalQuantity)}
+
+
+
Valor Total
+
${formatCurrency(selectedProduct.totalValue)}
+
+
+
Costo Promedio
+
${formatCurrency(selectedProduct.averageCost)}
+
+
+ + + +
+ +
+
+
+ )} +
+ ); +} + +export default ValuationReportsPage; diff --git a/src/pages/inventory/index.ts b/src/pages/inventory/index.ts new file mode 100644 index 0000000..8418f9e --- /dev/null +++ b/src/pages/inventory/index.ts @@ -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';