/** * Enhanced Control Panel Data Hook * * Handles initial API fetch, SSE integration, and data merging with priority rules * for the control panel page. */ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useState, useCallback, useRef } 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 { aiInsightsService } from '../services/aiInsights'; import { useSSEEvents } from '../../hooks/useSSE'; import { parseISO } from 'date-fns'; // Debounce delay for SSE-triggered query invalidations (ms) const SSE_INVALIDATION_DEBOUNCE_MS = 500; // Delay before SSE invalidations are allowed after initial load (ms) // This prevents duplicate API calls when SSE events arrive during/right after initial fetch const SSE_INITIAL_LOAD_GRACE_PERIOD_MS = 3000; // ============================================================ // Types // ============================================================ export interface ControlPanelData { // Raw data from APIs alerts: any[]; pendingPOs: any[]; productionBatches: any[]; deliveries: any[]; orchestrationSummary: OrchestrationSummary | null; aiInsights: any[]; // Computed/derived data preventedIssues: any[]; issuesRequiringAction: number; issuesPreventedByAI: number; // Filtered data for blocks overdueDeliveries: any[]; pendingDeliveries: any[]; lateToStartBatches: any[]; runningBatches: any[]; pendingBatches: any[]; // Categorized alerts equipmentAlerts: any[]; productionAlerts: any[]; otherAlerts: any[]; } export interface OrchestrationSummary { runTimestamp: string | null; runNumber?: number; status: string; purchaseOrdersCreated: number; productionBatchesCreated: number; userActionsRequired: number; aiHandlingRate?: number; estimatedSavingsEur?: number; } // ============================================================ // Data Priority and Merging Logic // ============================================================ /** * Merge data with priority rules: * 1. Services API data takes precedence * 2. Alerts data enriches services data * 3. Alerts data is used as fallback when no services data exists * 4. Deduplicate alerts for entities already shown in UI */ function mergeDataWithPriority( servicesData: any, alertsData: any, entityType: 'po' | 'batch' | 'delivery' ): any[] { const mergedEntities = [...servicesData]; const servicesEntityIds = new Set(servicesData.map((entity: any) => entity.id)); // Enrich services data with alerts data const enrichedEntities = mergedEntities.map(entity => { const matchingAlert = alertsData.find((alert: any) => alert.entity_links?.[entityType === 'po' ? 'purchase_order' : entityType === 'batch' ? 'production_batch' : 'delivery'] === entity.id ); if (matchingAlert) { return { ...entity, alert_reasoning: matchingAlert.reasoning_data, alert_priority: matchingAlert.priority_level, alert_timestamp: matchingAlert.timestamp, }; } return entity; }); // Add alerts data as fallback for entities not in services data alertsData.forEach((alert: any) => { const entityId = alert.entity_links?.[entityType === 'po' ? 'purchase_order' : entityType === 'batch' ? 'production_batch' : 'delivery']; if (entityId && !servicesEntityIds.has(entityId)) { // Create a synthetic entity from alert data const syntheticEntity = { id: entityId, status: alert.event_metadata?.status || 'UNKNOWN', alert_reasoning: alert.reasoning_data, alert_priority: alert.priority_level, alert_timestamp: alert.timestamp, source: 'alert_fallback', }; // Add entity-specific fields from alert metadata if (entityType === 'po') { (syntheticEntity as any).supplier_id = alert.event_metadata?.supplier_id; (syntheticEntity as any).po_number = alert.event_metadata?.po_number; } else if (entityType === 'batch') { (syntheticEntity as any).batch_number = alert.event_metadata?.batch_number; (syntheticEntity as any).product_id = alert.event_metadata?.product_id; } else if (entityType === 'delivery') { (syntheticEntity as any).expected_delivery_date = alert.event_metadata?.expected_delivery_date; } enrichedEntities.push(syntheticEntity); } }); return enrichedEntities; } /** * Categorize alerts by type */ function categorizeAlerts(alerts: any[], batchIds: Set, deliveryIds: Set): { equipmentAlerts: any[], productionAlerts: any[], otherAlerts: any[] } { const equipmentAlerts: any[] = []; const productionAlerts: any[] = []; const otherAlerts: any[] = []; alerts.forEach(alert => { const eventType = alert.event_type || ''; const batchId = alert.event_metadata?.batch_id || alert.entity_links?.production_batch; const deliveryId = alert.event_metadata?.delivery_id || alert.entity_links?.delivery; // Equipment alerts if (eventType.includes('equipment_') || eventType.includes('maintenance') || eventType.includes('machine_failure')) { equipmentAlerts.push(alert); } // Production alerts (not equipment-related) else if (eventType.includes('production.') || eventType.includes('batch_') || eventType.includes('production_') || eventType.includes('delay') || (batchId && !batchIds.has(batchId))) { productionAlerts.push(alert); } // Other alerts else { otherAlerts.push(alert); } }); return { equipmentAlerts, productionAlerts, otherAlerts }; } // ============================================================ // Main Hook // ============================================================ export function useControlPanelData(tenantId: string) { const queryClient = useQueryClient(); const [sseEvents, setSseEvents] = useState([]); // Subscribe to SSE events for control panel const { events: sseAlerts } = useSSEEvents({ channels: ['*.alerts', '*.notifications', 'recommendations'] }); // Update SSE events state when new events arrive useEffect(() => { if (sseAlerts.length > 0) { setSseEvents(prev => { // Deduplicate by event ID const eventIds = new Set(prev.map(e => e.id)); const newEvents = sseAlerts.filter(event => !eventIds.has(event.id)); return [...prev, ...newEvents]; }); } }, [sseAlerts]); const query = useQuery({ queryKey: ['control-panel-data', tenantId], queryFn: async () => { const today = new Date().toISOString().split('T')[0]; const now = new Date(); const nowUTC = new Date(); // Parallel fetch from all services const [alertsResponse, pendingPOs, productionResponse, deliveriesResponse, orchestration, suppliers, aiInsightsResponse] = 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(() => []), aiInsightsService.getInsights(tenantId, { status: 'new', priority: 'high', limit: 5 }).catch(() => ({ items: [], total: 0, limit: 5, offset: 0, has_more: false })), ]); // Normalize responses const alerts = Array.isArray(alertsResponse) ? alertsResponse : (alertsResponse?.items || []); const productionBatches = productionResponse?.batches || []; const deliveries = deliveriesResponse?.deliveries || []; const aiInsights = aiInsightsResponse?.items || []; // Create supplier map const supplierMap = new Map(); (suppliers || []).forEach((supplier: any) => { supplierMap.set(supplier.id, supplier.name || supplier.supplier_name); }); // Merge SSE events with API data (deduplicate by ID, prioritizing SSE events as they're newer) let allAlerts: any[]; if (sseEvents.length > 0) { const sseEventIds = new Set(sseEvents.map(e => e.id)); // Filter out API alerts that also exist in SSE (SSE has newer data) const uniqueApiAlerts = alerts.filter((alert: any) => !sseEventIds.has(alert.id)); allAlerts = [...uniqueApiAlerts, ...sseEvents]; } else { allAlerts = [...alerts]; } // Apply data priority rules for POs const enrichedPendingPOs = mergeDataWithPriority(pendingPOs, allAlerts, 'po'); // Apply data priority rules for batches const enrichedProductionBatches = mergeDataWithPriority(productionBatches, allAlerts, 'batch'); // Apply data priority rules for deliveries const enrichedDeliveries = mergeDataWithPriority(deliveries, allAlerts, 'delivery'); // Filter and categorize data const isPending = (status: string) => status === 'PENDING' || status === 'sent_to_supplier' || status === 'confirmed'; const overdueDeliveries = enrichedDeliveries.filter((d: any) => { if (!isPending(d.status)) return false; const expectedDate = parseISO(d.expected_delivery_date); return expectedDate < nowUTC; }).map((d: any) => ({ ...d, hoursOverdue: Math.ceil((nowUTC.getTime() - parseISO(d.expected_delivery_date).getTime()) / (1000 * 60 * 60)), })); const pendingDeliveriesFiltered = enrichedDeliveries.filter((d: any) => { if (!isPending(d.status)) return false; const expectedDate = parseISO(d.expected_delivery_date); return expectedDate >= nowUTC; }).map((d: any) => ({ ...d, hoursUntil: Math.ceil((parseISO(d.expected_delivery_date).getTime() - nowUTC.getTime()) / (1000 * 60 * 60)), })); // Filter production batches const lateToStartBatches = enrichedProductionBatches.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 = enrichedProductionBatches.filter((b: any) => b.status?.toUpperCase() === 'IN_PROGRESS' ); const pendingBatchesFiltered = enrichedProductionBatches.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; return parseISO(plannedStart) >= nowUTC; }); // Create sets for deduplication const lateBatchIds = new Set(lateToStartBatches.map((b: any) => b.id)); const runningBatchIds = new Set(runningBatches.map((b: any) => b.id)); const deliveryIds = new Set([...overdueDeliveries, ...pendingDeliveriesFiltered].map((d: any) => d.id)); // Create array of all batch IDs for categorization const allBatchIds = new Set([ ...Array.from(lateBatchIds), ...Array.from(runningBatchIds), ...pendingBatchesFiltered.map((b: any) => b.id) ]); // Categorize alerts and filter out duplicates for batches already shown const { equipmentAlerts, productionAlerts, otherAlerts } = categorizeAlerts( allAlerts, allBatchIds, deliveryIds ); // Additional deduplication: filter out equipment alerts for batches already shown in UI const deduplicatedEquipmentAlerts = equipmentAlerts.filter(alert => { const batchId = alert.event_metadata?.batch_id || alert.entity_links?.production_batch; if (batchId && allBatchIds.has(batchId)) { return false; // Filter out if batch is already shown } return true; }); // Compute derived data const preventedIssues = allAlerts.filter((a: any) => a.type_class === 'prevented_issue'); const actionNeededAlerts = allAlerts.filter((a: any) => a.type_class === 'action_needed' && !a.hidden_from_ui && a.status === 'active' ); // Debug: Log alert counts by type_class console.log('📊 [useControlPanelData] Alert analysis:', { totalAlerts: allAlerts.length, fromAPI: alerts.length, fromSSE: sseEvents.length, preventedIssuesCount: preventedIssues.length, actionNeededCount: actionNeededAlerts.length, typeClassBreakdown: allAlerts.reduce((acc: Record, a: any) => { const typeClass = a.type_class || 'unknown'; acc[typeClass] = (acc[typeClass] || 0) + 1; return acc; }, {}), apiAlertsSample: alerts.slice(0, 3).map((a: any) => ({ id: a.id, event_type: a.event_type, type_class: a.type_class, status: a.status, })), sseEventsSample: sseEvents.slice(0, 3).map((a: any) => ({ id: a.id, event_type: a.event_type, type_class: a.type_class, status: a.status, })), }); // Calculate total issues requiring action: // 1. Action needed alerts // 2. Pending PO approvals (each PO requires approval action) // 3. Late to start batches (each requires start action) const issuesRequiringAction = actionNeededAlerts.length + enrichedPendingPOs.length + lateToStartBatches.length; // Build orchestration summary let orchestrationSummary: OrchestrationSummary | null = null; if (orchestration && orchestration.timestamp) { orchestrationSummary = { runTimestamp: orchestration.timestamp, runNumber: orchestration.runNumber ?? undefined, status: 'completed', purchaseOrdersCreated: enrichedPendingPOs.length, productionBatchesCreated: enrichedProductionBatches.length, userActionsRequired: actionNeededAlerts.length, aiHandlingRate: preventedIssues.length > 0 ? Math.round((preventedIssues.length / (preventedIssues.length + actionNeededAlerts.length)) * 100) : undefined, estimatedSavingsEur: preventedIssues.length * 50, }; } return { // Raw data alerts: allAlerts, pendingPOs: enrichedPendingPOs, productionBatches: enrichedProductionBatches, deliveries: enrichedDeliveries, orchestrationSummary, aiInsights, // Computed preventedIssues, issuesRequiringAction, issuesPreventedByAI: preventedIssues.length, // Filtered for blocks overdueDeliveries, pendingDeliveries: pendingDeliveriesFiltered, lateToStartBatches, runningBatches, pendingBatches: pendingBatchesFiltered, // Categorized alerts (deduplicated to prevent showing alerts for batches already in UI) equipmentAlerts: deduplicatedEquipmentAlerts, productionAlerts, otherAlerts, }; }, enabled: !!tenantId, staleTime: 20000, // 20 seconds refetchOnMount: true, refetchOnWindowFocus: false, retry: 2, }); // Ref for debouncing SSE-triggered invalidations const invalidationTimeoutRef = useRef(null); const lastEventCountRef = useRef(0); // Track when the initial data was successfully fetched to avoid immediate SSE refetches const initialLoadTimestampRef = useRef(null); // Update initial load timestamp when query succeeds useEffect(() => { if (query.isSuccess && !initialLoadTimestampRef.current) { initialLoadTimestampRef.current = Date.now(); } }, [query.isSuccess]); // SSE integration - invalidate query on relevant events (debounced) useEffect(() => { // Skip if no new events since last check if (sseAlerts.length === 0 || !tenantId || sseAlerts.length === lastEventCountRef.current) { return; } // OPTIMIZATION: Skip SSE-triggered invalidation during grace period after initial load // This prevents duplicate API calls when SSE events arrive during/right after the initial fetch if (initialLoadTimestampRef.current) { const timeSinceInitialLoad = Date.now() - initialLoadTimestampRef.current; if (timeSinceInitialLoad < SSE_INITIAL_LOAD_GRACE_PERIOD_MS) { // Update the event count ref so we don't process these events later lastEventCountRef.current = sseAlerts.length; return; } } const relevantEvents = sseAlerts.filter(event => event.event_type?.includes('production.') || event.event_type?.includes('batch_') || event.event_type?.includes('delivery') || event.event_type?.includes('purchase_order') || event.event_type?.includes('equipment_') || event.event_type?.includes('insight') || event.event_type?.includes('recommendation') || event.event_type?.includes('ai_') || // Match ai_yield_prediction, ai_*, etc. event.event_class === 'recommendation' ); if (relevantEvents.length > 0) { // Clear existing timeout to debounce rapid events if (invalidationTimeoutRef.current) { clearTimeout(invalidationTimeoutRef.current); } // Debounce the invalidation to prevent multiple rapid refetches invalidationTimeoutRef.current = setTimeout(() => { lastEventCountRef.current = sseAlerts.length; queryClient.invalidateQueries({ queryKey: ['control-panel-data', tenantId], refetchType: 'active', }); }, SSE_INVALIDATION_DEBOUNCE_MS); } // Cleanup timeout on unmount or dependency change return () => { if (invalidationTimeoutRef.current) { clearTimeout(invalidationTimeoutRef.current); } }; }, [sseAlerts, tenantId, queryClient]); return query; } // ============================================================ // Real-time SSE Hook for Control Panel // ============================================================ export function useControlPanelRealtimeSync(tenantId: string) { const queryClient = useQueryClient(); // Subscribe to SSE events const { events: sseEvents } = useSSEEvents({ channels: ['*.alerts', '*.notifications', 'recommendations'] }); // Invalidate control panel data on relevant events useEffect(() => { if (sseEvents.length === 0 || !tenantId) return; const latest = sseEvents[0]; const relevantEventTypes = [ 'batch_completed', 'batch_started', 'batch_state_changed', 'delivery_received', 'delivery_overdue', 'delivery_arriving_soon', 'stock_receipt_incomplete', 'orchestration_run_completed', 'production_delay', 'batch_start_delayed', 'equipment_maintenance' ]; if (relevantEventTypes.includes(latest.event_type)) { queryClient.invalidateQueries({ queryKey: ['control-panel-data', tenantId], refetchType: 'active', }); } }, [sseEvents, tenantId, queryClient]); }