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,