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,
|
||||
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<void> => {
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
// 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