/** * 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({ 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(); (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(); 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]); }