Fix and UI imporvements 3

This commit is contained in:
Urtzi Alfaro
2025-12-10 11:23:53 +01:00
parent 46f5158536
commit e116ac244c
20 changed files with 2311 additions and 2948 deletions

View File

@@ -0,0 +1,317 @@
/**
* 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';
// ============================================================
// Types
// ============================================================
export interface DashboardData {
// Raw data from APIs
alerts: any[];
pendingPOs: any[];
productionBatches: any[];
deliveries: any[];
orchestrationSummary: OrchestrationSummary | null;
// 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();
// 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 = new Date(d.expected_delivery_date);
return expectedDate < now;
}).map((d: any) => ({
...d,
hoursOverdue: Math.ceil((now.getTime() - new Date(d.expected_delivery_date).getTime()) / (1000 * 60 * 60)),
}));
const pendingDeliveriesFiltered = deliveries.filter((d: any) => {
if (!isPending(d.status)) return false;
const expectedDate = new Date(d.expected_delivery_date);
return expectedDate >= now;
}).map((d: any) => ({
...d,
hoursUntil: Math.ceil((new Date(d.expected_delivery_date).getTime() - now.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 new Date(plannedStart) < now;
}).map((b: any) => ({
...b,
hoursLate: Math.ceil((now.getTime() - new Date(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 new Date(plannedStart) >= now;
});
// 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,
pendingPOs: enrichedPendingPOs,
productionBatches,
deliveries,
orchestrationSummary,
// 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'].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]);
}

View File

@@ -54,6 +54,13 @@ export interface SharedDashboardData {
pendingPOs: any[];
delayedBatches: any[];
inventoryData: any;
// Execution progress data for health component
executionProgress?: {
overdueDeliveries: number;
lateToStartBatches: number;
allProductionBatches: any[];
overdueDeliveryDetails?: any[];
};
}
// ============================================================
@@ -64,20 +71,39 @@ function buildChecklistItems(
productionDelays: number,
outOfStock: number,
pendingApprovals: number,
alerts: any[]
alerts: any[],
lateToStartBatches: number,
overdueDeliveries: number
): HealthChecklistItem[] {
const items: HealthChecklistItem[] = [];
// Production status (tri-state)
// Production status (tri-state) - includes ON_HOLD batches + late-to-start batches
const productionPrevented = alerts.filter(
a => a.type_class === 'prevented_issue' && a.alert_type?.includes('production')
);
if (productionDelays > 0) {
const totalProductionIssues = productionDelays + lateToStartBatches;
if (totalProductionIssues > 0) {
// Build detailed message based on what types of issues exist
let textKey = 'dashboard.health.production_issues';
let textParams: any = { total: totalProductionIssues };
if (productionDelays > 0 && lateToStartBatches > 0) {
textKey = 'dashboard.health.production_delayed_and_late';
textParams = { delayed: productionDelays, late: lateToStartBatches };
} else if (productionDelays > 0) {
textKey = 'dashboard.health.production_delayed';
textParams = { count: productionDelays };
} else if (lateToStartBatches > 0) {
textKey = 'dashboard.health.production_late_to_start';
textParams = { count: lateToStartBatches };
}
items.push({
icon: 'alert',
textKey: 'dashboard.health.production_delayed',
textParams: { count: productionDelays },
textKey,
textParams,
actionRequired: true,
status: 'needs_you',
actionPath: '/dashboard'
@@ -162,20 +188,28 @@ function buildChecklistItems(
});
}
// Delivery status (tri-state)
const deliveryAlerts = alerts.filter(
a => a.alert_type?.includes('delivery')
// Delivery status (tri-state) - use actual overdue count from execution progress
const deliveryPrevented = alerts.filter(
a => a.type_class === 'prevented_issue' && a.alert_type?.includes('delivery')
);
if (deliveryAlerts.length > 0) {
if (overdueDeliveries > 0) {
items.push({
icon: 'warning',
textKey: 'dashboard.health.deliveries_pending',
textParams: { count: deliveryAlerts.length },
icon: 'alert',
textKey: 'dashboard.health.deliveries_overdue',
textParams: { count: overdueDeliveries },
actionRequired: true,
status: 'needs_you',
actionPath: '/dashboard'
});
} else if (deliveryPrevented.length > 0) {
items.push({
icon: 'ai_handled',
textKey: 'dashboard.health.deliveries_ai_prevented',
textParams: { count: deliveryPrevented.length },
actionRequired: false,
status: 'ai_handled'
});
} else {
items.push({
icon: 'check',
@@ -267,8 +301,8 @@ export function useSharedDashboardData(tenantId: string) {
return useQuery<SharedDashboardData>({
queryKey: ['shared-dashboard-data', tenantId],
queryFn: async () => {
// Fetch data from 4 services in parallel - ONCE per dashboard load
const [alertsResponse, pendingPOs, delayedBatchesResp, inventoryData] = await Promise.all([
// Fetch data from services in parallel - ONCE per dashboard load
const [alertsResponse, pendingPOs, delayedBatchesResp, inventoryData, executionProgressResp] = await Promise.all([
// CHANGED: Add status=active filter and limit to 100 (backend max)
alertService.getEvents(tenantId, {
status: 'active',
@@ -277,6 +311,61 @@ export function useSharedDashboardData(tenantId: string) {
getPendingApprovalPurchaseOrders(tenantId, 100),
productionService.getBatches(tenantId, { status: ProductionStatus.ON_HOLD, page_size: 100 }),
inventoryService.getDashboardSummary(tenantId),
// NEW: Fetch execution progress for timing data
(async () => {
try {
// Fetch production batches and deliveries for timing calculations
const [prodBatches, deliveries] = await Promise.all([
productionService.getBatches(tenantId, {
start_date: new Date().toISOString().split('T')[0],
page_size: 100
}),
ProcurementService.getExpectedDeliveries(tenantId, {
days_ahead: 1,
include_overdue: true
}),
]);
// Calculate late-to-start batches (batches that should have started but haven't)
const now = new Date();
const allBatches = prodBatches?.batches || [];
const lateToStart = allBatches.filter((b: any) => {
// Only check PENDING or SCHEDULED batches (not started yet)
if (b.status !== 'PENDING' && b.status !== 'SCHEDULED') return false;
// Check if batch has a planned start time
const plannedStart = b.planned_start_time;
if (!plannedStart) return false;
// Check if planned start time is in the past (late to start)
return new Date(plannedStart) < now;
});
// Calculate overdue deliveries (pending deliveries with past due date)
const allDelivs = deliveries?.deliveries || [];
const isPending = (s: string) =>
s === 'PENDING' || s === 'sent_to_supplier' || s === 'confirmed';
const overdueDelivs = allDelivs.filter((d: any) =>
isPending(d.status) && new Date(d.expected_delivery_date) < now
);
return {
overdueDeliveries: overdueDelivs.length,
lateToStartBatches: lateToStart.length,
allProductionBatches: allBatches,
overdueDeliveryDetails: overdueDelivs,
};
} catch (err) {
// Fail gracefully - health will still work without execution progress
console.error('Failed to fetch execution progress for health:', err);
return {
overdueDeliveries: 0,
lateToStartBatches: 0,
allProductionBatches: [],
overdueDeliveryDetails: [],
};
}
})(),
]);
// FIX: Alert API returns array directly, not {items: []}
@@ -287,6 +376,7 @@ export function useSharedDashboardData(tenantId: string) {
pendingPOs: pendingPOs || [],
delayedBatches: delayedBatchesResp?.batches || [],
inventoryData: inventoryData || {},
executionProgress: executionProgressResp,
};
},
enabled: !!tenantId,
@@ -335,23 +425,47 @@ export function useBakeryHealthStatus(tenantId: string, sharedData?: SharedDashb
const criticalAlerts = alerts.filter((a: any) => a.priority_level === 'CRITICAL').length;
const aiPreventedCount = alerts.filter((a: any) => a.type_class === 'prevented_issue').length;
const pendingApprovals = pendingPOs.length;
const productionDelays = delayedBatches.length;
const productionDelays = delayedBatches.length; // ON_HOLD batches
const outOfStock = inventoryData?.out_of_stock_items || 0;
// Calculate health status (same logic as Python backend lines 245-268)
// Extract execution progress data (operational delays)
const executionProgress = sharedData?.executionProgress || {
overdueDeliveries: 0,
lateToStartBatches: 0,
allProductionBatches: [],
overdueDeliveryDetails: []
};
const overdueDeliveries = executionProgress.overdueDeliveries;
const lateToStartBatches = executionProgress.lateToStartBatches;
// Calculate health status - UPDATED to include operational delays
let status: 'green' | 'yellow' | 'red' = 'green';
if (criticalAlerts >= 3 || outOfStock > 0 || productionDelays > 2) {
// Red conditions: Include operational delays (overdue deliveries, late batches)
if (
criticalAlerts >= 3 ||
outOfStock > 0 ||
productionDelays > 2 ||
overdueDeliveries > 0 || // NEW: Any overdue delivery = red
lateToStartBatches > 0 // NEW: Any late batch = red
) {
status = 'red';
} else if (criticalAlerts > 0 || pendingApprovals > 0 || productionDelays > 0) {
} else if (
criticalAlerts > 0 ||
pendingApprovals > 0 ||
productionDelays > 0
) {
status = 'yellow';
}
// Generate tri-state checklist (same logic as Python backend lines 93-223)
// Generate tri-state checklist with operational delays
const checklistItems = buildChecklistItems(
productionDelays,
outOfStock,
pendingApprovals,
alerts
alerts,
lateToStartBatches, // NEW
overdueDeliveries // NEW
);
// Get last orchestration run timestamp from orchestrator DB
@@ -372,7 +486,9 @@ export function useBakeryHealthStatus(tenantId: string, sharedData?: SharedDashb
nextScheduledRun: nextRun.toISOString(),
checklistItems,
criticalIssues: criticalAlerts,
pendingActions: pendingApprovals + productionDelays + outOfStock,
// UPDATED: Include all operational delays (approvals, delays, stock, deliveries, late batches)
pendingActions: pendingApprovals + productionDelays + outOfStock +
overdueDeliveries + lateToStartBatches,
aiPreventedIssues: aiPreventedCount,
};
},