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

@@ -1,32 +1,33 @@
/*
* Performance Chart Component for Enterprise Dashboard
* Shows anonymized performance ranking of child outlets
* Shows performance ranking of child outlets with clickable names
*/
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/Card';
import { Badge } from '../ui/Badge';
import { BarChart3, TrendingUp, TrendingDown, ArrowUp, ArrowDown } from 'lucide-react';
import { BarChart3, TrendingUp, TrendingDown, ArrowUp, ArrowDown, ExternalLink, Package, ShoppingCart } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface PerformanceDataPoint {
rank: number;
tenant_id: string;
anonymized_name: string; // "Outlet 1", "Outlet 2", etc.
outlet_name: string;
metric_value: number;
original_name?: string; // Only for internal use, not displayed
}
interface PerformanceChartProps {
data: PerformanceDataPoint[];
metric: string;
period: number;
onOutletClick?: (tenantId: string, outletName: string) => void;
}
const PerformanceChart: React.FC<PerformanceChartProps> = ({
data = [],
metric,
period
const PerformanceChart: React.FC<PerformanceChartProps> = ({
data = [],
metric,
period,
onOutletClick
}) => {
const { t } = useTranslation('dashboard');
@@ -94,14 +95,31 @@ const PerformanceChart: React.FC<PerformanceChartProps> = ({
<div key={item.tenant_id} className="space-y-2">
<div className="flex justify-between items-center">
<div className="flex items-center gap-3">
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium ${
isTopPerformer
? 'bg-yellow-100 text-yellow-800 border border-yellow-300'
: 'bg-gray-100 text-gray-700'
}`}>
<div
className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium"
style={isTopPerformer ? {
backgroundColor: 'var(--color-warning-light, #fef3c7)',
color: 'var(--color-warning-dark, #92400e)',
borderColor: 'var(--color-warning, #fbbf24)',
borderWidth: '1px',
borderStyle: 'solid'
} : {
backgroundColor: 'var(--bg-tertiary, #f1f5f9)',
color: 'var(--text-secondary, #475569)'
}}>
{item.rank}
</div>
<span className="font-medium">{item.anonymized_name}</span>
{onOutletClick ? (
<button
onClick={() => onOutletClick(item.tenant_id, item.outlet_name)}
className="font-medium text-[var(--color-primary)] hover:text-[var(--color-primary-dark)] hover:underline flex items-center gap-1.5 transition-colors"
>
{item.outlet_name}
<ExternalLink className="w-3.5 h-3.5" />
</button>
) : (
<span className="font-medium">{item.outlet_name}</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="font-semibold">
@@ -114,15 +132,27 @@ const PerformanceChart: React.FC<PerformanceChartProps> = ({
)}
</div>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div className="w-full rounded-full h-3" style={{ backgroundColor: 'var(--bg-quaternary)' }}>
<div
className={`h-3 rounded-full transition-all duration-500 ${
isTopPerformer
? 'bg-gradient-to-r from-blue-500 to-purple-500'
: 'bg-blue-400'
}`}
style={{ width: `${percentage}%` }}
></div>
className="h-3 rounded-full transition-all duration-500 relative overflow-hidden"
style={{
width: `${percentage}%`,
background: isTopPerformer
? 'linear-gradient(90deg, var(--chart-secondary) 0%, var(--chart-primary) 100%)'
: 'var(--chart-secondary)'
}}
>
{/* Shimmer effect for top performer */}
{isTopPerformer && (
<div
className="absolute inset-0 animate-shimmer"
style={{
background: 'linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.4) 50%, transparent 100%)',
backgroundSize: '200% 100%',
}}
/>
)}
</div>
</div>
</div>
);

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>
);
}

View File

@@ -28,9 +28,8 @@ export const IncompleteIngredientsAlert: React.FC = () => {
}
const handleViewIncomplete = () => {
// Navigate to inventory page
// TODO: In the future, this could pass a filter parameter to show only incomplete items
navigate('/app/operations/inventory');
// Navigate to inventory page with filter to show only incomplete items
navigate('/app/operations/inventory?filter=incomplete&needs_review=true');
};
return (

View File

@@ -12,6 +12,7 @@ import type { RecipeResponse } from '../../../api/types/recipes';
import { useTranslation } from 'react-i18next';
import { useRecipes } from '../../../api/hooks/recipes';
import { useIngredients } from '../../../api/hooks/inventory';
import { useEquipment } from '../../../api/hooks/equipment';
import { recipesService } from '../../../api/services/recipes';
import { useCurrentTenant } from '../../../stores/tenant.store';
import { statusColors } from '../../../styles/colors';
@@ -41,6 +42,7 @@ export const CreateProductionBatchModal: React.FC<CreateProductionBatchModalProp
// API Data
const { data: recipes = [], isLoading: recipesLoading } = useRecipes(tenantId);
const { data: ingredients = [], isLoading: ingredientsLoading } = useIngredients(tenantId);
const { data: equipmentData, isLoading: equipmentLoading } = useEquipment(tenantId, { is_active: true });
// Stage labels for display
const STAGE_LABELS: Record<ProcessStage, string> = {
@@ -91,6 +93,14 @@ export const CreateProductionBatchModal: React.FC<CreateProductionBatchModalProp
label: recipe.name
})), [recipes]);
const equipmentOptions = useMemo(() => {
if (!equipmentData?.equipment) return [];
return equipmentData.equipment.map(equip => ({
value: equip.id,
label: `${equip.name} (${equip.equipment_code || equip.id.substring(0, 8)})`
}));
}, [equipmentData]);
const handleSave = async (formData: Record<string, any>) => {
// Validate that end time is after start time
const startTime = new Date(formData.planned_start_time);
@@ -111,6 +121,11 @@ export const CreateProductionBatchModal: React.FC<CreateProductionBatchModalProp
? formData.staff_assigned.split(',').map((s: string) => s.trim()).filter((s: string) => s.length > 0)
: [];
// Convert equipment_used from comma-separated string to array
const equipmentArray = formData.equipment_used
? formData.equipment_used.split(',').map((e: string) => e.trim()).filter((e: string) => e.length > 0)
: [];
const batchData: ProductionBatchCreate = {
product_id: formData.product_id,
product_name: selectedProduct?.name || '',
@@ -126,7 +141,7 @@ export const CreateProductionBatchModal: React.FC<CreateProductionBatchModalProp
batch_number: formData.batch_number || '',
order_id: formData.order_id || '',
forecast_id: formData.forecast_id || '',
equipment_used: [], // TODO: Add equipment selection if needed
equipment_used: equipmentArray,
staff_assigned: staffArray,
station_id: formData.station_id || ''
};
@@ -271,6 +286,16 @@ export const CreateProductionBatchModal: React.FC<CreateProductionBatchModalProp
title: 'Recursos y Notas',
icon: Users,
fields: [
{
label: 'Equipos a Utilizar',
name: 'equipment_used',
type: 'text' as const,
placeholder: 'Separar IDs de equipos con comas (opcional)',
span: 2,
helpText: equipmentOptions.length > 0
? `Equipos disponibles: ${equipmentOptions.map(e => e.label).join(', ')}`
: 'No hay equipos activos disponibles'
},
{
label: 'Personal Asignado',
name: 'staff_assigned',
@@ -288,7 +313,7 @@ export const CreateProductionBatchModal: React.FC<CreateProductionBatchModalProp
}
]
}
], [productOptions, recipeOptions, t]);
], [productOptions, recipeOptions, equipmentOptions, t]);
// Quality Requirements Preview Component
const qualityRequirementsPreview = selectedRecipe && (

View File

@@ -72,8 +72,7 @@ export const DemoBanner: React.FC = () => {
useTenantStore.getState().clearTenants();
// Clear notification storage to ensure notifications don't persist across sessions
const { clearNotificationStorage } = await import('../../../hooks/useNotifications');
clearNotificationStorage();
// Since useNotifications hook doesn't exist, we just continue without clearing
navigate('/demo');
};

View File

@@ -15,6 +15,7 @@ import {
Clock,
User
} from 'lucide-react';
import { showToast } from '../../../utils/toast';
export interface ActionButton {
id: string;
@@ -264,14 +265,15 @@ export const PageHeader = forwardRef<PageHeaderRef, PageHeaderProps>(({
// Render metadata item
const renderMetadata = (item: MetadataItem) => {
const ItemIcon = item.icon;
const handleCopy = async () => {
if (item.copyable && typeof item.value === 'string') {
try {
await navigator.clipboard.writeText(item.value);
// TODO: Show toast notification
showToast.success('Copiado al portapapeles');
} catch (error) {
console.error('Failed to copy:', error);
showToast.error('Error al copiar');
}
}
};

View File

@@ -8,6 +8,7 @@ import { useHasAccess } from '../../../hooks/useAccessControl';
import { getNavigationRoutes, canAccessRoute, ROUTES } from '../../../router/routes.config';
import { useSubscriptionAwareRoutes } from '../../../hooks/useSubscriptionAwareRoutes';
import { useSubscriptionEvents } from '../../../contexts/SubscriptionEventsContext';
import { useSubscription } from '../../../api/hooks/subscription';
import { Button } from '../../ui';
import { Badge } from '../../ui';
import { Tooltip } from '../../ui';
@@ -136,6 +137,7 @@ const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
insights: Lightbulb,
events: Activity,
list: List,
distribution: Truck,
};
/**
@@ -162,7 +164,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
showCollapseButton = true,
showFooter = true,
}, ref) => {
const { t } = useTranslation();
const { t } = useTranslation(['common']);
const location = useLocation();
const navigate = useNavigate();
const user = useAuthUser();
@@ -170,7 +172,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
const hasAccess = useHasAccess(); // For UI visibility
const currentTenantAccess = useCurrentTenantAccess();
const { logout } = useAuthActions();
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
const [hoveredItem, setHoveredItem] = useState<string | null>(null);
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
@@ -179,6 +181,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
const searchInputRef = React.useRef<HTMLInputElement>(null);
const sidebarRef = React.useRef<HTMLDivElement>(null);
const { subscriptionVersion } = useSubscriptionEvents();
const { subscriptionInfo } = useSubscription();
// Get subscription-aware navigation routes
const baseNavigationRoutes = useMemo(() => getNavigationRoutes(), []);
@@ -186,6 +189,11 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
// Map route paths to translation keys
const getTranslationKey = (routePath: string): string => {
// Special case for Enterprise tier: Rename "Mi Panadería" to "Central Baker"
if (routePath === '/app/database' && subscriptionInfo.plan === 'enterprise') {
return 'navigation.central_baker';
}
const pathMappings: Record<string, string> = {
'/app/dashboard': 'navigation.dashboard',
'/app/operations': 'navigation.operations',
@@ -193,6 +201,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
'/app/operations/production': 'navigation.production',
'/app/operations/maquinaria': 'navigation.equipment',
'/app/operations/pos': 'navigation.pos',
'/app/operations/distribution': 'navigation.distribution',
'/app/bakery': 'navigation.bakery',
'/app/bakery/recipes': 'navigation.recipes',
'/app/database': 'navigation.data',
@@ -436,11 +445,11 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
const findParentPaths = useCallback((items: NavigationItem[], targetPath: string, parents: string[] = []): string[] => {
for (const item of items) {
const currentPath = [...parents, item.id];
if (item.path === targetPath) {
return parents;
}
if (item.children) {
const found = findParentPaths(item.children, targetPath, currentPath);
if (found.length > 0) {
@@ -479,7 +488,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
let touchStartX = 0;
let touchStartY = 0;
const handleTouchStart = (e: TouchEvent) => {
if (isOpen) {
touchStartX = e.touches[0].clientX;
@@ -489,12 +498,12 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
const handleTouchMove = (e: TouchEvent) => {
if (!isOpen || !onClose) return;
const touchCurrentX = e.touches[0].clientX;
const touchCurrentY = e.touches[0].clientY;
const deltaX = touchStartX - touchCurrentX;
const deltaY = Math.abs(touchStartY - touchCurrentY);
// Only trigger swipe left to close if it's more horizontal than vertical
// and the swipe distance is significant
if (deltaX > 50 && deltaX > deltaY * 2) {
@@ -536,7 +545,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
e.preventDefault();
focusSearch();
}
// Escape to close menus
if (e.key === 'Escape') {
setIsProfileMenuOpen(false);
@@ -651,18 +660,18 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
)}
/>
)}
{/* Submenu indicator for collapsed sidebar */}
{isCollapsed && hasChildren && level === 0 && item.children && item.children.length > 0 && (
<div className="absolute -bottom-1 -right-1 w-2 h-2 bg-[var(--color-primary)] rounded-full opacity-75" />
)}
</div>
{!ItemIcon && level > 0 && (
<Dot className={clsx(
'flex-shrink-0 w-4 h-4 mr-3 transition-colors duration-200',
isActive
? 'text-[var(--color-primary)]'
isActive
? 'text-[var(--color-primary)]'
: 'text-[var(--text-tertiary)]'
)} />
)}
@@ -671,8 +680,8 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
<>
<span className={clsx(
'flex-1 truncate transition-colors duration-200 text-sm font-medium',
isActive
? 'text-[var(--color-primary)]'
isActive
? 'text-[var(--color-primary)]'
: 'text-[var(--text-primary)] group-hover:text-[var(--text-primary)]'
)}>
{item.label}
@@ -692,8 +701,8 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
<ChevronDown className={clsx(
'flex-shrink-0 w-4 h-4 ml-2 transition-transform duration-200',
isExpanded && 'transform rotate-180',
isActive
? 'text-[var(--color-primary)]'
isActive
? 'text-[var(--color-primary)]'
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
)} />
)}
@@ -733,7 +742,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
>
{itemContent}
</button>
{/* Submenu overlay for collapsed sidebar */}
{isCollapsed && hasChildren && level === 0 && isHovered && item.children && item.children.length > 0 && (
<div
@@ -788,7 +797,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
{/* Search */}
{!isCollapsed && (
<div className="px-4 pt-4" data-search>
<form
<form
onSubmit={handleSearchSubmit}
className="relative"
>
@@ -989,7 +998,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
{/* Mobile search - always visible in mobile view */}
<div className="p-4 border-b border-[var(--border-primary)]">
<form
<form
onSubmit={handleSearchSubmit}
className="relative"
>

View File

@@ -40,7 +40,7 @@ interface RouteData {
total_distance_km: number;
estimated_duration_minutes: number;
status: 'planned' | 'in_progress' | 'completed' | 'cancelled';
route_points: RoutePoint[];
route_points?: RoutePoint[];
}
interface ShipmentStatusData {
@@ -55,9 +55,9 @@ interface DistributionMapProps {
shipments?: ShipmentStatusData;
}
const DistributionMap: React.FC<DistributionMapProps> = ({
routes = [],
shipments = { pending: 0, in_transit: 0, delivered: 0, failed: 0 }
const DistributionMap: React.FC<DistributionMapProps> = ({
routes = [],
shipments = { pending: 0, in_transit: 0, delivered: 0, failed: 0 }
}) => {
const { t } = useTranslation('dashboard');
const [selectedRoute, setSelectedRoute] = useState<RouteData | null>(null);
@@ -66,77 +66,167 @@ const DistributionMap: React.FC<DistributionMapProps> = ({
const renderMapVisualization = () => {
if (!routes || routes.length === 0) {
return (
<div className="h-96 bg-gray-50 rounded-lg border-2 border-dashed border-gray-300 flex items-center justify-center">
<div className="text-center">
<MapPin className="w-12 h-12 mx-auto mb-4 text-gray-400" />
<p className="text-lg font-medium text-gray-900">{t('enterprise.no_active_routes')}</p>
<p className="text-gray-500">{t('enterprise.no_shipments_today')}</p>
<div className="h-96 flex items-center justify-center rounded-xl border-2 border-dashed" style={{ borderColor: 'var(--border-secondary)', backgroundColor: 'var(--bg-secondary)' }}>
<div className="text-center px-6">
<div
className="w-24 h-24 rounded-full flex items-center justify-center mb-4 mx-auto shadow-lg"
style={{
backgroundColor: 'var(--color-info-100)',
}}
>
<MapPin className="w-12 h-12" style={{ color: 'var(--color-info-600)' }} />
</div>
<h3 className="text-xl font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
{t('enterprise.no_active_routes')}
</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{t('enterprise.no_shipments_today')}
</p>
</div>
</div>
);
}
// Find active routes (in_progress or planned for today)
const activeRoutes = routes.filter(route =>
const activeRoutes = routes.filter(route =>
route.status === 'in_progress' || route.status === 'planned'
);
if (activeRoutes.length === 0) {
return (
<div className="h-96 bg-gray-50 rounded-lg border-2 border-dashed border-gray-300 flex items-center justify-center">
<div className="text-center">
<CheckCircle className="w-12 h-12 mx-auto mb-4 text-green-400" />
<p className="text-lg font-medium text-gray-900">{t('enterprise.all_routes_completed')}</p>
<p className="text-gray-500">{t('enterprise.no_active_deliveries')}</p>
<div className="h-96 flex items-center justify-center rounded-xl border-2 border-dashed" style={{ borderColor: 'var(--border-secondary)', backgroundColor: 'var(--bg-secondary)' }}>
<div className="text-center px-6">
<div
className="w-24 h-24 rounded-full flex items-center justify-center mb-4 mx-auto shadow-lg"
style={{
backgroundColor: 'var(--color-success-100)',
}}
>
<CheckCircle className="w-12 h-12" style={{ color: 'var(--color-success-600)' }} />
</div>
<h3 className="text-xl font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
{t('enterprise.all_routes_completed')}
</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{t('enterprise.no_active_deliveries')}
</p>
</div>
</div>
);
}
// This would normally render an interactive map, but we'll create a visual representation
// Enhanced visual representation with improved styling
return (
<div className="h-96 bg-gradient-to-b from-blue-50 to-indigo-50 rounded-lg border border-gray-200 relative">
{/* Map visualization placeholder with route indicators */}
<div className="relative h-64 lg:h-96 rounded-xl overflow-hidden border-2" style={{
background: 'linear-gradient(135deg, var(--color-info-50) 0%, var(--color-primary-50) 50%, var(--color-secondary-50) 100%)',
borderColor: 'var(--border-primary)'
}}>
{/* Bakery-themed pattern overlay */}
<div className="absolute inset-0 opacity-5 bg-pattern" />
{/* Central Info Display */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<MapIcon className="w-16 h-16 mx-auto text-blue-400 mb-2" />
<div className="text-lg font-medium text-gray-700">{t('enterprise.distribution_map')}</div>
<div className="text-sm text-gray-500 mt-1">
<div
className="w-20 h-20 rounded-2xl flex items-center justify-center mb-3 mx-auto shadow-lg"
style={{
backgroundColor: 'var(--color-info-100)',
}}
>
<MapIcon className="w-10 h-10" style={{ color: 'var(--color-info-600)' }} />
</div>
<div className="text-2xl font-bold mb-1" style={{ color: 'var(--text-primary)' }}>
{t('enterprise.distribution_map')}
</div>
<div className="text-sm font-medium px-4 py-2 rounded-full inline-block" style={{
backgroundColor: 'var(--color-info-100)',
color: 'var(--color-info-900)'
}}>
{activeRoutes.length} {t('enterprise.active_routes')}
</div>
</div>
</div>
{/* Route visualization elements */}
{activeRoutes.map((route, index) => (
<div key={route.id} className="absolute top-4 left-4 bg-white p-3 rounded-lg shadow-md text-sm max-w-xs">
<div className="font-medium text-gray-900 flex items-center gap-2">
<Route className="w-4 h-4 text-blue-600" />
{t('enterprise.route')} {route.route_number}
{/* Glassmorphism Route Info Cards */}
<div className="absolute top-4 left-4 right-4 flex flex-wrap gap-2">
{activeRoutes.slice(0, 3).map((route, index) => (
<div
key={route.id}
className="glass-effect p-3 rounded-lg shadow-md backdrop-blur-sm max-w-xs"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.9)',
border: '1px solid var(--border-primary)'
}}
>
<div className="flex items-center gap-2">
<div
className="w-8 h-8 rounded-lg flex items-center justify-center"
style={{
backgroundColor: 'var(--color-info-100)',
}}
>
<Route className="w-4 h-4" style={{ color: 'var(--color-info-600)' }} />
</div>
<div className="flex-1 min-w-0">
<div className="font-semibold text-sm truncate" style={{ color: 'var(--text-primary)' }}>
{t('enterprise.route')} {route.route_number}
</div>
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{route.total_distance_km.toFixed(1)} km {Math.ceil(route.estimated_duration_minutes / 60)}h
</div>
</div>
</div>
</div>
<div className="text-gray-600 mt-1">{route.status.replace('_', ' ')}</div>
<div className="text-gray-500">{route.total_distance_km.toFixed(1)} km {Math.ceil(route.estimated_duration_minutes / 60)}h</div>
</div>
))}
))}
{activeRoutes.length > 3 && (
<div
className="glass-effect p-3 rounded-lg shadow-md backdrop-blur-sm"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.9)',
border: '1px solid var(--border-primary)'
}}
>
<div className="text-sm font-semibold" style={{ color: 'var(--text-secondary)' }}>
+{activeRoutes.length - 3} more
</div>
</div>
)}
</div>
{/* Shipment status indicators */}
<div className="absolute bottom-4 right-4 bg-white p-3 rounded-lg shadow-md space-y-2">
{/* Status Legend */}
<div
className="absolute bottom-4 right-4 glass-effect p-4 rounded-lg shadow-lg backdrop-blur-sm space-y-2"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.95)',
border: '1px solid var(--border-primary)'
}}
>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-yellow-400"></div>
<span className="text-sm">{t('enterprise.pending')}: {shipments.pending}</span>
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: 'var(--color-warning)' }}></div>
<span className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
{t('enterprise.pending')}: {shipments.pending}
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-blue-400"></div>
<span className="text-sm">{t('enterprise.in_transit')}: {shipments.in_transit}</span>
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: 'var(--color-info)' }}></div>
<span className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
{t('enterprise.in_transit')}: {shipments.in_transit}
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-400"></div>
<span className="text-sm">{t('enterprise.delivered')}: {shipments.delivered}</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-red-400"></div>
<span className="text-sm">{t('enterprise.failed')}: {shipments.failed}</span>
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: 'var(--color-success)' }}></div>
<span className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
{t('enterprise.delivered')}: {shipments.delivered}
</span>
</div>
{shipments.failed > 0 && (
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: 'var(--color-error)' }}></div>
<span className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
{t('enterprise.failed')}: {shipments.failed}
</span>
</div>
)}
</div>
</div>
);
@@ -173,111 +263,274 @@ const DistributionMap: React.FC<DistributionMapProps> = ({
};
return (
<div className="space-y-4">
{/* Shipment Status Summary */}
<div className="grid grid-cols-4 gap-4 mb-4">
<div className="bg-yellow-50 p-3 rounded-lg border border-yellow-200">
<div className="flex items-center gap-2">
<Clock className="w-5 h-5 text-yellow-600" />
<span className="text-sm font-medium text-yellow-800">{t('enterprise.pending')}</span>
<div className="space-y-6">
{/* Shipment Status Summary - Hero Icon Pattern */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
{/* Pending Status Card */}
<div className="relative group">
<div
className="p-6 rounded-xl border-2 transition-all duration-300 hover:shadow-xl hover:-translate-y-1 cursor-pointer"
style={{
backgroundColor: 'var(--color-warning-50)',
borderColor: 'var(--color-warning-200)',
}}
>
<div
className="w-14 h-14 rounded-full flex items-center justify-center mb-3 shadow-md"
style={{
backgroundColor: 'var(--color-warning-100)',
}}
>
<Clock className="w-7 h-7" style={{ color: 'var(--color-warning-600)' }} />
</div>
<p className="text-4xl font-bold mb-1" style={{ color: 'var(--color-warning-900)' }}>
{shipments?.pending || 0}
</p>
<p className="text-sm font-medium" style={{ color: 'var(--color-warning-700)' }}>
{t('enterprise.pending')}
</p>
</div>
<p className="text-2xl font-bold text-yellow-900">{shipments?.pending || 0}</p>
</div>
<div className="bg-blue-50 p-3 rounded-lg border border-blue-200">
<div className="flex items-center gap-2">
<Truck className="w-5 h-5 text-blue-600" />
<span className="text-sm font-medium text-blue-800">{t('enterprise.in_transit')}</span>
{/* In Transit Status Card */}
<div className="relative group">
<div
className="p-6 rounded-xl border-2 transition-all duration-300 hover:shadow-xl hover:-translate-y-1 cursor-pointer"
style={{
backgroundColor: 'var(--color-info-50)',
borderColor: 'var(--color-info-200)',
}}
>
<div
className="w-14 h-14 rounded-full flex items-center justify-center mb-3 shadow-md"
style={{
backgroundColor: 'var(--color-info-100)',
}}
>
<Truck className="w-7 h-7" style={{ color: 'var(--color-info-600)' }} />
</div>
<p className="text-4xl font-bold mb-1" style={{ color: 'var(--color-info-900)' }}>
{shipments?.in_transit || 0}
</p>
<p className="text-sm font-medium" style={{ color: 'var(--color-info-700)' }}>
{t('enterprise.in_transit')}
</p>
</div>
<p className="text-2xl font-bold text-blue-900">{shipments?.in_transit || 0}</p>
</div>
<div className="bg-green-50 p-3 rounded-lg border border-green-200">
<div className="flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-green-600" />
<span className="text-sm font-medium text-green-800">{t('enterprise.delivered')}</span>
{/* Delivered Status Card */}
<div className="relative group">
<div
className="p-6 rounded-xl border-2 transition-all duration-300 hover:shadow-xl hover:-translate-y-1 cursor-pointer"
style={{
backgroundColor: 'var(--color-success-50)',
borderColor: 'var(--color-success-200)',
}}
>
<div
className="w-14 h-14 rounded-full flex items-center justify-center mb-3 shadow-md"
style={{
backgroundColor: 'var(--color-success-100)',
}}
>
<CheckCircle className="w-7 h-7" style={{ color: 'var(--color-success-600)' }} />
</div>
<p className="text-4xl font-bold mb-1" style={{ color: 'var(--color-success-900)' }}>
{shipments?.delivered || 0}
</p>
<p className="text-sm font-medium" style={{ color: 'var(--color-success-700)' }}>
{t('enterprise.delivered')}
</p>
</div>
<p className="text-2xl font-bold text-green-900">{shipments?.delivered || 0}</p>
</div>
<div className="bg-red-50 p-3 rounded-lg border border-red-200">
<div className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-red-600" />
<span className="text-sm font-medium text-red-800">{t('enterprise.failed')}</span>
{/* Failed Status Card */}
<div className="relative group">
<div
className="p-6 rounded-xl border-2 transition-all duration-300 hover:shadow-xl hover:-translate-y-1 cursor-pointer"
style={{
backgroundColor: 'var(--color-error-50)',
borderColor: 'var(--color-error-200)',
}}
>
<div
className="w-14 h-14 rounded-full flex items-center justify-center mb-3 shadow-md"
style={{
backgroundColor: 'var(--color-error-100)',
}}
>
<AlertTriangle className="w-7 h-7" style={{ color: 'var(--color-error-600)' }} />
</div>
<p className="text-4xl font-bold mb-1" style={{ color: 'var(--color-error-900)' }}>
{shipments?.failed || 0}
</p>
<p className="text-sm font-medium" style={{ color: 'var(--color-error-700)' }}>
{t('enterprise.failed')}
</p>
</div>
<p className="text-2xl font-bold text-red-900">{shipments?.failed || 0}</p>
</div>
</div>
{/* Map Visualization */}
{renderMapVisualization()}
{/* Route Details Panel */}
<div className="mt-4">
<div className="flex justify-between items-center mb-3">
<h3 className="text-sm font-medium text-gray-700">{t('enterprise.active_routes')}</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setShowAllRoutes(!showAllRoutes)}
>
{showAllRoutes ? t('enterprise.hide_routes') : t('enterprise.show_routes')}
</Button>
</div>
{/* Route Details Panel - Timeline Pattern */}
<div className="mt-6">
<h3 className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
{t('enterprise.active_routes')} ({routes.filter(r => r.status === 'in_progress' || r.status === 'planned').length})
</h3>
{showAllRoutes && routes.length > 0 ? (
<div className="space-y-3">
{routes.length > 0 ? (
<div className="space-y-4">
{routes
.filter(route => route.status === 'in_progress' || route.status === 'planned')
.map(route => (
<Card key={route.id} className="hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex justify-between items-start">
<div
key={route.id}
className="p-5 rounded-xl border-2 transition-all duration-300 hover:shadow-lg"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)'
}}
>
{/* Route Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div
className="w-12 h-12 rounded-lg flex items-center justify-center shadow-md"
style={{
backgroundColor: 'var(--color-info-100)',
}}
>
<Truck className="w-6 h-6" style={{ color: 'var(--color-info-600)' }} />
</div>
<div>
<CardTitle className="text-sm">
<h4 className="font-semibold text-base" style={{ color: 'var(--text-primary)' }}>
{t('enterprise.route')} {route.route_number}
</CardTitle>
<p className="text-xs text-gray-500 mt-1">
</h4>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{route.total_distance_km.toFixed(1)} km {Math.ceil(route.estimated_duration_minutes / 60)}h
</p>
</div>
<Badge className={getStatusColor(route.status)}>
{getStatusIcon(route.status)}
<span className="ml-1 capitalize">
{t(`enterprise.route_status.${route.status}`) || route.status}
</span>
</Badge>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
{route.route_points.map((point, index) => (
<div key={index} className="flex items-center gap-3 text-sm">
<div className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
point.status === 'delivered' ? 'bg-green-500 text-white' :
point.status === 'in_transit' ? 'bg-blue-500 text-white' :
point.status === 'failed' ? 'bg-red-500 text-white' :
'bg-yellow-500 text-white'
}`}>
{point.sequence}
<Badge
className="px-3 py-1"
style={{
backgroundColor: route.status === 'in_progress'
? 'var(--color-info-100)'
: 'var(--color-warning-100)',
color: route.status === 'in_progress'
? 'var(--color-info-900)'
: 'var(--color-warning-900)',
borderColor: route.status === 'in_progress'
? 'var(--color-info-300)'
: 'var(--color-warning-300)',
borderWidth: '1px'
}}
>
{getStatusIcon(route.status)}
<span className="ml-1 capitalize font-medium">
{t(`enterprise.route_status.${route.status}`) || route.status.replace('_', ' ')}
</span>
</Badge>
</div>
{/* Timeline of Stops */}
{route.route_points && route.route_points.length > 0 && (
<div className="ml-6 border-l-2 pl-6 space-y-3" style={{ borderColor: 'var(--border-secondary)' }}>
{route.route_points.map((point, idx) => {
const getPointStatusColor = (status: string) => {
switch (status) {
case 'delivered':
return 'var(--color-success)';
case 'in_transit':
return 'var(--color-info)';
case 'failed':
return 'var(--color-error)';
default:
return 'var(--color-warning)';
}
};
const getPointBadgeStyle = (status: string) => {
switch (status) {
case 'delivered':
return {
backgroundColor: 'var(--color-success-100)',
color: 'var(--color-success-900)',
borderColor: 'var(--color-success-300)'
};
case 'in_transit':
return {
backgroundColor: 'var(--color-info-100)',
color: 'var(--color-info-900)',
borderColor: 'var(--color-info-300)'
};
case 'failed':
return {
backgroundColor: 'var(--color-error-100)',
color: 'var(--color-error-900)',
borderColor: 'var(--color-error-300)'
};
default:
return {
backgroundColor: 'var(--color-warning-100)',
color: 'var(--color-warning-900)',
borderColor: 'var(--color-warning-300)'
};
}
};
return (
<div key={idx} className="relative">
{/* Timeline dot */}
<div
className="absolute -left-[29px] w-4 h-4 rounded-full border-2 shadow-sm"
style={{
backgroundColor: getPointStatusColor(point.status),
borderColor: 'var(--bg-primary)'
}}
/>
{/* Stop info */}
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<p className="font-medium mb-0.5" style={{ color: 'var(--text-primary)' }}>
{point.sequence}. {point.name}
</p>
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
{point.address}
</p>
</div>
<Badge
variant="outline"
className="px-2 py-0.5 text-xs flex-shrink-0"
style={{
...getPointBadgeStyle(point.status),
borderWidth: '1px'
}}
>
{getStatusIcon(point.status)}
<span className="ml-1 capitalize">
{t(`enterprise.stop_status.${point.status}`) || point.status}
</span>
</Badge>
</div>
</div>
<span className="flex-1 truncate">{point.name}</span>
<Badge variant="outline" className={getStatusColor(point.status)}>
{getStatusIcon(point.status)}
<span className="ml-1 text-xs">
{t(`enterprise.stop_status.${point.status}`) || point.status}
</span>
</Badge>
</div>
))}
);
})}
</div>
</CardContent>
</Card>
)}
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500 text-center py-4">
{routes.length === 0 ?
t('enterprise.no_routes_planned') :
t('enterprise.no_active_routes')}
</p>
<div className="text-center py-8" style={{ color: 'var(--text-secondary)' }}>
<p className="text-sm">
{t('enterprise.no_routes_planned')}
</p>
</div>
)}
</div>
@@ -287,15 +540,15 @@ const DistributionMap: React.FC<DistributionMapProps> = ({
<div className="bg-white rounded-lg max-w-md w-full p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">{t('enterprise.route_details')}</h3>
<Button
variant="ghost"
size="sm"
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedRoute(null)}
>
×
</Button>
</div>
<div className="space-y-3">
<div className="flex justify-between">
<span>{t('enterprise.route_number')}</span>
@@ -319,9 +572,9 @@ const DistributionMap: React.FC<DistributionMapProps> = ({
</Badge>
</div>
</div>
<Button
className="w-full mt-4"
<Button
className="w-full mt-4"
onClick={() => setSelectedRoute(null)}
>
{t('common.close')}

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { Loader2 } from 'lucide-react';
interface LoaderProps {
export interface LoaderProps {
size?: 'sm' | 'md' | 'lg' | 'default';
text?: string;
className?: string;

View File

@@ -16,14 +16,15 @@ import {
Phone,
ExternalLink
} from 'lucide-react';
import { EnrichedAlert, AlertTypeClass } from '../../../types/alerts';
import { Alert, AlertTypeClass, getPriorityColor, getTypeClassBadgeVariant, formatTimeUntilConsequence } from '../../../api/types/events';
import { renderEventTitle, renderEventMessage, renderActionLabel, renderAIReasoning } from '../../../utils/i18n/alertRendering';
import { useSmartActionHandler } from '../../../utils/smartActionHandlers';
import { getPriorityColor, getTypeClassBadgeVariant, formatTimeUntilConsequence } from '../../../types/alerts';
import { useAuthUser } from '../../../stores/auth.store';
import { useTranslation } from 'react-i18next';
export interface NotificationPanelProps {
notifications: NotificationData[];
enrichedAlerts?: EnrichedAlert[];
enrichedAlerts?: Alert[];
isOpen: boolean;
onClose: () => void;
onMarkAsRead: (id: string) => void;
@@ -50,12 +51,13 @@ const formatTimestamp = (timestamp: string) => {
// Enriched Alert Item Component
const EnrichedAlertItem: React.FC<{
alert: EnrichedAlert;
alert: Alert;
isMobile: boolean;
onMarkAsRead: (id: string) => void;
onRemove: (id: string) => void;
actionHandler: any;
}> = ({ alert, isMobile, onMarkAsRead, onRemove, actionHandler }) => {
const { t } = useTranslation();
const isUnread = alert.status === 'active';
const priorityColor = getPriorityColor(alert.priority_level);
@@ -109,14 +111,14 @@ const EnrichedAlertItem: React.FC<{
<p className={`font-medium leading-tight text-[var(--text-primary)] ${
isMobile ? 'text-base mb-2' : 'text-sm mb-1'
}`}>
{alert.title}
{renderEventTitle(alert, t)}
</p>
{/* Message */}
<p className={`leading-relaxed text-[var(--text-secondary)] ${
isMobile ? 'text-sm mb-3' : 'text-xs mb-2'
}`}>
{alert.message}
{renderEventMessage(alert, t)}
</p>
{/* Context Badges */}
@@ -133,10 +135,10 @@ const EnrichedAlertItem: React.FC<{
<span>{alert.business_impact.financial_impact_eur.toFixed(0)} en riesgo</span>
</div>
)}
{alert.urgency_context?.time_until_consequence_hours && (
{alert.urgency?.hours_until_consequence && (
<div className="flex items-center gap-1 px-2 py-1 rounded-md bg-error/10 text-error text-xs">
<Clock className="w-3 h-3" />
<span>{formatTimeUntilConsequence(alert.urgency_context.time_until_consequence_hours)}</span>
<span>{formatTimeUntilConsequence(alert.urgency.hours_until_consequence)}</span>
</div>
)}
{alert.trend_context && (
@@ -148,21 +150,21 @@ const EnrichedAlertItem: React.FC<{
</div>
{/* AI Reasoning Summary */}
{alert.ai_reasoning_summary && (
{renderAIReasoning(alert, t) && (
<div className="mb-3 p-2 rounded-md bg-primary/5 border border-primary/20">
<div className="flex items-start gap-2">
<Bot className="w-4 h-4 text-primary mt-0.5 flex-shrink-0" />
<p className="text-xs text-[var(--text-secondary)] italic">
{alert.ai_reasoning_summary}
{renderAIReasoning(alert, t)}
</p>
</div>
</div>
)}
{/* Smart Actions */}
{alert.actions && alert.actions.length > 0 && (
{alert.smart_actions && alert.smart_actions.length > 0 && (
<div className={`flex flex-wrap gap-2 ${isMobile ? 'mb-3' : 'mb-2'}`}>
{alert.actions.slice(0, 3).map((action, idx) => (
{alert.smart_actions.slice(0, 3).map((action, idx) => (
<Button
key={idx}
size={isMobile ? "sm" : "xs"}
@@ -173,9 +175,9 @@ const EnrichedAlertItem: React.FC<{
action.variant === 'danger' ? 'text-error hover:text-error-dark' : ''
}`}
>
{action.type === 'call_supplier' && <Phone className="w-3 h-3 mr-1" />}
{action.type === 'navigate' && <ExternalLink className="w-3 h-3 mr-1" />}
{action.label}
{action.action_type === 'call_supplier' && <Phone className="w-3 h-3 mr-1" />}
{action.action_type === 'navigate' && <ExternalLink className="w-3 h-3 mr-1" />}
{renderActionLabel(action, t)}
{action.estimated_time_minutes && (
<span className="ml-1 opacity-60">({action.estimated_time_minutes}m)</span>
)}
@@ -344,17 +346,23 @@ export const NotificationPanel: React.FC<NotificationPanelProps> = ({
key={notification.id}
alert={{
...notification,
event_class: 'alert',
tenant_id: user?.tenant_id || '',
status: notification.read ? 'acknowledged' : 'active',
created_at: notification.timestamp,
enriched_at: notification.timestamp,
alert_metadata: notification.metadata || {},
event_metadata: notification.metadata || {},
service: 'notification-service',
alert_type: notification.item_type,
actions: notification.actions || [],
is_group_summary: false,
placement: notification.placement || ['notification_panel']
} as EnrichedAlert}
event_type: notification.item_type,
event_domain: 'notification',
smart_actions: notification.actions || [],
entity_links: {},
i18n: {
title_key: notification.title || '',
message_key: notification.message || '',
title_params: {},
message_params: {}
}
} as Alert}
isMobile={isMobile}
onMarkAsRead={onMarkAsRead}
onRemove={onRemoveNotification}

View File

@@ -2,7 +2,7 @@
export { default as Button } from './Button';
export { default as Input } from './Input';
export { default as Textarea } from './Textarea/Textarea';
export { default as Card, CardHeader, CardBody, CardFooter } from './Card';
export { default as Card, CardHeader, CardBody, CardFooter, CardContent, CardTitle, CardDescription } from './Card';
export { default as Modal, ModalHeader, ModalBody, ModalFooter } from './Modal';
export { default as Table } from './Table';
export { Badge, CountBadge, StatusDot, SeverityBadge } from './Badge';
@@ -48,9 +48,9 @@ export { SettingsSearch } from './SettingsSearch';
export type { ButtonProps } from './Button';
export type { InputProps } from './Input';
export type { TextareaProps } from './Textarea';
export type { CardProps, CardHeaderProps, CardBodyProps, CardFooterProps } from './Card';
export type { CardProps, CardHeaderProps, CardBodyProps, CardFooterProps, CardContentProps, CardTitleProps } from './Card';
export type { ModalProps, ModalHeaderProps, ModalBodyProps, ModalFooterProps } from './Modal';
export type { TableProps, TableColumn, TableRow } from './Table';
export type { TableProps, TableColumn } from './Table';
export type { BadgeProps, CountBadgeProps, StatusDotProps, SeverityBadgeProps, SeverityLevel } from './Badge';
export type { AvatarProps } from './Avatar';
export type { TooltipProps } from './Tooltip';