Improve the frontend
This commit is contained in:
246
frontend/src/api/hooks/dashboard.ts
Normal file
246
frontend/src/api/hooks/dashboard.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Dashboard React Query hooks
|
||||
* Aggregates data from multiple services for dashboard metrics
|
||||
*/
|
||||
|
||||
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
|
||||
import { useSalesAnalytics } from './sales';
|
||||
import { useOrdersDashboard } from './orders';
|
||||
import { inventoryService } from '../services/inventory';
|
||||
import { getAlertAnalytics } from '../services/alert_analytics';
|
||||
import { ApiError } from '../client/apiClient';
|
||||
import type { InventoryDashboardSummary } from '../types/dashboard';
|
||||
import type { AlertAnalytics } from '../services/alert_analytics';
|
||||
import type { SalesAnalytics } from '../types/sales';
|
||||
import type { OrdersDashboardSummary } from '../types/orders';
|
||||
|
||||
export interface DashboardStats {
|
||||
// Alert metrics
|
||||
activeAlerts: number;
|
||||
criticalAlerts: number;
|
||||
|
||||
// Order metrics
|
||||
pendingOrders: number;
|
||||
ordersToday: number;
|
||||
ordersTrend: number; // percentage change
|
||||
|
||||
// Sales metrics
|
||||
salesToday: number;
|
||||
salesTrend: number; // percentage change
|
||||
salesCurrency: string;
|
||||
|
||||
// Inventory metrics
|
||||
criticalStock: number;
|
||||
lowStockCount: number;
|
||||
outOfStockCount: number;
|
||||
expiringSoon: number;
|
||||
|
||||
// Production metrics
|
||||
productsSoldToday: number;
|
||||
productsSoldTrend: number;
|
||||
|
||||
// Data freshness
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
interface AggregatedDashboardData {
|
||||
alerts?: AlertAnalytics;
|
||||
orders?: OrdersDashboardSummary;
|
||||
sales?: SalesAnalytics;
|
||||
inventory?: InventoryDashboardSummary;
|
||||
}
|
||||
|
||||
// Query Keys
|
||||
export const dashboardKeys = {
|
||||
all: ['dashboard'] as const,
|
||||
stats: (tenantId: string) => [...dashboardKeys.all, 'stats', tenantId] as const,
|
||||
inventory: (tenantId: string) => [...dashboardKeys.all, 'inventory', tenantId] as const,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Fetch inventory dashboard summary
|
||||
*/
|
||||
export const useInventoryDashboard = (
|
||||
tenantId: string,
|
||||
options?: Omit<UseQueryOptions<InventoryDashboardSummary, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<InventoryDashboardSummary, ApiError>({
|
||||
queryKey: dashboardKeys.inventory(tenantId),
|
||||
queryFn: () => inventoryService.getDashboardSummary(tenantId),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch alert analytics
|
||||
*/
|
||||
export const useAlertAnalytics = (
|
||||
tenantId: string,
|
||||
days: number = 7,
|
||||
options?: Omit<UseQueryOptions<AlertAnalytics, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<AlertAnalytics, ApiError>({
|
||||
queryKey: ['alerts', 'analytics', tenantId, days],
|
||||
queryFn: () => getAlertAnalytics(tenantId, days),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate percentage change between two values
|
||||
*/
|
||||
function calculateTrend(current: number, previous: number): number {
|
||||
if (previous === 0) return current > 0 ? 100 : 0;
|
||||
return Math.round(((current - previous) / previous) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate today's sales from sales records
|
||||
*/
|
||||
function calculateTodaySales(salesData?: SalesAnalytics): { amount: number; trend: number; productsSold: number; productsTrend: number } {
|
||||
if (!salesData) {
|
||||
return { amount: 0, trend: 0, productsSold: 0, productsTrend: 0 };
|
||||
}
|
||||
|
||||
// Sales data should have today's revenue and comparison
|
||||
const todayRevenue = salesData.total_revenue || 0;
|
||||
const previousRevenue = salesData.previous_period_revenue || 0;
|
||||
const todayUnits = salesData.total_units_sold || 0;
|
||||
const previousUnits = salesData.previous_period_units_sold || 0;
|
||||
|
||||
return {
|
||||
amount: todayRevenue,
|
||||
trend: calculateTrend(todayRevenue, previousRevenue),
|
||||
productsSold: todayUnits,
|
||||
productsTrend: calculateTrend(todayUnits, previousUnits),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate orders metrics
|
||||
*/
|
||||
function calculateOrdersMetrics(ordersData?: OrdersDashboardSummary): { pending: number; today: number; trend: number } {
|
||||
if (!ordersData) {
|
||||
return { pending: 0, today: 0, trend: 0 };
|
||||
}
|
||||
|
||||
const pendingCount = ordersData.pending_orders_count || 0;
|
||||
const todayCount = ordersData.orders_today_count || 0;
|
||||
const yesterdayCount = ordersData.orders_yesterday_count || 0;
|
||||
|
||||
return {
|
||||
pending: pendingCount,
|
||||
today: todayCount,
|
||||
trend: calculateTrend(todayCount, yesterdayCount),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate dashboard data from all services
|
||||
*/
|
||||
function aggregateDashboardStats(data: AggregatedDashboardData): DashboardStats {
|
||||
const sales = calculateTodaySales(data.sales);
|
||||
const orders = calculateOrdersMetrics(data.orders);
|
||||
|
||||
const criticalStockCount =
|
||||
(data.inventory?.low_stock_count || 0) +
|
||||
(data.inventory?.out_of_stock_count || 0);
|
||||
|
||||
return {
|
||||
// Alerts
|
||||
activeAlerts: data.alerts?.activeAlerts || 0,
|
||||
criticalAlerts: data.alerts?.totalAlerts || 0,
|
||||
|
||||
// Orders
|
||||
pendingOrders: orders.pending,
|
||||
ordersToday: orders.today,
|
||||
ordersTrend: orders.trend,
|
||||
|
||||
// Sales
|
||||
salesToday: sales.amount,
|
||||
salesTrend: sales.trend,
|
||||
salesCurrency: '€', // Default to EUR for bakery
|
||||
|
||||
// Inventory
|
||||
criticalStock: criticalStockCount,
|
||||
lowStockCount: data.inventory?.low_stock_count || 0,
|
||||
outOfStockCount: data.inventory?.out_of_stock_count || 0,
|
||||
expiringSoon: data.inventory?.expiring_soon_count || 0,
|
||||
|
||||
// Products
|
||||
productsSoldToday: sales.productsSold,
|
||||
productsSoldTrend: sales.productsTrend,
|
||||
|
||||
// Metadata
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Main hook to fetch aggregated dashboard statistics
|
||||
* Combines data from multiple services into a single cohesive dashboard view
|
||||
*/
|
||||
export const useDashboardStats = (
|
||||
tenantId: string,
|
||||
options?: Omit<UseQueryOptions<DashboardStats, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
// Get today's date range for sales
|
||||
const today = new Date();
|
||||
const todayStr = today.toISOString().split('T')[0];
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const yesterdayStr = yesterday.toISOString().split('T')[0];
|
||||
|
||||
return useQuery<DashboardStats, ApiError>({
|
||||
queryKey: dashboardKeys.stats(tenantId),
|
||||
queryFn: async () => {
|
||||
// Fetch all data in parallel
|
||||
const [alertsData, ordersData, salesData, inventoryData] = await Promise.allSettled([
|
||||
getAlertAnalytics(tenantId, 7),
|
||||
// Note: OrdersService methods are static
|
||||
import('../services/orders').then(({ OrdersService }) =>
|
||||
OrdersService.getDashboardSummary(tenantId)
|
||||
),
|
||||
// Fetch today's sales with comparison to yesterday
|
||||
import('../services/sales').then(({ salesService }) =>
|
||||
salesService.getSalesAnalytics(tenantId, todayStr, todayStr)
|
||||
),
|
||||
inventoryService.getDashboardSummary(tenantId),
|
||||
]);
|
||||
|
||||
// Extract data or use undefined for failed requests
|
||||
const aggregatedData: AggregatedDashboardData = {
|
||||
alerts: alertsData.status === 'fulfilled' ? alertsData.value : undefined,
|
||||
orders: ordersData.status === 'fulfilled' ? ordersData.value : undefined,
|
||||
sales: salesData.status === 'fulfilled' ? salesData.value : undefined,
|
||||
inventory: inventoryData.status === 'fulfilled' ? inventoryData.value : undefined,
|
||||
};
|
||||
|
||||
// Log any failures for debugging
|
||||
if (alertsData.status === 'rejected') {
|
||||
console.warn('[Dashboard] Failed to fetch alerts:', alertsData.reason);
|
||||
}
|
||||
if (ordersData.status === 'rejected') {
|
||||
console.warn('[Dashboard] Failed to fetch orders:', ordersData.reason);
|
||||
}
|
||||
if (salesData.status === 'rejected') {
|
||||
console.warn('[Dashboard] Failed to fetch sales:', salesData.reason);
|
||||
}
|
||||
if (inventoryData.status === 'rejected') {
|
||||
console.warn('[Dashboard] Failed to fetch inventory:', inventoryData.reason);
|
||||
}
|
||||
|
||||
return aggregateDashboardStats(aggregatedData);
|
||||
},
|
||||
enabled: !!tenantId,
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
refetchInterval: 60 * 1000, // Auto-refresh every minute
|
||||
retry: 2, // Retry failed requests twice
|
||||
retryDelay: 1000, // Wait 1s between retries
|
||||
...options,
|
||||
});
|
||||
};
|
||||
259
frontend/src/api/hooks/purchase-orders.ts
Normal file
259
frontend/src/api/hooks/purchase-orders.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Purchase Orders React Query hooks
|
||||
* Handles data fetching and mutations for purchase orders
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
|
||||
import { ApiError } from '../client/apiClient';
|
||||
import type {
|
||||
PurchaseOrderSummary,
|
||||
PurchaseOrderDetail,
|
||||
PurchaseOrderSearchParams,
|
||||
PurchaseOrderUpdateData,
|
||||
PurchaseOrderStatus
|
||||
} from '../services/purchase_orders';
|
||||
import {
|
||||
listPurchaseOrders,
|
||||
getPurchaseOrder,
|
||||
getPendingApprovalPurchaseOrders,
|
||||
getPurchaseOrdersByStatus,
|
||||
updatePurchaseOrder,
|
||||
approvePurchaseOrder,
|
||||
rejectPurchaseOrder,
|
||||
bulkApprovePurchaseOrders,
|
||||
deletePurchaseOrder
|
||||
} from '../services/purchase_orders';
|
||||
|
||||
// Query Keys
|
||||
export const purchaseOrderKeys = {
|
||||
all: ['purchase-orders'] as const,
|
||||
lists: () => [...purchaseOrderKeys.all, 'list'] as const,
|
||||
list: (tenantId: string, params?: PurchaseOrderSearchParams) =>
|
||||
[...purchaseOrderKeys.lists(), tenantId, params] as const,
|
||||
details: () => [...purchaseOrderKeys.all, 'detail'] as const,
|
||||
detail: (tenantId: string, poId: string) =>
|
||||
[...purchaseOrderKeys.details(), tenantId, poId] as const,
|
||||
byStatus: (tenantId: string, status: PurchaseOrderStatus) =>
|
||||
[...purchaseOrderKeys.lists(), tenantId, 'status', status] as const,
|
||||
pendingApproval: (tenantId: string) =>
|
||||
[...purchaseOrderKeys.lists(), tenantId, 'pending-approval'] as const,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Hook to list purchase orders with optional filters
|
||||
*/
|
||||
export const usePurchaseOrders = (
|
||||
tenantId: string,
|
||||
params?: PurchaseOrderSearchParams,
|
||||
options?: Omit<UseQueryOptions<PurchaseOrderSummary[], ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<PurchaseOrderSummary[], ApiError>({
|
||||
queryKey: purchaseOrderKeys.list(tenantId, params),
|
||||
queryFn: () => listPurchaseOrders(tenantId, params),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get pending approval purchase orders
|
||||
*/
|
||||
export const usePendingApprovalPurchaseOrders = (
|
||||
tenantId: string,
|
||||
limit: number = 50,
|
||||
options?: Omit<UseQueryOptions<PurchaseOrderSummary[], ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<PurchaseOrderSummary[], ApiError>({
|
||||
queryKey: purchaseOrderKeys.pendingApproval(tenantId),
|
||||
queryFn: () => getPendingApprovalPurchaseOrders(tenantId, limit),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 15 * 1000, // 15 seconds - more frequent for pending approvals
|
||||
refetchInterval: 30 * 1000, // Auto-refetch every 30 seconds
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get purchase orders by status
|
||||
*/
|
||||
export const usePurchaseOrdersByStatus = (
|
||||
tenantId: string,
|
||||
status: PurchaseOrderStatus,
|
||||
limit: number = 50,
|
||||
options?: Omit<UseQueryOptions<PurchaseOrderSummary[], ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<PurchaseOrderSummary[], ApiError>({
|
||||
queryKey: purchaseOrderKeys.byStatus(tenantId, status),
|
||||
queryFn: () => getPurchaseOrdersByStatus(tenantId, status, limit),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get a single purchase order detail
|
||||
*/
|
||||
export const usePurchaseOrder = (
|
||||
tenantId: string,
|
||||
poId: string,
|
||||
options?: Omit<UseQueryOptions<PurchaseOrderDetail, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<PurchaseOrderDetail, ApiError>({
|
||||
queryKey: purchaseOrderKeys.detail(tenantId, poId),
|
||||
queryFn: () => getPurchaseOrder(tenantId, poId),
|
||||
enabled: !!tenantId && !!poId,
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to update a purchase order
|
||||
*/
|
||||
export const useUpdatePurchaseOrder = (
|
||||
options?: UseMutationOptions<
|
||||
PurchaseOrderDetail,
|
||||
ApiError,
|
||||
{ tenantId: string; poId: string; data: PurchaseOrderUpdateData }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
PurchaseOrderDetail,
|
||||
ApiError,
|
||||
{ tenantId: string; poId: string; data: PurchaseOrderUpdateData }
|
||||
>({
|
||||
mutationFn: ({ tenantId, poId, data }) => updatePurchaseOrder(tenantId, poId, data),
|
||||
onSuccess: (data, variables) => {
|
||||
// Invalidate and refetch related queries
|
||||
queryClient.invalidateQueries({ queryKey: purchaseOrderKeys.lists() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: purchaseOrderKeys.detail(variables.tenantId, variables.poId)
|
||||
});
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to approve a purchase order
|
||||
*/
|
||||
export const useApprovePurchaseOrder = (
|
||||
options?: UseMutationOptions<
|
||||
PurchaseOrderDetail,
|
||||
ApiError,
|
||||
{ tenantId: string; poId: string; notes?: string }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
PurchaseOrderDetail,
|
||||
ApiError,
|
||||
{ tenantId: string; poId: string; notes?: string }
|
||||
>({
|
||||
mutationFn: ({ tenantId, poId, notes }) => approvePurchaseOrder(tenantId, poId, notes),
|
||||
onSuccess: (data, variables) => {
|
||||
// Invalidate pending approvals list
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: purchaseOrderKeys.pendingApproval(variables.tenantId)
|
||||
});
|
||||
// Invalidate all lists
|
||||
queryClient.invalidateQueries({ queryKey: purchaseOrderKeys.lists() });
|
||||
// Invalidate detail
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: purchaseOrderKeys.detail(variables.tenantId, variables.poId)
|
||||
});
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to reject a purchase order
|
||||
*/
|
||||
export const useRejectPurchaseOrder = (
|
||||
options?: UseMutationOptions<
|
||||
PurchaseOrderDetail,
|
||||
ApiError,
|
||||
{ tenantId: string; poId: string; reason: string }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
PurchaseOrderDetail,
|
||||
ApiError,
|
||||
{ tenantId: string; poId: string; reason: string }
|
||||
>({
|
||||
mutationFn: ({ tenantId, poId, reason }) => rejectPurchaseOrder(tenantId, poId, reason),
|
||||
onSuccess: (data, variables) => {
|
||||
// Invalidate pending approvals list
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: purchaseOrderKeys.pendingApproval(variables.tenantId)
|
||||
});
|
||||
// Invalidate all lists
|
||||
queryClient.invalidateQueries({ queryKey: purchaseOrderKeys.lists() });
|
||||
// Invalidate detail
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: purchaseOrderKeys.detail(variables.tenantId, variables.poId)
|
||||
});
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to bulk approve purchase orders
|
||||
*/
|
||||
export const useBulkApprovePurchaseOrders = (
|
||||
options?: UseMutationOptions<
|
||||
PurchaseOrderDetail[],
|
||||
ApiError,
|
||||
{ tenantId: string; poIds: string[]; notes?: string }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
PurchaseOrderDetail[],
|
||||
ApiError,
|
||||
{ tenantId: string; poIds: string[]; notes?: string }
|
||||
>({
|
||||
mutationFn: ({ tenantId, poIds, notes }) => bulkApprovePurchaseOrders(tenantId, poIds, notes),
|
||||
onSuccess: (data, variables) => {
|
||||
// Invalidate all PO queries
|
||||
queryClient.invalidateQueries({ queryKey: purchaseOrderKeys.all });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to delete a purchase order
|
||||
*/
|
||||
export const useDeletePurchaseOrder = (
|
||||
options?: UseMutationOptions<
|
||||
{ message: string },
|
||||
ApiError,
|
||||
{ tenantId: string; poId: string }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
{ message: string },
|
||||
ApiError,
|
||||
{ tenantId: string; poId: string }
|
||||
>({
|
||||
mutationFn: ({ tenantId, poId }) => deletePurchaseOrder(tenantId, poId),
|
||||
onSuccess: (data, variables) => {
|
||||
// Invalidate all PO queries
|
||||
queryClient.invalidateQueries({ queryKey: purchaseOrderKeys.all });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user