347 lines
14 KiB
TypeScript
347 lines
14 KiB
TypeScript
/**
|
|
* Unified Dashboard Data Hook
|
|
*
|
|
* Single data fetch for all 4 dashboard blocks.
|
|
* Fetches data once and computes derived values for efficiency.
|
|
*/
|
|
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useEffect } from 'react';
|
|
import { alertService } from '../services/alertService';
|
|
import { getPendingApprovalPurchaseOrders } from '../services/purchase_orders';
|
|
import { productionService } from '../services/production';
|
|
import { ProcurementService } from '../services/procurement-service';
|
|
import * as orchestratorService from '../services/orchestrator';
|
|
import { suppliersService } from '../services/suppliers';
|
|
import { useBatchNotifications, useDeliveryNotifications, useOrchestrationNotifications } from '../../hooks/useEventNotifications';
|
|
import { useSSEEvents } from '../../hooks/useSSE';
|
|
import { parseISO } from 'date-fns';
|
|
|
|
// ============================================================
|
|
// Types
|
|
// ============================================================
|
|
|
|
export interface DashboardData {
|
|
// Raw data from APIs
|
|
alerts: any[];
|
|
pendingPOs: any[];
|
|
productionBatches: any[];
|
|
deliveries: any[];
|
|
orchestrationSummary: OrchestrationSummary | null;
|
|
aiInsights: any[]; // AI-generated insights for professional/enterprise tiers
|
|
|
|
// Computed/derived data
|
|
preventedIssues: any[];
|
|
issuesRequiringAction: number;
|
|
issuesPreventedByAI: number;
|
|
|
|
// Filtered data for blocks
|
|
overdueDeliveries: any[];
|
|
pendingDeliveries: any[];
|
|
lateToStartBatches: any[];
|
|
runningBatches: any[];
|
|
pendingBatches: any[];
|
|
}
|
|
|
|
export interface OrchestrationSummary {
|
|
runTimestamp: string | null;
|
|
runNumber?: number;
|
|
status: string;
|
|
purchaseOrdersCreated: number;
|
|
productionBatchesCreated: number;
|
|
userActionsRequired: number;
|
|
aiHandlingRate?: number;
|
|
estimatedSavingsEur?: number;
|
|
}
|
|
|
|
// ============================================================
|
|
// Main Hook
|
|
// ============================================================
|
|
|
|
/**
|
|
* Unified dashboard data hook.
|
|
* Fetches ALL data needed by the 4 dashboard blocks in a single parallel request.
|
|
*
|
|
* @param tenantId - Tenant identifier
|
|
* @returns Dashboard data for all blocks
|
|
*/
|
|
export function useDashboardData(tenantId: string) {
|
|
const queryClient = useQueryClient();
|
|
|
|
const query = useQuery<DashboardData>({
|
|
queryKey: ['dashboard-data', tenantId],
|
|
queryFn: async () => {
|
|
const today = new Date().toISOString().split('T')[0];
|
|
const now = new Date(); // Keep for local time display
|
|
const nowUTC = new Date(); // UTC time for accurate comparison with API dates
|
|
|
|
// Parallel fetch ALL data needed by all 4 blocks (including suppliers for PO enrichment)
|
|
const [alertsResponse, pendingPOs, productionResponse, deliveriesResponse, orchestration, suppliers] = await Promise.all([
|
|
alertService.getEvents(tenantId, { status: 'active', limit: 100 }).catch(() => []),
|
|
getPendingApprovalPurchaseOrders(tenantId, 100).catch(() => []),
|
|
productionService.getBatches(tenantId, { start_date: today, page_size: 100 }).catch(() => ({ batches: [] })),
|
|
ProcurementService.getExpectedDeliveries(tenantId, { days_ahead: 1, include_overdue: true }).catch(() => ({ deliveries: [] })),
|
|
orchestratorService.getLastOrchestrationRun(tenantId).catch(() => null),
|
|
suppliersService.getSuppliers(tenantId).catch(() => []),
|
|
]);
|
|
|
|
// Normalize alerts (API returns array directly or {items: []})
|
|
const alerts = Array.isArray(alertsResponse) ? alertsResponse : (alertsResponse?.items || []);
|
|
const productionBatches = productionResponse?.batches || [];
|
|
const deliveries = deliveriesResponse?.deliveries || [];
|
|
|
|
// Create supplier ID -> supplier name map for quick lookup
|
|
const supplierMap = new Map<string, string>();
|
|
(suppliers || []).forEach((supplier: any) => {
|
|
supplierMap.set(supplier.id, supplier.name || supplier.supplier_name);
|
|
});
|
|
|
|
// Compute derived data - prevented issues and action-needed counts
|
|
const preventedIssues = alerts.filter((a: any) => a.type_class === 'prevented_issue');
|
|
const actionNeededAlerts = alerts.filter((a: any) =>
|
|
a.type_class === 'action_needed' &&
|
|
!a.hidden_from_ui &&
|
|
a.status === 'active'
|
|
);
|
|
|
|
// Find PO approval alerts to get reasoning data
|
|
const poApprovalAlerts = alerts.filter((a: any) =>
|
|
a.event_type === 'po_approval_needed' ||
|
|
a.event_type === 'purchase_order_created'
|
|
);
|
|
|
|
// Create a map of PO ID -> reasoning data from alerts
|
|
const poReasoningMap = new Map<string, any>();
|
|
poApprovalAlerts.forEach((alert: any) => {
|
|
// Get PO ID from multiple possible locations
|
|
const poId = alert.event_metadata?.po_id ||
|
|
alert.entity_links?.purchase_order ||
|
|
alert.entity_id ||
|
|
alert.metadata?.purchase_order_id ||
|
|
alert.reference_id;
|
|
|
|
// Get reasoning data from multiple possible locations
|
|
const reasoningData = alert.event_metadata?.reasoning_data ||
|
|
alert.ai_reasoning_details ||
|
|
alert.reasoning_data ||
|
|
alert.ai_reasoning ||
|
|
alert.metadata?.reasoning;
|
|
|
|
// Get supplier name from reasoning data (which has the actual name, not the placeholder)
|
|
const supplierNameFromReasoning = reasoningData?.parameters?.supplier_name;
|
|
|
|
if (poId && reasoningData) {
|
|
poReasoningMap.set(poId, {
|
|
reasoning_data: reasoningData,
|
|
ai_reasoning_summary: alert.ai_reasoning_summary || alert.description || alert.i18n?.message_key,
|
|
supplier_name_from_alert: supplierNameFromReasoning, // Real supplier name from alert reasoning
|
|
});
|
|
}
|
|
});
|
|
|
|
// Enrich POs with reasoning data from alerts AND supplier names
|
|
const enrichedPendingPOs = (pendingPOs || []).map((po: any) => {
|
|
const reasoningInfo = poReasoningMap.get(po.id);
|
|
// Prioritize supplier name from alert reasoning (has actual name in demo data)
|
|
const supplierName = reasoningInfo?.supplier_name_from_alert ||
|
|
supplierMap.get(po.supplier_id) ||
|
|
po.supplier_name;
|
|
|
|
return {
|
|
...po,
|
|
supplier_name: supplierName, // Enrich with actual supplier name
|
|
reasoning_data: reasoningInfo?.reasoning_data,
|
|
ai_reasoning_summary: reasoningInfo?.ai_reasoning_summary,
|
|
};
|
|
});
|
|
|
|
// Filter deliveries by status
|
|
const isPending = (status: string) =>
|
|
status === 'PENDING' || status === 'sent_to_supplier' || status === 'confirmed';
|
|
|
|
const overdueDeliveries = deliveries.filter((d: any) => {
|
|
if (!isPending(d.status)) return false;
|
|
const expectedDate = parseISO(d.expected_delivery_date); // Proper UTC parsing
|
|
return expectedDate < nowUTC;
|
|
}).map((d: any) => ({
|
|
...d,
|
|
hoursOverdue: Math.ceil((nowUTC.getTime() - parseISO(d.expected_delivery_date).getTime()) / (1000 * 60 * 60)),
|
|
}));
|
|
|
|
const pendingDeliveriesFiltered = deliveries.filter((d: any) => {
|
|
if (!isPending(d.status)) return false;
|
|
const expectedDate = parseISO(d.expected_delivery_date); // Proper UTC parsing
|
|
return expectedDate >= nowUTC;
|
|
}).map((d: any) => ({
|
|
...d,
|
|
hoursUntil: Math.ceil((parseISO(d.expected_delivery_date).getTime() - nowUTC.getTime()) / (1000 * 60 * 60)),
|
|
}));
|
|
|
|
// Filter production batches by status
|
|
const lateToStartBatches = productionBatches.filter((b: any) => {
|
|
const status = b.status?.toUpperCase();
|
|
if (status !== 'PENDING' && status !== 'SCHEDULED') return false;
|
|
const plannedStart = b.planned_start_time;
|
|
if (!plannedStart) return false;
|
|
return parseISO(plannedStart) < nowUTC;
|
|
}).map((b: any) => ({
|
|
...b,
|
|
hoursLate: Math.ceil((nowUTC.getTime() - parseISO(b.planned_start_time).getTime()) / (1000 * 60 * 60)),
|
|
}));
|
|
|
|
const runningBatches = productionBatches.filter((b: any) =>
|
|
b.status?.toUpperCase() === 'IN_PROGRESS'
|
|
);
|
|
|
|
const pendingBatchesFiltered = productionBatches.filter((b: any) => {
|
|
const status = b.status?.toUpperCase();
|
|
if (status !== 'PENDING' && status !== 'SCHEDULED') return false;
|
|
const plannedStart = b.planned_start_time;
|
|
if (!plannedStart) return true; // No planned start, count as pending
|
|
return parseISO(plannedStart) >= nowUTC;
|
|
});
|
|
|
|
// Create set of batch IDs that we already show in the UI (late or running)
|
|
const lateBatchIds = new Set(lateToStartBatches.map((b: any) => b.id));
|
|
const runningBatchIds = new Set(runningBatches.map((b: any) => b.id));
|
|
|
|
// Filter alerts to exclude those for batches already shown in the UI
|
|
// This prevents duplicate display: batch card + separate alert for the same batch
|
|
const deduplicatedAlerts = alerts.filter((a: any) => {
|
|
const eventType = a.event_type || '';
|
|
const batchId = a.event_metadata?.batch_id || a.entity_links?.production_batch;
|
|
|
|
if (!batchId) return true; // Keep alerts not related to batches
|
|
|
|
// Filter out batch_start_delayed alerts for batches shown in "late to start" section
|
|
if (eventType.includes('batch_start_delayed') && lateBatchIds.has(batchId)) {
|
|
return false; // Already shown as late batch
|
|
}
|
|
|
|
// Filter out production_delay alerts for batches shown in "running" section
|
|
if (eventType.includes('production_delay') && runningBatchIds.has(batchId)) {
|
|
return false; // Already shown as running batch (with progress bar showing delay)
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
// Build orchestration summary
|
|
// Note: The API only returns timestamp and runNumber, other stats are computed/estimated
|
|
let orchestrationSummary: OrchestrationSummary | null = null;
|
|
if (orchestration && orchestration.timestamp) {
|
|
orchestrationSummary = {
|
|
runTimestamp: orchestration.timestamp,
|
|
runNumber: orchestration.runNumber ?? undefined,
|
|
status: 'completed',
|
|
purchaseOrdersCreated: enrichedPendingPOs.length, // Estimate from pending POs
|
|
productionBatchesCreated: productionBatches.length,
|
|
userActionsRequired: actionNeededAlerts.length,
|
|
aiHandlingRate: preventedIssues.length > 0
|
|
? Math.round((preventedIssues.length / (preventedIssues.length + actionNeededAlerts.length)) * 100)
|
|
: undefined,
|
|
estimatedSavingsEur: preventedIssues.length * 50, // Rough estimate: €50 per prevented issue
|
|
};
|
|
}
|
|
|
|
return {
|
|
// Raw data
|
|
alerts: deduplicatedAlerts,
|
|
pendingPOs: enrichedPendingPOs,
|
|
productionBatches,
|
|
deliveries,
|
|
orchestrationSummary,
|
|
aiInsights: [], // AI-generated insights for professional/enterprise tiers
|
|
|
|
// Computed
|
|
preventedIssues,
|
|
issuesRequiringAction: actionNeededAlerts.length,
|
|
issuesPreventedByAI: preventedIssues.length,
|
|
|
|
// Filtered for blocks
|
|
overdueDeliveries,
|
|
pendingDeliveries: pendingDeliveriesFiltered,
|
|
lateToStartBatches,
|
|
runningBatches,
|
|
pendingBatches: pendingBatchesFiltered,
|
|
};
|
|
},
|
|
enabled: !!tenantId,
|
|
staleTime: 20000, // 20 seconds
|
|
refetchOnMount: 'always',
|
|
retry: 2,
|
|
});
|
|
|
|
return query;
|
|
}
|
|
|
|
// ============================================================
|
|
// Real-time SSE Hook
|
|
// ============================================================
|
|
|
|
/**
|
|
* Real-time dashboard synchronization via SSE.
|
|
* Invalidates the dashboard-data query when relevant events occur.
|
|
*
|
|
* @param tenantId - Tenant identifier
|
|
*/
|
|
export function useDashboardRealtimeSync(tenantId: string) {
|
|
const queryClient = useQueryClient();
|
|
|
|
// Subscribe to SSE notifications
|
|
const { notifications: batchNotifications } = useBatchNotifications();
|
|
const { notifications: deliveryNotifications } = useDeliveryNotifications();
|
|
const { recentNotifications: orchestrationNotifications } = useOrchestrationNotifications();
|
|
const { events: alertEvents } = useSSEEvents({ channels: ['*.alerts'] });
|
|
|
|
// Invalidate dashboard data on batch events
|
|
useEffect(() => {
|
|
if (batchNotifications.length === 0 || !tenantId) return;
|
|
|
|
const latest = batchNotifications[0];
|
|
if (['batch_completed', 'batch_started', 'batch_state_changed'].includes(latest.event_type)) {
|
|
queryClient.invalidateQueries({
|
|
queryKey: ['dashboard-data', tenantId],
|
|
refetchType: 'active',
|
|
});
|
|
}
|
|
}, [batchNotifications, tenantId, queryClient]);
|
|
|
|
// Invalidate dashboard data on delivery events
|
|
useEffect(() => {
|
|
if (deliveryNotifications.length === 0 || !tenantId) return;
|
|
|
|
const latest = deliveryNotifications[0];
|
|
if (['delivery_received', 'delivery_overdue', 'delivery_arriving_soon', 'stock_receipt_incomplete'].includes(latest.event_type)) {
|
|
queryClient.invalidateQueries({
|
|
queryKey: ['dashboard-data', tenantId],
|
|
refetchType: 'active',
|
|
});
|
|
}
|
|
}, [deliveryNotifications, tenantId, queryClient]);
|
|
|
|
// Invalidate dashboard data on orchestration events
|
|
useEffect(() => {
|
|
if (orchestrationNotifications.length === 0 || !tenantId) return;
|
|
|
|
const latest = orchestrationNotifications[0];
|
|
if (latest.event_type === 'orchestration_run_completed') {
|
|
queryClient.invalidateQueries({
|
|
queryKey: ['dashboard-data', tenantId],
|
|
refetchType: 'active',
|
|
});
|
|
}
|
|
}, [orchestrationNotifications, tenantId, queryClient]);
|
|
|
|
// Invalidate dashboard data on alert events
|
|
useEffect(() => {
|
|
if (!alertEvents || alertEvents.length === 0 || !tenantId) return;
|
|
|
|
// Any new alert should trigger a refresh
|
|
queryClient.invalidateQueries({
|
|
queryKey: ['dashboard-data', tenantId],
|
|
refetchType: 'active',
|
|
});
|
|
}, [alertEvents, tenantId, queryClient]);
|
|
}
|