526 lines
20 KiB
TypeScript
526 lines
20 KiB
TypeScript
/**
|
|
* 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<string>, deliveryIds: Set<string>): {
|
|
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<any[]>([]);
|
|
|
|
// 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<ControlPanelData>({
|
|
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<string, string>();
|
|
(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<string, number>, 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<NodeJS.Timeout | null>(null);
|
|
const lastEventCountRef = useRef<number>(0);
|
|
// Track when the initial data was successfully fetched to avoid immediate SSE refetches
|
|
const initialLoadTimestampRef = useRef<number | null>(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]);
|
|
} |