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:
rckrdmrd 2026-01-18 09:54:51 -06:00
parent c9e1c3fb06
commit 28c8bbb768
10 changed files with 3059 additions and 0 deletions

View File

@ -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;
},
};

View File

@ -0,0 +1,16 @@
export {
useStockLevels,
useMovements,
useWarehouses,
useLocations,
useStockOperations,
useInventoryCounts,
useInventoryAdjustments,
} from './useInventory';
export type {
UseStockLevelsOptions,
UseMovementsOptions,
UseInventoryCountsOptions,
UseInventoryAdjustmentsOptions,
} from './useInventory';

View 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,
};
}

View File

@ -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;
}

View 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;

View 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;

View 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;

View 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;

View 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">&times;</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;

View 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';