From e116ac244ccd2f4b300537538e808ae583a402ba Mon Sep 17 00:00:00 2001 From: Urtzi Alfaro Date: Wed, 10 Dec 2025 11:23:53 +0100 Subject: [PATCH] Fix and UI imporvements 3 --- SUPPLIER_ID_MISMATCH_ROOT_CAUSE.md | 238 ++++ frontend/src/api/hooks/useDashboardData.ts | 317 +++++ .../src/api/hooks/useProfessionalDashboard.ts | 158 ++- .../dashboard/ExecutionProgressTracker.tsx | 548 --------- .../dashboard/GlanceableHealthHero.tsx | 382 ------ .../IntelligentSystemSummaryCard.tsx | 529 --------- .../dashboard/UnifiedActionQueueCard.tsx | 1041 ----------------- .../blocks/PendingDeliveriesBlock.tsx | 286 +++++ .../blocks/PendingPurchasesBlock.tsx | 321 +++++ .../blocks/ProductionStatusBlock.tsx | 417 +++++++ .../dashboard/blocks/SystemStatusBlock.tsx | 265 +++++ .../src/components/dashboard/blocks/index.ts | 10 + frontend/src/components/dashboard/index.ts | 14 +- .../procurement/ModifyPurchaseOrderModal.tsx | 11 +- .../src/components/ui/AddModal/AddModal.tsx | 18 +- frontend/src/locales/en/dashboard.json | 78 +- frontend/src/locales/es/dashboard.json | 78 +- frontend/src/locales/eu/dashboard.json | 71 ++ frontend/src/pages/app/DashboardPage.tsx | 465 ++------ .../scripts/demo/seed_demo_purchase_orders.py | 12 +- 20 files changed, 2311 insertions(+), 2948 deletions(-) create mode 100644 SUPPLIER_ID_MISMATCH_ROOT_CAUSE.md create mode 100644 frontend/src/api/hooks/useDashboardData.ts delete mode 100644 frontend/src/components/dashboard/ExecutionProgressTracker.tsx delete mode 100644 frontend/src/components/dashboard/GlanceableHealthHero.tsx delete mode 100644 frontend/src/components/dashboard/IntelligentSystemSummaryCard.tsx delete mode 100644 frontend/src/components/dashboard/UnifiedActionQueueCard.tsx create mode 100644 frontend/src/components/dashboard/blocks/PendingDeliveriesBlock.tsx create mode 100644 frontend/src/components/dashboard/blocks/PendingPurchasesBlock.tsx create mode 100644 frontend/src/components/dashboard/blocks/ProductionStatusBlock.tsx create mode 100644 frontend/src/components/dashboard/blocks/SystemStatusBlock.tsx create mode 100644 frontend/src/components/dashboard/blocks/index.ts diff --git a/SUPPLIER_ID_MISMATCH_ROOT_CAUSE.md b/SUPPLIER_ID_MISMATCH_ROOT_CAUSE.md new file mode 100644 index 00000000..0d8eaf06 --- /dev/null +++ b/SUPPLIER_ID_MISMATCH_ROOT_CAUSE.md @@ -0,0 +1,238 @@ +# Root Cause Analysis: Supplier ID Mismatch in Demo Sessions + +## Problem Summary + +In demo sessions, the supplier names are showing as "Unknown" in the Pending Purchases block, even though: +1. The Supplier API returns valid suppliers with real names (e.g., "Lácteos del Valle S.A.") +2. The alerts contain reasoning data with supplier names +3. The PO data has supplier IDs + +## Root Cause + +**The supplier IDs in the alert's reasoning data DO NOT match the cloned supplier IDs.** + +### Why This Happens + +The system uses an XOR-based strategy to generate tenant-specific UUIDs: + +```python +# Formula used in all seed scripts: +supplier_id = uuid.UUID(int=tenant_int ^ base_supplier_int) +``` + +However, **the alert seeding script uses hardcoded placeholder IDs that don't follow this pattern:** + +#### In `seed_enriched_alert_demo.py` (Line 45): +```python +YEAST_SUPPLIER_ID = "supplier-levadura-fresh" # ❌ String ID, not UUID +FLOUR_PO_ID = "po-flour-demo-001" # ❌ String ID, not UUID +``` + +#### In `seed_demo_purchase_orders.py` (Lines 62-67): +```python +# Hardcoded base supplier IDs (correct pattern) +BASE_SUPPLIER_IDS = [ + uuid.UUID("40000000-0000-0000-0000-000000000001"), # Molinos San José S.L. + uuid.UUID("40000000-0000-0000-0000-000000000002"), # Lácteos del Valle S.A. + uuid.UUID("40000000-0000-0000-0000-000000000005"), # Lesaffre Ibérica +] +``` + +These base IDs are then XORed with the tenant ID to create unique supplier IDs for each tenant: + +```python +# Line 136 of seed_demo_purchase_orders.py +tenant_int = int(tenant_id.hex, 16) +base_int = int(base_id.hex, 16) +supplier_id = uuid.UUID(int=tenant_int ^ base_int) # ✅ Correct cloning pattern +``` + +## The Data Flow Mismatch + +### 1. Supplier Seeding (Template Tenants) +File: `services/suppliers/scripts/demo/seed_demo_suppliers.py` + +```python +# Line 155-158: Creates suppliers with XOR-based IDs +base_supplier_id = uuid.UUID(supplier_data["id"]) # From proveedores_es.json +tenant_int = int(tenant_id.hex, 16) +supplier_id = uuid.UUID(int=tenant_int ^ int(base_supplier_id.hex, 16)) +``` + +**Result:** Suppliers are created with tenant-specific UUIDs like: +- `uuid.UUID("6e1f9009-e640-48c7-95c5-17d6e7c1da55")` (example from API response) + +### 2. Purchase Order Seeding (Template Tenants) +File: `services/procurement/scripts/demo/seed_demo_purchase_orders.py` + +```python +# Lines 111-144: Uses same XOR pattern +def get_demo_supplier_ids(tenant_id: uuid.UUID): + tenant_int = int(tenant_id.hex, 16) + + for i, base_id in enumerate(BASE_SUPPLIER_IDS): + base_int = int(base_id.hex, 16) + supplier_id = uuid.UUID(int=tenant_int ^ base_int) # ✅ Matches supplier seeding +``` + +**PO reasoning_data contains:** +```python +reasoning_data = create_po_reasoning_low_stock( + supplier_name=supplier.name, # ✅ CORRECT: Real supplier name like "Lácteos del Valle S.A." + product_names=product_names, + # ... other parameters +) +``` + +**Result:** +- POs are created with correct supplier IDs matching the suppliers +- `reasoning_data.parameters.supplier_name` contains the real supplier name (e.g., "Lácteos del Valle S.A.") + +### 3. Alert Seeding (Demo Sessions) +File: `services/demo_session/scripts/seed_enriched_alert_demo.py` + +**Problem:** Uses hardcoded string IDs instead of XOR-generated UUIDs: + +```python +# Lines 40-46 ❌ WRONG: String IDs instead of proper UUIDs +FLOUR_INGREDIENT_ID = "flour-tipo-55" +YEAST_INGREDIENT_ID = "yeast-fresh" +CROISSANT_PRODUCT_ID = "croissant-mantequilla" +CROISSANT_BATCH_ID = "batch-croissants-001" +YEAST_SUPPLIER_ID = "supplier-levadura-fresh" # ❌ This doesn't match anything! +FLOUR_PO_ID = "po-flour-demo-001" +``` + +These IDs are then embedded in the alert metadata, but they don't match the actual cloned supplier IDs. + +### 4. Session Cloning Process +File: `services/demo_session/app/services/clone_orchestrator.py` + +When a user creates a demo session: +1. **Base template tenant** (e.g., `a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6`) is cloned +2. **Virtual tenant** is created (e.g., `f8e7d6c5-b4a3-2918-1726-354443526178`) +3. **Suppliers are cloned** using XOR pattern: + ```python + # In services/suppliers/app/api/internal_demo.py + new_supplier_id = uuid.UUID(int=virtual_tenant_int ^ base_supplier_int) + ``` +4. **Purchase orders are cloned** with matching supplier IDs +5. **Alerts are generated** but use placeholder string IDs ❌ + +## Why the Frontend Shows "Unknown" + +In `useDashboardData.ts` (line 142-144), the code tries to look up supplier names: + +```typescript +const supplierName = reasoningInfo?.supplier_name_from_alert || // ✅ This works! + supplierMap.get(po.supplier_id) || // ❌ This fails (ID mismatch) + po.supplier_name; // ❌ Fallback also fails +``` + +**However, our fix IS working!** The first line: +```typescript +reasoningInfo?.supplier_name_from_alert +``` + +This extracts the supplier name from the alert's reasoning data, which was correctly set during PO creation in `seed_demo_purchase_orders.py` (line 336): + +```python +reasoning_data = create_po_reasoning_low_stock( + supplier_name=supplier.name, # ✅ Real name like "Lácteos del Valle S.A." + # ... +) +``` + +## The Fix We Applied + +In `useDashboardData.ts` (lines 127, 133-134, 142-144): + +```typescript +// Extract supplier name from reasoning data +const supplierNameFromReasoning = reasoningData?.parameters?.supplier_name; + +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 PO creation +}); + +// Prioritize supplier name from alert reasoning (has actual name in demo data) +const supplierName = reasoningInfo?.supplier_name_from_alert || // ✅ NOW WORKS! + supplierMap.get(po.supplier_id) || + po.supplier_name; +``` + +## Why This Fix Works + +The **PO reasoning data is created during PO seeding**, not during alert seeding. When POs are created in `seed_demo_purchase_orders.py`, the code has access to the real supplier objects: + +```python +# Line 490: Get suppliers using XOR pattern +suppliers = get_demo_supplier_ids(tenant_id) + +# Line 498: Use supplier with correct ID and name +supplier_high_trust = high_trust_suppliers[0] if high_trust_suppliers else suppliers[0] + +# Lines 533-545: Create PO with supplier reference +po3 = await create_purchase_order( + db, tenant_id, supplier_high_trust, # ✅ Has correct ID and name + PurchaseOrderStatus.pending_approval, + Decimal("450.00"), + # ... +) + +# Line 336: Reasoning data includes real supplier name +reasoning_data = create_po_reasoning_low_stock( + supplier_name=supplier.name, # ✅ "Lácteos del Valle S.A." + # ... +) +``` + +## Why the Alert Seeder Doesn't Matter (For This Issue) + +The alert seeder (`seed_enriched_alert_demo.py`) creates generic demo alerts with placeholder IDs, but these are NOT used for the PO approval alerts we see in the dashboard. + +The **actual PO approval alerts are created automatically** by the procurement service when POs are created, and those alerts include the correct reasoning data with real supplier names. + +## Summary + +| Component | Supplier ID Source | Status | +|-----------|-------------------|--------| +| **Supplier Seed** | XOR(tenant_id, base_supplier_id) | ✅ Correct UUID | +| **PO Seed** | XOR(tenant_id, base_supplier_id) | ✅ Correct UUID | +| **PO Reasoning Data** | `supplier.name` (real name) | ✅ "Lácteos del Valle S.A." | +| **Alert Seed** | Hardcoded string "supplier-levadura-fresh" | ❌ Wrong format (but not used for PO alerts) | +| **Session Clone** | XOR(virtual_tenant_id, base_supplier_id) | ✅ Correct UUID | +| **Frontend Lookup** | `supplierMap.get(po.supplier_id)` | ❌ Fails (ID mismatch in demo) | +| **Frontend Fix** | `reasoningInfo?.supplier_name_from_alert` | ✅ WORKS! Gets name from PO reasoning | + +## Verification + +The fix should now work because: +1. ✅ POs are created with `reasoning_data` containing `supplier_name` parameter +2. ✅ Frontend extracts `supplier_name` from `reasoning_data.parameters.supplier_name` +3. ✅ Frontend prioritizes this value over ID lookup +4. ✅ User should now see "Lácteos del Valle S.A." instead of "Unknown" + +## Long-term Fix (Optional) + +To fully resolve the underlying issue, the alert seeder should be updated to use proper XOR-based UUID generation instead of hardcoded string IDs: + +```python +# In seed_enriched_alert_demo.py, replace lines 40-46 with: + +# Demo tenant ID (should match existing demo tenant) +DEMO_TENANT_ID = uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6") + +# Base IDs matching suppliers seed +BASE_SUPPLIER_MOLINOS = uuid.UUID("40000000-0000-0000-0000-000000000001") +BASE_SUPPLIER_LACTEOS = uuid.UUID("40000000-0000-0000-0000-000000000002") + +# Generate tenant-specific IDs using XOR +tenant_int = int(DEMO_TENANT_ID.hex, 16) +MOLINOS_SUPPLIER_ID = uuid.UUID(int=tenant_int ^ int(BASE_SUPPLIER_MOLINOS.hex, 16)) +LACTEOS_SUPPLIER_ID = uuid.UUID(int=tenant_int ^ int(BASE_SUPPLIER_LACTEOS.hex, 16)) +``` + +However, this is not necessary for fixing the current dashboard issue, as PO alerts use the correct reasoning data from PO creation. diff --git a/frontend/src/api/hooks/useDashboardData.ts b/frontend/src/api/hooks/useDashboardData.ts new file mode 100644 index 00000000..c72985a8 --- /dev/null +++ b/frontend/src/api/hooks/useDashboardData.ts @@ -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({ + 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(); + (suppliers || []).forEach((supplier: any) => { + supplierMap.set(supplier.id, supplier.name || supplier.supplier_name); + }); + + // Compute derived data - prevented issues and action-needed counts + const preventedIssues = alerts.filter((a: any) => a.type_class === 'prevented_issue'); + const actionNeededAlerts = alerts.filter((a: any) => + a.type_class === 'action_needed' && + !a.hidden_from_ui && + a.status === 'active' + ); + + // Find PO approval alerts to get reasoning data + const poApprovalAlerts = alerts.filter((a: any) => + a.event_type === 'po_approval_needed' || + a.event_type === 'purchase_order_created' + ); + + // Create a map of PO ID -> reasoning data from alerts + const poReasoningMap = new Map(); + poApprovalAlerts.forEach((alert: any) => { + // Get PO ID from multiple possible locations + const poId = alert.event_metadata?.po_id || + alert.entity_links?.purchase_order || + alert.entity_id || + alert.metadata?.purchase_order_id || + alert.reference_id; + + // Get reasoning data from multiple possible locations + const reasoningData = alert.event_metadata?.reasoning_data || + alert.ai_reasoning_details || + alert.reasoning_data || + alert.ai_reasoning || + alert.metadata?.reasoning; + + // Get supplier name from reasoning data (which has the actual name, not the placeholder) + const supplierNameFromReasoning = reasoningData?.parameters?.supplier_name; + + if (poId && reasoningData) { + poReasoningMap.set(poId, { + reasoning_data: reasoningData, + ai_reasoning_summary: alert.ai_reasoning_summary || alert.description || alert.i18n?.message_key, + supplier_name_from_alert: supplierNameFromReasoning, // Real supplier name from alert reasoning + }); + } + }); + + // Enrich POs with reasoning data from alerts AND supplier names + const enrichedPendingPOs = (pendingPOs || []).map((po: any) => { + const reasoningInfo = poReasoningMap.get(po.id); + // Prioritize supplier name from alert reasoning (has actual name in demo data) + const supplierName = reasoningInfo?.supplier_name_from_alert || + supplierMap.get(po.supplier_id) || + po.supplier_name; + + return { + ...po, + supplier_name: supplierName, // Enrich with actual supplier name + reasoning_data: reasoningInfo?.reasoning_data, + ai_reasoning_summary: reasoningInfo?.ai_reasoning_summary, + }; + }); + + // Filter deliveries by status + const isPending = (status: string) => + status === 'PENDING' || status === 'sent_to_supplier' || status === 'confirmed'; + + const overdueDeliveries = deliveries.filter((d: any) => { + if (!isPending(d.status)) return false; + const expectedDate = 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]); +} diff --git a/frontend/src/api/hooks/useProfessionalDashboard.ts b/frontend/src/api/hooks/useProfessionalDashboard.ts index d9d0bffb..69f72079 100644 --- a/frontend/src/api/hooks/useProfessionalDashboard.ts +++ b/frontend/src/api/hooks/useProfessionalDashboard.ts @@ -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({ 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, }; }, diff --git a/frontend/src/components/dashboard/ExecutionProgressTracker.tsx b/frontend/src/components/dashboard/ExecutionProgressTracker.tsx deleted file mode 100644 index 43c113ec..00000000 --- a/frontend/src/components/dashboard/ExecutionProgressTracker.tsx +++ /dev/null @@ -1,548 +0,0 @@ -// ================================================================ -// frontend/src/components/dashboard/ExecutionProgressTracker.tsx -// ================================================================ -/** - * Execution Progress Tracker - Plan vs Actual - * - * Shows how today's execution is progressing vs the plan. - * Helps identify bottlenecks early (e.g., deliveries running late). - * - * Features: - * - Production progress (plan vs actual batches) - * - Delivery status (received, pending, overdue) - * - Approval tracking - * - "What's next" preview - * - Status indicators (on_track, at_risk, completed) - */ - -import React from 'react'; -import { - Package, - Truck, - CheckCircle, - Clock, - AlertCircle, - TrendingUp, - Calendar, -} from 'lucide-react'; -import { useTranslation } from 'react-i18next'; -import { formatTime as formatTimeUtil } from '../../utils/date'; - -// ============================================================ -// Types -// ============================================================ - -export interface ProductionProgress { - status: 'no_plan' | 'completed' | 'on_track' | 'at_risk'; - total: number; - completed: number; - inProgress: number; - pending: number; - inProgressBatches?: Array<{ - id: string; - batchNumber: string; - productName: string; - quantity: number; - actualStartTime: string; - estimatedCompletion: string; - }>; - nextBatch?: { - productName: string; - plannedStart: string; // ISO datetime - batchNumber: string; - }; -} - -export interface DeliveryInfo { - poId: string; - poNumber: string; - supplierName: string; - supplierPhone?: string; - expectedDeliveryDate: string; - status: string; - lineItems: Array<{ - product_name: string; - quantity: number; - unit: string; - }>; - totalAmount: number; - currency: string; - itemCount: number; - hoursOverdue?: number; - hoursUntil?: number; -} - -export interface DeliveryProgress { - status: 'no_deliveries' | 'completed' | 'on_track' | 'at_risk'; - total: number; - received: number; - pending: number; - overdue: number; - overdueDeliveries?: DeliveryInfo[]; - pendingDeliveries?: DeliveryInfo[]; - receivedDeliveries?: DeliveryInfo[]; -} - -export interface ApprovalProgress { - status: 'completed' | 'on_track' | 'at_risk'; - pending: number; -} - -export interface ExecutionProgress { - production: ProductionProgress; - deliveries: DeliveryProgress; - approvals: ApprovalProgress; -} - -interface ExecutionProgressTrackerProps { - progress: ExecutionProgress | null | undefined; - loading?: boolean; -} - -// ============================================================ -// Helper Functions -// ============================================================ - -function getStatusColor(status: string): { - bg: string; - border: string; - text: string; - icon: string; -} { - switch (status) { - case 'completed': - return { - bg: 'var(--color-success-50)', - border: 'var(--color-success-300)', - text: 'var(--color-success-900)', - icon: 'var(--color-success-600)', - }; - case 'on_track': - return { - bg: 'var(--color-info-50)', - border: 'var(--color-info-300)', - text: 'var(--color-info-900)', - icon: 'var(--color-info-600)', - }; - case 'at_risk': - return { - bg: 'var(--color-error-50)', - border: 'var(--color-error-300)', - text: 'var(--color-error-900)', - icon: 'var(--color-error-600)', - }; - case 'no_plan': - case 'no_deliveries': - return { - bg: 'var(--bg-secondary)', - border: 'var(--border-secondary)', - text: 'var(--text-secondary)', - icon: 'var(--text-tertiary)', - }; - default: - return { - bg: 'var(--bg-secondary)', - border: 'var(--border-secondary)', - text: 'var(--text-primary)', - icon: 'var(--text-secondary)', - }; - } -} - -function formatTime(isoDate: string): string { - return formatTimeUtil(isoDate, 'HH:mm'); -} - -// ============================================================ -// Sub-Components -// ============================================================ - -interface SectionProps { - title: string; - icon: React.ElementType; - status: string; - statusLabel: string; - children: React.ReactNode; -} - -function Section({ title, icon: Icon, status, statusLabel, children }: SectionProps) { - const colors = getStatusColor(status); - - return ( -
- {/* Section Header */} -
-
- -

- {title} -

-
- - {statusLabel} - -
- - {/* Section Content */} - {children} -
- ); -} - -// ============================================================ -// Main Component -// ============================================================ - -export function ExecutionProgressTracker({ - progress, - loading, -}: ExecutionProgressTrackerProps) { - const { t } = useTranslation(['dashboard', 'common']); - - if (loading) { - return ( -
-
-
-
-
-
-
-
- ); - } - - if (!progress) { - return null; - } - - return ( -
- {/* Header with Hero Icon */} -
- {/* Hero Icon */} -
- -
- - {/* Title */} -
-

- {t('dashboard:execution_progress.title')} -

-

- {t('dashboard:execution_progress.subtitle')} -

-
-
- -
- {/* Production Section */} -
- {progress.production.status === 'no_plan' ? ( -

- {t('dashboard:execution_progress.no_production_plan')} -

- ) : ( - <> - {/* Progress Bar */} -
-
- - {progress.production.completed} / {progress.production.total} {t('dashboard:execution_progress.batches_complete')} - - - {Math.round((progress.production.completed / progress.production.total) * 100)}% - -
-
-
-
-
- - {/* Status Breakdown */} -
-
- {t('dashboard:execution_progress.completed')}: - - {progress.production.completed} - -
-
- {t('dashboard:execution_progress.in_progress')}: - - {progress.production.inProgress} - - {progress.production.inProgressBatches && progress.production.inProgressBatches.length > 0 && ( -
- {progress.production.inProgressBatches.map((batch) => ( -
- • {batch.productName} - ({batch.batchNumber}) -
- ))} -
- )} -
-
- {t('dashboard:execution_progress.pending')}: - - {progress.production.pending} - -
-
- - {/* Next Batch */} - {progress.production.nextBatch && ( -
-
- - - {t('dashboard:execution_progress.whats_next')} - -
-

- {progress.production.nextBatch.productName} -

-

- {progress.production.nextBatch.batchNumber} · {t('dashboard:execution_progress.starts_at')}{' '} - {formatTime(progress.production.nextBatch.plannedStart)} -

-
- )} - - )} -
- - {/* Deliveries Section */} -
- {progress.deliveries.status === 'no_deliveries' ? ( -

- {t('dashboard:execution_progress.no_deliveries_today')} -

- ) : ( - <> - {/* Summary Grid */} -
-
-
- -
-
- {progress.deliveries.received} -
-
- {t('dashboard:execution_progress.received')} -
-
- -
-
- -
-
- {progress.deliveries.pending} -
-
- {t('dashboard:execution_progress.pending')} -
-
- -
-
- -
-
- {progress.deliveries.overdue} -
-
- {t('dashboard:execution_progress.overdue')} -
-
-
- - {/* Overdue Deliveries List */} - {progress.deliveries.overdueDeliveries && progress.deliveries.overdueDeliveries.length > 0 && ( -
-
- - {t('dashboard:execution_progress.overdue_deliveries')} -
-
- {progress.deliveries.overdueDeliveries.map((delivery) => ( -
-
-
-
- {delivery.supplierName} -
-
- {delivery.poNumber} · {delivery.hoursOverdue}h {t('dashboard:execution_progress.overdue_label')} -
-
-
- {delivery.totalAmount.toFixed(2)} {delivery.currency} -
-
-
- {delivery.lineItems.slice(0, 2).map((item, idx) => ( -
• {item.product_name} ({item.quantity} {item.unit})
- ))} - {delivery.itemCount > 2 && ( -
+ {delivery.itemCount - 2} {t('dashboard:execution_progress.more_items')}
- )} -
-
- ))} -
-
- )} - - {/* Pending Deliveries List */} - {progress.deliveries.pendingDeliveries && progress.deliveries.pendingDeliveries.length > 0 && ( -
-
- - {t('dashboard:execution_progress.pending_deliveries')} -
-
- {progress.deliveries.pendingDeliveries.slice(0, 3).map((delivery) => ( -
-
-
-
- {delivery.supplierName} -
-
- {delivery.poNumber} · {delivery.hoursUntil !== undefined && delivery.hoursUntil >= 0 - ? `${t('dashboard:execution_progress.arriving_in')} ${delivery.hoursUntil}h` - : formatTime(delivery.expectedDeliveryDate)} -
-
-
- {delivery.totalAmount.toFixed(2)} {delivery.currency} -
-
-
- {delivery.lineItems.slice(0, 2).map((item, idx) => ( -
• {item.product_name} ({item.quantity} {item.unit})
- ))} - {delivery.itemCount > 2 && ( -
+ {delivery.itemCount - 2} {t('dashboard:execution_progress.more_items')}
- )} -
-
- ))} - {progress.deliveries.pendingDeliveries.length > 3 && ( -
- + {progress.deliveries.pendingDeliveries.length - 3} {t('dashboard:execution_progress.more_deliveries')} -
- )} -
-
- )} - - )} -
- - {/* Approvals Section */} -
-
- - {t('dashboard:execution_progress.pending_approvals')} - - - {progress.approvals.pending} - -
-
-
-
- ); -} diff --git a/frontend/src/components/dashboard/GlanceableHealthHero.tsx b/frontend/src/components/dashboard/GlanceableHealthHero.tsx deleted file mode 100644 index 7275565f..00000000 --- a/frontend/src/components/dashboard/GlanceableHealthHero.tsx +++ /dev/null @@ -1,382 +0,0 @@ -// ================================================================ -// frontend/src/components/dashboard/GlanceableHealthHero.tsx -// ================================================================ -/** - * Glanceable Health Hero - Simplified Dashboard Status - * - * JTBD-Aligned Design: - * - Core Job: "Quickly understand if anything requires my immediate attention" - * - Emotional Job: "Feel confident to proceed or know to stop and fix" - * - Design Principle: Progressive disclosure (traffic light → details) - * - * States: - * - 🟢 Green: "Everything looks good - proceed with your day" - * - 🟡 Yellow: "Some items need attention - but not urgent" - * - 🔴 Red: "Critical issues - stop and fix these first" - */ - -import React, { useState, useMemo } from 'react'; -import { CheckCircle, AlertTriangle, AlertCircle, Clock, RefreshCw, Zap, ChevronDown, ChevronUp, ChevronRight } from 'lucide-react'; -import { BakeryHealthStatus } from '../../api/hooks/useProfessionalDashboard'; -import { formatDistanceToNow } from 'date-fns'; -import { es, eu, enUS } from 'date-fns/locale'; -import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; -import { useEventNotifications } from '../../hooks/useEventNotifications'; - -interface GlanceableHealthHeroProps { - healthStatus: BakeryHealthStatus; - loading?: boolean; - urgentActionCount?: number; // New: show count of urgent actions -} - -const statusConfig = { - green: { - bgColor: 'var(--color-success-50)', - borderColor: 'var(--color-success-200)', - textColor: 'var(--color-success-900)', - icon: CheckCircle, - iconColor: 'var(--color-success-600)', - iconBg: 'var(--color-success-100)', - }, - yellow: { - bgColor: 'var(--color-warning-50)', - borderColor: 'var(--color-warning-300)', - textColor: 'var(--color-warning-900)', - icon: AlertTriangle, - iconColor: 'var(--color-warning-600)', - iconBg: 'var(--color-warning-100)', - }, - red: { - bgColor: 'var(--color-error-50)', - borderColor: 'var(--color-error-300)', - textColor: 'var(--color-error-900)', - icon: AlertCircle, - iconColor: 'var(--color-error-600)', - iconBg: 'var(--color-error-100)', - }, -}; - -const iconMap = { - check: CheckCircle, - warning: AlertTriangle, - alert: AlertCircle, - ai_handled: Zap, -}; - -/** - * Helper function to translate keys with proper namespace handling - */ -function translateKey( - key: string, - params: Record, - t: any -): string { - const namespaceMap: Record = { - 'health.': 'dashboard', - 'dashboard.health.': 'dashboard', - 'dashboard.': 'dashboard', - 'reasoning.': 'reasoning', - 'production.': 'production', - 'jtbd.': 'reasoning', - }; - - let namespace = 'common'; - let translationKey = key; - - for (const [prefix, ns] of Object.entries(namespaceMap)) { - if (key.startsWith(prefix)) { - namespace = ns; - if (prefix === 'reasoning.') { - translationKey = key.substring(prefix.length); - } else if (prefix === 'dashboard.health.') { - translationKey = key.substring('dashboard.'.length); - } else if (prefix === 'dashboard.' && !key.startsWith('dashboard.health.')) { - translationKey = key.substring('dashboard.'.length); - } - break; - } - } - - return t(translationKey, { ...params, ns: namespace, defaultValue: key }); -} - -export function GlanceableHealthHero({ healthStatus, loading, urgentActionCount = 0 }: GlanceableHealthHeroProps) { - const { t, i18n } = useTranslation(['dashboard', 'reasoning', 'production']); - const navigate = useNavigate(); - const { notifications } = useEventNotifications(); - const [detailsExpanded, setDetailsExpanded] = useState(false); - - // Get date-fns locale - const dateLocale = i18n.language === 'es' ? es : i18n.language === 'eu' ? eu : enUS; - - // ============================================================================ - // ALL HOOKS MUST BE CALLED BEFORE ANY EARLY RETURNS - // This ensures hooks are called in the same order on every render - // ============================================================================ - - // Optimize notifications filtering - cache the filtered array itself - const criticalAlerts = useMemo(() => { - if (!notifications || notifications.length === 0) return []; - return notifications.filter( - n => n.priority_level === 'critical' && !n.read && n.type_class !== 'prevented_issue' - ); - }, [notifications]); - - const criticalAlertsCount = criticalAlerts.length; - - // Filter prevented issues from last 7 days to match IntelligentSystemSummaryCard - const preventedIssuesCount = useMemo(() => { - if (!notifications || notifications.length === 0) return 0; - const sevenDaysAgo = new Date(); - sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); - - return notifications.filter( - n => n.type_class === 'prevented_issue' && new Date(n.timestamp) >= sevenDaysAgo - ).length; - }, [notifications]); - - // Create stable key for checklist items to prevent infinite re-renders - const checklistItemsKey = useMemo(() => { - if (!healthStatus?.checklistItems || healthStatus.checklistItems.length === 0) return 'empty'; - return healthStatus.checklistItems.map(item => item.textKey).join(','); - }, [healthStatus?.checklistItems]); - - // Update checklist items with real-time data - const updatedChecklistItems = useMemo(() => { - if (!healthStatus?.checklistItems) return []; - - return healthStatus.checklistItems.map(item => { - if (item.textKey === 'dashboard.health.critical_issues' && criticalAlertsCount > 0) { - return { - ...item, - textParams: { ...item.textParams, count: criticalAlertsCount }, - status: 'needs_you' as const, - actionRequired: true, - }; - } - return item; - }); - }, [checklistItemsKey, criticalAlertsCount]); - - // Status and config (use safe defaults for loading state) - const status = healthStatus?.status || 'green'; - const config = statusConfig[status]; - const StatusIcon = config?.icon || (() =>
🟢
); - - // Determine simplified headline for glanceable view (safe for loading state) - const simpleHeadline = useMemo(() => { - if (status === 'green') { - return t('jtbd.health_status.green_simple', { ns: 'reasoning', defaultValue: '✅ Todo listo para hoy' }); - } else if (status === 'yellow') { - if (urgentActionCount > 0) { - return t('jtbd.health_status.yellow_simple_with_count', { count: urgentActionCount, ns: 'reasoning', defaultValue: `⚠️ ${urgentActionCount} acción${urgentActionCount > 1 ? 'es' : ''} necesaria${urgentActionCount > 1 ? 's' : ''}` }); - } - return t('jtbd.health_status.yellow_simple', { ns: 'reasoning', defaultValue: '⚠️ Algunas cosas necesitan atención' }); - } else { - return t('jtbd.health_status.red_simple', { ns: 'reasoning', defaultValue: '🔴 Problemas críticos requieren acción' }); - } - }, [status, urgentActionCount, t]); - - const displayCriticalIssues = criticalAlertsCount > 0 ? criticalAlertsCount : (healthStatus?.criticalIssues || 0); - - // ============================================================================ - // NOW it's safe to early return - all hooks have been called - // ============================================================================ - if (loading || !healthStatus) { - return ( -
-
-
-
- ); - } - - return ( -
- {/* Glanceable Hero View (Always Visible) */} -
-
- {/* Status Icon */} -
- -
- - {/* Headline + Quick Stats */} -
-

- {simpleHeadline} -

- - {/* Quick Stats Row */} -
- {/* Last Update */} -
- - - {healthStatus.lastOrchestrationRun - ? formatDistanceToNow(new Date(healthStatus.lastOrchestrationRun), { - addSuffix: true, - locale: dateLocale, - }) - : t('jtbd.health_status.never', { ns: 'reasoning' })} - -
- - {/* Critical Issues Badge */} - {displayCriticalIssues > 0 && ( -
- 0 ? 'animate-pulse' : ''}`} /> - {displayCriticalIssues} crítico{displayCriticalIssues > 1 ? 's' : ''} -
- )} - - {/* Pending Actions Badge */} - {healthStatus.pendingActions > 0 && ( -
- - {healthStatus.pendingActions} pendiente{healthStatus.pendingActions > 1 ? 's' : ''} -
- )} - - {/* AI Prevented Badge - Show last 7 days to match detail section */} - {preventedIssuesCount > 0 && ( -
- - {preventedIssuesCount} evitado{preventedIssuesCount > 1 ? 's' : ''} -
- )} -
-
- - {/* Expand/Collapse Button */} - -
-
- - {/* Detailed Checklist (Collapsible) */} - {detailsExpanded && ( -
- {/* Full Headline */} -

- {typeof healthStatus.headline === 'object' && healthStatus.headline?.key - ? translateKey(healthStatus.headline.key, healthStatus.headline.params || {}, t) - : healthStatus.headline} -

- - {/* Checklist */} - {updatedChecklistItems && updatedChecklistItems.length > 0 && ( -
- {updatedChecklistItems.map((item, index) => { - // Safely get the icon with proper validation - const SafeIconComponent = iconMap[item.icon]; - const ItemIcon = SafeIconComponent || AlertCircle; - - const getStatusStyles = () => { - switch (item.status) { - case 'good': - return { - iconColor: 'var(--color-success-600)', - bgColor: 'var(--color-success-50)', - borderColor: 'transparent', - }; - case 'ai_handled': - return { - iconColor: 'var(--color-info-600)', - bgColor: 'var(--color-info-50)', - borderColor: 'var(--color-info-300)', - }; - case 'needs_you': - return { - iconColor: 'var(--color-warning-600)', - bgColor: 'var(--color-warning-50)', - borderColor: 'var(--color-warning-300)', - }; - default: - return { - iconColor: item.actionRequired ? 'var(--color-warning-600)' : 'var(--color-success-600)', - bgColor: item.actionRequired ? 'var(--bg-primary)' : 'var(--bg-secondary)', - borderColor: 'transparent', - }; - } - }; - - const styles = getStatusStyles(); - const displayText = item.textKey - ? translateKey(item.textKey, item.textParams || {}, t) - : item.text || ''; - - const handleClick = () => { - if (item.actionPath && (item.status === 'needs_you' || item.actionRequired)) { - navigate(item.actionPath); - } - }; - - const isClickable = Boolean(item.actionPath && (item.status === 'needs_you' || item.actionRequired)); - - return ( -
- - - {displayText} - - {isClickable && ( - - )} -
- ); - })} -
- )} - - {/* Next Check */} - {healthStatus.nextScheduledRun && ( -
- - - {t('jtbd.health_status.next_check', { ns: 'reasoning' })}:{' '} - {formatDistanceToNow(new Date(healthStatus.nextScheduledRun), { addSuffix: true, locale: dateLocale })} - -
- )} -
- )} -
- ); -} diff --git a/frontend/src/components/dashboard/IntelligentSystemSummaryCard.tsx b/frontend/src/components/dashboard/IntelligentSystemSummaryCard.tsx deleted file mode 100644 index 30b5f7d0..00000000 --- a/frontend/src/components/dashboard/IntelligentSystemSummaryCard.tsx +++ /dev/null @@ -1,529 +0,0 @@ -// ================================================================ -// frontend/src/components/dashboard/IntelligentSystemSummaryCard.tsx -// ================================================================ -/** - * Intelligent System Summary Card - Unified AI Impact Component - * - * Simplified design matching GlanceableHealthHero pattern: - * - Clean, scannable header with inline metrics badges - * - Minimal orchestration summary (details shown elsewhere) - * - Progressive disclosure for prevented issues details - */ - -import React, { useState, useEffect, useMemo } from 'react'; -import { - Bot, - TrendingUp, - TrendingDown, - Clock, - CheckCircle, - ChevronDown, - ChevronUp, - Zap, - ShieldCheck, - Euro, - Package, -} from 'lucide-react'; -import { OrchestrationSummary } from '../../api/hooks/useProfessionalDashboard'; -import { useTranslation } from 'react-i18next'; -import { formatTime, formatRelativeTime } from '../../utils/date'; -import { useTenant } from '../../stores/tenant.store'; -import { useEventNotifications } from '../../hooks/useEventNotifications'; -import { Alert } from '../../api/types/events'; -import { renderEventTitle, renderEventMessage } from '../../utils/i18n/alertRendering'; -import { Badge } from '../ui/Badge'; - -interface PeriodComparison { - current_period: { - days: number; - total_alerts: number; - prevented_issues: number; - handling_rate_percentage: number; - }; - previous_period: { - days: number; - total_alerts: number; - prevented_issues: number; - handling_rate_percentage: number; - }; - changes: { - handling_rate_change_percentage: number; - alert_count_change_percentage: number; - trend_direction: 'up' | 'down' | 'stable'; - }; -} - -interface DashboardAnalytics { - period_days: number; - total_alerts: number; - active_alerts: number; - ai_handling_rate: number; - prevented_issues_count: number; - estimated_savings_eur: number; - total_financial_impact_at_risk_eur: number; - period_comparison?: PeriodComparison; -} - -interface IntelligentSystemSummaryCardProps { - orchestrationSummary: OrchestrationSummary; - orchestrationLoading?: boolean; - onWorkflowComplete?: () => void; - className?: string; -} - -export function IntelligentSystemSummaryCard({ - orchestrationSummary, - orchestrationLoading, - onWorkflowComplete, - className = '', -}: IntelligentSystemSummaryCardProps) { - const { t } = useTranslation(['dashboard', 'reasoning']); - const { currentTenant } = useTenant(); - const { notifications } = useEventNotifications(); - - const [analytics, setAnalytics] = useState(null); - const [preventedAlerts, setPreventedAlerts] = useState([]); - const [analyticsLoading, setAnalyticsLoading] = useState(true); - const [preventedIssuesExpanded, setPreventedIssuesExpanded] = useState(false); - const [orchestrationExpanded, setOrchestrationExpanded] = useState(false); - - // Fetch analytics data - useEffect(() => { - const fetchAnalytics = async () => { - if (!currentTenant?.id) { - setAnalyticsLoading(false); - return; - } - - try { - setAnalyticsLoading(true); - const { apiClient } = await import('../../api/client/apiClient'); - - const [analyticsData, alertsData] = await Promise.all([ - apiClient.get( - `/tenants/${currentTenant.id}/alerts/analytics/dashboard`, - { params: { days: 30 } } - ), - apiClient.get<{ alerts: Alert[] }>( - `/tenants/${currentTenant.id}/alerts`, - { params: { limit: 100 } } - ), - ]); - - setAnalytics(analyticsData); - - const sevenDaysAgo = new Date(); - sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); - - const filteredAlerts = (alertsData.alerts || []) - .filter( - (alert) => - alert.type_class === 'prevented_issue' && - new Date(alert.created_at) >= sevenDaysAgo - ) - .slice(0, 20); - - setPreventedAlerts(filteredAlerts); - } catch (err) { - console.error('Error fetching intelligent system data:', err); - } finally { - setAnalyticsLoading(false); - } - }; - - fetchAnalytics(); - }, [currentTenant?.id]); - - // Real-time prevented issues from SSE - merge with API data - const allPreventedAlerts = useMemo(() => { - if (!notifications || notifications.length === 0) return preventedAlerts; - - // Filter SSE notifications for prevented issues from last 7 days - const sevenDaysAgo = new Date(); - sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); - - const ssePreventedIssues = notifications.filter( - (n) => n.type_class === 'prevented_issue' && new Date(n.created_at) >= sevenDaysAgo - ); - - // Deduplicate: combine SSE + API data, removing duplicates by ID - const existingIds = new Set(preventedAlerts.map((a) => a.id)); - const newSSEAlerts = ssePreventedIssues.filter((n) => !existingIds.has(n.id)); - - // Merge and sort by created_at (newest first) - const merged = [...preventedAlerts, ...newSSEAlerts].sort( - (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime() - ); - - return merged.slice(0, 20); // Keep only top 20 - }, [preventedAlerts, notifications]); - - // Calculate metrics - const totalSavings = analytics?.estimated_savings_eur || 0; - const trendPercentage = analytics?.period_comparison?.changes?.handling_rate_change_percentage || 0; - const hasPositiveTrend = trendPercentage > 0; - - // Loading state - if (analyticsLoading || orchestrationLoading) { - return ( -
-
-
-
-
-
-
-
-
-
-
- ); - } - - return ( -
- {/* Always Visible Header - GlanceableHealthHero Style */} -
-
- {/* Icon */} -
- -
- - {/* Title + Metrics Badges */} -
-

- {t('dashboard:intelligent_system.title', 'Intelligent System Summary')} -

- - {/* Inline Metrics Badges */} -
- {/* AI Handling Rate Badge */} -
- - {analytics?.ai_handling_rate.toFixed(1)}% - - {hasPositiveTrend ? ( - - ) : ( - - )} - {trendPercentage !== 0 && ( - - {trendPercentage > 0 ? '+' : ''}{trendPercentage}% - - )} -
- - {/* Prevented Issues Badge - Show actual count from last 7 days to match detail section */} -
- - - {allPreventedAlerts.length} - - - {t('dashboard:intelligent_system.prevented_issues', 'issues')} - -
- - {/* Savings Badge */} -
- - - €{totalSavings.toFixed(0)} - - - saved - -
-
-
- - {/* Expand Button */} - -
-
- - {/* Collapsible Section: Prevented Issues Details */} - {preventedIssuesExpanded && ( -
- {allPreventedAlerts.length === 0 ? ( -
-

- {t('dashboard:intelligent_system.no_prevented_issues', 'No issues prevented this week - all systems running smoothly!')} -

-
- ) : ( - <> - {/* Celebration Message */} -
-

- {t('dashboard:intelligent_system.celebration', 'Great news! AI prevented {count} issue(s) before they became problems.', { - count: allPreventedAlerts.length, - })} -

-
- - {/* Prevented Issues List */} -
- {allPreventedAlerts.map((alert) => { - const savings = alert.orchestrator_context?.estimated_savings_eur || 0; - const actionTaken = alert.orchestrator_context?.action_taken || 'AI intervention'; - const timeAgo = formatRelativeTime(alert.created_at) || 'Fecha desconocida'; - - return ( -
-
-
- -
-

- {renderEventTitle(alert, t)} -

-

- {renderEventMessage(alert, t)} -

-
-
- {savings > 0 && ( - - - €{savings.toFixed(0)} - - )} -
- -
-
- - {actionTaken} -
-
- - {timeAgo} -
-
-
- ); - })} -
- - )} -
- )} - - {/* Collapsible Section: Latest Orchestration Run (Ultra Minimal) */} -
- - - {orchestrationExpanded && ( -
- {orchestrationSummary && orchestrationSummary.status !== 'no_runs' ? ( -
- {/* Run Info Line */} -
- - - {t('reasoning:jtbd.orchestration_summary.run_info', 'Run #{runNumber}', { - runNumber: orchestrationSummary.runNumber || 0, - })}{' '} - •{' '} - {orchestrationSummary.runTimestamp - ? formatRelativeTime(orchestrationSummary.runTimestamp) || 'recently' - : 'recently'} - {orchestrationSummary.durationSeconds && ` • ${orchestrationSummary.durationSeconds}s`} - -
- - {/* Summary Line */} -
- {orchestrationSummary.purchaseOrdersCreated > 0 && ( - - {orchestrationSummary.purchaseOrdersCreated}{' '} - {orchestrationSummary.purchaseOrdersCreated === 1 ? 'purchase order' : 'purchase orders'} - {orchestrationSummary.purchaseOrdersSummary && orchestrationSummary.purchaseOrdersSummary.length > 0 && ( - - {' '}(€ - {orchestrationSummary.purchaseOrdersSummary - .reduce((sum, po) => sum + (po.totalAmount || 0), 0) - .toFixed(0)} - ) - - )} - - )} - {orchestrationSummary.purchaseOrdersCreated > 0 && orchestrationSummary.productionBatchesCreated > 0 && ' • '} - {orchestrationSummary.productionBatchesCreated > 0 && ( - - {orchestrationSummary.productionBatchesCreated}{' '} - {orchestrationSummary.productionBatchesCreated === 1 ? 'production batch' : 'production batches'} - {orchestrationSummary.productionBatchesSummary && orchestrationSummary.productionBatchesSummary.length > 0 && ( - - {' '}( - {orchestrationSummary.productionBatchesSummary[0].readyByTime - ? formatTime(orchestrationSummary.productionBatchesSummary[0].readyByTime, 'HH:mm') - : 'TBD'} - {orchestrationSummary.productionBatchesSummary.length > 1 && - orchestrationSummary.productionBatchesSummary[orchestrationSummary.productionBatchesSummary.length - 1] - .readyByTime && - ` - ${formatTime( - orchestrationSummary.productionBatchesSummary[orchestrationSummary.productionBatchesSummary.length - 1] - .readyByTime, - 'HH:mm' - )}`} - ) - - )} - - )} - {orchestrationSummary.purchaseOrdersCreated === 0 && orchestrationSummary.productionBatchesCreated === 0 && ( - - {t('reasoning:jtbd.orchestration_summary.no_actions', 'No actions created')} - - )} -
- - {/* AI Reasoning Section */} - {orchestrationSummary.reasoning && orchestrationSummary.reasoning.reasoning_i18n && ( -
- {/* Reasoning Text Block */} -
-
- -

- {t('alerts:orchestration.reasoning_title', '🤖 Razonamiento del Orquestador Diario')} -

-
-

- {t( - orchestrationSummary.reasoning.reasoning_i18n.key, - orchestrationSummary.reasoning.reasoning_i18n.params || {} - )} -

-
- - {/* Business Impact Metrics */} - {(orchestrationSummary.reasoning.business_impact?.financial_impact_eur || - orchestrationSummary.reasoning.business_impact?.affected_orders) && ( -
- {orchestrationSummary.reasoning.business_impact.financial_impact_eur > 0 && ( -
- - - €{orchestrationSummary.reasoning.business_impact.financial_impact_eur.toFixed(0)}{' '} - {t('dashboard:intelligent_system.estimated_savings', 'impacto financiero')} - -
- )} - {orchestrationSummary.reasoning.business_impact.affected_orders > 0 && ( -
- - - {orchestrationSummary.reasoning.business_impact.affected_orders}{' '} - {t('common:orders', 'pedidos')} - -
- )} -
- )} - - {/* Urgency Context */} - {orchestrationSummary.reasoning.urgency_context?.time_until_consequence_hours > 0 && ( -
- - - {Math.round(orchestrationSummary.reasoning.urgency_context.time_until_consequence_hours)}h{' '} - {t('common:remaining', 'restantes')} - -
- )} -
- )} -
- ) : ( -
- {t('dashboard:orchestration.no_runs_message', 'No orchestration has been run yet.')} -
- )} -
- )} -
-
- ); -} diff --git a/frontend/src/components/dashboard/UnifiedActionQueueCard.tsx b/frontend/src/components/dashboard/UnifiedActionQueueCard.tsx deleted file mode 100644 index fe666846..00000000 --- a/frontend/src/components/dashboard/UnifiedActionQueueCard.tsx +++ /dev/null @@ -1,1041 +0,0 @@ -// ================================================================ -// frontend/src/components/dashboard/UnifiedActionQueueCard.tsx -// ================================================================ -/** - * Unified Action Queue Card - Time-Based Grouping - * - * NEW implementation replacing the old ActionQueueCard. - * Groups all action-needed alerts into URGENT/TODAY/WEEK sections. - * - * Features: - * - Time-based grouping (<6h, <24h, <7d) - * - Escalation badges for aged actions - * - Embedded delivery actions - * - Collapsible sections - * - Smart action handlers - */ - -import React, { useState, useMemo, useEffect } from 'react'; -import { - AlertCircle, - Clock, - ChevronDown, - ChevronUp, - Zap, - AlertTriangle, - Calendar, - TrendingUp, - Package, - Truck, - Bot, - Wifi, - WifiOff, - CheckCircle, - XCircle, - Phone, -} from 'lucide-react'; -import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; -import { Alert } from '../../api/types/events'; -import { renderEventTitle, renderEventMessage, renderActionLabel, renderAIReasoning } from '../../utils/i18n/alertRendering'; -import { useSmartActionHandler, mapActionVariantToButton } from '../../utils/smartActionHandlers'; -import { Button } from '../ui/Button'; -import { useEventNotifications } from '../../hooks/useEventNotifications'; -import { useQueryClient } from '@tanstack/react-query'; -import { StockReceiptModal } from './StockReceiptModal'; -import { ReasoningModal } from '../domain/dashboard/ReasoningModal'; -import { UnifiedPurchaseOrderModal } from '../domain/procurement/UnifiedPurchaseOrderModal'; - -// Unified Action Queue interface (keeping for compatibility with dashboard hook) -interface UnifiedActionQueue { - urgent: Alert[]; - today: Alert[]; - week: Alert[]; - urgentCount: number; - todayCount: number; - weekCount: number; - totalActions: number; -} - -interface UnifiedActionQueueCardProps { - actionQueue: UnifiedActionQueue; - loading?: boolean; - tenantId?: string; -} - -interface ActionCardProps { - alert: Alert; - showEscalationBadge?: boolean; - onActionSuccess?: () => void; - onActionError?: (error: string) => void; -} - -function getUrgencyColor(priorityLevel: string): { - bg: string; - border: string; - text: string; -} { - switch (priorityLevel.toUpperCase()) { - case 'CRITICAL': - return { - bg: 'var(--color-error-50)', - border: 'var(--color-error-300)', - text: 'var(--color-error-900)', - }; - case 'IMPORTANT': - return { - bg: 'var(--color-warning-50)', - border: 'var(--color-warning-300)', - text: 'var(--color-warning-900)', - }; - default: - return { - bg: 'var(--color-info-50)', - border: 'var(--color-info-300)', - text: 'var(--color-info-900)', - }; - } -} - -function EscalationBadge({ alert }: { alert: Alert }) { - const { t } = useTranslation('alerts'); - const escalation = alert.event_metadata?.escalation; - - if (!escalation || escalation.boost_applied === 0) return null; - - const hoursPending = alert.urgency?.hours_until_consequence - ? Math.round(alert.urgency.hours_until_consequence) - : null; - - return ( -
- - - {t('escalated')} +{escalation.boost_applied} - {hoursPending && ` (${hoursPending}h pending)`} - -
- ); -} - -/** - * Map action type to i18n translation key with extracted parameters - */ -function getActionLabelKey(actionType: string, metadata?: Record): { key: string; params: Record } { - const actionTypeMap: Record) => Record }> = { - 'approve_po': { - key: 'alerts:actions.approve_po', - extractParams: (meta) => ({ amount: meta.amount || meta.po_amount || 0 }) - }, - 'reject_po': { - key: 'alerts:actions.reject_po', - extractParams: () => ({}) - }, - 'modify_po': { - key: 'alerts:actions.modify_po', - extractParams: () => ({}) - }, - 'view_po_details': { - key: 'alerts:actions.view_po_details', - extractParams: () => ({}) - }, - 'call_supplier': { - key: 'alerts:actions.call_supplier', - extractParams: (meta) => ({ supplier: meta.supplier || meta.name || 'Supplier', phone: meta.phone || '' }) - }, - 'open_reasoning': { - key: 'alerts:actions.see_reasoning', - extractParams: () => ({}) - }, - 'complete_stock_receipt': { - key: 'alerts:actions.complete_receipt', - extractParams: () => ({}) - }, - 'mark_delivery_received': { - key: 'alerts:actions.mark_received', - extractParams: () => ({}) - }, - 'adjust_production': { - key: 'alerts:actions.adjust_production', - extractParams: () => ({}) - }, - 'navigate': { - key: 'alerts:actions.navigate', - extractParams: () => ({}) - }, - 'notify_customer': { - key: 'alerts:actions.notify_customer', - extractParams: (meta) => ({ customer: meta.customer_name || meta.customer || 'Customer' }) - }, - 'snooze': { - key: 'alerts:actions.snooze', - extractParams: (meta) => ({ hours: meta.duration_hours || meta.hours || 4 }) - }, - 'dismiss': { - key: 'alerts:actions.dismiss', - extractParams: () => ({}) - }, - 'mark_read': { - key: 'alerts:actions.mark_read', - extractParams: () => ({}) - }, - 'cancel_auto_action': { - key: 'alerts:actions.cancel_auto_action', - extractParams: () => ({}) - }, - }; - - const config = actionTypeMap[actionType] || { key: 'alerts:actions.navigate', extractParams: () => ({}) }; - const params = config.extractParams ? config.extractParams(metadata || {}) : {}; - - return { key: config.key, params }; -} - -function ActionCard({ alert, showEscalationBadge = false, onActionSuccess, onActionError }: ActionCardProps) { - const [loadingAction, setLoadingAction] = useState(null); - const [actionCompleted, setActionCompleted] = useState(false); - const [showReasoningModal, setShowReasoningModal] = useState(false); - const { t } = useTranslation(['alerts', 'reasoning']); - const colors = getUrgencyColor(alert.priority_level); - - // Action handler with callbacks - const actionHandler = useSmartActionHandler({ - onSuccess: (alertId) => { - setLoadingAction(null); - setActionCompleted(true); - onActionSuccess?.(); - - // Auto-hide completion state after 2 seconds - setTimeout(() => { - setActionCompleted(false); - }, 2000); - }, - onError: (error) => { - setLoadingAction(null); - onActionError?.(error); - }, - }); - - // Get icon based on alert type - const getAlertIcon = () => { - if (!alert.event_type) return AlertCircle; - if (alert.event_type.includes('delivery')) return Truck; - if (alert.event_type.includes('production')) return Package; - if (alert.event_type.includes('procurement') || alert.event_type.includes('po')) return Calendar; - return AlertCircle; - }; - - const AlertIcon = getAlertIcon(); - - // Get actions from alert, filter out "Ver razonamiento" since reasoning is now always visible - const alertActions = (alert.smart_actions || []).filter(action => action.action_type !== 'open_reasoning'); - - // Debug logging to diagnose action button issues (can be removed after verification) - if (alert.smart_actions && alert.smart_actions.length > 0 && alertActions.length === 0) { - console.warn('[ActionQueue] All actions filtered out for alert:', alert.id, alert.smart_actions); - } - if (alertActions.length > 0) { - console.debug('[ActionQueue] Rendering actions for alert:', alert.id, alertActions.map(a => ({ - type: a.action_type, - hasMetadata: !!a.metadata, - hasAmount: a.metadata ? 'amount' in a.metadata : false, - metadata: a.metadata - }))); - } - - // Get icon for action type - const getActionIcon = (actionType: string) => { - if (actionType.includes('approve')) return CheckCircle; - if (actionType.includes('reject') || actionType.includes('cancel')) return XCircle; - if (actionType.includes('call')) return Phone; - return AlertCircle; // Default fallback icon instead of null - }; - - // Determine if this is a critical alert that needs stronger visual treatment - const isCritical = alert.priority_level === 'critical' || alert.priority_level === 'CRITICAL'; - - // Detect if this is an AI-generated PO - const isAIGeneratedPO = alert.event_type?.includes('po') && - (alert.orchestrator_context?.already_addressed || - alert.orchestrator_context?.action_type === 'create_po' || - alert.event_metadata?.source === 'orchestrator' || - alert.event_metadata?.auto_generated === true || - alert.event_metadata?.reasoning_data?.metadata?.ai_assisted === true || - alert.event_metadata?.reasoning_data?.metadata?.trigger_source === 'orchestrator_auto' || - alert.ai_reasoning?.details?.metadata?.ai_assisted === true || - alert.ai_reasoning?.details?.metadata?.trigger_source === 'orchestrator_auto'); - - // Extract reasoning from alert using new rendering utility - const reasoningText = renderAIReasoning(alert, t) || ''; - - return ( -
- {/* Header */} -
- -
- {/* Header with Title and Escalation Badge */} -
-

- {renderEventTitle(alert, t)} -

- {/* Only show escalation badge if applicable */} - {showEscalationBadge && alert.event_metadata?.escalation && ( -
- - AGED - -
- )} -
- - {/* AI-Generated PO Badge */} - {isAIGeneratedPO && ( -
-
- - {t('alerts:orchestration.ai_generated_po')} -
-
- )} - - {/* Escalation Badge Details */} - {showEscalationBadge && } - - {/* What/Why/How Structure - Enhanced with clear section labels */} -
- {/* WHAT: What happened - The alert message */} -
-
- {t('reasoning:jtbd.action_queue.what_happened', 'What happened')} -
-

- {renderEventMessage(alert, t)} -

-
- - {/* WHY: Why this is needed - AI Reasoning */} - {reasoningText && ( -
-
-
- - {t('reasoning:jtbd.action_queue.why_needed', 'Why this is needed')} -
- - {t('alerts:orchestration.what_ai_did', 'AI Recommendation')} - - -
-
- {reasoningText} -
-
- )} - - {/* Context Badges - Matching Health Hero Style */} - {(alert.business_impact || alert.urgency || alert.type_class === 'prevented_issue') && ( -
- {alert.business_impact?.financial_impact_eur && ( -
- - €{alert.business_impact.financial_impact_eur.toFixed(0)} at risk -
- )} - {alert.urgency?.hours_until_consequence && ( -
- - {Math.round(alert.urgency.hours_until_consequence)}h left -
- )} - {alert.type_class === 'prevented_issue' && ( -
- - AI handled -
- )} -
- )} - - {/* HOW: What you should do - Action buttons */} - {alertActions.length > 0 && !actionCompleted && ( -
-
- {t('reasoning:jtbd.action_queue.what_to_do', 'What you should do')} -
-
- {alertActions.map((action, idx) => { - const buttonVariant = mapActionVariantToButton(action.variant); - const isPrimary = action.variant === 'primary'; - const ActionIcon = isPrimary ? getActionIcon(action.action_type) : null; - const isLoading = loadingAction === action.action_type; - - return ( - - ); - })} -
-
- )} - - {/* Action Completed State */} - {actionCompleted && ( -
- - Action completed successfully -
- )} -
-
-
- - {/* Reasoning Modal */} - {showReasoningModal && reasoningText && ( -
-
-
-

- {t('alerts:orchestration.reasoning_title', 'AI Reasoning')} -

- -
-
-

- {reasoningText} -

-
-
- -
-
-
- )} -
- ); -} - -interface SectionProps { - title: string; - icon: React.ElementType; - iconColor: string; - alerts: EnrichedAlert[]; - count: number; - defaultExpanded?: boolean; - showEscalationBadges?: boolean; - onActionSuccess?: () => void; - onActionError?: (error: string) => void; -} - -function ActionSection({ - title, - icon: RawIcon, - iconColor, - alerts, - count, - defaultExpanded = true, - showEscalationBadges = false, - onActionSuccess, - onActionError, -}: SectionProps) { - const [expanded, setExpanded] = useState(defaultExpanded); - - // Safely handle the icon to prevent React error #310 - const Icon = (RawIcon && typeof RawIcon === 'function') - ? RawIcon - : AlertCircle; - - if (count === 0) return null; - - return ( -
- {/* Section Header */} - - - {/* Section Content */} - {expanded && ( -
- {alerts.map((alert) => ( - - ))} -
- )} -
- ); -} - -export function UnifiedActionQueueCard({ - actionQueue, - loading, - tenantId, -}: UnifiedActionQueueCardProps) { - const { t } = useTranslation(['alerts', 'dashboard']); - const navigate = useNavigate(); - const queryClient = useQueryClient(); - const [toastMessage, setToastMessage] = useState<{ type: 'success' | 'error'; message: string } | null>(null); - - // REMOVED: Race condition workaround (lines 560-572) - no longer needed - // with refetchOnMount:'always' in useSharedDashboardData - - // Show toast notification - useEffect(() => { - if (toastMessage) { - const timer = setTimeout(() => { - setToastMessage(null); - }, 3000); - return () => clearTimeout(timer); - } - }, [toastMessage]); - - // Callbacks for action success/error - const handleActionSuccess = () => { - setToastMessage({ type: 'success', message: t('alerts:action_success', { defaultValue: 'Action completed successfully' }) }); - }; - - const handleActionError = (error: string) => { - setToastMessage({ type: 'error', message: error || t('alerts:action_error', { defaultValue: 'Action failed' }) }); - }; - - // StockReceiptModal state - const [isStockReceiptModalOpen, setIsStockReceiptModalOpen] = useState(false); - const [stockReceiptData, setStockReceiptData] = useState<{ - receipt: any; - mode: 'create' | 'edit'; - } | null>(null); - - // ReasoningModal state - const [reasoningModalOpen, setReasoningModalOpen] = useState(false); - const [reasoningData, setReasoningData] = useState(null); - - // PO Details Modal state - const [isPODetailsModalOpen, setIsPODetailsModalOpen] = useState(false); - const [selectedPOId, setSelectedPOId] = useState(null); - const [poModalMode, setPOModalMode] = useState<'view' | 'edit'>('view'); - - // Subscribe to SSE notifications for real-time alerts - const { notifications, isConnected } = useEventNotifications(); - - // Listen for stock receipt modal open events - useEffect(() => { - const handleStockReceiptOpen = (event: CustomEvent) => { - const { receipt_id, po_id, tenant_id, mode } = event.detail; - - // Build receipt object from event data - const receipt = { - id: receipt_id, - po_id: po_id, - tenant_id: tenant_id || tenantId, - line_items: [], - }; - - setStockReceiptData({ receipt, mode }); - setIsStockReceiptModalOpen(true); - }; - - window.addEventListener('stock-receipt:open' as any, handleStockReceiptOpen); - return () => { - window.removeEventListener('stock-receipt:open' as any, handleStockReceiptOpen); - }; - }, [tenantId]); - - // Listen for reasoning modal open events - useEffect(() => { - const handleReasoningOpen = (event: CustomEvent) => { - const { action_id, reasoning, po_id, batch_id } = event.detail; - - // Find the alert to get full context - const alert = [ - ...actionQueue.urgent, - ...actionQueue.today, - ...actionQueue.week - ].find(a => a.id === action_id); - - setReasoningData({ - action_id, - po_id, - batch_id, - reasoning: reasoning || renderAIReasoning(alert, t), - title: alert ? renderEventTitle(alert, t) : undefined, - ai_reasoning_summary: alert ? renderAIReasoning(alert, t) : undefined, - business_impact: alert?.business_impact, - urgency_context: alert?.urgency, - }); - setReasoningModalOpen(true); - }; - - window.addEventListener('reasoning:show' as any, handleReasoningOpen); - return () => { - window.removeEventListener('reasoning:show' as any, handleReasoningOpen); - }; - }, [actionQueue]); - - // Listen for PO details modal open events (view mode) - useEffect(() => { - const handlePODetailsOpen = (event: CustomEvent) => { - const { po_id, mode } = event.detail; - - if (po_id) { - setSelectedPOId(po_id); - setPOModalMode(mode || 'view'); - setIsPODetailsModalOpen(true); - } - }; - - window.addEventListener('po:open-details' as any, handlePODetailsOpen); - return () => { - window.removeEventListener('po:open-details' as any, handlePODetailsOpen); - }; - }, []); - - // Listen for PO edit modal open events (edit mode) - useEffect(() => { - const handlePOEditOpen = (event: CustomEvent) => { - const { po_id, mode } = event.detail; - - if (po_id) { - setSelectedPOId(po_id); - setPOModalMode(mode || 'edit'); - setIsPODetailsModalOpen(true); - } - }; - - window.addEventListener('po:open-edit' as any, handlePOEditOpen); - return () => { - window.removeEventListener('po:open-edit' as any, handlePOEditOpen); - }; - }, []); - - // Create a stable identifier for notifications to prevent infinite re-renders - // Only recalculate when the actual notification IDs and read states change - const notificationKey = useMemo(() => { - if (!notifications || notifications.length === 0) return 'empty'; - - const key = notifications - .filter(n => n.type_class === 'action_needed' && !n.read) - .map(n => n.id) - .sort() - .join(','); - - console.log('🔵 [UnifiedActionQueueCard] notificationKey recalculated:', key); - return key; - }, [notifications]); - - // Merge API action queue with SSE action-needed alerts - const mergedActionQueue = useMemo(() => { - if (!actionQueue) return null; - - // Filter SSE notifications to only action_needed alerts - // Guard against undefined notifications array - // NEW: Also filter by status to exclude acknowledged/resolved alerts - const sseActionAlerts = (notifications || []).filter( - n => n.type_class === 'action_needed' && !n.read && n.status === 'active' - ); - - // Create a set of existing alert IDs from API data - const existingIds = new Set([ - ...actionQueue.urgent.map(a => a.id), - ...actionQueue.today.map(a => a.id), - ...actionQueue.week.map(a => a.id), - ]); - - // Filter out SSE alerts that already exist in API data (deduplicate) - const newSSEAlerts = sseActionAlerts.filter(alert => !existingIds.has(alert.id)); - - // Helper function to categorize alerts by urgency - const categorizeByUrgency = (alert: any): 'urgent' | 'today' | 'week' => { - const now = new Date(); - const urgency = alert.urgency; - const deadline = urgency?.deadline_utc ? new Date(urgency.deadline_utc) : null; - - if (!deadline) { - // No deadline: categorize by priority level - if (alert.priority_level === 'critical' || alert.priority_level === 'CRITICAL') return 'urgent'; - if (alert.priority_level === 'important' || alert.priority_level === 'IMPORTANT') return 'today'; - return 'week'; - } - - const hoursUntilDeadline = (deadline.getTime() - now.getTime()) / (1000 * 60 * 60); - - if (hoursUntilDeadline < 6 || alert.priority_level === 'critical' || alert.priority_level === 'CRITICAL') { - return 'urgent'; - } else if (hoursUntilDeadline < 24) { - return 'today'; - } else { - return 'week'; - } - }; - - // Categorize new SSE alerts - const categorizedSSEAlerts = { - urgent: newSSEAlerts.filter(a => categorizeByUrgency(a) === 'urgent'), - today: newSSEAlerts.filter(a => categorizeByUrgency(a) === 'today'), - week: newSSEAlerts.filter(a => categorizeByUrgency(a) === 'week'), - }; - - // Merge and sort by priority_score (descending) - const sortByPriority = (alerts: any[]) => - alerts.sort((a, b) => (b.priority_score || 0) - (a.priority_score || 0)); - - const merged = { - urgent: sortByPriority([...actionQueue.urgent, ...categorizedSSEAlerts.urgent]), - today: sortByPriority([...actionQueue.today, ...categorizedSSEAlerts.today]), - week: sortByPriority([...actionQueue.week, ...categorizedSSEAlerts.week]), - urgentCount: actionQueue.urgentCount + categorizedSSEAlerts.urgent.length, - todayCount: actionQueue.todayCount + categorizedSSEAlerts.today.length, - weekCount: actionQueue.weekCount + categorizedSSEAlerts.week.length, - totalActions: actionQueue.totalActions + newSSEAlerts.length, - }; - - return merged; - }, [actionQueue, notificationKey]); // Use notificationKey instead of notifications to prevent infinite re-nders - - // Use merged data if available, otherwise use original API data - const displayQueue = mergedActionQueue || actionQueue; - - if (loading) { - return ( -
-
-
-
-
-
-
- ); - } - - if (!displayQueue || displayQueue.totalActions === 0) { - return ( -
-
- -
-

- {t('dashboard:all_caught_up')} -

-

- {t('dashboard:no_actions_needed')} -

-
- ); - } - - return ( -
- {/* Header with Hero Icon */} -
- {/* Hero Icon */} -
5 ? 'var(--color-error-100)' : 'var(--color-info-100)' }} - > - 5 ? 'var(--color-error-600)' : 'var(--color-info-600)' }} - /> -
- - {/* Title + Inline Metrics */} -
-

- {t('dashboard:action_queue_title')} -

- - {/* Inline Metric Badges */} -
- {/* Total Actions Badge */} -
5 - ? 'bg-[var(--color-error-100)] text-[var(--color-error-800)] border border-[var(--color-error-300)]' - : 'bg-[var(--color-info-100)] text-[var(--color-info-800)] border border-[var(--color-info-300)]' - }`} - > - {displayQueue.totalActions} - {t('dashboard:total_actions')} -
- - {/* SSE Connection Badge */} -
- {isConnected ? ( - <> - - Live - - ) : ( - <> - - Offline - - )} -
-
-
-
- - {/* Toast Notification */} - {toastMessage && ( -
- {toastMessage.type === 'success' ? ( - - ) : ( - - )} - {toastMessage.message} -
- )} - - {/* URGENT Section (<6h) */} - - - {/* TODAY Section (<24h) */} - - - {/* THIS WEEK Section (<7d) */} - - - {/* Stock Receipt Modal - Opened by delivery actions */} - {isStockReceiptModalOpen && stockReceiptData && ( - { - setIsStockReceiptModalOpen(false); - setStockReceiptData(null); - }} - receipt={stockReceiptData.receipt} - mode={stockReceiptData.mode} - onSaveDraft={async (receipt) => { - try { - // Save draft receipt - const response = await fetch( - `/api/tenants/${receipt.tenant_id}/inventory/stock-receipts/${receipt.id}`, - { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - notes: receipt.notes, - line_items: receipt.line_items - }) - } - ); - - if (!response.ok) { - throw new Error('Failed to save draft'); - } - - console.log('Draft saved successfully'); - } catch (error) { - console.error('Error saving draft:', error); - throw error; - } - }} - onConfirm={async (receipt) => { - try { - // Confirm receipt - updates inventory - const response = await fetch( - `/api/tenants/${receipt.tenant_id}/inventory/stock-receipts/${receipt.id}/confirm`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - confirmed_by_user_id: receipt.received_by_user_id - }) - } - ); - - if (!response.ok) { - throw new Error('Failed to confirm receipt'); - } - - console.log('Receipt confirmed successfully'); - setIsStockReceiptModalOpen(false); - setStockReceiptData(null); - - // Refresh data to show updated inventory - await refetch(); - } catch (error) { - console.error('Error confirming receipt:', error); - throw error; - } - }} - /> - )} - - {/* Reasoning Modal - Opened by "See Full Reasoning" action */} - {reasoningModalOpen && reasoningData && ( - { - setReasoningModalOpen(false); - setReasoningData(null); - }} - reasoning={reasoningData} - /> - )} - - {/* PO Details Modal - Opened by "Ver detalles" or "Modificar PO" action */} - {isPODetailsModalOpen && selectedPOId && tenantId && ( - { - setIsPODetailsModalOpen(false); - setSelectedPOId(null); - setPOModalMode('view'); - }} - showApprovalActions={true} - initialMode={poModalMode} - /> - )} -
- ); -} diff --git a/frontend/src/components/dashboard/blocks/PendingDeliveriesBlock.tsx b/frontend/src/components/dashboard/blocks/PendingDeliveriesBlock.tsx new file mode 100644 index 00000000..cfa70141 --- /dev/null +++ b/frontend/src/components/dashboard/blocks/PendingDeliveriesBlock.tsx @@ -0,0 +1,286 @@ +/** + * PendingDeliveriesBlock - Block 3: "Entregas Pendientes" + * + * Displays today's delivery status: + * - Overdue deliveries with alert styling + * - Pending deliveries expected today + * - Actions: Call Supplier, Mark Received + */ + +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + AlertTriangle, + CheckCircle2, + Clock, + Package, + Phone, + Truck, +} from 'lucide-react'; + +interface PendingDeliveriesBlockProps { + overdueDeliveries?: any[]; + pendingDeliveries?: any[]; + onCallSupplier?: (delivery: any) => void; + onMarkReceived?: (poId: string) => void; + loading?: boolean; +} + +export function PendingDeliveriesBlock({ + overdueDeliveries = [], + pendingDeliveries = [], + onCallSupplier, + onMarkReceived, + loading, +}: PendingDeliveriesBlockProps) { + const { t } = useTranslation(['dashboard', 'common']); + + if (loading) { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ ); + } + + const hasOverdue = overdueDeliveries.length > 0; + const hasPending = pendingDeliveries.length > 0; + const hasAnyDeliveries = hasOverdue || hasPending; + const totalCount = overdueDeliveries.length + pendingDeliveries.length; + + // Determine header status + const status = hasOverdue ? 'error' : hasPending ? 'warning' : 'success'; + + const statusStyles = { + success: { + iconBg: 'bg-[var(--color-success-100)]', + iconColor: 'text-[var(--color-success-600)]', + }, + warning: { + iconBg: 'bg-[var(--color-warning-100)]', + iconColor: 'text-[var(--color-warning-600)]', + }, + error: { + iconBg: 'bg-[var(--color-error-100)]', + iconColor: 'text-[var(--color-error-600)]', + }, + }; + + const styles = statusStyles[status]; + + // Format hours display + const formatHours = (hours: number) => { + if (hours < 1) return t('common:time.less_than_hour', '< 1h'); + if (hours === 1) return t('common:time.one_hour', '1h'); + return `${hours}h`; + }; + + return ( +
+ {/* Header */} +
+
+ {/* Icon */} +
+ {hasOverdue ? ( + + ) : hasAnyDeliveries ? ( + + ) : ( + + )} +
+ + {/* Title & Count */} +
+

+ {t('dashboard:new_dashboard.pending_deliveries.title')} +

+

+ {hasAnyDeliveries + ? t('dashboard:new_dashboard.pending_deliveries.count', { count: totalCount }) + : t('dashboard:new_dashboard.pending_deliveries.no_deliveries')} +

+
+ + {/* Count Badges */} +
+ {hasOverdue && ( +
+ {overdueDeliveries.length} {t('dashboard:new_dashboard.pending_deliveries.overdue_badge')} +
+ )} + {hasPending && ( +
+ {pendingDeliveries.length} +
+ )} +
+
+
+ + {/* Content */} + {hasAnyDeliveries ? ( +
+ {/* Overdue Section */} + {hasOverdue && ( +
+
+

+ + {t('dashboard:new_dashboard.pending_deliveries.overdue_section')} +

+
+ + {overdueDeliveries.map((delivery, index) => ( +
+
+ {/* Delivery Info */} +
+
+ + + {delivery.supplier_name || 'Unknown Supplier'} + +
+ +

+ {t('dashboard:new_dashboard.pending_deliveries.po_ref', { + number: delivery.po_number || delivery.po_id?.slice(0, 8), + })} +

+ + {/* Overdue Badge */} +
+ + {t('dashboard:new_dashboard.pending_deliveries.overdue_by', { + hours: formatHours(delivery.hoursOverdue || 0), + })} +
+
+ + {/* Actions */} +
+ {delivery.supplier_phone && onCallSupplier && ( + + )} + + {onMarkReceived && ( + + )} +
+
+
+ ))} +
+ )} + + {/* Pending Today Section */} + {hasPending && ( +
+
+

+ + {t('dashboard:new_dashboard.pending_deliveries.today_section')} +

+
+ + {pendingDeliveries.map((delivery, index) => ( +
+
+ {/* Delivery Info */} +
+
+ + + {delivery.supplier_name || 'Unknown Supplier'} + +
+ +

+ {t('dashboard:new_dashboard.pending_deliveries.po_ref', { + number: delivery.po_number || delivery.po_id?.slice(0, 8), + })} +

+ + {/* Arriving Badge */} +
+ + {t('dashboard:new_dashboard.pending_deliveries.arriving_in', { + hours: formatHours(delivery.hoursUntil || 0), + })} +
+
+ + {/* Actions */} +
+ {onMarkReceived && ( + + )} +
+
+
+ ))} +
+ )} +
+ ) : ( + /* Empty State */ +
+
+ +

+ {t('dashboard:new_dashboard.pending_deliveries.all_clear')} +

+
+
+ )} +
+ ); +} + +export default PendingDeliveriesBlock; diff --git a/frontend/src/components/dashboard/blocks/PendingPurchasesBlock.tsx b/frontend/src/components/dashboard/blocks/PendingPurchasesBlock.tsx new file mode 100644 index 00000000..cef5bfdb --- /dev/null +++ b/frontend/src/components/dashboard/blocks/PendingPurchasesBlock.tsx @@ -0,0 +1,321 @@ +/** + * PendingPurchasesBlock - Block 2: "Compras Pendientes" + * + * Displays pending purchase orders awaiting approval: + * - PO number, supplier, amount + * - AI reasoning for why the PO was created + * - Inline actions: Approve, Reject, View Details + */ + +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Brain, + Check, + CheckCircle2, + ChevronDown, + ChevronUp, + Eye, + ShoppingCart, + X, +} from 'lucide-react'; + +interface PendingPurchasesBlockProps { + pendingPOs: any[]; + onApprove?: (poId: string) => Promise; + onReject?: (poId: string, reason: string) => Promise; + onViewDetails?: (poId: string) => void; + loading?: boolean; +} + +export function PendingPurchasesBlock({ + pendingPOs = [], + onApprove, + onReject, + onViewDetails, + loading, +}: PendingPurchasesBlockProps) { + const { t } = useTranslation(['dashboard', 'common']); + const [expandedReasoningId, setExpandedReasoningId] = useState(null); + const [processingId, setProcessingId] = useState(null); + + if (loading) { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ ); + } + + const hasPendingPOs = pendingPOs.length > 0; + + // Handle approve action + const handleApprove = async (poId: string) => { + if (!onApprove || processingId) return; + setProcessingId(poId); + try { + await onApprove(poId); + } finally { + setProcessingId(null); + } + }; + + // Handle reject action + const handleReject = async (poId: string) => { + if (!onReject || processingId) return; + setProcessingId(poId); + try { + await onReject(poId, 'Rejected from dashboard'); + } finally { + setProcessingId(null); + } + }; + + // Toggle reasoning expansion + const toggleReasoning = (poId: string) => { + setExpandedReasoningId(expandedReasoningId === poId ? null : poId); + }; + + // Format AI reasoning from reasoning_data + const formatReasoning = (po: any): string | null => { + const reasoningData = po.reasoning_data || po.ai_reasoning; + + // If no structured reasoning data, try the summary field + if (!reasoningData) { + return po.ai_reasoning_summary || null; + } + + if (typeof reasoningData === 'string') return reasoningData; + + // Handle structured reasoning data + if (reasoningData.type === 'low_stock_forecast' || reasoningData.type === 'low_stock_detection') { + const params = reasoningData.parameters || {}; + const productNames = params.product_names || params.critical_products || []; + const productDetails = params.product_details || []; + const criticalCount = params.critical_product_count || productNames.length; + const minDepletionDays = Math.ceil(params.min_depletion_days || 0); + const affectedBatchesCount = params.affected_batches_count || 0; + const potentialLoss = params.potential_loss_eur || 0; + + // If we have detailed data (multiple products), show comprehensive message + if (criticalCount > 1 && productNames.length > 0) { + const productsStr = productNames.slice(0, 3).join(', ') + (productNames.length > 3 ? '...' : ''); + + return t('dashboard:new_dashboard.pending_purchases.reasoning.low_stock_detailed', { + count: criticalCount, + products: productsStr, + days: minDepletionDays, + batches: affectedBatchesCount, + loss: potentialLoss.toFixed(2), + }); + } + + // Simple version for single product + const firstProduct = productDetails[0] || {}; + const ingredient = firstProduct.product_name || productNames[0] || 'ingredient'; + const days = Math.ceil(firstProduct.days_until_depletion || minDepletionDays); + + return t('dashboard:new_dashboard.pending_purchases.reasoning.low_stock', { + ingredient, + days, + }); + } + + if (reasoningData.type === 'demand_forecast') { + return t('dashboard:new_dashboard.pending_purchases.reasoning.demand_forecast', { + product: reasoningData.parameters?.product_name || 'product', + increase: reasoningData.parameters?.demand_increase_percent || 0, + }); + } + + if (reasoningData.summary) return reasoningData.summary; + + // Fallback to ai_reasoning_summary if structured data doesn't have a matching type + return po.ai_reasoning_summary || null; + }; + + return ( +
+ {/* Header */} +
+
+ {/* Icon */} +
+ {hasPendingPOs ? ( + + ) : ( + + )} +
+ + {/* Title & Count */} +
+

+ {t('dashboard:new_dashboard.pending_purchases.title')} +

+

+ {hasPendingPOs + ? t('dashboard:new_dashboard.pending_purchases.count', { + count: pendingPOs.length, + }) + : t('dashboard:new_dashboard.pending_purchases.no_pending')} +

+
+ + {/* Count Badge */} + {hasPendingPOs && ( +
+ {pendingPOs.length} +
+ )} +
+
+ + {/* PO List */} + {hasPendingPOs ? ( +
+ {pendingPOs.map((po, index) => { + const poId = po.id || po.po_id; + const isProcessing = processingId === poId; + const isExpanded = expandedReasoningId === poId; + const reasoning = formatReasoning(po); + + return ( +
+ {/* PO Main Info */} +
+ {/* PO Details */} +
+
+ + {t('dashboard:new_dashboard.pending_purchases.po_number', { + number: po.po_number || po.id?.slice(0, 8), + })} + + {reasoning && ( + + )} +
+ +

+ {t('dashboard:new_dashboard.pending_purchases.supplier', { + name: po.supplier_name || po.supplier?.name || 'Unknown', + })} +

+ +

+ €{(po.total_amount || po.total || 0).toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} +

+
+ + {/* Actions */} +
+ {/* View Details */} + {onViewDetails && ( + + )} + + {/* Reject */} + {onReject && ( + + )} + + {/* Approve */} + {onApprove && ( + + )} +
+
+ + {/* AI Reasoning (Expanded) */} + {isExpanded && reasoning && ( +
+
+ +
+

+ {t('dashboard:new_dashboard.pending_purchases.ai_reasoning')} +

+

{reasoning}

+
+
+
+ )} +
+ ); + })} +
+ ) : ( + /* Empty State */ +
+
+ +

+ {t('dashboard:new_dashboard.pending_purchases.all_clear')} +

+
+
+ )} +
+ ); +} + +export default PendingPurchasesBlock; diff --git a/frontend/src/components/dashboard/blocks/ProductionStatusBlock.tsx b/frontend/src/components/dashboard/blocks/ProductionStatusBlock.tsx new file mode 100644 index 00000000..7ac4c2c5 --- /dev/null +++ b/frontend/src/components/dashboard/blocks/ProductionStatusBlock.tsx @@ -0,0 +1,417 @@ +/** + * ProductionStatusBlock - Block 4: "Estado de Produccion" + * + * Displays today's production overview: + * - Late to start batches (should have started but haven't) + * - Currently running batches (IN_PROGRESS) + * - Pending batches for today + * - AI reasoning for batch scheduling + */ + +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + AlertTriangle, + Brain, + CheckCircle2, + ChevronDown, + ChevronUp, + Clock, + Factory, + Play, + Timer, +} from 'lucide-react'; + +interface ProductionStatusBlockProps { + lateToStartBatches?: any[]; + runningBatches?: any[]; + pendingBatches?: any[]; + onStartBatch?: (batchId: string) => Promise; + onViewBatch?: (batchId: string) => void; + loading?: boolean; +} + +export function ProductionStatusBlock({ + lateToStartBatches = [], + runningBatches = [], + pendingBatches = [], + onStartBatch, + onViewBatch, + loading, +}: ProductionStatusBlockProps) { + const { t } = useTranslation(['dashboard', 'common', 'production']); + const [expandedReasoningId, setExpandedReasoningId] = useState(null); + const [processingId, setProcessingId] = useState(null); + + if (loading) { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ ); + } + + const hasLate = lateToStartBatches.length > 0; + const hasRunning = runningBatches.length > 0; + const hasPending = pendingBatches.length > 0; + const hasAnyProduction = hasLate || hasRunning || hasPending; + const totalCount = lateToStartBatches.length + runningBatches.length + pendingBatches.length; + + // Determine header status + const status = hasLate ? 'error' : hasRunning ? 'info' : hasPending ? 'warning' : 'success'; + + const statusStyles = { + success: { + iconBg: 'bg-[var(--color-success-100)]', + iconColor: 'text-[var(--color-success-600)]', + }, + warning: { + iconBg: 'bg-[var(--color-warning-100)]', + iconColor: 'text-[var(--color-warning-600)]', + }, + info: { + iconBg: 'bg-[var(--color-info-100)]', + iconColor: 'text-[var(--color-info-600)]', + }, + error: { + iconBg: 'bg-[var(--color-error-100)]', + iconColor: 'text-[var(--color-error-600)]', + }, + }; + + const styles = statusStyles[status]; + + // Handle start batch + const handleStartBatch = async (batchId: string) => { + if (!onStartBatch || processingId) return; + setProcessingId(batchId); + try { + await onStartBatch(batchId); + } finally { + setProcessingId(null); + } + }; + + // Toggle reasoning expansion + const toggleReasoning = (batchId: string) => { + setExpandedReasoningId(expandedReasoningId === batchId ? null : batchId); + }; + + // Format AI reasoning + const formatReasoning = (batch: any): string | null => { + const reasoningData = batch.reasoning_data; + if (!reasoningData) return null; + + if (typeof reasoningData === 'string') return reasoningData; + + if (reasoningData.type === 'forecast_demand') { + return t('dashboard:new_dashboard.production_status.reasoning.forecast_demand', { + product: reasoningData.parameters?.product_name || batch.product_name, + demand: reasoningData.parameters?.predicted_demand || batch.planned_quantity, + }); + } + + if (reasoningData.type === 'customer_order') { + return t('dashboard:new_dashboard.production_status.reasoning.customer_order', { + customer: reasoningData.parameters?.customer_name || 'customer', + }); + } + + if (reasoningData.summary) return reasoningData.summary; + + return null; + }; + + // Format time + const formatTime = (isoString: string | null | undefined) => { + if (!isoString) return '--:--'; + const date = new Date(isoString); + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + }; + + // Calculate progress percentage for running batches + const calculateProgress = (batch: any): number => { + if (!batch.actual_start_time || !batch.planned_end_time) return 0; + + const start = new Date(batch.actual_start_time).getTime(); + const end = new Date(batch.planned_end_time).getTime(); + const now = Date.now(); + + if (now >= end) return 100; + if (now <= start) return 0; + + return Math.round(((now - start) / (end - start)) * 100); + }; + + // Render a batch item + const renderBatchItem = (batch: any, type: 'late' | 'running' | 'pending', index: number, total: number) => { + const batchId = batch.id || batch.batch_id; + const isProcessing = processingId === batchId; + const isExpanded = expandedReasoningId === batchId; + const reasoning = formatReasoning(batch); + const progress = type === 'running' ? calculateProgress(batch) : 0; + + const typeStyles = { + late: { + timeBg: 'bg-[var(--color-error-100)]', + timeColor: 'text-[var(--color-error-700)]', + icon: , + }, + running: { + timeBg: 'bg-[var(--color-info-100)]', + timeColor: 'text-[var(--color-info-700)]', + icon: , + }, + pending: { + timeBg: 'bg-[var(--color-warning-100)]', + timeColor: 'text-[var(--color-warning-700)]', + icon: , + }, + }; + + const batchStyles = typeStyles[type]; + + return ( +
+
+ {/* Batch Info */} +
+
+ {batchStyles.icon} + + {batch.product_name || 'Unknown Product'} + + {reasoning && ( + + )} +
+ +

+ {t('dashboard:new_dashboard.production_status.batch_info', { + number: batch.batch_number || batchId?.slice(0, 8), + quantity: batch.planned_quantity || 0, + })} +

+ + {/* Time/Status Badge */} +
+ {type === 'late' && ( +
+ + {t('dashboard:new_dashboard.production_status.should_have_started', { + time: formatTime(batch.planned_start_time), + })} +
+ )} + + {type === 'running' && ( + <> +
+ + {t('dashboard:new_dashboard.production_status.started_at', { + time: formatTime(batch.actual_start_time), + })} +
+ {/* Progress Bar */} +
+
+
+
+ {progress}% +
+ + )} + + {type === 'pending' && batch.planned_start_time && ( +
+ + {t('dashboard:new_dashboard.production_status.starts_at', { + time: formatTime(batch.planned_start_time), + })} +
+ )} +
+
+ + {/* Actions */} +
+ {(type === 'late' || type === 'pending') && onStartBatch && ( + + )} + + {type === 'running' && onViewBatch && ( + + )} +
+
+ + {/* AI Reasoning (Expanded) */} + {isExpanded && reasoning && ( +
+
+ +
+

+ {t('dashboard:new_dashboard.production_status.ai_reasoning')} +

+

{reasoning}

+
+
+
+ )} +
+ ); + }; + + return ( +
+ {/* Header */} +
+
+ {/* Icon */} +
+ {hasLate ? ( + + ) : hasAnyProduction ? ( + + ) : ( + + )} +
+ + {/* Title & Count */} +
+

+ {t('dashboard:new_dashboard.production_status.title')} +

+

+ {hasAnyProduction + ? t('dashboard:new_dashboard.production_status.count', { count: totalCount }) + : t('dashboard:new_dashboard.production_status.no_production')} +

+
+ + {/* Count Badges */} +
+ {hasLate && ( +
+ {lateToStartBatches.length} {t('dashboard:new_dashboard.production_status.late_badge')} +
+ )} + {hasRunning && ( +
+ {runningBatches.length} {t('dashboard:new_dashboard.production_status.running_badge')} +
+ )} + {hasPending && ( +
+ {pendingBatches.length} +
+ )} +
+
+
+ + {/* Content */} + {hasAnyProduction ? ( +
+ {/* Late to Start Section */} + {hasLate && ( +
+
+

+ + {t('dashboard:new_dashboard.production_status.late_section')} +

+
+ {lateToStartBatches.map((batch, index) => + renderBatchItem(batch, 'late', index, lateToStartBatches.length) + )} +
+ )} + + {/* Running Section */} + {hasRunning && ( +
+
+

+ + {t('dashboard:new_dashboard.production_status.running_section')} +

+
+ {runningBatches.map((batch, index) => + renderBatchItem(batch, 'running', index, runningBatches.length) + )} +
+ )} + + {/* Pending Section */} + {hasPending && ( +
+
+

+ + {t('dashboard:new_dashboard.production_status.pending_section')} +

+
+ {pendingBatches.map((batch, index) => + renderBatchItem(batch, 'pending', index, pendingBatches.length) + )} +
+ )} +
+ ) : ( + /* Empty State */ +
+
+ +

+ {t('dashboard:new_dashboard.production_status.all_clear')} +

+
+
+ )} +
+ ); +} + +export default ProductionStatusBlock; diff --git a/frontend/src/components/dashboard/blocks/SystemStatusBlock.tsx b/frontend/src/components/dashboard/blocks/SystemStatusBlock.tsx new file mode 100644 index 00000000..0e32d24a --- /dev/null +++ b/frontend/src/components/dashboard/blocks/SystemStatusBlock.tsx @@ -0,0 +1,265 @@ +/** + * SystemStatusBlock - Block 1: "Estado del Sistema" + * + * Displays system status including: + * - Issues requiring user action + * - Issues prevented by AI + * - Last intelligent system run timestamp + * - AI handling rate and savings + */ + +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Activity, + AlertTriangle, + Bot, + CheckCircle2, + ChevronDown, + ChevronUp, + Clock, + Sparkles, + TrendingUp, +} from 'lucide-react'; +import type { DashboardData, OrchestrationSummary } from '../../../api/hooks/useDashboardData'; + +interface SystemStatusBlockProps { + data: DashboardData | undefined; + loading?: boolean; +} + +export function SystemStatusBlock({ data, loading }: SystemStatusBlockProps) { + const { t } = useTranslation(['dashboard', 'common']); + const [isExpanded, setIsExpanded] = useState(false); + + if (loading) { + return ( +
+
+
+
+
+
+
+
+
+ ); + } + + const issuesRequiringAction = data?.issuesRequiringAction || 0; + const issuesPreventedByAI = data?.issuesPreventedByAI || 0; + const orchestrationSummary = data?.orchestrationSummary; + const preventedIssues = data?.preventedIssues || []; + + // Determine status: green if no issues, yellow/red if issues exist + const hasIssues = issuesRequiringAction > 0; + const status = hasIssues ? 'warning' : 'success'; + + // Format last run time + const formatLastRun = (timestamp: string | null | undefined) => { + if (!timestamp) return t('dashboard:new_dashboard.system_status.never_run'); + + const date = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffMinutes = Math.floor(diffMs / (1000 * 60)); + + if (diffMinutes < 1) return t('common:time.just_now', 'Just now'); + if (diffMinutes < 60) return t('common:time.minutes_ago', '{{count}} min ago', { count: diffMinutes }); + if (diffHours < 24) return t('common:time.hours_ago', '{{count}}h ago', { count: diffHours }); + + return date.toLocaleDateString(); + }; + + // Status styling + const statusStyles = { + success: { + bg: 'bg-[var(--color-success-50)]', + border: 'border-[var(--color-success-200)]', + iconBg: 'bg-[var(--color-success-100)]', + iconColor: 'text-[var(--color-success-600)]', + }, + warning: { + bg: 'bg-[var(--color-warning-50)]', + border: 'border-[var(--color-warning-200)]', + iconBg: 'bg-[var(--color-warning-100)]', + iconColor: 'text-[var(--color-warning-600)]', + }, + }; + + const styles = statusStyles[status]; + + return ( +
+ {/* Main Content */} +
+
+ {/* Status Icon */} +
+ {hasIssues ? ( + + ) : ( + + )} +
+ + {/* Content */} +
+ {/* Title */} +

+ {t('dashboard:new_dashboard.system_status.title')} +

+ + {/* Status Message */} +

+ {hasIssues + ? t('dashboard:new_dashboard.system_status.issues_requiring_action', { + count: issuesRequiringAction, + }) + : t('dashboard:new_dashboard.system_status.all_clear')} +

+ + {/* Stats Row */} +
+ {/* Issues Requiring Action */} +
+ 0 ? 'text-[var(--color-warning-500)]' : 'text-[var(--text-tertiary)]' + }`} + /> + + {issuesRequiringAction} + + + {t('dashboard:new_dashboard.system_status.action_needed_label')} + +
+ + {/* Issues Prevented by AI */} +
+ + + {issuesPreventedByAI} + + + {t('dashboard:new_dashboard.system_status.ai_prevented_label')} + +
+ + {/* Last Run */} +
+ + + {t('dashboard:new_dashboard.system_status.last_run_label')}: + + + {formatLastRun(orchestrationSummary?.runTimestamp)} + +
+
+
+ + {/* Expand Button (if there are prevented issues to show) */} + {issuesPreventedByAI > 0 && ( + + )} +
+
+ + {/* Expanded AI Details Section */} + {isExpanded && issuesPreventedByAI > 0 && ( +
+

+ + {t('dashboard:new_dashboard.system_status.ai_prevented_details')} +

+ + {/* AI Stats */} + {orchestrationSummary && ( +
+ {orchestrationSummary.aiHandlingRate !== undefined && ( +
+
+ + {t('dashboard:new_dashboard.system_status.ai_handling_rate')} +
+
+ {Math.round(orchestrationSummary.aiHandlingRate)}% +
+
+ )} + + {orchestrationSummary.estimatedSavingsEur !== undefined && orchestrationSummary.estimatedSavingsEur > 0 && ( +
+
+ + {t('dashboard:new_dashboard.system_status.estimated_savings')} +
+
+ €{orchestrationSummary.estimatedSavingsEur.toLocaleString()} +
+
+ )} + +
+
+ + {t('dashboard:new_dashboard.system_status.issues_prevented')} +
+
+ {issuesPreventedByAI} +
+
+
+ )} + + {/* Prevented Issues List */} + {preventedIssues.length > 0 && ( +
+ {preventedIssues.slice(0, 5).map((issue: any, index: number) => ( +
+ +
+

+ {issue.title || issue.message || t('dashboard:new_dashboard.system_status.issue_prevented')} +

+ {issue.business_impact?.financial_impact_eur && ( +

+ {t('dashboard:new_dashboard.system_status.saved')}: €{issue.business_impact.financial_impact_eur.toLocaleString()} +

+ )} +
+
+ ))} + + {preventedIssues.length > 5 && ( +

+ {t('dashboard:new_dashboard.system_status.and_more', { + count: preventedIssues.length - 5, + })} +

+ )} +
+ )} +
+ )} +
+ ); +} + +export default SystemStatusBlock; diff --git a/frontend/src/components/dashboard/blocks/index.ts b/frontend/src/components/dashboard/blocks/index.ts new file mode 100644 index 00000000..bd6c1928 --- /dev/null +++ b/frontend/src/components/dashboard/blocks/index.ts @@ -0,0 +1,10 @@ +/** + * Dashboard Blocks - Barrel Export + * + * Export all dashboard block components for the new Panel de Control design. + */ + +export { SystemStatusBlock } from './SystemStatusBlock'; +export { PendingPurchasesBlock } from './PendingPurchasesBlock'; +export { PendingDeliveriesBlock } from './PendingDeliveriesBlock'; +export { ProductionStatusBlock } from './ProductionStatusBlock'; diff --git a/frontend/src/components/dashboard/index.ts b/frontend/src/components/dashboard/index.ts index 6ee1f045..47cc351a 100644 --- a/frontend/src/components/dashboard/index.ts +++ b/frontend/src/components/dashboard/index.ts @@ -3,14 +3,16 @@ // ================================================================ /** * Dashboard Components Export - * Barrel export for all JTBD dashboard components + * Barrel export for all dashboard components */ -// Core Dashboard Components (JTBD-Aligned) -export { GlanceableHealthHero } from './GlanceableHealthHero'; -export { UnifiedActionQueueCard } from './UnifiedActionQueueCard'; -export { ExecutionProgressTracker } from './ExecutionProgressTracker'; -export { IntelligentSystemSummaryCard } from './IntelligentSystemSummaryCard'; +// New Dashboard Blocks (4 focused blocks for Panel de Control) +export { + SystemStatusBlock, + PendingPurchasesBlock, + PendingDeliveriesBlock, + ProductionStatusBlock, +} from './blocks'; // Setup Flow Components export { SetupWizardBlocker } from './SetupWizardBlocker'; diff --git a/frontend/src/components/domain/procurement/ModifyPurchaseOrderModal.tsx b/frontend/src/components/domain/procurement/ModifyPurchaseOrderModal.tsx index 42d69ccb..a811412d 100644 --- a/frontend/src/components/domain/procurement/ModifyPurchaseOrderModal.tsx +++ b/frontend/src/components/domain/procurement/ModifyPurchaseOrderModal.tsx @@ -223,23 +223,26 @@ export const ModifyPurchaseOrderModal: React.FC = label: 'Unidad', type: 'select', required: true, - options: unitOptions + options: unitOptions, + disabled: true }, { name: 'unit_price', label: 'Precio Unitario (€)', type: 'currency', required: true, - placeholder: '0.00' + placeholder: '0.00', + disabled: true } ], addButtonLabel: 'Agregar Producto', emptyStateText: 'No hay productos en esta orden', showSubtotals: true, subtotalFields: { quantity: 'ordered_quantity', price: 'unit_price' }, - disabled: false + disabled: true, + disableRemove: true }, - helpText: 'Modifica las cantidades, unidades y precios según sea necesario' + helpText: 'Solo puedes modificar las cantidades. Los precios unitarios están fijados por el proveedor seleccionado.' } ] }; diff --git a/frontend/src/components/ui/AddModal/AddModal.tsx b/frontend/src/components/ui/AddModal/AddModal.tsx index b40955b8..20da3827 100644 --- a/frontend/src/components/ui/AddModal/AddModal.tsx +++ b/frontend/src/components/ui/AddModal/AddModal.tsx @@ -114,6 +114,7 @@ const ListFieldRenderer: React.FC = ({ field, value, onC }; const isDisabled = listConfig.disabled ?? false; + const disableRemove = listConfig.disableRemove ?? false; return (
@@ -148,13 +149,15 @@ const ListFieldRenderer: React.FC = ({ field, value, onC
Elemento #{itemIndex + 1} - + {!disableRemove && ( + + )}
@@ -223,6 +226,7 @@ export interface AddModalField { showSubtotals?: boolean; // For calculating item totals subtotalFields?: { quantity: string; price: string }; // Field names for calculation disabled?: boolean; // Disable adding new items + disableRemove?: boolean; // Disable removing existing items }; } diff --git a/frontend/src/locales/en/dashboard.json b/frontend/src/locales/en/dashboard.json index f2672e56..e3f340e1 100644 --- a/frontend/src/locales/en/dashboard.json +++ b/frontend/src/locales/en/dashboard.json @@ -171,14 +171,19 @@ "health": { "production_on_schedule": "Production on schedule", "production_delayed": "{count} production batch{count, plural, one {} other {es}} delayed", + "production_late_to_start": "{count} batch{count, plural, one {} other {es}} not started on time", + "production_delayed_and_late": "{delayed} batch{delayed, plural, one {} other {es}} delayed and {late} not started on time", + "production_issues": "{total} production issue{total, plural, one {} other {s}}", "production_ai_prevented": "AI prevented {count} production delay{count, plural, one {} other {s}}", "all_ingredients_in_stock": "All ingredients in stock", "ingredients_out_of_stock": "{count} ingredient{count, plural, one {} other {s}} out of stock", "inventory_ai_prevented": "AI prevented {count} inventory issue{count, plural, one {} other {s}}", "no_pending_approvals": "No pending approvals", "approvals_awaiting": "{count} purchase order{count, plural, one {} other {s}} awaiting approval", - "procurement_ai_created": "AI created {count} purchase order{count, plural, one {} other {s}} automatically", + "procurement_ai_prevented": "AI created {count} purchase order{count, plural, one {} other {s}} automatically", "deliveries_on_track": "All deliveries on track", + "deliveries_overdue": "{count} deliver{count, plural, one {y} other {ies}} overdue", + "deliveries_ai_prevented": "AI prevented {count} delivery issue{count, plural, one {} other {s}}", "deliveries_pending": "{count} pending deliver{count, plural, one {y} other {ies}}", "all_systems_operational": "All systems operational", "critical_issues": "{count} critical issue{count, plural, one {} other {s}}", @@ -403,5 +408,76 @@ "delivered": "Delivered", "failed": "Failed", "distribution_routes": "Distribution Routes" + }, + "new_dashboard": { + "system_status": { + "title": "System Status", + "issues_requiring_action": "{count, plural, one {# issue} other {# issues}} requiring your action", + "all_clear": "All systems running smoothly", + "never_run": "Never run", + "action_needed_label": "action needed", + "ai_prevented_label": "prevented by AI", + "last_run_label": "Last run", + "ai_prevented_details": "Issues Prevented by AI", + "ai_handling_rate": "AI Handling Rate", + "estimated_savings": "Estimated Savings", + "issues_prevented": "Issues Prevented", + "issue_prevented": "Issue prevented", + "saved": "Saved", + "and_more": "+{count} more" + }, + "pending_purchases": { + "title": "Pending Purchases", + "count": "{count, plural, one {# order} other {# orders}} awaiting approval", + "no_pending": "No pending purchase orders", + "all_clear": "No purchase orders pending approval", + "po_number": "PO #{number}", + "supplier": "Supplier: {name}", + "approve": "Approve", + "reject": "Reject", + "view_details": "View Details", + "ai_reasoning": "AI created this PO because:", + "reasoning": { + "low_stock": "{ingredient} will run out in {days, plural, =0 {less than a day} one {# day} other {# days}}", + "low_stock_detailed": "{count, plural, one {# critical ingredient} other {# critical ingredients}} at risk: {products}. Earliest depletion in {days, plural, =0 {<1 day} one {1 day} other {# days}}, affecting {batches, plural, one {# batch} other {# batches}}. Potential loss: €{loss}", + "demand_forecast": "Demand for {product} is expected to increase by {increase}%" + } + }, + "pending_deliveries": { + "title": "Pending Deliveries", + "count": "{count, plural, one {# delivery} other {# deliveries}} expected today", + "no_deliveries": "No deliveries expected today", + "all_clear": "No pending deliveries today", + "overdue_section": "Overdue Deliveries", + "today_section": "Expected Today", + "overdue_badge": "overdue", + "po_ref": "PO #{number}", + "overdue_by": "Overdue by {hours}", + "arriving_in": "Arriving in {hours}", + "call_supplier": "Call", + "mark_received": "Received" + }, + "production_status": { + "title": "Production Status", + "count": "{count, plural, one {# batch} other {# batches}} today", + "no_production": "No production scheduled for today", + "all_clear": "No production scheduled for today", + "late_section": "Late to Start", + "running_section": "Currently Running", + "pending_section": "Pending Today", + "late_badge": "late", + "running_badge": "running", + "batch_info": "Batch #{number} - {quantity} units", + "should_have_started": "Should have started at {time}", + "started_at": "Started at {time}", + "starts_at": "Starts at {time}", + "start_batch": "Start", + "view_details": "View", + "ai_reasoning": "AI scheduled this batch because:", + "reasoning": { + "forecast_demand": "Predicted demand of {demand} units for {product}", + "customer_order": "Customer order from {customer}" + } + } } } \ No newline at end of file diff --git a/frontend/src/locales/es/dashboard.json b/frontend/src/locales/es/dashboard.json index 4c843375..431563a8 100644 --- a/frontend/src/locales/es/dashboard.json +++ b/frontend/src/locales/es/dashboard.json @@ -198,14 +198,19 @@ "health": { "production_on_schedule": "Producción a tiempo", "production_delayed": "{count} lote{count, plural, one {} other {s}} de producción retrasado{count, plural, one {} other {s}}", + "production_late_to_start": "{count} lote{count, plural, one {} other {s}} no iniciado{count, plural, one {} other {s}} a tiempo", + "production_delayed_and_late": "{delayed} lote{delayed, plural, one {} other {s}} retrasado{delayed, plural, one {} other {s}} y {late} no iniciado{late, plural, one {} other {s}}", + "production_issues": "{total} problema{total, plural, one {} other {s}} de producción", "production_ai_prevented": "IA evitó {count} retraso{count, plural, one {} other {s}} de producción", "all_ingredients_in_stock": "Todos los ingredientes en stock", "ingredients_out_of_stock": "{count} ingrediente{count, plural, one {} other {s}} sin stock", "inventory_ai_prevented": "IA evitó {count} problema{count, plural, one {} other {s}} de inventario", "no_pending_approvals": "Sin aprobaciones pendientes", "approvals_awaiting": "{count} orden{count, plural, one {} other {es}} de compra esperando aprobación", - "procurement_ai_created": "IA creó {count} orden{count, plural, one {} other {es}} de compra automáticamente", + "procurement_ai_prevented": "IA creó {count} orden{count, plural, one {} other {es}} de compra automáticamente", "deliveries_on_track": "Todas las entregas a tiempo", + "deliveries_overdue": "{count} entrega{count, plural, one {} other {s}} atrasada{count, plural, one {} other {s}}", + "deliveries_ai_prevented": "IA evitó {count} problema{count, plural, one {} other {s}} de entrega", "deliveries_pending": "{count} entrega{count, plural, one {} other {s}} pendiente{count, plural, one {} other {s}}", "all_systems_operational": "Todos los sistemas operativos", "critical_issues": "{count} problema{count, plural, one {} other {s}} crítico{count, plural, one {} other {s}}", @@ -452,5 +457,76 @@ "delivered": "Entregada", "failed": "Fallida", "distribution_routes": "Rutas de Distribución" + }, + "new_dashboard": { + "system_status": { + "title": "Estado del Sistema", + "issues_requiring_action": "{count, plural, one {# problema requiere} other {# problemas requieren}} tu acción", + "all_clear": "Todos los sistemas funcionan correctamente", + "never_run": "Nunca ejecutado", + "action_needed_label": "acción requerida", + "ai_prevented_label": "evitados por IA", + "last_run_label": "Última ejecución", + "ai_prevented_details": "Problemas Evitados por IA", + "ai_handling_rate": "Tasa de Gestión IA", + "estimated_savings": "Ahorros Estimados", + "issues_prevented": "Problemas Evitados", + "issue_prevented": "Problema evitado", + "saved": "Ahorrado", + "and_more": "+{count} más" + }, + "pending_purchases": { + "title": "Compras Pendientes", + "count": "{count, plural, one {# orden} other {# órdenes}} esperando aprobación", + "no_pending": "Sin órdenes de compra pendientes", + "all_clear": "Sin órdenes de compra pendientes de aprobación", + "po_number": "OC #{number}", + "supplier": "Proveedor: {name}", + "approve": "Aprobar", + "reject": "Rechazar", + "view_details": "Ver Detalles", + "ai_reasoning": "IA creó esta OC porque:", + "reasoning": { + "low_stock": "{ingredient} se agotará en {days, plural, =0 {menos de un día} one {# día} other {# días}}", + "low_stock_detailed": "{count, plural, one {# ingrediente crítico} other {# ingredientes críticos}} en riesgo: {products}. Agotamiento más temprano en {days, plural, =0 {<1 día} one {1 día} other {# días}}, afectando {batches, plural, one {# lote} other {# lotes}}. Pérdida potencial: €{loss}", + "demand_forecast": "Se espera que la demanda de {product} aumente un {increase}%" + } + }, + "pending_deliveries": { + "title": "Entregas Pendientes", + "count": "{count, plural, one {# entrega} other {# entregas}} esperadas hoy", + "no_deliveries": "Sin entregas esperadas hoy", + "all_clear": "Sin entregas pendientes hoy", + "overdue_section": "Entregas Atrasadas", + "today_section": "Esperadas Hoy", + "overdue_badge": "atrasada", + "po_ref": "OC #{number}", + "overdue_by": "Atrasada {hours}", + "arriving_in": "Llega en {hours}", + "call_supplier": "Llamar", + "mark_received": "Recibido" + }, + "production_status": { + "title": "Estado de Producción", + "count": "{count, plural, one {# lote} other {# lotes}} hoy", + "no_production": "Sin producción programada para hoy", + "all_clear": "Sin producción programada para hoy", + "late_section": "Atrasados para Empezar", + "running_section": "En Ejecución", + "pending_section": "Pendientes Hoy", + "late_badge": "atrasado", + "running_badge": "en curso", + "batch_info": "Lote #{number} - {quantity} unidades", + "should_have_started": "Debía empezar a las {time}", + "started_at": "Empezó a las {time}", + "starts_at": "Empieza a las {time}", + "start_batch": "Iniciar", + "view_details": "Ver", + "ai_reasoning": "IA programó este lote porque:", + "reasoning": { + "forecast_demand": "Demanda prevista de {demand} unidades para {product}", + "customer_order": "Pedido del cliente {customer}" + } + } } } \ No newline at end of file diff --git a/frontend/src/locales/eu/dashboard.json b/frontend/src/locales/eu/dashboard.json index 0550d2cb..346b8cec 100644 --- a/frontend/src/locales/eu/dashboard.json +++ b/frontend/src/locales/eu/dashboard.json @@ -395,5 +395,76 @@ "delivered": "Entregatua", "failed": "Huts egin du", "distribution_routes": "Banaketa Ibilbideak" + }, + "new_dashboard": { + "system_status": { + "title": "Sistema Egoera", + "issues_requiring_action": "{count, plural, one {# arazok} other {# arazok}} zure ekintza behar {count, plural, one {du} other {dute}}", + "all_clear": "Sistema guztiak ondo dabiltza", + "never_run": "Inoiz exekutatu gabe", + "action_needed_label": "ekintza behar", + "ai_prevented_label": "IAk saihestua", + "last_run_label": "Azken exekuzioa", + "ai_prevented_details": "IAk Saihestutako Arazoak", + "ai_handling_rate": "IA Kudeaketa Tasa", + "estimated_savings": "Aurrezki Estimatuak", + "issues_prevented": "Saihestutako Arazoak", + "issue_prevented": "Arazo saihestua", + "saved": "Aurreztua", + "and_more": "+{count} gehiago" + }, + "pending_purchases": { + "title": "Erosketa Zain", + "count": "{count, plural, one {# agindu} other {# agindu}} onarpenaren zai", + "no_pending": "Ez dago erosketa-agindu zain", + "all_clear": "Ez dago erosketa-agindu onartzeko zain", + "po_number": "EA #{number}", + "supplier": "Hornitzailea: {name}", + "approve": "Onartu", + "reject": "Baztertu", + "view_details": "Xehetasunak Ikusi", + "ai_reasoning": "IAk EA hau sortu zuen zeren:", + "reasoning": { + "low_stock": "{ingredient} {days, plural, =0 {egun bat baino gutxiago} one {# egunean} other {# egunetan}} agortuko da", + "low_stock_detailed": "{count, plural, one {# osagai kritiko} other {# osagai kritiko}} arriskuan: {products}. Lehen agortze {days, plural, =0 {<1 egun} one {1 egun} other {# egun}}, {batches, plural, one {# lote} other {# lote}} ukituz. Galera potentziala: €{loss}", + "demand_forecast": "{product} produktuaren eskaria %{increase} igotzea espero da" + } + }, + "pending_deliveries": { + "title": "Entrega Zain", + "count": "{count, plural, one {# entrega} other {# entrega}} gaur espero", + "no_deliveries": "Ez dago entregarik gaur esperatzen", + "all_clear": "Ez dago entregarik zain gaur", + "overdue_section": "Atzeratutako Entregak", + "today_section": "Gaur Esperatzen", + "overdue_badge": "atzeratua", + "po_ref": "EA #{number}", + "overdue_by": "{hours} atzeratuta", + "arriving_in": "{hours} barru iristen", + "call_supplier": "Deitu", + "mark_received": "Jasota" + }, + "production_status": { + "title": "Ekoizpen Egoera", + "count": "{count, plural, one {# lote} other {# lote}} gaur", + "no_production": "Ez dago ekoizpenik programatuta gaur", + "all_clear": "Ez dago ekoizpenik programatuta gaur", + "late_section": "Hasteko Atzeratua", + "running_section": "Martxan", + "pending_section": "Gaur Zain", + "late_badge": "atzeratua", + "running_badge": "martxan", + "batch_info": "Lote #{number} - {quantity} unitate", + "should_have_started": "{time}-an hasi behar zen", + "started_at": "{time}-an hasi zen", + "starts_at": "{time}-an hasiko da", + "start_batch": "Hasi", + "view_details": "Ikusi", + "ai_reasoning": "IAk lote hau programatu zuen zeren:", + "reasoning": { + "forecast_demand": "{product}-rentzat {demand} unitateko eskaria aurreikusita", + "customer_order": "{customer} bezeroaren eskaera" + } + } } } \ No newline at end of file diff --git a/frontend/src/pages/app/DashboardPage.tsx b/frontend/src/pages/app/DashboardPage.tsx index f5b72222..a20d2ef6 100644 --- a/frontend/src/pages/app/DashboardPage.tsx +++ b/frontend/src/pages/app/DashboardPage.tsx @@ -15,45 +15,33 @@ * - Trust-building (explain system reasoning) */ -import React, { useState, useEffect, useMemo, useRef } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { RefreshCw, ExternalLink, Plus, Sparkles, Wifi, WifiOff } from 'lucide-react'; +import { useState, useEffect, useMemo } from 'react'; +import { Plus, Sparkles } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useTenant } from '../../stores/tenant.store'; import { - useBakeryHealthStatus, - useOrchestrationSummary, - useUnifiedActionQueue, - useProductionTimeline, useApprovePurchaseOrder, useStartProductionBatch, - usePauseProductionBatch, - useExecutionProgress, - useDashboardRealtime, // PHASE 3: SSE state sync - useProgressiveDashboard, // PHASE 4: Progressive loading } from '../../api/hooks/useProfessionalDashboard'; +import { useDashboardData, useDashboardRealtimeSync } from '../../api/hooks/useDashboardData'; import { useRejectPurchaseOrder } from '../../api/hooks/purchase-orders'; import { useIngredients } from '../../api/hooks/inventory'; import { useSuppliers } from '../../api/hooks/suppliers'; import { useRecipes } from '../../api/hooks/recipes'; import { useQualityTemplates } from '../../api/hooks/qualityTemplates'; -import { GlanceableHealthHero } from '../../components/dashboard/GlanceableHealthHero'; import { SetupWizardBlocker } from '../../components/dashboard/SetupWizardBlocker'; import { CollapsibleSetupBanner } from '../../components/dashboard/CollapsibleSetupBanner'; -import { UnifiedActionQueueCard } from '../../components/dashboard/UnifiedActionQueueCard'; -import { ExecutionProgressTracker } from '../../components/dashboard/ExecutionProgressTracker'; -import { IntelligentSystemSummaryCard } from '../../components/dashboard/IntelligentSystemSummaryCard'; -import { useAuthUser } from '../../stores'; +import { + SystemStatusBlock, + PendingPurchasesBlock, + PendingDeliveriesBlock, + ProductionStatusBlock, +} from '../../components/dashboard/blocks'; import { UnifiedPurchaseOrderModal } from '../../components/domain/procurement/UnifiedPurchaseOrderModal'; import { UnifiedAddWizard } from '../../components/domain/unified-wizard'; import type { ItemType } from '../../components/domain/unified-wizard'; import { useDemoTour, shouldStartTour, clearTourStartPending } from '../../features/demo-onboarding'; import { Package, Users, BookOpen, Shield } from 'lucide-react'; -import { - useBatchNotifications, - useDeliveryNotifications, - useOrchestrationNotifications, -} from '../../hooks'; // Import Enterprise Dashboard @@ -63,28 +51,21 @@ import { SUBSCRIPTION_TIERS } from '../../api/types/subscription'; // Rename the existing component to BakeryDashboard export function BakeryDashboard() { - const navigate = useNavigate(); const { t } = useTranslation(['dashboard', 'common', 'alerts']); const { currentTenant } = useTenant(); const tenantId = currentTenant?.id || ''; const { startTour } = useDemoTour(); const isDemoMode = localStorage.getItem('demo_mode') === 'true'; - - - // Unified Add Wizard state const [isAddWizardOpen, setIsAddWizardOpen] = useState(false); - const [addWizardError, setAddWizardError] = useState(null); // PO Details Modal state const [selectedPOId, setSelectedPOId] = useState(null); const [isPOModalOpen, setIsPOModalOpen] = useState(false); const [poModalMode, setPOModalMode] = useState<'view' | 'edit'>('view'); - // Setup Progress Data - // Always fetch setup data to determine true progress, but use localStorage as fallback during loading - // PHASE 1 OPTIMIZATION: Only use cached value if we're still waiting for API to respond + // Setup Progress Data - use localStorage as fallback during loading const setupProgressFromStorage = useMemo(() => { try { const cached = localStorage.getItem(`setup_progress_${tenantId}`); @@ -94,7 +75,7 @@ export function BakeryDashboard() { } }, [tenantId]); - // Always fetch the actual data to determine true progress + // Fetch setup data to determine true progress const { data: ingredients = [], isLoading: loadingIngredients } = useIngredients( tenantId, {}, @@ -117,296 +98,57 @@ export function BakeryDashboard() { ); const qualityTemplates = Array.isArray(qualityData?.templates) ? qualityData.templates : []; - // PHASE 4: Progressive data loading for perceived performance boost + // NEW: Single unified data fetch for all 4 dashboard blocks const { - health: { - data: healthStatus, - isLoading: healthLoading, - refetch: refetchHealth, - }, - actionQueue: { - data: actionQueue, - isLoading: actionQueueLoading, - refetch: refetchActionQueue, - }, - progress: { - data: executionProgress, - isLoading: executionProgressLoading, - refetch: refetchExecutionProgress, - }, - overallLoading, - isReady, - } = useProgressiveDashboard(tenantId); + data: dashboardData, + isLoading: dashboardLoading, + refetch: refetchDashboard, + } = useDashboardData(tenantId); - // Additional hooks not part of progressive loading - const { - data: orchestrationSummary, - isLoading: orchestrationLoading, - refetch: refetchOrchestration, - } = useOrchestrationSummary(tenantId); - - const { - data: productionTimeline, - isLoading: timelineLoading, - refetch: refetchTimeline, - } = useProductionTimeline(tenantId); - - // Insights functionality removed as it's not needed with new architecture - const insights = undefined; - const insightsLoading = false; - const refetchInsights = () => {}; - - // PHASE 3: Enable SSE real-time state synchronization - useDashboardRealtime(tenantId); - - - // PHASE 6: Performance monitoring - useEffect(() => { - const loadTime = performance.now(); - console.log(`📊 [Performance] Dashboard loaded in ${loadTime.toFixed(0)}ms`); - - // Calculate setup completion status based on stored progress (approximation since actual data may not be loaded yet) - const setupComplete = setupProgressFromStorage >= 100; - - if (loadTime > 1000) { - console.warn('⚠️ [Performance] Dashboard load time exceeded target (>1000ms):', { - loadTime: `${loadTime.toFixed(0)}ms`, - target: '1000ms', - setupComplete, - queriesSkipped: setupComplete ? 4 : 0, - }); - } else { - console.log('✅ [Performance] Dashboard load time within target:', { - loadTime: `${loadTime.toFixed(0)}ms`, - target: '<1000ms', - setupComplete, - queriesSkipped: setupComplete ? 4 : 0, - }); - } - }, [setupProgressFromStorage]); // Include setupProgressFromStorage as dependency - - // Real-time event subscriptions for automatic refetching - const { notifications: batchNotifications } = useBatchNotifications(); - const { notifications: deliveryNotifications } = useDeliveryNotifications(); - const { recentNotifications: orchestrationNotifications } = useOrchestrationNotifications(); - - console.log('🔄 [Dashboard] Component render - notification counts:', { - batch: batchNotifications.length, - delivery: deliveryNotifications.length, - orchestration: orchestrationNotifications.length, - batchIds: batchNotifications.map(n => n.id).join(','), - deliveryIds: deliveryNotifications.map(n => n.id).join(','), - orchestrationIds: orchestrationNotifications.map(n => n.id).join(','), - }); - - // SSE connection status - const sseConnected = true; // Simplified - based on other notification hooks - - // Store refetch callbacks in a ref to prevent infinite loop from dependency changes - // React Query refetch functions are recreated on every query state change, which would - // trigger useEffect again if they were in the dependency array - const refetchCallbacksRef = useRef({ - refetchActionQueue, - refetchHealth, - refetchExecutionProgress, - refetchOrchestration, - }); - - // Store previous notification IDs to prevent infinite refetch loops - const prevBatchNotificationsRef = useRef(''); - const prevDeliveryNotificationsRef = useRef(''); - const prevOrchestrationNotificationsRef = useRef(''); - - // Update ref with latest callbacks on every render - useEffect(() => { - refetchCallbacksRef.current = { - refetchActionQueue, - refetchHealth, - refetchExecutionProgress, - refetchOrchestration, - }; - }); - - // Track the latest notification ID to prevent re-running on same notification - // Use stringified ID array to create stable dependency that only changes when IDs actually change - const batchIdsString = JSON.stringify(batchNotifications.map(n => n.id)); - const deliveryIdsString = JSON.stringify(deliveryNotifications.map(n => n.id)); - const orchestrationIdsString = JSON.stringify(orchestrationNotifications.map(n => n.id)); - - console.log('📝 [Dashboard] Stringified ID arrays:', { - batchIdsString, - deliveryIdsString, - orchestrationIdsString, - }); - - const latestBatchNotificationId = useMemo(() => { - const result = batchNotifications.length === 0 ? '' : (batchNotifications[0]?.id || ''); - console.log('🧮 [Dashboard] latestBatchNotificationId useMemo recalculated:', { - result, - dependency: batchIdsString, - notificationCount: batchNotifications.length, - }); - return result; - }, [batchIdsString]); - - const latestDeliveryNotificationId = useMemo(() => { - const result = deliveryNotifications.length === 0 ? '' : (deliveryNotifications[0]?.id || ''); - console.log('🧮 [Dashboard] latestDeliveryNotificationId useMemo recalculated:', { - result, - dependency: deliveryIdsString, - notificationCount: deliveryNotifications.length, - }); - return result; - }, [deliveryIdsString]); - - const latestOrchestrationNotificationId = useMemo(() => { - const result = orchestrationNotifications.length === 0 ? '' : (orchestrationNotifications[0]?.id || ''); - console.log('🧮 [Dashboard] latestOrchestrationNotificationId useMemo recalculated:', { - result, - dependency: orchestrationIdsString, - notificationCount: orchestrationNotifications.length, - }); - return result; - }, [orchestrationIdsString]); - - useEffect(() => { - console.log('⚡ [Dashboard] batchNotifications useEffect triggered', { - latestBatchNotificationId, - prevValue: prevBatchNotificationsRef.current, - hasChanged: latestBatchNotificationId !== prevBatchNotificationsRef.current, - notificationCount: batchNotifications.length, - firstNotification: batchNotifications[0], - }); - - if (latestBatchNotificationId && - latestBatchNotificationId !== prevBatchNotificationsRef.current) { - console.log('🔥 [Dashboard] NEW batch notification detected, updating ref and refetching'); - prevBatchNotificationsRef.current = latestBatchNotificationId; - const latest = batchNotifications[0]; - - if (['batch_completed', 'batch_started'].includes(latest.event_type)) { - console.log('🚀 [Dashboard] Triggering refetch for batch event:', latest.event_type); - refetchCallbacksRef.current.refetchExecutionProgress(); - refetchCallbacksRef.current.refetchHealth(); - } else { - console.log('⏭️ [Dashboard] Skipping refetch - event type not relevant:', latest.event_type); - } - } - }, [latestBatchNotificationId]); // Only run when a NEW notification arrives - - useEffect(() => { - console.log('⚡ [Dashboard] deliveryNotifications useEffect triggered', { - latestDeliveryNotificationId, - prevValue: prevDeliveryNotificationsRef.current, - hasChanged: latestDeliveryNotificationId !== prevDeliveryNotificationsRef.current, - notificationCount: deliveryNotifications.length, - firstNotification: deliveryNotifications[0], - }); - - if (latestDeliveryNotificationId && - latestDeliveryNotificationId !== prevDeliveryNotificationsRef.current) { - console.log('🔥 [Dashboard] NEW delivery notification detected, updating ref and refetching'); - prevDeliveryNotificationsRef.current = latestDeliveryNotificationId; - const latest = deliveryNotifications[0]; - - if (['delivery_received', 'delivery_overdue'].includes(latest.event_type)) { - console.log('🚀 [Dashboard] Triggering refetch for delivery event:', latest.event_type); - refetchCallbacksRef.current.refetchExecutionProgress(); - refetchCallbacksRef.current.refetchHealth(); - } else { - console.log('⏭️ [Dashboard] Skipping refetch - event type not relevant:', latest.event_type); - } - } - }, [latestDeliveryNotificationId]); // Only run when a NEW notification arrives - - useEffect(() => { - console.log('⚡ [Dashboard] orchestrationNotifications useEffect triggered', { - latestOrchestrationNotificationId, - prevValue: prevOrchestrationNotificationsRef.current, - hasChanged: latestOrchestrationNotificationId !== prevOrchestrationNotificationsRef.current, - notificationCount: orchestrationNotifications.length, - firstNotification: orchestrationNotifications[0], - }); - - if (latestOrchestrationNotificationId && - latestOrchestrationNotificationId !== prevOrchestrationNotificationsRef.current) { - console.log('🔥 [Dashboard] NEW orchestration notification detected, updating ref and refetching'); - prevOrchestrationNotificationsRef.current = latestOrchestrationNotificationId; - const latest = orchestrationNotifications[0]; - - if (latest.event_type === 'orchestration_run_completed') { - console.log('🚀 [Dashboard] Triggering refetch for orchestration event:', latest.event_type); - refetchCallbacksRef.current.refetchOrchestration(); - refetchCallbacksRef.current.refetchActionQueue(); - } else { - console.log('⏭️ [Dashboard] Skipping refetch - event type not relevant:', latest.event_type); - } - } - }, [latestOrchestrationNotificationId]); // Only run when a NEW notification arrives + // Enable SSE real-time state synchronization + useDashboardRealtimeSync(tenantId); // Mutations const approvePO = useApprovePurchaseOrder(); const rejectPO = useRejectPurchaseOrder(); const startBatch = useStartProductionBatch(); - const pauseBatch = usePauseProductionBatch(); // Handlers - const handleApprove = async (actionId: string) => { + const handleApprove = async (poId: string) => { try { - await approvePO.mutateAsync({ tenantId, poId: actionId }); - // Refetch to update UI - refetchActionQueue(); - refetchHealth(); + await approvePO.mutateAsync({ tenantId, poId }); + // SSE will handle refetch, but trigger immediate refetch for responsiveness + refetchDashboard(); } catch (error) { console.error('Error approving PO:', error); } }; - const handleReject = async (actionId: string, reason: string) => { + const handleReject = async (poId: string, reason: string) => { try { - await rejectPO.mutateAsync({ tenantId, poId: actionId, reason }); - // Refetch to update UI - refetchActionQueue(); - refetchHealth(); + await rejectPO.mutateAsync({ tenantId, poId, reason }); + refetchDashboard(); } catch (error) { console.error('Error rejecting PO:', error); } }; - const handleViewDetails = (actionId: string) => { + const handleViewDetails = (poId: string) => { // Open modal to show PO details in view mode - setSelectedPOId(actionId); + setSelectedPOId(poId); setPOModalMode('view'); setIsPOModalOpen(true); }; - const handleModify = (actionId: string) => { - // Open modal to edit PO details - setSelectedPOId(actionId); - setPOModalMode('edit'); - setIsPOModalOpen(true); - }; - const handleStartBatch = async (batchId: string) => { try { await startBatch.mutateAsync({ tenantId, batchId }); - refetchTimeline(); - refetchHealth(); + refetchDashboard(); } catch (error) { console.error('Error starting batch:', error); } }; - const handlePauseBatch = async (batchId: string) => { - try { - await pauseBatch.mutateAsync({ tenantId, batchId }); - refetchTimeline(); - refetchHealth(); - } catch (error) { - console.error('Error pausing batch:', error); - } - }; - // Calculate configuration sections for setup flow const setupSections = useMemo(() => { // Create safe fallbacks for icons to prevent React error #310 @@ -514,19 +256,11 @@ export function BakeryDashboard() { }; }, [setupSections, tenantId, loadingIngredients, loadingSuppliers, loadingRecipes, loadingQuality, setupProgressFromStorage]); - const handleRefreshAll = () => { - refetchHealth(); - refetchOrchestration(); - refetchActionQueue(); - refetchExecutionProgress(); - refetchTimeline(); - refetchInsights(); - }; - const handleAddWizardComplete = (itemType: ItemType, data?: any) => { console.log('Item created:', itemType, data); - // Refetch relevant data based on what was added - handleRefreshAll(); + // SSE events will handle most updates automatically, but we refetch here + // to ensure immediate feedback after user actions + refetchDashboard(); }; // Keyboard shortcut for Quick Add (Cmd/Ctrl + K) @@ -600,14 +334,6 @@ export function BakeryDashboard() { {/* Action Buttons */}
- - {/* Unified Add Button with Keyboard Shortcut */}
{/* Setup Flow - Three States */} - {loadingIngredients || loadingSuppliers || loadingRecipes || loadingQuality || !isReady ? ( - /* Loading state - only show spinner until first priority data (health) is ready */ + {loadingIngredients || loadingSuppliers || loadingRecipes || loadingQuality ? ( + /* Loading state - only show spinner until setup data is ready */
@@ -653,103 +379,45 @@ export function BakeryDashboard() { /> )} - {/* Main Dashboard Layout */} + {/* Main Dashboard Layout - 4 New Focused Blocks */}
- {/* SECTION 1: Glanceable Health Hero (Traffic Light) - PRIORITY 1 */} + {/* BLOCK 1: System Status + AI Summary */}
- {healthLoading ? ( -
-
-
- ) : ( - - )} -
- - {/* SECTION 2: What Needs Your Attention (Unified Action Queue) - PRIORITY 2 */} -
- {actionQueueLoading ? ( -
-
-
-
-
-
- ) : ( - - )} -
- - {/* SECTION 3: Execution Progress Tracker (Plan vs Actual) - PRIORITY 3 */} -
- {executionProgressLoading ? ( -
-
-
-
-
-
- ) : ( - - )} -
- - {/* SECTION 4: Intelligent System Summary - Unified AI Impact & Orchestration */} -
-
- {/* SECTION 6: Quick Action Links */} -
-

{t('dashboard:sections.quick_actions')}

-
- + {/* BLOCK 2: Pending Purchases (PO Approvals) */} +
+ +
- + {/* BLOCK 3: Pending Deliveries (Overdue + Today) */} +
+ +
- - - -
+ {/* BLOCK 4: Production Status (Late/Running/Pending) */} +
+
@@ -777,7 +445,8 @@ export function BakeryDashboard() { setIsPOModalOpen(false); setSelectedPOId(null); setPOModalMode('view'); - handleRefreshAll(); + // SSE events will handle most updates automatically + refetchDashboard(); }} onApprove={handleApprove} onReject={handleReject} diff --git a/services/procurement/scripts/demo/seed_demo_purchase_orders.py b/services/procurement/scripts/demo/seed_demo_purchase_orders.py index 2dd3298a..440a502b 100644 --- a/services/procurement/scripts/demo/seed_demo_purchase_orders.py +++ b/services/procurement/scripts/demo/seed_demo_purchase_orders.py @@ -643,7 +643,7 @@ async def seed_purchase_orders_for_tenant(db: AsyncSession, tenant_id: uuid.UUID {"name": "Sal Fina", "quantity": 30, "unit_price": 0.85, "uom": "kg"} ] ) - po10.notes = "⚠️ ESCALATED: Pending approval for 72+ hours - Production batch depends on tomorrow morning delivery" + # Note: Manual notes removed to reflect real orchestrator behavior pos_created.append(po10) # 11. DELIVERY OVERDUE - Expected delivery is 4 hours late (URGENT dashboard alert) @@ -661,7 +661,6 @@ async def seed_purchase_orders_for_tenant(db: AsyncSession, tenant_id: uuid.UUID # Override delivery date to be 4 hours ago (overdue) po11.required_delivery_date = delivery_overdue_time po11.expected_delivery_date = delivery_overdue_time - po11.notes = "🔴 OVERDUE: Expected delivery was 4 hours ago - Contact supplier immediately" pos_created.append(po11) # 12. DELIVERY ARRIVING SOON - Arriving in 8 hours (TODAY dashboard alert) @@ -680,7 +679,6 @@ async def seed_purchase_orders_for_tenant(db: AsyncSession, tenant_id: uuid.UUID # Override delivery date to be in 8 hours po12.expected_delivery_date = arriving_soon_time po12.required_delivery_date = arriving_soon_time - po12.notes = "📦 ARRIVING SOON: Delivery expected in 8 hours - Prepare for stock receipt" pos_created.append(po12) # 13. DELIVERY TODAY MORNING - Scheduled for 10 AM today @@ -697,7 +695,6 @@ async def seed_purchase_orders_for_tenant(db: AsyncSession, tenant_id: uuid.UUID ) po13.expected_delivery_date = delivery_today_morning po13.required_delivery_date = delivery_today_morning - po13.notes = "📦 Delivery scheduled for 10 AM - Essential ingredients for morning production" pos_created.append(po13) # 14. DELIVERY TODAY AFTERNOON - Scheduled for 3 PM today @@ -714,7 +711,6 @@ async def seed_purchase_orders_for_tenant(db: AsyncSession, tenant_id: uuid.UUID ) po14.expected_delivery_date = delivery_today_afternoon po14.required_delivery_date = delivery_today_afternoon - po14.notes = "📦 Packaging delivery expected at 3 PM" pos_created.append(po14) # 15. DELIVERY TOMORROW EARLY - Scheduled for 8 AM tomorrow (high priority) @@ -724,6 +720,7 @@ async def seed_purchase_orders_for_tenant(db: AsyncSession, tenant_id: uuid.UUID PurchaseOrderStatus.approved, Decimal("445.00"), created_offset_days=-1, + priority="high", items_data=[ {"name": "Harina Integral", "quantity": 300, "unit_price": 0.95, "uom": "kg"}, {"name": "Sal Marina", "quantity": 50, "unit_price": 1.60, "uom": "kg"} @@ -731,8 +728,6 @@ async def seed_purchase_orders_for_tenant(db: AsyncSession, tenant_id: uuid.UUID ) po15.expected_delivery_date = delivery_tomorrow_early po15.required_delivery_date = delivery_tomorrow_early - po15.priority = "high" - po15.notes = "🔔 Critical delivery for weekend production - Confirm with supplier" pos_created.append(po15) # 16. DELIVERY TOMORROW LATE - Scheduled for 5 PM tomorrow @@ -749,7 +744,6 @@ async def seed_purchase_orders_for_tenant(db: AsyncSession, tenant_id: uuid.UUID ) po16.expected_delivery_date = delivery_tomorrow_late po16.required_delivery_date = delivery_tomorrow_late - po16.notes = "📦 Specialty ingredients for chocolate products" pos_created.append(po16) # 17. DELIVERY DAY AFTER - Scheduled for 11 AM in 2 days @@ -766,7 +760,6 @@ async def seed_purchase_orders_for_tenant(db: AsyncSession, tenant_id: uuid.UUID ) po17.expected_delivery_date = delivery_day_after po17.required_delivery_date = delivery_day_after - po17.notes = "📦 Dairy delivery for mid-week production" pos_created.append(po17) # 18. DELIVERY THIS WEEK - Scheduled for 2 PM in 4 days @@ -784,7 +777,6 @@ async def seed_purchase_orders_for_tenant(db: AsyncSession, tenant_id: uuid.UUID ) po18.expected_delivery_date = delivery_this_week po18.required_delivery_date = delivery_this_week - po18.notes = "📦 Specialty items for artisan products" pos_created.append(po18) await db.commit()