New alert service
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)' }}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user