feat(dashboard): Add reasoning modal for action buttons
- 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 <noreply@anthropic.com>
This commit is contained in:
772
frontend/src/components/dashboard/UnifiedActionQueueCard.tsx
Normal file
772
frontend/src/components/dashboard/UnifiedActionQueueCard.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-xs font-semibold"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-warning-100)',
|
||||
color: 'var(--color-warning-900)',
|
||||
}}
|
||||
>
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
<span>
|
||||
{t('escalated')} +{escalation.boost_applied}
|
||||
{hoursPending && ` (${hoursPending}h pending)`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionCard({ alert, showEscalationBadge = false, onActionSuccess, onActionError }: ActionCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [loadingAction, setLoadingAction] = useState<string | null>(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 (
|
||||
<div
|
||||
className={`border-2 rounded-lg p-4 transition-all duration-200 hover:shadow-md ${
|
||||
actionCompleted ? 'opacity-60' : ''
|
||||
} ${isCritical ? 'shadow-md' : ''}`}
|
||||
style={{
|
||||
backgroundColor: colors.bg,
|
||||
borderColor: colors.border,
|
||||
borderWidth: isCritical ? '3px' : '2px',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<AlertIcon
|
||||
className={`flex-shrink-0 mt-1 ${isCritical ? 'w-8 h-8' : 'w-7 h-7'}`}
|
||||
style={{ color: colors.border }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<h3 className={`font-bold ${isCritical ? 'text-xl' : 'text-lg'}`} style={{ color: colors.text }}>
|
||||
{alert.title}
|
||||
</h3>
|
||||
{/* Only show escalation badge if applicable */}
|
||||
{showEscalationBadge && alert.alert_metadata?.escalation && (
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span
|
||||
className="px-2 py-1 rounded text-xs font-semibold uppercase"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-error-100)',
|
||||
color: 'var(--color-error-800)',
|
||||
}}
|
||||
>
|
||||
AGED
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Escalation Badge Details */}
|
||||
{showEscalationBadge && <EscalationBadge alert={alert} />}
|
||||
|
||||
{/* Message */}
|
||||
<p className="text-sm mb-3" style={{ color: 'var(--text-secondary)' }}>
|
||||
{alert.message}
|
||||
</p>
|
||||
|
||||
{/* Context Badges - Matching Health Hero Style */}
|
||||
{(alert.business_impact || alert.urgency_context || alert.type_class === 'prevented_issue') && (
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{alert.business_impact?.financial_impact_eur && (
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-semibold"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-warning-100)',
|
||||
color: 'var(--color-warning-800)',
|
||||
}}
|
||||
>
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
<span>€{alert.business_impact.financial_impact_eur.toFixed(0)} at risk</span>
|
||||
</div>
|
||||
)}
|
||||
{alert.urgency_context?.time_until_consequence_hours && (
|
||||
<div
|
||||
className={`flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-semibold ${
|
||||
alert.urgency_context.time_until_consequence_hours < 6 ? 'animate-pulse' : ''
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: 'var(--color-error-100)',
|
||||
color: 'var(--color-error-800)',
|
||||
}}
|
||||
>
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>{Math.round(alert.urgency_context.time_until_consequence_hours)}h left</span>
|
||||
</div>
|
||||
)}
|
||||
{alert.type_class === 'prevented_issue' && (
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-semibold"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-success-100)',
|
||||
color: 'var(--color-success-800)',
|
||||
}}
|
||||
>
|
||||
<Bot className="w-4 h-4" />
|
||||
<span>AI handled</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Reasoning (expandable) */}
|
||||
{alert.ai_reasoning_summary && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center gap-2 text-sm font-medium transition-colors mb-2"
|
||||
style={{ color: 'var(--color-info-700)' }}
|
||||
>
|
||||
{expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
<Zap className="w-4 h-4" />
|
||||
<span>AI Reasoning</span>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div
|
||||
className="rounded-md p-3 mb-3"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-secondary)',
|
||||
borderLeft: '3px solid var(--color-info-600)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<Bot className="w-4 h-4 flex-shrink-0 mt-0.5" style={{ color: 'var(--color-info-700)' }} />
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{alert.ai_reasoning_summary}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Smart Actions - Improved with loading states and icons */}
|
||||
{alertActions.length > 0 && !actionCompleted && (
|
||||
<div className="flex flex-wrap gap-2 mt-3 sm:flex-row flex-col sm:items-center">
|
||||
{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 (
|
||||
<Button
|
||||
key={idx}
|
||||
variant={buttonVariant}
|
||||
size={isPrimary ? 'md' : 'sm'}
|
||||
isLoading={isLoading}
|
||||
disabled={action.disabled || loadingAction !== null}
|
||||
leftIcon={ActionIcon && !isLoading ? <ActionIcon className="w-4 h-4" /> : undefined}
|
||||
onClick={async () => {
|
||||
setLoadingAction(action.type);
|
||||
await actionHandler.handleAction(action as any, alert.id);
|
||||
}}
|
||||
className={`${isPrimary ? 'font-semibold' : ''} sm:w-auto w-full`}
|
||||
>
|
||||
{action.label}
|
||||
{action.estimated_time_minutes && !isLoading && (
|
||||
<span className="ml-1 opacity-60 text-xs">({action.estimated_time_minutes}m)</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Completed State */}
|
||||
{actionCompleted && (
|
||||
<div
|
||||
className="flex items-center gap-2 mt-3 px-3 py-2 rounded-md"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-success-100)',
|
||||
color: 'var(--color-success-800)',
|
||||
}}
|
||||
>
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
<span className="font-semibold">Action completed successfully</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mb-6">
|
||||
{/* Section Header */}
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center justify-between w-full mb-4 group"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="w-5 h-5" style={{ color: iconColor }} />
|
||||
<h3 className="text-lg font-bold" style={{ color: 'var(--text-primary)' }}>
|
||||
{title}
|
||||
</h3>
|
||||
<span
|
||||
className="px-2 py-1 rounded-full text-xs font-bold"
|
||||
style={{
|
||||
backgroundColor: iconColor.replace('700', '100'),
|
||||
color: iconColor,
|
||||
}}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<ChevronUp className="w-5 h-5 transition-transform" style={{ color: 'var(--text-secondary)' }} />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 transition-transform" style={{ color: 'var(--text-secondary)' }} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Section Content */}
|
||||
{expanded && (
|
||||
<div className="space-y-3">
|
||||
{alerts.map((alert) => (
|
||||
<ActionCard
|
||||
key={alert.id}
|
||||
alert={alert}
|
||||
showEscalationBadge={showEscalationBadges}
|
||||
onActionSuccess={onActionSuccess}
|
||||
onActionError={onActionError}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<any>(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 (
|
||||
<div
|
||||
className="rounded-xl shadow-lg p-6 border"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
borderColor: 'var(--border-primary)',
|
||||
}}
|
||||
>
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-6 rounded w-1/2" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
|
||||
<div className="h-32 rounded" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
|
||||
<div className="h-32 rounded" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!displayQueue || displayQueue.totalActions === 0) {
|
||||
return (
|
||||
<div
|
||||
className="border-2 rounded-xl p-8 text-center shadow-lg"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-success-50)',
|
||||
borderColor: 'var(--color-success-200)',
|
||||
}}
|
||||
>
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full flex items-center justify-center" style={{ backgroundColor: 'var(--color-success-100)' }}>
|
||||
<Zap className="w-8 h-8" style={{ color: 'var(--color-success-600)' }} />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold mb-2" style={{ color: 'var(--color-success-900)' }}>
|
||||
{t('dashboard:all_caught_up')}
|
||||
</h3>
|
||||
<p style={{ color: 'var(--color-success-700)' }}>
|
||||
{t('dashboard:no_actions_needed')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl shadow-lg p-6 border"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
borderColor: 'var(--border-primary)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('dashboard:action_queue_title')}
|
||||
</h2>
|
||||
{/* SSE Connection Indicator */}
|
||||
<div className="flex items-center gap-1.5 text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
{isConnected ? (
|
||||
<>
|
||||
<Wifi className="w-3.5 h-3.5" style={{ color: 'var(--color-success)' }} />
|
||||
<span className="hidden sm:inline">Live</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<WifiOff className="w-3.5 h-3.5" style={{ color: 'var(--color-error)' }} />
|
||||
<span className="hidden sm:inline">Offline</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className="px-3 py-1 rounded-full text-sm font-semibold"
|
||||
style={{
|
||||
backgroundColor: displayQueue.totalActions > 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')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Toast Notification */}
|
||||
{toastMessage && (
|
||||
<div
|
||||
className="fixed top-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 animate-slide-in-right"
|
||||
style={{
|
||||
backgroundColor: toastMessage.type === 'success' ? 'var(--color-success-600)' : 'var(--color-error-600)',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
{toastMessage.type === 'success' ? (
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
) : (
|
||||
<XCircle className="w-5 h-5" />
|
||||
)}
|
||||
<span className="font-medium">{toastMessage.message}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* URGENT Section (<6h) */}
|
||||
<ActionSection
|
||||
title="🔴 URGENT - Next 6 Hours"
|
||||
icon={AlertCircle}
|
||||
iconColor="var(--color-error-700)"
|
||||
alerts={displayQueue.urgent}
|
||||
count={displayQueue.urgentCount}
|
||||
defaultExpanded={true}
|
||||
showEscalationBadges={true}
|
||||
onActionSuccess={handleActionSuccess}
|
||||
onActionError={handleActionError}
|
||||
/>
|
||||
|
||||
{/* TODAY Section (<24h) */}
|
||||
<ActionSection
|
||||
title="🟡 TODAY - Next 24 Hours"
|
||||
icon={Clock}
|
||||
iconColor="var(--color-warning-700)"
|
||||
alerts={displayQueue.today}
|
||||
count={displayQueue.todayCount}
|
||||
defaultExpanded={true}
|
||||
showEscalationBadges={true}
|
||||
onActionSuccess={handleActionSuccess}
|
||||
onActionError={handleActionError}
|
||||
/>
|
||||
|
||||
{/* THIS WEEK Section (<7d) */}
|
||||
<ActionSection
|
||||
title="🟢 THIS WEEK"
|
||||
icon={Calendar}
|
||||
iconColor="var(--color-success-700)"
|
||||
alerts={displayQueue.week}
|
||||
count={displayQueue.weekCount}
|
||||
defaultExpanded={false}
|
||||
showEscalationBadges={true}
|
||||
onActionSuccess={handleActionSuccess}
|
||||
onActionError={handleActionError}
|
||||
/>
|
||||
|
||||
{/* Stock Receipt Modal - Opened by delivery actions */}
|
||||
{isStockReceiptModalOpen && stockReceiptData && (
|
||||
<StockReceiptModal
|
||||
isOpen={isStockReceiptModalOpen}
|
||||
onClose={() => {
|
||||
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 && (
|
||||
<ReasoningModal
|
||||
isOpen={reasoningModalOpen}
|
||||
onClose={() => {
|
||||
setReasoningModalOpen(false);
|
||||
setReasoningData(null);
|
||||
}}
|
||||
reasoning={reasoningData}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
128
frontend/src/components/domain/dashboard/ReasoningModal.tsx
Normal file
128
frontend/src/components/domain/dashboard/ReasoningModal.tsx
Normal file
@@ -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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
|
||||
<div
|
||||
className="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[80vh] overflow-y-auto"
|
||||
style={{ backgroundColor: 'var(--bg-primary)' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 flex items-center justify-between p-6 border-b" style={{ backgroundColor: 'var(--bg-primary)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-full flex items-center justify-center" style={{ backgroundColor: 'var(--color-info-100)' }}>
|
||||
<Brain className="w-6 h-6" style={{ color: 'var(--color-info-600)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>
|
||||
{reasoning.title || t('alerts:orchestration.reasoning_title')}
|
||||
</h2>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('common:ai_reasoning')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:bg-black/5 transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6" style={{ color: 'var(--text-secondary)' }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-4">
|
||||
{/* AI Reasoning Summary */}
|
||||
<div className="rounded-lg p-4 border-l-4" style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--color-info-600)' }}>
|
||||
<p className="text-base leading-relaxed" style={{ color: 'var(--text-primary)' }}>
|
||||
{displayText}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Business Impact (if available) */}
|
||||
{reasoning.business_impact && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('alerts:business_impact')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{reasoning.business_impact.financial_impact_eur !== undefined && (
|
||||
<div className="rounded-lg p-3" style={{ backgroundColor: 'var(--color-warning-50)' }}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<TrendingUp className="w-4 h-4" style={{ color: 'var(--color-warning-600)' }} />
|
||||
<span className="text-xs font-semibold" style={{ color: 'var(--color-warning-800)' }}>
|
||||
{t('alerts:context.financial_impact', { amount: reasoning.business_impact.financial_impact_eur.toFixed(0) })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{reasoning.business_impact.affected_orders !== undefined && (
|
||||
<div className="rounded-lg p-3" style={{ backgroundColor: 'var(--color-info-50)' }}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<AlertCircle className="w-4 h-4" style={{ color: 'var(--color-info-600)' }} />
|
||||
<span className="text-xs font-semibold" style={{ color: 'var(--color-info-800)' }}>
|
||||
{reasoning.business_impact.affected_orders} {t('common:orders_affected')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Urgency Context (if available) */}
|
||||
{reasoning.urgency_context?.time_until_consequence_hours !== undefined && (
|
||||
<div className="rounded-lg p-4" style={{ backgroundColor: 'var(--color-error-50)', borderLeft: '4px solid var(--color-error-600)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-5 h-5" style={{ color: 'var(--color-error-600)' }} />
|
||||
<span className="font-semibold" style={{ color: 'var(--color-error-800)' }}>
|
||||
{Math.round(reasoning.urgency_context.time_until_consequence_hours)}h {t('common:remaining')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="sticky bottom-0 p-6 border-t" style={{ backgroundColor: 'var(--bg-primary)', borderColor: 'var(--border-primary)' }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full px-6 py-3 rounded-lg font-semibold transition-colors"
|
||||
style={{ backgroundColor: 'var(--color-primary)', color: 'white' }}
|
||||
>
|
||||
{t('common:close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user