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