510 lines
14 KiB
TypeScript
510 lines
14 KiB
TypeScript
// frontend/src/api/hooks/useInventory.ts
|
|
/**
|
|
* Inventory Management React Hook
|
|
* Provides comprehensive state management for inventory operations
|
|
*/
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import toast from 'react-hot-toast';
|
|
|
|
import {
|
|
inventoryService,
|
|
InventoryItem,
|
|
StockLevel,
|
|
StockMovement,
|
|
StockAlert,
|
|
InventorySearchParams,
|
|
CreateInventoryItemRequest,
|
|
UpdateInventoryItemRequest,
|
|
StockAdjustmentRequest,
|
|
PaginatedResponse,
|
|
InventoryDashboardData
|
|
} from '../services/inventory.service';
|
|
|
|
import { useTenantId } from '../../hooks/useTenantId';
|
|
|
|
// ========== HOOK INTERFACES ==========
|
|
|
|
interface UseInventoryReturn {
|
|
// State
|
|
items: InventoryItem[];
|
|
stockLevels: Record<string, StockLevel>;
|
|
movements: StockMovement[];
|
|
alerts: StockAlert[];
|
|
dashboardData: InventoryDashboardData | null;
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
pagination: {
|
|
page: number;
|
|
limit: number;
|
|
total: number;
|
|
totalPages: number;
|
|
};
|
|
|
|
// Actions
|
|
loadItems: (params?: InventorySearchParams) => Promise<void>;
|
|
loadItem: (itemId: string) => Promise<InventoryItem | null>;
|
|
createItem: (data: CreateInventoryItemRequest) => Promise<InventoryItem | null>;
|
|
updateItem: (itemId: string, data: UpdateInventoryItemRequest) => Promise<InventoryItem | null>;
|
|
deleteItem: (itemId: string) => Promise<boolean>;
|
|
|
|
// Stock operations
|
|
loadStockLevels: () => Promise<void>;
|
|
adjustStock: (itemId: string, adjustment: StockAdjustmentRequest) => Promise<StockMovement | null>;
|
|
loadMovements: (params?: any) => Promise<void>;
|
|
|
|
// Alerts
|
|
loadAlerts: () => Promise<void>;
|
|
acknowledgeAlert: (alertId: string) => Promise<boolean>;
|
|
|
|
// Dashboard
|
|
loadDashboard: () => Promise<void>;
|
|
|
|
// Utility
|
|
searchItems: (query: string) => Promise<InventoryItem[]>;
|
|
refresh: () => Promise<void>;
|
|
clearError: () => void;
|
|
}
|
|
|
|
interface UseInventoryDashboardReturn {
|
|
dashboardData: InventoryDashboardData | null;
|
|
alerts: StockAlert[];
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
refresh: () => Promise<void>;
|
|
}
|
|
|
|
interface UseInventoryItemReturn {
|
|
item: InventoryItem | null;
|
|
stockLevel: StockLevel | null;
|
|
recentMovements: StockMovement[];
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
updateItem: (data: UpdateInventoryItemRequest) => Promise<boolean>;
|
|
adjustStock: (adjustment: StockAdjustmentRequest) => Promise<boolean>;
|
|
refresh: () => Promise<void>;
|
|
}
|
|
|
|
// ========== MAIN INVENTORY HOOK ==========
|
|
|
|
export const useInventory = (autoLoad = true): UseInventoryReturn => {
|
|
const tenantId = useTenantId();
|
|
|
|
// State
|
|
const [items, setItems] = useState<InventoryItem[]>([]);
|
|
const [stockLevels, setStockLevels] = useState<Record<string, StockLevel>>({});
|
|
const [movements, setMovements] = useState<StockMovement[]>([]);
|
|
const [alerts, setAlerts] = useState<StockAlert[]>([]);
|
|
const [dashboardData, setDashboardData] = useState<InventoryDashboardData | null>(null);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [pagination, setPagination] = useState({
|
|
page: 1,
|
|
limit: 20,
|
|
total: 0,
|
|
totalPages: 0
|
|
});
|
|
|
|
// Clear error
|
|
const clearError = useCallback(() => setError(null), []);
|
|
|
|
// Load inventory items
|
|
const loadItems = useCallback(async (params?: InventorySearchParams) => {
|
|
if (!tenantId) return;
|
|
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const response = await inventoryService.getInventoryItems(tenantId, params);
|
|
setItems(response.items);
|
|
setPagination({
|
|
page: response.page,
|
|
limit: response.limit,
|
|
total: response.total,
|
|
totalPages: response.total_pages
|
|
});
|
|
} catch (err: any) {
|
|
const errorMessage = err.response?.data?.detail || err.message || 'Error loading inventory items';
|
|
setError(errorMessage);
|
|
toast.error(errorMessage);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [tenantId]);
|
|
|
|
// Load single item
|
|
const loadItem = useCallback(async (itemId: string): Promise<InventoryItem | null> => {
|
|
if (!tenantId) return null;
|
|
|
|
try {
|
|
const item = await inventoryService.getInventoryItem(tenantId, itemId);
|
|
|
|
// Update in local state if it exists
|
|
setItems(prev => prev.map(i => i.id === itemId ? item : i));
|
|
|
|
return item;
|
|
} catch (err: any) {
|
|
const errorMessage = err.response?.data?.detail || err.message || 'Error loading item';
|
|
setError(errorMessage);
|
|
return null;
|
|
}
|
|
}, [tenantId]);
|
|
|
|
// Create item
|
|
const createItem = useCallback(async (data: CreateInventoryItemRequest): Promise<InventoryItem | null> => {
|
|
if (!tenantId) return null;
|
|
|
|
setIsLoading(true);
|
|
|
|
try {
|
|
const newItem = await inventoryService.createInventoryItem(tenantId, data);
|
|
setItems(prev => [newItem, ...prev]);
|
|
toast.success(`Created ${newItem.name} successfully`);
|
|
return newItem;
|
|
} catch (err: any) {
|
|
const errorMessage = err.response?.data?.detail || err.message || 'Error creating item';
|
|
setError(errorMessage);
|
|
toast.error(errorMessage);
|
|
return null;
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [tenantId]);
|
|
|
|
// Update item
|
|
const updateItem = useCallback(async (
|
|
itemId: string,
|
|
data: UpdateInventoryItemRequest
|
|
): Promise<InventoryItem | null> => {
|
|
if (!tenantId) return null;
|
|
|
|
try {
|
|
const updatedItem = await inventoryService.updateInventoryItem(tenantId, itemId, data);
|
|
setItems(prev => prev.map(i => i.id === itemId ? updatedItem : i));
|
|
toast.success(`Updated ${updatedItem.name} successfully`);
|
|
return updatedItem;
|
|
} catch (err: any) {
|
|
const errorMessage = err.response?.data?.detail || err.message || 'Error updating item';
|
|
setError(errorMessage);
|
|
toast.error(errorMessage);
|
|
return null;
|
|
}
|
|
}, [tenantId]);
|
|
|
|
// Delete item
|
|
const deleteItem = useCallback(async (itemId: string): Promise<boolean> => {
|
|
if (!tenantId) return false;
|
|
|
|
try {
|
|
await inventoryService.deleteInventoryItem(tenantId, itemId);
|
|
setItems(prev => prev.filter(i => i.id !== itemId));
|
|
toast.success('Item deleted successfully');
|
|
return true;
|
|
} catch (err: any) {
|
|
const errorMessage = err.response?.data?.detail || err.message || 'Error deleting item';
|
|
setError(errorMessage);
|
|
toast.error(errorMessage);
|
|
return false;
|
|
}
|
|
}, [tenantId]);
|
|
|
|
// Load stock levels
|
|
const loadStockLevels = useCallback(async () => {
|
|
if (!tenantId) return;
|
|
|
|
try {
|
|
const levels = await inventoryService.getAllStockLevels(tenantId);
|
|
const levelMap = levels.reduce((acc, level) => {
|
|
acc[level.item_id] = level;
|
|
return acc;
|
|
}, {} as Record<string, StockLevel>);
|
|
setStockLevels(levelMap);
|
|
} catch (err: any) {
|
|
console.error('Error loading stock levels:', err);
|
|
}
|
|
}, [tenantId]);
|
|
|
|
// Adjust stock
|
|
const adjustStock = useCallback(async (
|
|
itemId: string,
|
|
adjustment: StockAdjustmentRequest
|
|
): Promise<StockMovement | null> => {
|
|
if (!tenantId) return null;
|
|
|
|
try {
|
|
const movement = await inventoryService.adjustStock(tenantId, itemId, adjustment);
|
|
|
|
// Update local movements
|
|
setMovements(prev => [movement, ...prev.slice(0, 49)]); // Keep last 50
|
|
|
|
// Reload stock level for this item
|
|
const updatedLevel = await inventoryService.getStockLevel(tenantId, itemId);
|
|
setStockLevels(prev => ({ ...prev, [itemId]: updatedLevel }));
|
|
|
|
toast.success('Stock adjusted successfully');
|
|
return movement;
|
|
} catch (err: any) {
|
|
const errorMessage = err.response?.data?.detail || err.message || 'Error adjusting stock';
|
|
setError(errorMessage);
|
|
toast.error(errorMessage);
|
|
return null;
|
|
}
|
|
}, [tenantId]);
|
|
|
|
// Load movements
|
|
const loadMovements = useCallback(async (params?: any) => {
|
|
if (!tenantId) return;
|
|
|
|
try {
|
|
const response = await inventoryService.getStockMovements(tenantId, params);
|
|
setMovements(response.items);
|
|
} catch (err: any) {
|
|
console.error('Error loading movements:', err);
|
|
}
|
|
}, [tenantId]);
|
|
|
|
// Load alerts
|
|
const loadAlerts = useCallback(async () => {
|
|
if (!tenantId) return;
|
|
|
|
try {
|
|
const alertsData = await inventoryService.getStockAlerts(tenantId);
|
|
setAlerts(alertsData);
|
|
} catch (err: any) {
|
|
console.error('Error loading alerts:', err);
|
|
}
|
|
}, [tenantId]);
|
|
|
|
// Acknowledge alert
|
|
const acknowledgeAlert = useCallback(async (alertId: string): Promise<boolean> => {
|
|
if (!tenantId) return false;
|
|
|
|
try {
|
|
await inventoryService.acknowledgeAlert(tenantId, alertId);
|
|
setAlerts(prev => prev.map(a =>
|
|
a.id === alertId ? { ...a, is_acknowledged: true, acknowledged_at: new Date().toISOString() } : a
|
|
));
|
|
return true;
|
|
} catch (err: any) {
|
|
toast.error('Error acknowledging alert');
|
|
return false;
|
|
}
|
|
}, [tenantId]);
|
|
|
|
// Load dashboard
|
|
const loadDashboard = useCallback(async () => {
|
|
if (!tenantId) return;
|
|
|
|
try {
|
|
const data = await inventoryService.getDashboardData(tenantId);
|
|
setDashboardData(data);
|
|
} catch (err: any) {
|
|
console.error('Error loading dashboard:', err);
|
|
}
|
|
}, [tenantId]);
|
|
|
|
// Search items
|
|
const searchItems = useCallback(async (query: string): Promise<InventoryItem[]> => {
|
|
if (!tenantId || !query.trim()) return [];
|
|
|
|
try {
|
|
return await inventoryService.searchItems(tenantId, query);
|
|
} catch (err: any) {
|
|
console.error('Error searching items:', err);
|
|
return [];
|
|
}
|
|
}, [tenantId]);
|
|
|
|
// Refresh all data
|
|
const refresh = useCallback(async () => {
|
|
await Promise.all([
|
|
loadItems(),
|
|
loadStockLevels(),
|
|
loadAlerts(),
|
|
loadDashboard()
|
|
]);
|
|
}, [loadItems, loadStockLevels, loadAlerts, loadDashboard]);
|
|
|
|
// Auto-load on mount
|
|
useEffect(() => {
|
|
if (autoLoad && tenantId) {
|
|
refresh();
|
|
}
|
|
}, [autoLoad, tenantId, refresh]);
|
|
|
|
return {
|
|
// State
|
|
items,
|
|
stockLevels,
|
|
movements,
|
|
alerts,
|
|
dashboardData,
|
|
isLoading,
|
|
error,
|
|
pagination,
|
|
|
|
// Actions
|
|
loadItems,
|
|
loadItem,
|
|
createItem,
|
|
updateItem,
|
|
deleteItem,
|
|
|
|
// Stock operations
|
|
loadStockLevels,
|
|
adjustStock,
|
|
loadMovements,
|
|
|
|
// Alerts
|
|
loadAlerts,
|
|
acknowledgeAlert,
|
|
|
|
// Dashboard
|
|
loadDashboard,
|
|
|
|
// Utility
|
|
searchItems,
|
|
refresh,
|
|
clearError
|
|
};
|
|
};
|
|
|
|
// ========== DASHBOARD HOOK ==========
|
|
|
|
export const useInventoryDashboard = (): UseInventoryDashboardReturn => {
|
|
const tenantId = useTenantId();
|
|
const [dashboardData, setDashboardData] = useState<InventoryDashboardData | null>(null);
|
|
const [alerts, setAlerts] = useState<StockAlert[]>([]);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const refresh = useCallback(async () => {
|
|
if (!tenantId) return;
|
|
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const [dashboard, alertsData] = await Promise.all([
|
|
inventoryService.getDashboardData(tenantId),
|
|
inventoryService.getStockAlerts(tenantId)
|
|
]);
|
|
|
|
setDashboardData(dashboard);
|
|
setAlerts(alertsData);
|
|
} catch (err: any) {
|
|
const errorMessage = err.response?.data?.detail || err.message || 'Error loading dashboard';
|
|
setError(errorMessage);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [tenantId]);
|
|
|
|
useEffect(() => {
|
|
if (tenantId) {
|
|
refresh();
|
|
}
|
|
}, [tenantId, refresh]);
|
|
|
|
return {
|
|
dashboardData,
|
|
alerts,
|
|
isLoading,
|
|
error,
|
|
refresh
|
|
};
|
|
};
|
|
|
|
// ========== SINGLE ITEM HOOK ==========
|
|
|
|
export const useInventoryItem = (itemId: string): UseInventoryItemReturn => {
|
|
const tenantId = useTenantId();
|
|
const [item, setItem] = useState<InventoryItem | null>(null);
|
|
const [stockLevel, setStockLevel] = useState<StockLevel | null>(null);
|
|
const [recentMovements, setRecentMovements] = useState<StockMovement[]>([]);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const refresh = useCallback(async () => {
|
|
if (!tenantId || !itemId) return;
|
|
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const [itemData, stockData, movementsData] = await Promise.all([
|
|
inventoryService.getInventoryItem(tenantId, itemId),
|
|
inventoryService.getStockLevel(tenantId, itemId),
|
|
inventoryService.getStockMovements(tenantId, { item_id: itemId, limit: 10 })
|
|
]);
|
|
|
|
setItem(itemData);
|
|
setStockLevel(stockData);
|
|
setRecentMovements(movementsData.items);
|
|
} catch (err: any) {
|
|
const errorMessage = err.response?.data?.detail || err.message || 'Error loading item';
|
|
setError(errorMessage);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [tenantId, itemId]);
|
|
|
|
const updateItem = useCallback(async (data: UpdateInventoryItemRequest): Promise<boolean> => {
|
|
if (!tenantId || !itemId) return false;
|
|
|
|
try {
|
|
const updatedItem = await inventoryService.updateInventoryItem(tenantId, itemId, data);
|
|
setItem(updatedItem);
|
|
toast.success('Item updated successfully');
|
|
return true;
|
|
} catch (err: any) {
|
|
const errorMessage = err.response?.data?.detail || err.message || 'Error updating item';
|
|
setError(errorMessage);
|
|
toast.error(errorMessage);
|
|
return false;
|
|
}
|
|
}, [tenantId, itemId]);
|
|
|
|
const adjustStock = useCallback(async (adjustment: StockAdjustmentRequest): Promise<boolean> => {
|
|
if (!tenantId || !itemId) return false;
|
|
|
|
try {
|
|
const movement = await inventoryService.adjustStock(tenantId, itemId, adjustment);
|
|
|
|
// Refresh data
|
|
const [updatedStock, updatedMovements] = await Promise.all([
|
|
inventoryService.getStockLevel(tenantId, itemId),
|
|
inventoryService.getStockMovements(tenantId, { item_id: itemId, limit: 10 })
|
|
]);
|
|
|
|
setStockLevel(updatedStock);
|
|
setRecentMovements(updatedMovements.items);
|
|
|
|
toast.success('Stock adjusted successfully');
|
|
return true;
|
|
} catch (err: any) {
|
|
const errorMessage = err.response?.data?.detail || err.message || 'Error adjusting stock';
|
|
setError(errorMessage);
|
|
toast.error(errorMessage);
|
|
return false;
|
|
}
|
|
}, [tenantId, itemId]);
|
|
|
|
useEffect(() => {
|
|
if (tenantId && itemId) {
|
|
refresh();
|
|
}
|
|
}, [tenantId, itemId, refresh]);
|
|
|
|
return {
|
|
item,
|
|
stockLevel,
|
|
recentMovements,
|
|
isLoading,
|
|
error,
|
|
updateItem,
|
|
adjustStock,
|
|
refresh
|
|
};
|
|
}; |