New alert service

This commit is contained in:
Urtzi Alfaro
2025-12-05 20:07:01 +01:00
parent 1fe3a73549
commit 667e6e0404
393 changed files with 26002 additions and 61033 deletions

View File

@@ -53,12 +53,34 @@ export interface ProductionProgress {
};
}
export interface DeliveryInfo {
poId: string;
poNumber: string;
supplierName: string;
supplierPhone?: string;
expectedDeliveryDate: string;
status: string;
lineItems: Array<{
product_name: string;
quantity: number;
unit: string;
}>;
totalAmount: number;
currency: string;
itemCount: number;
hoursOverdue?: number;
hoursUntil?: number;
}
export interface DeliveryProgress {
status: 'no_deliveries' | 'completed' | 'on_track' | 'at_risk';
total: number;
received: number;
pending: number;
overdue: number;
overdueDeliveries?: DeliveryInfo[];
pendingDeliveries?: DeliveryInfo[];
receivedDeliveries?: DeliveryInfo[];
}
export interface ApprovalProgress {
@@ -356,43 +378,141 @@ export function ExecutionProgressTracker({
{t('dashboard:execution_progress.no_deliveries_today')}
</p>
) : (
<div className="grid grid-cols-3 gap-3">
<div className="text-center">
<div className="flex items-center justify-center mb-1">
<CheckCircle className="w-4 h-4" style={{ color: 'var(--color-success-600)' }} />
<>
{/* Summary Grid */}
<div className="grid grid-cols-3 gap-3 mb-4">
<div className="text-center">
<div className="flex items-center justify-center mb-1">
<CheckCircle className="w-4 h-4" style={{ color: 'var(--color-success-600)' }} />
</div>
<div className="text-2xl font-bold" style={{ color: 'var(--color-success-700)' }}>
{progress.deliveries.received}
</div>
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{t('dashboard:execution_progress.received')}
</div>
</div>
<div className="text-2xl font-bold" style={{ color: 'var(--color-success-700)' }}>
{progress.deliveries.received}
<div className="text-center">
<div className="flex items-center justify-center mb-1">
<Clock className="w-4 h-4" style={{ color: 'var(--color-info-600)' }} />
</div>
<div className="text-2xl font-bold" style={{ color: 'var(--color-info-700)' }}>
{progress.deliveries.pending}
</div>
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{t('dashboard:execution_progress.pending')}
</div>
</div>
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{t('dashboard:execution_progress.received')}
<div className="text-center">
<div className="flex items-center justify-center mb-1">
<AlertCircle className="w-4 h-4" style={{ color: 'var(--color-error-600)' }} />
</div>
<div className="text-2xl font-bold" style={{ color: 'var(--color-error-700)' }}>
{progress.deliveries.overdue}
</div>
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{t('dashboard:execution_progress.overdue')}
</div>
</div>
</div>
<div className="text-center">
<div className="flex items-center justify-center mb-1">
<Clock className="w-4 h-4" style={{ color: 'var(--color-info-600)' }} />
{/* Overdue Deliveries List */}
{progress.deliveries.overdueDeliveries && progress.deliveries.overdueDeliveries.length > 0 && (
<div className="mb-3">
<div className="text-xs font-semibold mb-2 flex items-center gap-1" style={{ color: 'var(--color-error-700)' }}>
<AlertCircle className="w-3 h-3" />
{t('dashboard:execution_progress.overdue_deliveries')}
</div>
<div className="space-y-2">
{progress.deliveries.overdueDeliveries.map((delivery) => (
<div
key={delivery.poId}
className="p-3 rounded-lg border"
style={{
backgroundColor: 'var(--color-error-50)',
borderColor: 'var(--color-error-200)',
}}
>
<div className="flex items-start justify-between mb-1">
<div>
<div className="font-semibold text-sm" style={{ color: 'var(--text-primary)' }}>
{delivery.supplierName}
</div>
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{delivery.poNumber} · {delivery.hoursOverdue}h {t('dashboard:execution_progress.overdue_label')}
</div>
</div>
<div className="text-xs font-semibold" style={{ color: 'var(--text-secondary)' }}>
{delivery.totalAmount.toFixed(2)} {delivery.currency}
</div>
</div>
<div className="text-xs mt-2" style={{ color: 'var(--text-tertiary)' }}>
{delivery.lineItems.slice(0, 2).map((item, idx) => (
<div key={idx}> {item.product_name} ({item.quantity} {item.unit})</div>
))}
{delivery.itemCount > 2 && (
<div>+ {delivery.itemCount - 2} {t('dashboard:execution_progress.more_items')}</div>
)}
</div>
</div>
))}
</div>
</div>
<div className="text-2xl font-bold" style={{ color: 'var(--color-info-700)' }}>
{progress.deliveries.pending}
</div>
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{t('dashboard:execution_progress.pending')}
</div>
</div>
)}
<div className="text-center">
<div className="flex items-center justify-center mb-1">
<AlertCircle className="w-4 h-4" style={{ color: 'var(--color-error-600)' }} />
{/* Pending Deliveries List */}
{progress.deliveries.pendingDeliveries && progress.deliveries.pendingDeliveries.length > 0 && (
<div>
<div className="text-xs font-semibold mb-2 flex items-center gap-1" style={{ color: 'var(--color-info-700)' }}>
<Clock className="w-3 h-3" />
{t('dashboard:execution_progress.pending_deliveries')}
</div>
<div className="space-y-2">
{progress.deliveries.pendingDeliveries.slice(0, 3).map((delivery) => (
<div
key={delivery.poId}
className="p-3 rounded-lg border"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-secondary)',
}}
>
<div className="flex items-start justify-between mb-1">
<div>
<div className="font-semibold text-sm" style={{ color: 'var(--text-primary)' }}>
{delivery.supplierName}
</div>
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{delivery.poNumber} · {delivery.hoursUntil !== undefined && delivery.hoursUntil >= 0
? `${t('dashboard:execution_progress.arriving_in')} ${delivery.hoursUntil}h`
: formatTime(delivery.expectedDeliveryDate)}
</div>
</div>
<div className="text-xs font-semibold" style={{ color: 'var(--text-secondary)' }}>
{delivery.totalAmount.toFixed(2)} {delivery.currency}
</div>
</div>
<div className="text-xs mt-2" style={{ color: 'var(--text-tertiary)' }}>
{delivery.lineItems.slice(0, 2).map((item, idx) => (
<div key={idx}> {item.product_name} ({item.quantity} {item.unit})</div>
))}
{delivery.itemCount > 2 && (
<div>+ {delivery.itemCount - 2} {t('dashboard:execution_progress.more_items')}</div>
)}
</div>
</div>
))}
{progress.deliveries.pendingDeliveries.length > 3 && (
<div className="text-xs text-center py-1" style={{ color: 'var(--text-tertiary)' }}>
+ {progress.deliveries.pendingDeliveries.length - 3} {t('dashboard:execution_progress.more_deliveries')}
</div>
)}
</div>
</div>
<div className="text-2xl font-bold" style={{ color: 'var(--color-error-700)' }}>
{progress.deliveries.overdue}
</div>
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{t('dashboard:execution_progress.overdue')}
</div>
</div>
</div>
)}
</>
)}
</Section>

View File

@@ -17,12 +17,12 @@
import React, { useState, useMemo } from 'react';
import { CheckCircle, AlertTriangle, AlertCircle, Clock, RefreshCw, Zap, ChevronDown, ChevronUp, ChevronRight } from 'lucide-react';
import { BakeryHealthStatus } from '../../api/hooks/newDashboard';
import { BakeryHealthStatus } from '../../api/hooks/useProfessionalDashboard';
import { formatDistanceToNow } from 'date-fns';
import { es, eu, enUS } from 'date-fns/locale';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useNotifications } from '../../hooks/useNotifications';
import { useEventNotifications } from '../../hooks/useEventNotifications';
interface GlanceableHealthHeroProps {
healthStatus: BakeryHealthStatus;
@@ -104,7 +104,7 @@ function translateKey(
export function GlanceableHealthHero({ healthStatus, loading, urgentActionCount = 0 }: GlanceableHealthHeroProps) {
const { t, i18n } = useTranslation(['dashboard', 'reasoning', 'production']);
const navigate = useNavigate();
const { notifications } = useNotifications();
const { notifications } = useEventNotifications();
const [detailsExpanded, setDetailsExpanded] = useState(false);
// Get date-fns locale
@@ -119,12 +119,23 @@ export function GlanceableHealthHero({ healthStatus, loading, urgentActionCount
const criticalAlerts = useMemo(() => {
if (!notifications || notifications.length === 0) return [];
return notifications.filter(
n => n.priority_level === 'CRITICAL' && !n.read && n.type_class !== 'prevented_issue'
n => n.priority_level === 'critical' && !n.read && n.type_class !== 'prevented_issue'
);
}, [notifications]);
const criticalAlertsCount = criticalAlerts.length;
// Filter prevented issues from last 7 days to match IntelligentSystemSummaryCard
const preventedIssuesCount = useMemo(() => {
if (!notifications || notifications.length === 0) return 0;
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
return notifications.filter(
n => n.type_class === 'prevented_issue' && new Date(n.timestamp) >= sevenDaysAgo
).length;
}, [notifications]);
// Create stable key for checklist items to prevent infinite re-renders
const checklistItemsKey = useMemo(() => {
if (!healthStatus?.checklistItems || healthStatus.checklistItems.length === 0) return 'empty';
@@ -237,11 +248,11 @@ export function GlanceableHealthHero({ healthStatus, loading, urgentActionCount
</div>
)}
{/* AI Prevented Badge */}
{healthStatus.aiPreventedIssues && healthStatus.aiPreventedIssues > 0 && (
{/* AI Prevented Badge - Show last 7 days to match detail section */}
{preventedIssuesCount > 0 && (
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md" style={{ backgroundColor: 'var(--color-info-100)', color: 'var(--color-info-800)' }}>
<Zap className="w-4 h-4" />
<span className="font-semibold">{healthStatus.aiPreventedIssues} evitado{healthStatus.aiPreventedIssues > 1 ? 's' : ''}</span>
<span className="font-semibold">{preventedIssuesCount} evitado{preventedIssuesCount > 1 ? 's' : ''}</span>
</div>
)}
</div>

View File

@@ -22,13 +22,15 @@ import {
Zap,
ShieldCheck,
Euro,
Package,
} from 'lucide-react';
import { OrchestrationSummary } from '../../api/hooks/newDashboard';
import { OrchestrationSummary } from '../../api/hooks/useProfessionalDashboard';
import { useTranslation } from 'react-i18next';
import { formatTime, formatRelativeTime } from '../../utils/date';
import { useTenant } from '../../stores/tenant.store';
import { useNotifications } from '../../hooks/useNotifications';
import { EnrichedAlert } from '../../types/alerts';
import { useEventNotifications } from '../../hooks/useEventNotifications';
import { Alert } from '../../api/types/events';
import { renderEventTitle, renderEventMessage } from '../../utils/i18n/alertRendering';
import { Badge } from '../ui/Badge';
interface PeriodComparison {
@@ -77,10 +79,10 @@ export function IntelligentSystemSummaryCard({
}: IntelligentSystemSummaryCardProps) {
const { t } = useTranslation(['dashboard', 'reasoning']);
const { currentTenant } = useTenant();
const { notifications } = useNotifications();
const { notifications } = useEventNotifications();
const [analytics, setAnalytics] = useState<DashboardAnalytics | null>(null);
const [preventedAlerts, setPreventedAlerts] = useState<EnrichedAlert[]>([]);
const [preventedAlerts, setPreventedAlerts] = useState<Alert[]>([]);
const [analyticsLoading, setAnalyticsLoading] = useState(true);
const [preventedIssuesExpanded, setPreventedIssuesExpanded] = useState(false);
const [orchestrationExpanded, setOrchestrationExpanded] = useState(false);
@@ -102,7 +104,7 @@ export function IntelligentSystemSummaryCard({
`/tenants/${currentTenant.id}/alerts/analytics/dashboard`,
{ params: { days: 30 } }
),
apiClient.get<{ alerts: EnrichedAlert[] }>(
apiClient.get<{ alerts: Alert[] }>(
`/tenants/${currentTenant.id}/alerts`,
{ params: { limit: 100 } }
),
@@ -132,15 +134,29 @@ export function IntelligentSystemSummaryCard({
fetchAnalytics();
}, [currentTenant?.id]);
// Real-time prevented issues from SSE
const preventedIssuesKey = useMemo(() => {
if (!notifications || notifications.length === 0) return 'empty';
return notifications
.filter((n) => n.type_class === 'prevented_issue' && !n.read)
.map((n) => n.id)
.sort()
.join(',');
}, [notifications]);
// Real-time prevented issues from SSE - merge with API data
const allPreventedAlerts = useMemo(() => {
if (!notifications || notifications.length === 0) return preventedAlerts;
// Filter SSE notifications for prevented issues from last 7 days
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const ssePreventedIssues = notifications.filter(
(n) => n.type_class === 'prevented_issue' && new Date(n.created_at) >= sevenDaysAgo
);
// Deduplicate: combine SSE + API data, removing duplicates by ID
const existingIds = new Set(preventedAlerts.map((a) => a.id));
const newSSEAlerts = ssePreventedIssues.filter((n) => !existingIds.has(n.id));
// Merge and sort by created_at (newest first)
const merged = [...preventedAlerts, ...newSSEAlerts].sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
return merged.slice(0, 20); // Keep only top 20
}, [preventedAlerts, notifications]);
// Calculate metrics
const totalSavings = analytics?.estimated_savings_eur || 0;
@@ -214,14 +230,14 @@ export function IntelligentSystemSummaryCard({
)}
</div>
{/* Prevented Issues Badge */}
{/* Prevented Issues Badge - Show actual count from last 7 days to match detail section */}
<div
className="flex items-center gap-1 px-2 py-1 rounded-md"
style={{ backgroundColor: 'var(--color-primary-100)', border: '1px solid var(--color-primary-300)' }}
>
<ShieldCheck className="w-4 h-4" style={{ color: 'var(--color-primary-600)' }} />
<span className="text-sm font-semibold" style={{ color: 'var(--color-primary-700)' }}>
{analytics?.prevented_issues_count || 0}
{allPreventedAlerts.length}
</span>
<span className="text-xs" style={{ color: 'var(--color-primary-600)' }}>
{t('dashboard:intelligent_system.prevented_issues', 'issues')}
@@ -261,7 +277,7 @@ export function IntelligentSystemSummaryCard({
{/* Collapsible Section: Prevented Issues Details */}
{preventedIssuesExpanded && (
<div className="px-6 pb-6 pt-2 border-t" style={{ borderColor: 'var(--color-success-200)' }}>
{preventedAlerts.length === 0 ? (
{allPreventedAlerts.length === 0 ? (
<div className="text-center py-8">
<p className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
{t('dashboard:intelligent_system.no_prevented_issues', 'No issues prevented this week - all systems running smoothly!')}
@@ -276,14 +292,14 @@ export function IntelligentSystemSummaryCard({
>
<p className="text-sm font-semibold" style={{ color: 'var(--color-success-700)' }}>
{t('dashboard:intelligent_system.celebration', 'Great news! AI prevented {count} issue(s) before they became problems.', {
count: preventedAlerts.length,
count: allPreventedAlerts.length,
})}
</p>
</div>
{/* Prevented Issues List */}
<div className="space-y-2">
{preventedAlerts.map((alert) => {
{allPreventedAlerts.map((alert) => {
const savings = alert.orchestrator_context?.estimated_savings_eur || 0;
const actionTaken = alert.orchestrator_context?.action_taken || 'AI intervention';
const timeAgo = formatRelativeTime(alert.created_at) || 'Fecha desconocida';
@@ -299,10 +315,10 @@ export function IntelligentSystemSummaryCard({
<CheckCircle className="w-4 h-4 mt-0.5 flex-shrink-0" style={{ color: 'var(--color-success-600)' }} />
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-sm mb-1" style={{ color: 'var(--text-primary)' }}>
{alert.title}
{renderEventTitle(alert, t)}
</h4>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{alert.message}
{renderEventMessage(alert, t)}
</p>
</div>
</div>
@@ -418,6 +434,87 @@ export function IntelligentSystemSummaryCard({
</span>
)}
</div>
{/* AI Reasoning Section */}
{orchestrationSummary.reasoning && orchestrationSummary.reasoning.reasoning_i18n && (
<div className="mt-4 space-y-3">
{/* Reasoning Text Block */}
<div
className="rounded-lg p-4 border-l-4"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--color-info-600)',
}}
>
<div className="flex items-start gap-2 mb-2">
<Bot className="w-5 h-5 mt-0.5" style={{ color: 'var(--color-info-600)' }} />
<h4 className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
{t('alerts:orchestration.reasoning_title', '🤖 Razonamiento del Orquestador Diario')}
</h4>
</div>
<p className="text-sm leading-relaxed" style={{ color: 'var(--text-primary)' }}>
{t(
orchestrationSummary.reasoning.reasoning_i18n.key,
orchestrationSummary.reasoning.reasoning_i18n.params || {}
)}
</p>
</div>
{/* Business Impact Metrics */}
{(orchestrationSummary.reasoning.business_impact?.financial_impact_eur ||
orchestrationSummary.reasoning.business_impact?.affected_orders) && (
<div className="flex flex-wrap gap-2">
{orchestrationSummary.reasoning.business_impact.financial_impact_eur > 0 && (
<div
className="flex items-center gap-2 px-3 py-2 rounded-md"
style={{
backgroundColor: 'var(--color-success-100)',
border: '1px solid var(--color-success-300)',
}}
>
<TrendingUp className="w-4 h-4" style={{ color: 'var(--color-success-600)' }} />
<span className="text-sm font-semibold" style={{ color: 'var(--color-success-700)' }}>
{orchestrationSummary.reasoning.business_impact.financial_impact_eur.toFixed(0)}{' '}
{t('dashboard:intelligent_system.estimated_savings', 'impacto financiero')}
</span>
</div>
)}
{orchestrationSummary.reasoning.business_impact.affected_orders > 0 && (
<div
className="flex items-center gap-2 px-3 py-2 rounded-md"
style={{
backgroundColor: 'var(--color-info-100)',
border: '1px solid var(--color-info-300)',
}}
>
<Package className="w-4 h-4" style={{ color: 'var(--color-info-600)' }} />
<span className="text-sm font-semibold" style={{ color: 'var(--color-info-700)' }}>
{orchestrationSummary.reasoning.business_impact.affected_orders}{' '}
{t('common:orders', 'pedidos')}
</span>
</div>
)}
</div>
)}
{/* Urgency Context */}
{orchestrationSummary.reasoning.urgency_context?.time_until_consequence_hours > 0 && (
<div
className="rounded-lg p-3 flex items-center gap-2"
style={{
backgroundColor: 'var(--color-warning-50)',
borderLeft: '4px solid var(--color-warning-600)',
}}
>
<Clock className="w-4 h-4" style={{ color: 'var(--color-warning-600)' }} />
<span className="text-sm font-semibold" style={{ color: 'var(--color-warning-800)' }}>
{Math.round(orchestrationSummary.reasoning.urgency_context.time_until_consequence_hours)}h{' '}
{t('common:remaining', 'restantes')}
</span>
</div>
)}
</div>
)}
</div>
) : (
<div className="text-sm text-center py-4" style={{ color: 'var(--text-secondary)' }}>

View File

@@ -36,12 +36,26 @@ import {
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { UnifiedActionQueue, EnrichedAlert } from '../../api/hooks/newDashboard';
import { Alert } from '../../api/types/events';
import { renderEventTitle, renderEventMessage, renderActionLabel, renderAIReasoning } from '../../utils/i18n/alertRendering';
import { useSmartActionHandler, mapActionVariantToButton } from '../../utils/smartActionHandlers';
import { Button } from '../ui/Button';
import { useNotifications } from '../../hooks/useNotifications';
import { useEventNotifications } from '../../hooks/useEventNotifications';
import { useQueryClient } from '@tanstack/react-query';
import { StockReceiptModal } from './StockReceiptModal';
import { ReasoningModal } from '../domain/dashboard/ReasoningModal';
import { UnifiedPurchaseOrderModal } from '../domain/procurement/UnifiedPurchaseOrderModal';
// Unified Action Queue interface (keeping for compatibility with dashboard hook)
interface UnifiedActionQueue {
urgent: Alert[];
today: Alert[];
week: Alert[];
urgentCount: number;
todayCount: number;
weekCount: number;
totalActions: number;
}
interface UnifiedActionQueueCardProps {
actionQueue: UnifiedActionQueue;
@@ -50,7 +64,7 @@ interface UnifiedActionQueueCardProps {
}
interface ActionCardProps {
alert: EnrichedAlert;
alert: Alert;
showEscalationBadge?: boolean;
onActionSuccess?: () => void;
onActionError?: (error: string) => void;
@@ -83,14 +97,14 @@ function getUrgencyColor(priorityLevel: string): {
}
}
function EscalationBadge({ alert }: { alert: EnrichedAlert }) {
function EscalationBadge({ alert }: { alert: Alert }) {
const { t } = useTranslation('alerts');
const escalation = alert.alert_metadata?.escalation;
const escalation = alert.event_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)
const hoursPending = alert.urgency?.hours_until_consequence
? Math.round(alert.urgency.hours_until_consequence)
: null;
return (
@@ -119,6 +133,10 @@ function getActionLabelKey(actionType: string, metadata?: Record<string, any>):
key: 'alerts:actions.reject_po',
extractParams: () => ({})
},
'view_po_details': {
key: 'alerts:actions.view_po_details',
extractParams: () => ({})
},
'call_supplier': {
key: 'alerts:actions.call_supplier',
extractParams: (meta) => ({ supplier: meta.supplier || meta.name || 'Supplier', phone: meta.phone || '' })
@@ -172,10 +190,10 @@ function getActionLabelKey(actionType: string, metadata?: Record<string, any>):
}
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 [showReasoningModal, setShowReasoningModal] = useState(false);
const { t } = useTranslation(['alerts', 'reasoning']);
const colors = getUrgencyColor(alert.priority_level);
// Action handler with callbacks
@@ -198,17 +216,30 @@ function ActionCard({ alert, showEscalationBadge = false, onActionSuccess, onAct
// 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;
if (!alert.event_type) return AlertCircle;
if (alert.event_type.includes('delivery')) return Truck;
if (alert.event_type.includes('production')) return Package;
if (alert.event_type.includes('procurement') || alert.event_type.includes('po')) return Calendar;
return AlertCircle;
};
const AlertIcon = getAlertIcon();
// Get actions from alert
const alertActions = alert.actions || [];
// Get actions from alert, filter out "Ver razonamiento" since reasoning is now always visible
const alertActions = (alert.smart_actions || []).filter(action => action.action_type !== 'open_reasoning');
// Debug logging to diagnose action button issues (can be removed after verification)
if (alert.smart_actions && alert.smart_actions.length > 0 && alertActions.length === 0) {
console.warn('[ActionQueue] All actions filtered out for alert:', alert.id, alert.smart_actions);
}
if (alertActions.length > 0) {
console.debug('[ActionQueue] Rendering actions for alert:', alert.id, alertActions.map(a => ({
type: a.action_type,
hasMetadata: !!a.metadata,
hasAmount: a.metadata ? 'amount' in a.metadata : false,
metadata: a.metadata
})));
}
// Get icon for action type
const getActionIcon = (actionType: string) => {
@@ -219,7 +250,10 @@ function ActionCard({ alert, showEscalationBadge = false, onActionSuccess, onAct
};
// Determine if this is a critical alert that needs stronger visual treatment
const isCritical = alert.priority_level === 'CRITICAL';
const isCritical = alert.priority_level === 'critical' || alert.priority_level === 'CRITICAL';
// Extract reasoning from alert using new rendering utility
const reasoningText = renderAIReasoning(alert, t) || '';
return (
<div
@@ -239,12 +273,13 @@ function ActionCard({ alert, showEscalationBadge = false, onActionSuccess, onAct
style={{ color: colors.border }}
/>
<div className="flex-1 min-w-0">
{/* Header with Title and Escalation Badge */}
<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}
{renderEventTitle(alert, t)}
</h3>
{/* Only show escalation badge if applicable */}
{showEscalationBadge && alert.alert_metadata?.escalation && (
{showEscalationBadge && alert.event_metadata?.escalation && (
<div className="flex items-center gap-2 flex-shrink-0">
<span
className="px-2 py-1 rounded text-xs font-semibold uppercase"
@@ -262,117 +297,160 @@ function ActionCard({ alert, showEscalationBadge = false, onActionSuccess, onAct
{/* Escalation Badge Details */}
{showEscalationBadge && <EscalationBadge alert={alert} />}
{/* Message */}
<p className="text-sm mb-3 text-[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 bg-[var(--color-warning-100)] text-[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' : ''
} bg-[var(--color-error-100)] text-[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 bg-[var(--color-success-100)] text-[var(--color-success-800)]"
>
<Bot className="w-4 h-4" />
<span>AI handled</span>
</div>
)}
{/* What/Why/How Structure - Enhanced with clear section labels */}
<div className="space-y-3">
{/* WHAT: What happened - The alert message */}
<div className="space-y-2">
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
{t('reasoning:jtbd.action_queue.what_happened', 'What happened')}
</div>
<p className="text-sm text-[var(--text-secondary)]">
{renderEventMessage(alert, t)}
</p>
</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 text-[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 bg-[var(--bg-secondary)] border-l-4 border-l-[var(--color-info-600)]"
>
<div className="flex items-start gap-2">
<Bot className="w-4 h-4 flex-shrink-0 mt-0.5 text-[var(--color-info-700)]" />
<p className="text-sm text-[var(--text-secondary)]">
{alert.ai_reasoning_summary}
</p>
{/* WHY: Why this is needed - AI Reasoning */}
{reasoningText && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 flex items-center gap-1">
<Bot className="w-3 h-3" />
{t('reasoning:jtbd.action_queue.why_needed', 'Why this is needed')}
</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`}
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-[var(--color-info-100)] text-[var(--color-info-800)] border border-[var(--color-info-300)]">
{t('alerts:orchestration.what_ai_did', 'AI Recommendation')}
</span>
<button
onClick={() => setShowReasoningModal(true)}
className="text-xs text-[var(--color-info-700)] hover:text-[var(--color-info-900)] underline cursor-pointer"
>
{(() => {
const { key, params } = getActionLabelKey(action.type, action.metadata);
return String(t(key, params));
})()}
{action.estimated_time_minutes && !isLoading && (
<span className="ml-1 opacity-60 text-xs">({action.estimated_time_minutes}m)</span>
)}
</Button>
);
})}
</div>
)}
{t('alerts:actions.see_reasoning', 'See full reasoning')}
</button>
</div>
<div
className="p-3 rounded-md text-sm leading-relaxed border-l-3"
style={{
backgroundColor: 'rgba(59, 130, 246, 0.05)',
borderLeftColor: 'rgb(59, 130, 246)',
borderLeftWidth: '3px'
}}
>
{reasoningText}
</div>
</div>
)}
{/* Action Completed State */}
{actionCompleted && (
<div
className="flex items-center gap-2 mt-3 px-3 py-2 rounded-md bg-[var(--color-success-100)] text-[var(--color-success-800)]"
>
<CheckCircle className="w-5 h-5" />
<span className="font-semibold">Action completed successfully</span>
</div>
)}
{/* Context Badges - Matching Health Hero Style */}
{(alert.business_impact || alert.urgency || 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 bg-[var(--color-warning-100)] text-[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?.hours_until_consequence && (
<div
className={`flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-semibold ${
alert.urgency.hours_until_consequence < 6 ? 'animate-pulse' : ''
} bg-[var(--color-error-100)] text-[var(--color-error-800)]`}
>
<Clock className="w-4 h-4" />
<span>{Math.round(alert.urgency.hours_until_consequence)}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 bg-[var(--color-success-100)] text-[var(--color-success-800)]"
>
<Bot className="w-4 h-4" />
<span>AI handled</span>
</div>
)}
</div>
)}
{/* HOW: What you should do - Action buttons */}
{alertActions.length > 0 && !actionCompleted && (
<div className="mt-4">
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">
{t('reasoning:jtbd.action_queue.what_to_do', 'What you should do')}
</div>
<div className="flex flex-wrap gap-2 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.action_type) : null;
const isLoading = loadingAction === action.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.action_type);
await actionHandler.handleAction(action as any, alert.id);
}}
className={`${isPrimary ? 'font-semibold' : ''} sm:w-auto w-full`}
>
{renderActionLabel(action, t)}
{action.estimated_time_minutes && !isLoading && (
<span className="ml-1 opacity-60 text-xs">({action.estimated_time_minutes}m)</span>
)}
</Button>
);
})}
</div>
</div>
)}
{/* Action Completed State */}
{actionCompleted && (
<div
className="flex items-center gap-2 mt-3 px-3 py-2 rounded-md bg-[var(--color-success-100)] text-[var(--color-success-800)]"
>
<CheckCircle className="w-5 h-5" />
<span className="font-semibold">Action completed successfully</span>
</div>
)}
</div>
</div>
</div>
{/* Reasoning Modal */}
{showReasoningModal && reasoningText && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg max-w-2xl w-full max-h-96 overflow-y-auto p-6">
<div className="flex justify-between items-start mb-4">
<h3 className="text-lg font-bold text-[var(--text-primary)]">
{t('alerts:orchestration.reasoning_title', 'AI Reasoning')}
</h3>
<button
onClick={() => setShowReasoningModal(false)}
className="text-gray-500 hover:text-gray-700"
>
</button>
</div>
<div className="prose max-w-none">
<p className="text-[var(--text-secondary)] whitespace-pre-wrap">
{reasoningText}
</p>
</div>
<div className="mt-4 flex justify-end">
<Button variant="secondary" onClick={() => setShowReasoningModal(false)}>
{t('common:close', 'Close')}
</Button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -463,8 +541,11 @@ export function UnifiedActionQueueCard({
}: UnifiedActionQueueCardProps) {
const { t } = useTranslation(['alerts', 'dashboard']);
const navigate = useNavigate();
const queryClient = useQueryClient();
const [toastMessage, setToastMessage] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
// REMOVED: Race condition workaround (lines 560-572) - no longer needed
// with refetchOnMount:'always' in useSharedDashboardData
// Show toast notification
useEffect(() => {
@@ -496,8 +577,12 @@ export function UnifiedActionQueueCard({
const [reasoningModalOpen, setReasoningModalOpen] = useState(false);
const [reasoningData, setReasoningData] = useState<any>(null);
// PO Details Modal state
const [isPODetailsModalOpen, setIsPODetailsModalOpen] = useState(false);
const [selectedPOId, setSelectedPOId] = useState<string | null>(null);
// Subscribe to SSE notifications for real-time alerts
const { notifications, isConnected } = useNotifications();
const { notifications, isConnected } = useEventNotifications();
// Listen for stock receipt modal open events
useEffect(() => {
@@ -538,11 +623,11 @@ export function UnifiedActionQueueCard({
action_id,
po_id,
batch_id,
reasoning: reasoning || alert?.ai_reasoning_summary,
title: alert?.title,
ai_reasoning_summary: alert?.ai_reasoning_summary,
reasoning: reasoning || renderAIReasoning(alert, t),
title: alert ? renderEventTitle(alert, t) : undefined,
ai_reasoning_summary: alert ? renderAIReasoning(alert, t) : undefined,
business_impact: alert?.business_impact,
urgency_context: alert?.urgency_context,
urgency_context: alert?.urgency,
});
setReasoningModalOpen(true);
};
@@ -553,6 +638,23 @@ export function UnifiedActionQueueCard({
};
}, [actionQueue]);
// Listen for PO details modal open events
useEffect(() => {
const handlePODetailsOpen = (event: CustomEvent) => {
const { po_id } = event.detail;
if (po_id) {
setSelectedPOId(po_id);
setIsPODetailsModalOpen(true);
}
};
window.addEventListener('po:open-details' as any, handlePODetailsOpen);
return () => {
window.removeEventListener('po:open-details' as any, handlePODetailsOpen);
};
}, []);
// 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(() => {
@@ -574,8 +676,9 @@ export function UnifiedActionQueueCard({
// Filter SSE notifications to only action_needed alerts
// Guard against undefined notifications array
// NEW: Also filter by status to exclude acknowledged/resolved alerts
const sseActionAlerts = (notifications || []).filter(
n => n.type_class === 'action_needed' && !n.read
n => n.type_class === 'action_needed' && !n.read && n.status === 'active'
);
// Create a set of existing alert IDs from API data
@@ -591,19 +694,19 @@ export function UnifiedActionQueueCard({
// 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;
const urgency = alert.urgency;
const deadline = urgency?.deadline_utc ? new Date(urgency.deadline_utc) : null;
if (!deadline) {
// No deadline: categorize by priority level
if (alert.priority_level === 'CRITICAL') return 'urgent';
if (alert.priority_level === 'IMPORTANT') return 'today';
if (alert.priority_level === 'critical' || alert.priority_level === 'CRITICAL') return 'urgent';
if (alert.priority_level === 'important' || alert.priority_level === 'IMPORTANT') return 'today';
return 'week';
}
const hoursUntilDeadline = (deadline.getTime() - now.getTime()) / (1000 * 60 * 60);
if (hoursUntilDeadline < 6 || alert.priority_level === 'CRITICAL') {
if (hoursUntilDeadline < 6 || alert.priority_level === 'critical' || alert.priority_level === 'CRITICAL') {
return 'urgent';
} else if (hoursUntilDeadline < 24) {
return 'today';
@@ -805,14 +908,58 @@ export function UnifiedActionQueueCard({
receipt={stockReceiptData.receipt}
mode={stockReceiptData.mode}
onSaveDraft={async (receipt) => {
console.log('Draft saved:', receipt);
// TODO: Implement save draft API call
try {
// Save draft receipt
const response = await fetch(
`/api/tenants/${receipt.tenant_id}/inventory/stock-receipts/${receipt.id}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
notes: receipt.notes,
line_items: receipt.line_items
})
}
);
if (!response.ok) {
throw new Error('Failed to save draft');
}
console.log('Draft saved successfully');
} catch (error) {
console.error('Error saving draft:', error);
throw error;
}
}}
onConfirm={async (receipt) => {
console.log('Receipt confirmed:', receipt);
// TODO: Implement confirm receipt API call
setIsStockReceiptModalOpen(false);
setStockReceiptData(null);
try {
// Confirm receipt - updates inventory
const response = await fetch(
`/api/tenants/${receipt.tenant_id}/inventory/stock-receipts/${receipt.id}/confirm`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
confirmed_by_user_id: receipt.received_by_user_id
})
}
);
if (!response.ok) {
throw new Error('Failed to confirm receipt');
}
console.log('Receipt confirmed successfully');
setIsStockReceiptModalOpen(false);
setStockReceiptData(null);
// Refresh data to show updated inventory
await refetch();
} catch (error) {
console.error('Error confirming receipt:', error);
throw error;
}
}}
/>
)}
@@ -828,6 +975,21 @@ export function UnifiedActionQueueCard({
reasoning={reasoningData}
/>
)}
{/* PO Details Modal - Opened by "Ver detalles" action */}
{isPODetailsModalOpen && selectedPOId && tenantId && (
<UnifiedPurchaseOrderModal
poId={selectedPOId}
tenantId={tenantId}
isOpen={isPODetailsModalOpen}
onClose={() => {
setIsPODetailsModalOpen(false);
setSelectedPOId(null);
}}
showApprovalActions={true}
initialMode="view"
/>
)}
</div>
);
}