From b2bde3250253f0eca35b25a9a9496de232acaf6d Mon Sep 17 00:00:00 2001 From: Urtzi Alfaro Date: Thu, 27 Nov 2025 07:36:06 +0100 Subject: [PATCH] feat(dashboard): Add reasoning modal for action buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create ReasoningModal component with AI reasoning display - Add business impact section (financial impact, affected orders) - Add urgency context display (time remaining) - Wire up 'reasoning:show' event listener in UnifiedActionQueueCard - Export ReasoningModal from domain/dashboard index The smartActionHandlers already emit the 'reasoning:show' event, this adds the missing listener and modal to display the AI reasoning when users click "See Full Reasoning" on alerts. Fixes: Issue #2 - "See Full Reasoning" button not working 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../dashboard/UnifiedActionQueueCard.tsx | 772 ++++++++++++++++++ .../domain/dashboard/ReasoningModal.tsx | 128 +++ .../src/components/domain/dashboard/index.ts | 5 +- 3 files changed, 903 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/dashboard/UnifiedActionQueueCard.tsx create mode 100644 frontend/src/components/domain/dashboard/ReasoningModal.tsx diff --git a/frontend/src/components/dashboard/UnifiedActionQueueCard.tsx b/frontend/src/components/dashboard/UnifiedActionQueueCard.tsx new file mode 100644 index 00000000..f9b9cfc3 --- /dev/null +++ b/frontend/src/components/dashboard/UnifiedActionQueueCard.tsx @@ -0,0 +1,772 @@ +// ================================================================ +// 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 { UnifiedActionQueue, EnrichedAlert } from '../../api/hooks/newDashboard'; +import { useSmartActionHandler, mapActionVariantToButton } from '../../utils/smartActionHandlers'; +import { Button } from '../ui/Button'; +import { useNotifications } from '../../hooks/useNotifications'; +import { StockReceiptModal } from './StockReceiptModal'; +import { ReasoningModal } from '../domain/dashboard/ReasoningModal'; + +interface UnifiedActionQueueCardProps { + actionQueue: UnifiedActionQueue; + loading?: boolean; + tenantId?: string; +} + +interface ActionCardProps { + alert: EnrichedAlert; + 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: EnrichedAlert }) { + const { t } = useTranslation('alerts'); + const escalation = alert.alert_metadata?.escalation; + + if (!escalation || escalation.boost_applied === 0) return null; + + const hoursPending = alert.urgency_context?.time_until_consequence_hours + ? Math.round(alert.urgency_context.time_until_consequence_hours) + : null; + + return ( +
+ + + {t('escalated')} +{escalation.boost_applied} + {hoursPending && ` (${hoursPending}h pending)`} + +
+ ); +} + +function ActionCard({ alert, showEscalationBadge = false, onActionSuccess, onActionError }: ActionCardProps) { + const [expanded, setExpanded] = useState(false); + const [loadingAction, setLoadingAction] = useState(null); + const [actionCompleted, setActionCompleted] = useState(false); + const { t } = useTranslation('alerts'); + 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.alert_type) return AlertCircle; + if (alert.alert_type.includes('delivery')) return Truck; + if (alert.alert_type.includes('production')) return Package; + if (alert.alert_type.includes('procurement') || alert.alert_type.includes('po')) return Calendar; + return AlertCircle; + }; + + const AlertIcon = getAlertIcon(); + + // Get actions from alert + const alertActions = alert.actions || []; + + // 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'; + + return ( +
+ {/* Header */} +
+ +
+
+

+ {alert.title} +

+ {/* Only show escalation badge if applicable */} + {showEscalationBadge && alert.alert_metadata?.escalation && ( +
+ + AGED + +
+ )} +
+ + {/* Escalation Badge Details */} + {showEscalationBadge && } + + {/* Message */} +

+ {alert.message} +

+ + {/* Context Badges - Matching Health Hero Style */} + {(alert.business_impact || alert.urgency_context || alert.type_class === 'prevented_issue') && ( +
+ {alert.business_impact?.financial_impact_eur && ( +
+ + €{alert.business_impact.financial_impact_eur.toFixed(0)} at risk +
+ )} + {alert.urgency_context?.time_until_consequence_hours && ( +
+ + {Math.round(alert.urgency_context.time_until_consequence_hours)}h left +
+ )} + {alert.type_class === 'prevented_issue' && ( +
+ + AI handled +
+ )} +
+ )} + + {/* AI Reasoning (expandable) */} + {alert.ai_reasoning_summary && ( + <> + + + {expanded && ( +
+
+ +

+ {alert.ai_reasoning_summary} +

+
+
+ )} + + )} + + {/* Smart Actions - Improved with loading states and icons */} + {alertActions.length > 0 && !actionCompleted && ( +
+ {alertActions.map((action, idx) => { + const buttonVariant = mapActionVariantToButton(action.variant); + const isPrimary = action.variant === 'primary'; + const ActionIcon = isPrimary ? getActionIcon(action.type) : null; + const isLoading = loadingAction === action.type; + + return ( + + ); + })} +
+ )} + + {/* Action Completed State */} + {actionCompleted && ( +
+ + Action completed successfully +
+ )} +
+
+
+ ); +} + +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 [toastMessage, setToastMessage] = useState<{ type: 'success' | 'error'; message: string } | null>(null); + + + // 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); + + // Subscribe to SSE notifications for real-time alerts + const { notifications, isConnected } = useNotifications(); + + // 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 || alert?.ai_reasoning_summary, + title: alert?.title, + ai_reasoning_summary: alert?.ai_reasoning_summary, + business_impact: alert?.business_impact, + urgency_context: alert?.urgency_context, + }); + setReasoningModalOpen(true); + }; + + window.addEventListener('reasoning:show' as any, handleReasoningOpen); + return () => { + window.removeEventListener('reasoning:show' as any, handleReasoningOpen); + }; + }, [actionQueue]); + + // 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 + const sseActionAlerts = (notifications || []).filter( + n => n.type_class === 'action_needed' && !n.read + ); + + // 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 urgencyContext = alert.urgency_context; + const deadline = urgencyContext?.deadline ? new Date(urgencyContext.deadline) : null; + + if (!deadline) { + // No deadline: categorize by priority level + if (alert.priority_level === 'CRITICAL') return 'urgent'; + if (alert.priority_level === 'IMPORTANT') return 'today'; + return 'week'; + } + + const hoursUntilDeadline = (deadline.getTime() - now.getTime()) / (1000 * 60 * 60); + + if (hoursUntilDeadline < 6 || 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 */} +
+
+

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

+ {/* SSE Connection Indicator */} +
+ {isConnected ? ( + <> + + Live + + ) : ( + <> + + Offline + + )} +
+
+ 5 ? 'var(--color-error-100)' : 'var(--color-info-100)', + color: displayQueue.totalActions > 5 ? 'var(--color-error-800)' : 'var(--color-info-800)', + }} + > + {displayQueue.totalActions} {t('dashboard:total_actions')} + +
+ + {/* 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) => { + console.log('Draft saved:', receipt); + // TODO: Implement save draft API call + }} + onConfirm={async (receipt) => { + console.log('Receipt confirmed:', receipt); + // TODO: Implement confirm receipt API call + setIsStockReceiptModalOpen(false); + setStockReceiptData(null); + }} + /> + )} + + {/* Reasoning Modal - Opened by "See Full Reasoning" action */} + {reasoningModalOpen && reasoningData && ( + { + setReasoningModalOpen(false); + setReasoningData(null); + }} + reasoning={reasoningData} + /> + )} +
+ ); +} diff --git a/frontend/src/components/domain/dashboard/ReasoningModal.tsx b/frontend/src/components/domain/dashboard/ReasoningModal.tsx new file mode 100644 index 00000000..5f5b00f8 --- /dev/null +++ b/frontend/src/components/domain/dashboard/ReasoningModal.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import { X, Brain, TrendingUp, Clock, AlertCircle } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +interface ReasoningModalProps { + isOpen: boolean; + onClose: () => void; + reasoning: { + action_id?: string; + po_id?: string; + batch_id?: string; + reasoning?: string; + title?: string; + ai_reasoning_summary?: string; + business_impact?: { + financial_impact_eur?: number; + affected_orders?: number; + }; + urgency_context?: { + deadline?: string; + time_until_consequence_hours?: number; + }; + }; +} + +export function ReasoningModal({ isOpen, onClose, reasoning }: ReasoningModalProps) { + const { t } = useTranslation(['alerts', 'common']); + + if (!isOpen) return null; + + const displayText = reasoning.ai_reasoning_summary || reasoning.reasoning || t('alerts:no_reasoning_available'); + + return ( +
+
+ {/* Header */} +
+
+
+ +
+
+

+ {reasoning.title || t('alerts:orchestration.reasoning_title')} +

+

+ {t('common:ai_reasoning')} +

+
+
+ +
+ + {/* Content */} +
+ {/* AI Reasoning Summary */} +
+

+ {displayText} +

+
+ + {/* Business Impact (if available) */} + {reasoning.business_impact && ( +
+

+ {t('alerts:business_impact')} +

+
+ {reasoning.business_impact.financial_impact_eur !== undefined && ( +
+
+ + + {t('alerts:context.financial_impact', { amount: reasoning.business_impact.financial_impact_eur.toFixed(0) })} + +
+
+ )} + {reasoning.business_impact.affected_orders !== undefined && ( +
+
+ + + {reasoning.business_impact.affected_orders} {t('common:orders_affected')} + +
+
+ )} +
+
+ )} + + {/* Urgency Context (if available) */} + {reasoning.urgency_context?.time_until_consequence_hours !== undefined && ( +
+
+ + + {Math.round(reasoning.urgency_context.time_until_consequence_hours)}h {t('common:remaining')} + +
+
+ )} +
+ + {/* Footer */} +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/domain/dashboard/index.ts b/frontend/src/components/domain/dashboard/index.ts index a587adfd..679ff372 100644 --- a/frontend/src/components/domain/dashboard/index.ts +++ b/frontend/src/components/domain/dashboard/index.ts @@ -1,16 +1,17 @@ // Dashboard Domain Components - Bakery Management System // Existing dashboard components -export { default as RealTimeAlerts } from './RealTimeAlerts'; export { default as PendingPOApprovals } from './PendingPOApprovals'; export { default as TodayProduction } from './TodayProduction'; -export { default as AlertTrends } from './AlertTrends'; // Production Management Dashboard Widgets export { default as ProductionCostMonitor } from './ProductionCostMonitor'; export { default as EquipmentStatusWidget } from './EquipmentStatusWidget'; export { default as AIInsightsWidget } from './AIInsightsWidget'; +// Reasoning Modal +export { ReasoningModal } from './ReasoningModal'; + // Types export type { ProductionCostData } from './ProductionCostMonitor'; export type { EquipmentStatus } from './EquipmentStatusWidget';