From 70931cb4fd0694afd23a21a7b0ba7280b63d6647 Mon Sep 17 00:00:00 2001 From: Urtzi Alfaro Date: Thu, 27 Nov 2025 07:37:50 +0100 Subject: [PATCH] feat(dashboard): Add production batch details to execution progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend changes (dashboard_service.py): - Collect in-progress batch details with id, batchNumber, productName, etc. - Add inProgressBatches array to production progress response Frontend changes (ExecutionProgressTracker.tsx): - Update ProductionProgress interface to include inProgressBatches array - Display batch names and numbers under "En Progreso" count - Show which specific batches are currently running Users can now see which production batches are in progress instead of just a count (e.g., "โ€ข Pan (BATCH-001)"). Fixes: Issue #5 - Missing production batch details ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../dashboard/ExecutionProgressTracker.tsx | 420 ++++++++++++++++++ .../app/services/dashboard_service.py | 357 +++++++++++++-- 2 files changed, 738 insertions(+), 39 deletions(-) create mode 100644 frontend/src/components/dashboard/ExecutionProgressTracker.tsx diff --git a/frontend/src/components/dashboard/ExecutionProgressTracker.tsx b/frontend/src/components/dashboard/ExecutionProgressTracker.tsx new file mode 100644 index 00000000..5cc486d9 --- /dev/null +++ b/frontend/src/components/dashboard/ExecutionProgressTracker.tsx @@ -0,0 +1,420 @@ +// ================================================================ +// 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 DeliveryProgress { + status: 'no_deliveries' | 'completed' | 'on_track' | 'at_risk'; + total: number; + received: number; + pending: number; + overdue: number; +} + +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 */} +
+ +
+

+ {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')} +

+ ) : ( +
+
+
+ +
+
+ {progress.deliveries.received} +
+
+ {t('dashboard:execution_progress.received')} +
+
+ +
+
+ +
+
+ {progress.deliveries.pending} +
+
+ {t('dashboard:execution_progress.pending')} +
+
+ +
+
+ +
+
+ {progress.deliveries.overdue} +
+
+ {t('dashboard:execution_progress.overdue')} +
+
+
+ )} +
+ + {/* Approvals Section */} +
+
+ + {t('dashboard:execution_progress.pending_approvals')} + + + {progress.approvals.pending} + +
+
+
+
+ ); +} diff --git a/services/orchestrator/app/services/dashboard_service.py b/services/orchestrator/app/services/dashboard_service.py index 73d2ef9c..0b9eb8b9 100644 --- a/services/orchestrator/app/services/dashboard_service.py +++ b/services/orchestrator/app/services/dashboard_service.py @@ -13,6 +13,7 @@ from decimal import Decimal from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, and_, or_, desc import logging +import uuid from ..models.orchestration_run import OrchestrationRun, OrchestrationStatus @@ -57,10 +58,12 @@ class DashboardService: pending_approvals: int, production_delays: int, out_of_stock_count: int, - system_errors: int + system_errors: int, + ai_prevented_count: int = 0, + action_needed_alerts: List[Dict[str, Any]] = None ) -> Dict[str, Any]: """ - Calculate overall bakery health status based on multiple signals + Calculate overall bakery health status with tri-state checklist Args: tenant_id: Tenant identifier @@ -69,9 +72,11 @@ class DashboardService: production_delays: Number of delayed production batches out_of_stock_count: Number of out-of-stock ingredients system_errors: Number of system errors + ai_prevented_count: Number of issues AI prevented + action_needed_alerts: List of action_needed alerts for detailed checklist Returns: - Health status with headline and checklist + Health status with headline and tri-state checklist """ # Determine overall status status = self._calculate_health_status( @@ -85,52 +90,119 @@ class DashboardService: # Get last orchestration run last_run = await self._get_last_orchestration_run(tenant_id) - # Generate checklist items + # Generate tri-state checklist items checklist_items = [] - # Production status - if production_delays == 0: + # Production status - tri-state (โœ… good / โšก AI handled / โŒ needs you) + production_alerts = [a for a in (action_needed_alerts or []) + if a.get('alert_type', '').startswith('production_')] + production_prevented = [a for a in (action_needed_alerts or []) + if a.get('type_class') == 'prevented_issue' and 'production' in a.get('alert_type', '')] + + if production_delays > 0: + checklist_items.append({ + "icon": "alert", + "textKey": "dashboard.health.production_delayed", + "textParams": {"count": production_delays}, + "actionRequired": True, + "status": "needs_you", + "actionPath": "/dashboard" # Links to action queue + }) + elif len(production_prevented) > 0: + checklist_items.append({ + "icon": "ai_handled", + "textKey": "dashboard.health.production_ai_prevented", + "textParams": {"count": len(production_prevented)}, + "actionRequired": False, + "status": "ai_handled" + }) + else: checklist_items.append({ "icon": "check", "textKey": "dashboard.health.production_on_schedule", - "actionRequired": False - }) - else: - checklist_items.append({ - "icon": "warning", - "textKey": "dashboard.health.production_delayed", - "textParams": {"count": production_delays}, - "actionRequired": True + "actionRequired": False, + "status": "good" }) - # Inventory status - if out_of_stock_count == 0: - checklist_items.append({ - "icon": "check", - "textKey": "dashboard.health.all_ingredients_in_stock", - "actionRequired": False - }) - else: + # Inventory status - tri-state + inventory_alerts = [a for a in (action_needed_alerts or []) + if 'stock' in a.get('alert_type', '').lower() or 'inventory' in a.get('alert_type', '').lower()] + inventory_prevented = [a for a in (action_needed_alerts or []) + if a.get('type_class') == 'prevented_issue' and 'stock' in a.get('alert_type', '').lower()] + + if out_of_stock_count > 0: checklist_items.append({ "icon": "alert", "textKey": "dashboard.health.ingredients_out_of_stock", "textParams": {"count": out_of_stock_count}, - "actionRequired": True + "actionRequired": True, + "status": "needs_you", + "actionPath": "/inventory" }) - - # Approval status - if pending_approvals == 0: + elif len(inventory_prevented) > 0: checklist_items.append({ - "icon": "check", - "textKey": "dashboard.health.no_pending_approvals", - "actionRequired": False + "icon": "ai_handled", + "textKey": "dashboard.health.inventory_ai_prevented", + "textParams": {"count": len(inventory_prevented)}, + "actionRequired": False, + "status": "ai_handled" }) else: + checklist_items.append({ + "icon": "check", + "textKey": "dashboard.health.all_ingredients_in_stock", + "actionRequired": False, + "status": "good" + }) + + # Procurement/Approval status - tri-state + po_prevented = [a for a in (action_needed_alerts or []) + if a.get('type_class') == 'prevented_issue' and 'procurement' in a.get('alert_type', '').lower()] + + if pending_approvals > 0: checklist_items.append({ "icon": "warning", "textKey": "dashboard.health.approvals_awaiting", "textParams": {"count": pending_approvals}, - "actionRequired": True + "actionRequired": True, + "status": "needs_you", + "actionPath": "/dashboard" + }) + elif len(po_prevented) > 0: + checklist_items.append({ + "icon": "ai_handled", + "textKey": "dashboard.health.procurement_ai_created", + "textParams": {"count": len(po_prevented)}, + "actionRequired": False, + "status": "ai_handled" + }) + else: + checklist_items.append({ + "icon": "check", + "textKey": "dashboard.health.no_pending_approvals", + "actionRequired": False, + "status": "good" + }) + + # Delivery status - tri-state + delivery_alerts = [a for a in (action_needed_alerts or []) + if 'delivery' in a.get('alert_type', '').lower()] + + if len(delivery_alerts) > 0: + checklist_items.append({ + "icon": "warning", + "textKey": "dashboard.health.deliveries_pending", + "textParams": {"count": len(delivery_alerts)}, + "actionRequired": True, + "status": "needs_you", + "actionPath": "/dashboard" + }) + else: + checklist_items.append({ + "icon": "check", + "textKey": "dashboard.health.deliveries_on_track", + "actionRequired": False, + "status": "good" }) # System health @@ -138,18 +210,20 @@ class DashboardService: checklist_items.append({ "icon": "check", "textKey": "dashboard.health.all_systems_operational", - "actionRequired": False + "actionRequired": False, + "status": "good" }) else: checklist_items.append({ "icon": "alert", "textKey": "dashboard.health.critical_issues", "textParams": {"count": critical_alerts + system_errors}, - "actionRequired": True + "actionRequired": True, + "status": "needs_you" }) # Generate headline - headline = self._generate_health_headline(status, critical_alerts, pending_approvals) + headline = self._generate_health_headline(status, critical_alerts, pending_approvals, ai_prevented_count) # Calculate next scheduled run (5:30 AM next day) now = datetime.now(timezone.utc) @@ -164,7 +238,8 @@ class DashboardService: "nextScheduledRun": next_run.isoformat(), "checklistItems": checklist_items, "criticalIssues": critical_alerts + system_errors, - "pendingActions": pending_approvals + production_delays + out_of_stock_count + "pendingActions": pending_approvals + production_delays + out_of_stock_count, + "aiPreventedIssues": ai_prevented_count } def _calculate_health_status( @@ -196,10 +271,16 @@ class DashboardService: self, status: str, critical_alerts: int, - pending_approvals: int + pending_approvals: int, + ai_prevented_count: int = 0 ) -> Dict[str, Any]: """Generate i18n-ready headline based on status""" if status == HealthStatus.GREEN: + if ai_prevented_count > 0: + return { + "key": "health.headline_green_ai_assisted", + "params": {"count": ai_prevented_count} + } return { "key": "health.headline_green", "params": {} @@ -230,7 +311,7 @@ class DashboardService: """Get the most recent orchestration run""" result = await self.db.execute( select(OrchestrationRun) - .where(OrchestrationRun.tenant_id == tenant_id) + .where(OrchestrationRun.tenant_id == uuid.UUID(tenant_id)) .where(OrchestrationRun.status.in_([ OrchestrationStatus.completed, OrchestrationStatus.partial_success @@ -271,12 +352,12 @@ class DashboardService: result = await self.db.execute( select(OrchestrationRun) .where(OrchestrationRun.id == last_run_id) - .where(OrchestrationRun.tenant_id == tenant_id) + .where(OrchestrationRun.tenant_id == uuid.UUID(tenant_id)) ) else: result = await self.db.execute( select(OrchestrationRun) - .where(OrchestrationRun.tenant_id == tenant_id) + .where(OrchestrationRun.tenant_id == uuid.UUID(tenant_id)) .where(OrchestrationRun.status.in_([ OrchestrationStatus.completed, OrchestrationStatus.partial_success @@ -303,7 +384,7 @@ class DashboardService: "userActionsRequired": 0, "status": "no_runs", "message_i18n": { - "key": "orchestration.no_runs_message", + "key": "jtbd.orchestration_summary.ready_to_plan", # Match frontend expectation "params": {} } } @@ -681,6 +762,204 @@ class DashboardService: return timeline + async def get_unified_action_queue( + self, + tenant_id: str, + alerts: List[Dict[str, Any]] + ) -> Dict[str, Any]: + """ + Build unified action queue with time-based grouping + + Combines all alerts (PO approvals, delivery tracking, production issues, etc.) + into urgency-based sections: URGENT (<6h), TODAY (<24h), THIS WEEK (<7d) + + Args: + tenant_id: Tenant identifier + alerts: List of enriched alerts from alert processor + + Returns: + Dict with urgent, today, and week action lists + """ + now = datetime.now(timezone.utc) + + # Filter to only action_needed alerts that aren't hidden + action_alerts = [ + a for a in alerts + if a.get('type_class') == 'action_needed' + and not a.get('hidden_from_ui', False) + ] + + # Group by urgency based on deadline or escalation + urgent_actions = [] # <6h to deadline + today_actions = [] # <24h to deadline + week_actions = [] # <7d to deadline + + for alert in action_alerts: + urgency_context = alert.get('urgency_context', {}) + deadline = urgency_context.get('deadline') + + # Calculate time until deadline + time_until_deadline = None + if deadline: + if isinstance(deadline, str): + deadline = datetime.fromisoformat(deadline.replace('Z', '+00:00')) + time_until_deadline = deadline - now + + # Check for escalation (aged actions) + escalation = alert.get('alert_metadata', {}).get('escalation', {}) + is_escalated = escalation.get('boost_applied', 0) > 0 + + # Categorize by urgency + # CRITICAL or <6h deadline โ†’ URGENT + if alert.get('priority_level') == 'CRITICAL' or (time_until_deadline and time_until_deadline.total_seconds() < 21600): + urgent_actions.append(alert) + # IMPORTANT or <48h deadline or PO approvals โ†’ TODAY + elif (alert.get('priority_level') == 'IMPORTANT' or + (time_until_deadline and time_until_deadline.total_seconds() < 172800) or + alert.get('alert_type') == 'po_approval_needed'): + today_actions.append(alert) + # <7d deadline or escalated โ†’ THIS WEEK + elif is_escalated or (time_until_deadline and time_until_deadline.total_seconds() < 604800): + week_actions.append(alert) + else: + # Default to week for any remaining items + week_actions.append(alert) + + # Sort each group by priority score descending + urgent_actions.sort(key=lambda x: x.get('priority_score', 0), reverse=True) + today_actions.sort(key=lambda x: x.get('priority_score', 0), reverse=True) + week_actions.sort(key=lambda x: x.get('priority_score', 0), reverse=True) + + return { + "urgent": urgent_actions[:10], # Limit to 10 per section + "today": today_actions[:10], + "week": week_actions[:10], + "totalActions": len(action_alerts), + "urgentCount": len(urgent_actions), + "todayCount": len(today_actions), + "weekCount": len(week_actions) + } + + async def get_execution_progress( + self, + tenant_id: str, + todays_batches: List[Dict[str, Any]], + expected_deliveries: List[Dict[str, Any]], + pending_approvals: int + ) -> Dict[str, Any]: + """ + Track execution progress for today's plan + + Shows plan vs actual for production batches, deliveries, and approvals + + Args: + tenant_id: Tenant identifier + todays_batches: Production batches planned for today + expected_deliveries: Deliveries expected today + pending_approvals: Number of pending approvals + + Returns: + Execution progress with plan vs actual + """ + now = datetime.now(timezone.utc) + + # Production progress + production_total = len(todays_batches) + production_completed = sum(1 for b in todays_batches if b.get('status') == 'COMPLETED') + production_in_progress = sum(1 for b in todays_batches if b.get('status') == 'IN_PROGRESS') + production_pending = sum(1 for b in todays_batches if b.get('status') in ['PENDING', 'SCHEDULED']) + + # Determine production status + if production_total == 0: + production_status = "no_plan" + elif production_completed == production_total: + production_status = "completed" + elif production_completed + production_in_progress >= production_total * 0.8: + production_status = "on_track" + elif now.hour >= 18: # After 6 PM + production_status = "at_risk" + else: + production_status = "on_track" + + # Get in-progress batch details + in_progress_batches = [ + { + "id": batch.get('id'), + "batchNumber": batch.get('batch_number'), + "productName": batch.get('product_name'), + "quantity": batch.get('planned_quantity'), + "actualStartTime": batch.get('actual_start_time'), + "estimatedCompletion": batch.get('planned_end_time'), + } + for batch in todays_batches + if batch.get('status') == 'IN_PROGRESS' + ] + + # Find next batch + next_batch = None + for batch in sorted(todays_batches, key=lambda x: x.get('planned_start_time', '')): + if batch.get('status') in ['PENDING', 'SCHEDULED']: + next_batch = { + "productName": batch.get('product_name'), + "plannedStart": batch.get('planned_start_time'), + "batchNumber": batch.get('batch_number') + } + break + + # Delivery progress + delivery_total = len(expected_deliveries) + delivery_received = sum(1 for d in expected_deliveries if d.get('received', False)) + delivery_pending = delivery_total - delivery_received + + # Check for overdue deliveries + delivery_overdue = 0 + for delivery in expected_deliveries: + expected_date = delivery.get('expected_delivery_date') + if expected_date and isinstance(expected_date, str): + expected_date = datetime.fromisoformat(expected_date.replace('Z', '+00:00')) + if expected_date and now > expected_date + timedelta(hours=4): + delivery_overdue += 1 + + if delivery_total == 0: + delivery_status = "no_deliveries" + elif delivery_overdue > 0: + delivery_status = "at_risk" + elif delivery_received == delivery_total: + delivery_status = "completed" + else: + delivery_status = "on_track" + + # Approval progress + if pending_approvals == 0: + approval_status = "completed" + elif pending_approvals <= 2: + approval_status = "on_track" + else: + approval_status = "at_risk" + + return { + "production": { + "status": production_status, + "total": production_total, + "completed": production_completed, + "inProgress": production_in_progress, + "pending": production_pending, + "inProgressBatches": in_progress_batches, + "nextBatch": next_batch + }, + "deliveries": { + "status": delivery_status, + "total": delivery_total, + "received": delivery_received, + "pending": delivery_pending, + "overdue": delivery_overdue + }, + "approvals": { + "status": approval_status, + "pending": pending_approvals + } + } + async def calculate_insights( self, tenant_id: str,