Fix Demo enterprise
This commit is contained in:
443
frontend/src/api/hooks/useControlPanelData.ts
Normal file
443
frontend/src/api/hooks/useControlPanelData.ts
Normal file
@@ -0,0 +1,443 @@
|
||||
/**
|
||||
* 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 } 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';
|
||||
|
||||
// ============================================================
|
||||
// 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
|
||||
const allAlerts = [...alerts];
|
||||
if (sseEvents.length > 0) {
|
||||
// Merge SSE events, prioritizing newer events
|
||||
const sseEventIds = new Set(sseEvents.map(e => e.id));
|
||||
const mergedAlerts = alerts.filter(alert => !sseEventIds.has(alert.id));
|
||||
allAlerts.push(...sseEvents);
|
||||
}
|
||||
|
||||
// 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'
|
||||
);
|
||||
|
||||
// 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: 'always',
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
// SSE integration - invalidate query on relevant events
|
||||
useEffect(() => {
|
||||
if (sseAlerts.length > 0 && tenantId) {
|
||||
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_')
|
||||
);
|
||||
|
||||
if (relevantEvents.length > 0) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['control-panel-data', tenantId],
|
||||
refetchType: 'active',
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [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]);
|
||||
}
|
||||
@@ -1,410 +0,0 @@
|
||||
/**
|
||||
* 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 { aiInsightsService } from '../services/aiInsights';
|
||||
import { useBatchNotifications, useDeliveryNotifications, useOrchestrationNotifications } from '../../hooks/useEventNotifications';
|
||||
import { useSSEEvents } from '../../hooks/useSSE';
|
||||
import { parseISO } from 'date-fns';
|
||||
|
||||
// ============================================================
|
||||
// Helper Functions
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Map AI insight category to dashboard block type
|
||||
*/
|
||||
function mapInsightTypeToBlockType(category: string): string {
|
||||
const mapping: Record<string, string> = {
|
||||
'inventory': 'safety_stock',
|
||||
'forecasting': 'demand_forecast',
|
||||
'demand': 'demand_forecast',
|
||||
'procurement': 'cost_optimization',
|
||||
'cost': 'cost_optimization',
|
||||
'production': 'waste_reduction',
|
||||
'quality': 'risk_alert',
|
||||
'efficiency': 'waste_reduction',
|
||||
};
|
||||
return mapping[category] || 'demand_forecast';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map AI insight priority to dashboard impact level
|
||||
*/
|
||||
function mapPriorityToImpact(priority: string): 'high' | 'medium' | 'low' {
|
||||
if (priority === 'critical' || priority === 'high') return 'high';
|
||||
if (priority === 'medium') return 'medium';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 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 and AI insights)
|
||||
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 alerts (API returns array directly or {items: []})
|
||||
const alerts = Array.isArray(alertsResponse) ? alertsResponse : (alertsResponse?.items || []);
|
||||
const productionBatches = productionResponse?.batches || [];
|
||||
const deliveries = deliveriesResponse?.deliveries || [];
|
||||
const aiInsights = aiInsightsResponse?.items || [];
|
||||
|
||||
// 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.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
|
||||
// Prioritize reasoning_data from PO itself, then fall back to alert
|
||||
reasoning_data: po.reasoning_data || reasoningInfo?.reasoning_data,
|
||||
ai_reasoning_summary: po.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
|
||||
};
|
||||
}
|
||||
|
||||
// Map AI insights to dashboard format
|
||||
const mappedAiInsights = aiInsights.map((insight: any) => ({
|
||||
id: insight.id,
|
||||
title: insight.title,
|
||||
description: insight.description,
|
||||
type: mapInsightTypeToBlockType(insight.category),
|
||||
impact: mapPriorityToImpact(insight.priority),
|
||||
impact_value: insight.impact_value?.toString(),
|
||||
impact_currency: insight.impact_unit === 'euros' ? '€' : '',
|
||||
created_at: insight.created_at,
|
||||
recommendation_actions: insight.recommendation_actions || [],
|
||||
}));
|
||||
|
||||
return {
|
||||
// Raw data
|
||||
alerts: deduplicatedAlerts,
|
||||
pendingPOs: enrichedPendingPOs,
|
||||
productionBatches,
|
||||
deliveries,
|
||||
orchestrationSummary,
|
||||
aiInsights: mappedAiInsights,
|
||||
|
||||
// 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'] });
|
||||
const { events: aiInsightEvents } = useSSEEvents({ channels: ['*.ai_insights'] });
|
||||
|
||||
// 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]);
|
||||
|
||||
// Invalidate dashboard data on AI insight events
|
||||
useEffect(() => {
|
||||
if (!aiInsightEvents || aiInsightEvents.length === 0 || !tenantId) return;
|
||||
|
||||
// Any new AI insight should trigger a refresh
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['dashboard-data', tenantId],
|
||||
refetchType: 'active',
|
||||
});
|
||||
}, [aiInsightEvents, tenantId, queryClient]);
|
||||
}
|
||||
@@ -13,8 +13,8 @@
|
||||
// Mirror: app/models/inventory.py
|
||||
|
||||
export enum ProductType {
|
||||
INGREDIENT = 'ingredient',
|
||||
FINISHED_PRODUCT = 'finished_product'
|
||||
INGREDIENT = 'INGREDIENT',
|
||||
FINISHED_PRODUCT = 'FINISHED_PRODUCT'
|
||||
}
|
||||
|
||||
export enum ProductionStage {
|
||||
@@ -26,15 +26,15 @@ export enum ProductionStage {
|
||||
}
|
||||
|
||||
export enum UnitOfMeasure {
|
||||
KILOGRAMS = 'kg',
|
||||
GRAMS = 'g',
|
||||
LITERS = 'l',
|
||||
MILLILITERS = 'ml',
|
||||
UNITS = 'units',
|
||||
PIECES = 'pcs',
|
||||
PACKAGES = 'pkg',
|
||||
BAGS = 'bags',
|
||||
BOXES = 'boxes'
|
||||
KILOGRAMS = 'KILOGRAMS',
|
||||
GRAMS = 'GRAMS',
|
||||
LITERS = 'LITERS',
|
||||
MILLILITERS = 'MILLILITERS',
|
||||
UNITS = 'UNITS',
|
||||
PIECES = 'PIECES',
|
||||
PACKAGES = 'PACKAGES',
|
||||
BAGS = 'BAGS',
|
||||
BOXES = 'BOXES'
|
||||
}
|
||||
|
||||
export enum IngredientCategory {
|
||||
|
||||
Reference in New Issue
Block a user