Fix and UI imporvements 3

This commit is contained in:
Urtzi Alfaro
2025-12-10 11:23:53 +01:00
parent 46f5158536
commit e116ac244c
20 changed files with 2311 additions and 2948 deletions

View File

@@ -1,548 +0,0 @@
// ================================================================
// frontend/src/components/dashboard/ExecutionProgressTracker.tsx
// ================================================================
/**
* Execution Progress Tracker - Plan vs Actual
*
* Shows how today's execution is progressing vs the plan.
* Helps identify bottlenecks early (e.g., deliveries running late).
*
* Features:
* - Production progress (plan vs actual batches)
* - Delivery status (received, pending, overdue)
* - Approval tracking
* - "What's next" preview
* - Status indicators (on_track, at_risk, completed)
*/
import React from 'react';
import {
Package,
Truck,
CheckCircle,
Clock,
AlertCircle,
TrendingUp,
Calendar,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { formatTime as formatTimeUtil } from '../../utils/date';
// ============================================================
// Types
// ============================================================
export interface ProductionProgress {
status: 'no_plan' | 'completed' | 'on_track' | 'at_risk';
total: number;
completed: number;
inProgress: number;
pending: number;
inProgressBatches?: Array<{
id: string;
batchNumber: string;
productName: string;
quantity: number;
actualStartTime: string;
estimatedCompletion: string;
}>;
nextBatch?: {
productName: string;
plannedStart: string; // ISO datetime
batchNumber: string;
};
}
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 {
status: 'completed' | 'on_track' | 'at_risk';
pending: number;
}
export interface ExecutionProgress {
production: ProductionProgress;
deliveries: DeliveryProgress;
approvals: ApprovalProgress;
}
interface ExecutionProgressTrackerProps {
progress: ExecutionProgress | null | undefined;
loading?: boolean;
}
// ============================================================
// Helper Functions
// ============================================================
function getStatusColor(status: string): {
bg: string;
border: string;
text: string;
icon: string;
} {
switch (status) {
case 'completed':
return {
bg: 'var(--color-success-50)',
border: 'var(--color-success-300)',
text: 'var(--color-success-900)',
icon: 'var(--color-success-600)',
};
case 'on_track':
return {
bg: 'var(--color-info-50)',
border: 'var(--color-info-300)',
text: 'var(--color-info-900)',
icon: 'var(--color-info-600)',
};
case 'at_risk':
return {
bg: 'var(--color-error-50)',
border: 'var(--color-error-300)',
text: 'var(--color-error-900)',
icon: 'var(--color-error-600)',
};
case 'no_plan':
case 'no_deliveries':
return {
bg: 'var(--bg-secondary)',
border: 'var(--border-secondary)',
text: 'var(--text-secondary)',
icon: 'var(--text-tertiary)',
};
default:
return {
bg: 'var(--bg-secondary)',
border: 'var(--border-secondary)',
text: 'var(--text-primary)',
icon: 'var(--text-secondary)',
};
}
}
function formatTime(isoDate: string): string {
return formatTimeUtil(isoDate, 'HH:mm');
}
// ============================================================
// Sub-Components
// ============================================================
interface SectionProps {
title: string;
icon: React.ElementType;
status: string;
statusLabel: string;
children: React.ReactNode;
}
function Section({ title, icon: Icon, status, statusLabel, children }: SectionProps) {
const colors = getStatusColor(status);
return (
<div
className="rounded-lg border-2 p-4"
style={{
backgroundColor: colors.bg,
borderColor: colors.border,
}}
>
{/* Section Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Icon className="w-5 h-5" style={{ color: colors.icon }} />
<h3 className="font-bold" style={{ color: colors.text }}>
{title}
</h3>
</div>
<span
className="px-3 py-1 rounded-full text-xs font-semibold"
style={{
backgroundColor: colors.border,
color: 'white',
}}
>
{statusLabel}
</span>
</div>
{/* Section Content */}
{children}
</div>
);
}
// ============================================================
// Main Component
// ============================================================
export function ExecutionProgressTracker({
progress,
loading,
}: ExecutionProgressTrackerProps) {
const { t } = useTranslation(['dashboard', 'common']);
if (loading) {
return (
<div
className="rounded-xl shadow-lg p-6 border"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
}}
>
<div className="animate-pulse space-y-4">
<div className="h-6 rounded w-1/3" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
<div className="h-32 rounded" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
<div className="h-32 rounded" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
<div className="h-32 rounded" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
</div>
</div>
);
}
if (!progress) {
return null;
}
return (
<div
className="rounded-xl shadow-xl p-6 border-2 transition-all duration-300 hover:shadow-2xl"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
}}
>
{/* Header with Hero Icon */}
<div className="flex items-center gap-4 mb-6">
{/* Hero Icon */}
<div
className="flex-shrink-0 w-16 h-16 md:w-20 md:h-20 rounded-full flex items-center justify-center shadow-md"
style={{ backgroundColor: 'var(--color-primary-100)' }}
>
<TrendingUp className="w-8 h-8 md:w-10 md:h-10" strokeWidth={2.5} style={{ color: 'var(--color-primary-600)' }} />
</div>
{/* Title */}
<div className="flex-1 min-w-0">
<h2 className="text-2xl md:text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
{t('dashboard:execution_progress.title')}
</h2>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{t('dashboard:execution_progress.subtitle')}
</p>
</div>
</div>
<div className="space-y-4">
{/* Production Section */}
<Section
title={t('dashboard:execution_progress.production')}
icon={Package}
status={progress.production.status}
statusLabel={t(`dashboard:execution_progress.status.${progress.production.status}`)}
>
{progress.production.status === 'no_plan' ? (
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{t('dashboard:execution_progress.no_production_plan')}
</p>
) : (
<>
{/* Progress Bar */}
<div className="mb-3">
<div className="flex items-center justify-between text-sm mb-2">
<span style={{ color: 'var(--text-secondary)' }}>
{progress.production.completed} / {progress.production.total} {t('dashboard:execution_progress.batches_complete')}
</span>
<span className="font-semibold" style={{ color: 'var(--text-primary)' }}>
{Math.round((progress.production.completed / progress.production.total) * 100)}%
</span>
</div>
<div
className="h-3 rounded-full overflow-hidden"
style={{ backgroundColor: 'var(--bg-tertiary)' }}
>
<div
className="h-full transition-all duration-500"
style={{
width: `${(progress.production.completed / progress.production.total) * 100}%`,
backgroundColor:
progress.production.status === 'at_risk'
? 'var(--color-error-500)'
: progress.production.status === 'completed'
? 'var(--color-success-500)'
: 'var(--color-info-500)',
}}
/>
</div>
</div>
{/* Status Breakdown */}
<div className="grid grid-cols-3 gap-2 text-sm">
<div>
<span style={{ color: 'var(--text-secondary)' }}>{t('dashboard:execution_progress.completed')}:</span>
<span className="ml-2 font-semibold" style={{ color: 'var(--color-success-700)' }}>
{progress.production.completed}
</span>
</div>
<div>
<span style={{ color: 'var(--text-secondary)' }}>{t('dashboard:execution_progress.in_progress')}:</span>
<span className="ml-2 font-semibold" style={{ color: 'var(--color-info-700)' }}>
{progress.production.inProgress}
</span>
{progress.production.inProgressBatches && progress.production.inProgressBatches.length > 0 && (
<div className="ml-6 mt-1 text-xs" style={{ color: 'var(--text-tertiary)' }}>
{progress.production.inProgressBatches.map((batch) => (
<div key={batch.id} className="flex items-center gap-2">
<span> {batch.productName}</span>
<span className="opacity-60">({batch.batchNumber})</span>
</div>
))}
</div>
)}
</div>
<div>
<span style={{ color: 'var(--text-secondary)' }}>{t('dashboard:execution_progress.pending')}:</span>
<span className="ml-2 font-semibold" style={{ color: 'var(--text-tertiary)' }}>
{progress.production.pending}
</span>
</div>
</div>
{/* Next Batch */}
{progress.production.nextBatch && (
<div
className="mt-3 p-3 rounded-lg border"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-secondary)',
}}
>
<div className="flex items-center gap-2 mb-1">
<Clock className="w-4 h-4" style={{ color: 'var(--color-info)' }} />
<span className="text-xs font-semibold" style={{ color: 'var(--text-secondary)' }}>
{t('dashboard:execution_progress.whats_next')}
</span>
</div>
<p className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
{progress.production.nextBatch.productName}
</p>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{progress.production.nextBatch.batchNumber} · {t('dashboard:execution_progress.starts_at')}{' '}
{formatTime(progress.production.nextBatch.plannedStart)}
</p>
</div>
)}
</>
)}
</Section>
{/* Deliveries Section */}
<Section
title={t('dashboard:execution_progress.deliveries')}
icon={Truck}
status={progress.deliveries.status}
statusLabel={t(`dashboard:execution_progress.status.${progress.deliveries.status}`)}
>
{progress.deliveries.status === 'no_deliveries' ? (
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{t('dashboard:execution_progress.no_deliveries_today')}
</p>
) : (
<>
{/* 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-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-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>
{/* 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>
)}
{/* 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>
)}
</>
)}
</Section>
{/* Approvals Section */}
<Section
title={t('dashboard:execution_progress.approvals')}
icon={Calendar}
status={progress.approvals.status}
statusLabel={t(`dashboard:execution_progress.status.${progress.approvals.status}`)}
>
<div className="flex items-center justify-between">
<span style={{ color: 'var(--text-secondary)' }}>
{t('dashboard:execution_progress.pending_approvals')}
</span>
<span
className="text-3xl font-bold"
style={{
color:
progress.approvals.status === 'at_risk'
? 'var(--color-error-700)'
: progress.approvals.status === 'completed'
? 'var(--color-success-700)'
: 'var(--color-info-700)',
}}
>
{progress.approvals.pending}
</span>
</div>
</Section>
</div>
</div>
);
}

View File

@@ -1,382 +0,0 @@
// ================================================================
// frontend/src/components/dashboard/GlanceableHealthHero.tsx
// ================================================================
/**
* Glanceable Health Hero - Simplified Dashboard Status
*
* JTBD-Aligned Design:
* - Core Job: "Quickly understand if anything requires my immediate attention"
* - Emotional Job: "Feel confident to proceed or know to stop and fix"
* - Design Principle: Progressive disclosure (traffic light → details)
*
* States:
* - 🟢 Green: "Everything looks good - proceed with your day"
* - 🟡 Yellow: "Some items need attention - but not urgent"
* - 🔴 Red: "Critical issues - stop and fix these first"
*/
import React, { useState, useMemo } from 'react';
import { CheckCircle, AlertTriangle, AlertCircle, Clock, RefreshCw, Zap, ChevronDown, ChevronUp, ChevronRight } from 'lucide-react';
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 { useEventNotifications } from '../../hooks/useEventNotifications';
interface GlanceableHealthHeroProps {
healthStatus: BakeryHealthStatus;
loading?: boolean;
urgentActionCount?: number; // New: show count of urgent actions
}
const statusConfig = {
green: {
bgColor: 'var(--color-success-50)',
borderColor: 'var(--color-success-200)',
textColor: 'var(--color-success-900)',
icon: CheckCircle,
iconColor: 'var(--color-success-600)',
iconBg: 'var(--color-success-100)',
},
yellow: {
bgColor: 'var(--color-warning-50)',
borderColor: 'var(--color-warning-300)',
textColor: 'var(--color-warning-900)',
icon: AlertTriangle,
iconColor: 'var(--color-warning-600)',
iconBg: 'var(--color-warning-100)',
},
red: {
bgColor: 'var(--color-error-50)',
borderColor: 'var(--color-error-300)',
textColor: 'var(--color-error-900)',
icon: AlertCircle,
iconColor: 'var(--color-error-600)',
iconBg: 'var(--color-error-100)',
},
};
const iconMap = {
check: CheckCircle,
warning: AlertTriangle,
alert: AlertCircle,
ai_handled: Zap,
};
/**
* Helper function to translate keys with proper namespace handling
*/
function translateKey(
key: string,
params: Record<string, any>,
t: any
): string {
const namespaceMap: Record<string, string> = {
'health.': 'dashboard',
'dashboard.health.': 'dashboard',
'dashboard.': 'dashboard',
'reasoning.': 'reasoning',
'production.': 'production',
'jtbd.': 'reasoning',
};
let namespace = 'common';
let translationKey = key;
for (const [prefix, ns] of Object.entries(namespaceMap)) {
if (key.startsWith(prefix)) {
namespace = ns;
if (prefix === 'reasoning.') {
translationKey = key.substring(prefix.length);
} else if (prefix === 'dashboard.health.') {
translationKey = key.substring('dashboard.'.length);
} else if (prefix === 'dashboard.' && !key.startsWith('dashboard.health.')) {
translationKey = key.substring('dashboard.'.length);
}
break;
}
}
return t(translationKey, { ...params, ns: namespace, defaultValue: key });
}
export function GlanceableHealthHero({ healthStatus, loading, urgentActionCount = 0 }: GlanceableHealthHeroProps) {
const { t, i18n } = useTranslation(['dashboard', 'reasoning', 'production']);
const navigate = useNavigate();
const { notifications } = useEventNotifications();
const [detailsExpanded, setDetailsExpanded] = useState(false);
// Get date-fns locale
const dateLocale = i18n.language === 'es' ? es : i18n.language === 'eu' ? eu : enUS;
// ============================================================================
// ALL HOOKS MUST BE CALLED BEFORE ANY EARLY RETURNS
// This ensures hooks are called in the same order on every render
// ============================================================================
// Optimize notifications filtering - cache the filtered array itself
const criticalAlerts = useMemo(() => {
if (!notifications || notifications.length === 0) return [];
return notifications.filter(
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';
return healthStatus.checklistItems.map(item => item.textKey).join(',');
}, [healthStatus?.checklistItems]);
// Update checklist items with real-time data
const updatedChecklistItems = useMemo(() => {
if (!healthStatus?.checklistItems) return [];
return healthStatus.checklistItems.map(item => {
if (item.textKey === 'dashboard.health.critical_issues' && criticalAlertsCount > 0) {
return {
...item,
textParams: { ...item.textParams, count: criticalAlertsCount },
status: 'needs_you' as const,
actionRequired: true,
};
}
return item;
});
}, [checklistItemsKey, criticalAlertsCount]);
// Status and config (use safe defaults for loading state)
const status = healthStatus?.status || 'green';
const config = statusConfig[status];
const StatusIcon = config?.icon || (() => <div>🟢</div>);
// Determine simplified headline for glanceable view (safe for loading state)
const simpleHeadline = useMemo(() => {
if (status === 'green') {
return t('jtbd.health_status.green_simple', { ns: 'reasoning', defaultValue: '✅ Todo listo para hoy' });
} else if (status === 'yellow') {
if (urgentActionCount > 0) {
return t('jtbd.health_status.yellow_simple_with_count', { count: urgentActionCount, ns: 'reasoning', defaultValue: `⚠️ ${urgentActionCount} acción${urgentActionCount > 1 ? 'es' : ''} necesaria${urgentActionCount > 1 ? 's' : ''}` });
}
return t('jtbd.health_status.yellow_simple', { ns: 'reasoning', defaultValue: '⚠️ Algunas cosas necesitan atención' });
} else {
return t('jtbd.health_status.red_simple', { ns: 'reasoning', defaultValue: '🔴 Problemas críticos requieren acción' });
}
}, [status, urgentActionCount, t]);
const displayCriticalIssues = criticalAlertsCount > 0 ? criticalAlertsCount : (healthStatus?.criticalIssues || 0);
// ============================================================================
// NOW it's safe to early return - all hooks have been called
// ============================================================================
if (loading || !healthStatus) {
return (
<div className="animate-pulse rounded-xl shadow-lg p-6 border-2 border-[var(--border-primary)] bg-[var(--bg-primary)]">
<div className="h-16 rounded w-2/3 mb-4 bg-[var(--bg-tertiary)]"></div>
<div className="h-6 rounded w-1/2 bg-[var(--bg-tertiary)]"></div>
</div>
);
}
return (
<div
className="border-2 rounded-xl shadow-xl transition-all duration-300 hover:shadow-2xl"
style={{
backgroundColor: config.bgColor,
borderColor: config.borderColor,
}}
>
{/* Glanceable Hero View (Always Visible) */}
<div className="p-6">
<div className="flex items-center gap-4">
{/* Status Icon */}
<div
className="flex-shrink-0 w-16 h-16 md:w-20 md:h-20 rounded-full flex items-center justify-center shadow-md"
style={{ backgroundColor: config.iconBg }}
>
<StatusIcon className="w-8 h-8 md:w-10 md:h-10" strokeWidth={2.5} style={{ color: config.iconColor }} />
</div>
{/* Headline + Quick Stats */}
<div className="flex-1 min-w-0">
<h2 className="text-2xl md:text-3xl font-bold mb-2" style={{ color: config.textColor }}>
{simpleHeadline}
</h2>
{/* Quick Stats Row */}
<div className="flex flex-wrap items-center gap-3 text-sm">
{/* Last Update */}
<div className="flex items-center gap-1.5" style={{ color: 'var(--text-secondary)' }}>
<Clock className="w-4 h-4" />
<span>
{healthStatus.lastOrchestrationRun
? formatDistanceToNow(new Date(healthStatus.lastOrchestrationRun), {
addSuffix: true,
locale: dateLocale,
})
: t('jtbd.health_status.never', { ns: 'reasoning' })}
</span>
</div>
{/* Critical Issues Badge */}
{displayCriticalIssues > 0 && (
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md" style={{ backgroundColor: 'var(--color-error-100)', color: 'var(--color-error-800)' }}>
<AlertCircle className={`w-4 h-4 ${criticalAlertsCount > 0 ? 'animate-pulse' : ''}`} />
<span className="font-semibold">{displayCriticalIssues} crítico{displayCriticalIssues > 1 ? 's' : ''}</span>
</div>
)}
{/* Pending Actions Badge */}
{healthStatus.pendingActions > 0 && (
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md" style={{ backgroundColor: 'var(--color-warning-100)', color: 'var(--color-warning-800)' }}>
<AlertTriangle className="w-4 h-4" />
<span className="font-semibold">{healthStatus.pendingActions} pendiente{healthStatus.pendingActions > 1 ? 's' : ''}</span>
</div>
)}
{/* 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">{preventedIssuesCount} evitado{preventedIssuesCount > 1 ? 's' : ''}</span>
</div>
)}
</div>
</div>
{/* Expand/Collapse Button */}
<button
onClick={() => setDetailsExpanded(!detailsExpanded)}
className="flex-shrink-0 p-2 rounded-lg transition-colors hover:bg-[var(--bg-tertiary)]"
title={detailsExpanded ? 'Ocultar detalles' : 'Ver detalles'}
>
{detailsExpanded ? (
<ChevronUp className="w-6 h-6" style={{ color: config.textColor }} />
) : (
<ChevronDown className="w-6 h-6" style={{ color: config.textColor }} />
)}
</button>
</div>
</div>
{/* Detailed Checklist (Collapsible) */}
{detailsExpanded && (
<div
className="px-6 pb-6 pt-2 border-t border-[var(--border-primary)]"
style={{ borderColor: config.borderColor }}
>
{/* Full Headline */}
<p className="text-base mb-4 text-[var(--text-secondary)]">
{typeof healthStatus.headline === 'object' && healthStatus.headline?.key
? translateKey(healthStatus.headline.key, healthStatus.headline.params || {}, t)
: healthStatus.headline}
</p>
{/* Checklist */}
{updatedChecklistItems && updatedChecklistItems.length > 0 && (
<div className="space-y-2">
{updatedChecklistItems.map((item, index) => {
// Safely get the icon with proper validation
const SafeIconComponent = iconMap[item.icon];
const ItemIcon = SafeIconComponent || AlertCircle;
const getStatusStyles = () => {
switch (item.status) {
case 'good':
return {
iconColor: 'var(--color-success-600)',
bgColor: 'var(--color-success-50)',
borderColor: 'transparent',
};
case 'ai_handled':
return {
iconColor: 'var(--color-info-600)',
bgColor: 'var(--color-info-50)',
borderColor: 'var(--color-info-300)',
};
case 'needs_you':
return {
iconColor: 'var(--color-warning-600)',
bgColor: 'var(--color-warning-50)',
borderColor: 'var(--color-warning-300)',
};
default:
return {
iconColor: item.actionRequired ? 'var(--color-warning-600)' : 'var(--color-success-600)',
bgColor: item.actionRequired ? 'var(--bg-primary)' : 'var(--bg-secondary)',
borderColor: 'transparent',
};
}
};
const styles = getStatusStyles();
const displayText = item.textKey
? translateKey(item.textKey, item.textParams || {}, t)
: item.text || '';
const handleClick = () => {
if (item.actionPath && (item.status === 'needs_you' || item.actionRequired)) {
navigate(item.actionPath);
}
};
const isClickable = Boolean(item.actionPath && (item.status === 'needs_you' || item.actionRequired));
return (
<div
key={index}
className={`flex items-center gap-3 p-3 rounded-lg border transition-all ${
isClickable ? 'cursor-pointer hover:shadow-md hover:scale-[1.01] bg-[var(--bg-tertiary)]' : ''
}`}
style={{
backgroundColor: styles.bgColor,
borderColor: styles.borderColor,
}}
onClick={handleClick}
>
<ItemIcon className="w-5 h-5 flex-shrink-0" style={{ color: styles.iconColor }} />
<span
className={`flex-1 text-sm ${
item.status === 'needs_you' || item.actionRequired ? 'font-semibold' : ''
} text-[var(--text-primary)]`}
>
{displayText}
</span>
{isClickable && (
<ChevronRight className="w-4 h-4 flex-shrink-0 text-[var(--text-tertiary)]" />
)}
</div>
);
})}
</div>
)}
{/* Next Check */}
{healthStatus.nextScheduledRun && (
<div className="flex items-center gap-2 text-sm mt-4 p-3 rounded-lg bg-[var(--bg-secondary)] text-[var(--text-secondary)]">
<RefreshCw className="w-4 h-4" />
<span>
{t('jtbd.health_status.next_check', { ns: 'reasoning' })}:{' '}
{formatDistanceToNow(new Date(healthStatus.nextScheduledRun), { addSuffix: true, locale: dateLocale })}
</span>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -1,529 +0,0 @@
// ================================================================
// frontend/src/components/dashboard/IntelligentSystemSummaryCard.tsx
// ================================================================
/**
* Intelligent System Summary Card - Unified AI Impact Component
*
* Simplified design matching GlanceableHealthHero pattern:
* - Clean, scannable header with inline metrics badges
* - Minimal orchestration summary (details shown elsewhere)
* - Progressive disclosure for prevented issues details
*/
import React, { useState, useEffect, useMemo } from 'react';
import {
Bot,
TrendingUp,
TrendingDown,
Clock,
CheckCircle,
ChevronDown,
ChevronUp,
Zap,
ShieldCheck,
Euro,
Package,
} from 'lucide-react';
import { OrchestrationSummary } from '../../api/hooks/useProfessionalDashboard';
import { useTranslation } from 'react-i18next';
import { formatTime, formatRelativeTime } from '../../utils/date';
import { useTenant } from '../../stores/tenant.store';
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 {
current_period: {
days: number;
total_alerts: number;
prevented_issues: number;
handling_rate_percentage: number;
};
previous_period: {
days: number;
total_alerts: number;
prevented_issues: number;
handling_rate_percentage: number;
};
changes: {
handling_rate_change_percentage: number;
alert_count_change_percentage: number;
trend_direction: 'up' | 'down' | 'stable';
};
}
interface DashboardAnalytics {
period_days: number;
total_alerts: number;
active_alerts: number;
ai_handling_rate: number;
prevented_issues_count: number;
estimated_savings_eur: number;
total_financial_impact_at_risk_eur: number;
period_comparison?: PeriodComparison;
}
interface IntelligentSystemSummaryCardProps {
orchestrationSummary: OrchestrationSummary;
orchestrationLoading?: boolean;
onWorkflowComplete?: () => void;
className?: string;
}
export function IntelligentSystemSummaryCard({
orchestrationSummary,
orchestrationLoading,
onWorkflowComplete,
className = '',
}: IntelligentSystemSummaryCardProps) {
const { t } = useTranslation(['dashboard', 'reasoning']);
const { currentTenant } = useTenant();
const { notifications } = useEventNotifications();
const [analytics, setAnalytics] = useState<DashboardAnalytics | null>(null);
const [preventedAlerts, setPreventedAlerts] = useState<Alert[]>([]);
const [analyticsLoading, setAnalyticsLoading] = useState(true);
const [preventedIssuesExpanded, setPreventedIssuesExpanded] = useState(false);
const [orchestrationExpanded, setOrchestrationExpanded] = useState(false);
// Fetch analytics data
useEffect(() => {
const fetchAnalytics = async () => {
if (!currentTenant?.id) {
setAnalyticsLoading(false);
return;
}
try {
setAnalyticsLoading(true);
const { apiClient } = await import('../../api/client/apiClient');
const [analyticsData, alertsData] = await Promise.all([
apiClient.get<DashboardAnalytics>(
`/tenants/${currentTenant.id}/alerts/analytics/dashboard`,
{ params: { days: 30 } }
),
apiClient.get<{ alerts: Alert[] }>(
`/tenants/${currentTenant.id}/alerts`,
{ params: { limit: 100 } }
),
]);
setAnalytics(analyticsData);
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const filteredAlerts = (alertsData.alerts || [])
.filter(
(alert) =>
alert.type_class === 'prevented_issue' &&
new Date(alert.created_at) >= sevenDaysAgo
)
.slice(0, 20);
setPreventedAlerts(filteredAlerts);
} catch (err) {
console.error('Error fetching intelligent system data:', err);
} finally {
setAnalyticsLoading(false);
}
};
fetchAnalytics();
}, [currentTenant?.id]);
// 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;
const trendPercentage = analytics?.period_comparison?.changes?.handling_rate_change_percentage || 0;
const hasPositiveTrend = trendPercentage > 0;
// Loading state
if (analyticsLoading || orchestrationLoading) {
return (
<div
className={`rounded-xl shadow-xl p-6 border-2 ${className}`}
style={{ backgroundColor: 'var(--bg-primary)', borderColor: 'var(--border-primary)' }}
>
<div className="animate-pulse space-y-4">
<div className="flex items-center gap-4">
<div className="w-16 h-16 rounded-full" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
<div className="flex-1">
<div className="h-6 rounded w-1/2 mb-2" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
<div className="h-4 rounded w-3/4" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
</div>
</div>
</div>
</div>
);
}
return (
<div
className={`rounded-xl shadow-xl border-2 transition-all duration-300 hover:shadow-2xl ${className}`}
style={{
backgroundColor: 'var(--color-success-50)',
borderColor: 'var(--color-success-200)',
}}
>
{/* Always Visible Header - GlanceableHealthHero Style */}
<div className="p-6">
<div className="flex items-center gap-4">
{/* Icon */}
<div
className="w-16 h-16 md:w-20 md:h-20 rounded-full flex items-center justify-center flex-shrink-0 shadow-md"
style={{ backgroundColor: 'var(--color-success-100)' }}
>
<Bot className="w-8 h-8 md:w-10 md:h-10" style={{ color: 'var(--color-success-600)' }} />
</div>
{/* Title + Metrics Badges */}
<div className="flex-1 min-w-0">
<h2 className="text-2xl md:text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
{t('dashboard:intelligent_system.title', 'Intelligent System Summary')}
</h2>
{/* Inline Metrics Badges */}
<div className="flex flex-wrap items-center gap-3">
{/* AI Handling Rate Badge */}
<div
className="flex items-center gap-2 px-2 py-1 rounded-md"
style={{ backgroundColor: 'var(--color-success-100)', border: '1px solid var(--color-success-300)' }}
>
<span className="text-sm font-semibold" style={{ color: 'var(--color-success-700)' }}>
{analytics?.ai_handling_rate.toFixed(1)}%
</span>
{hasPositiveTrend ? (
<TrendingUp className="w-3 h-3" style={{ color: 'var(--color-success-600)' }} />
) : (
<TrendingDown className="w-3 h-3" style={{ color: 'var(--color-error)' }} />
)}
{trendPercentage !== 0 && (
<span className="text-xs" style={{ color: 'var(--color-success-600)' }}>
{trendPercentage > 0 ? '+' : ''}{trendPercentage}%
</span>
)}
</div>
{/* 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)' }}>
{allPreventedAlerts.length}
</span>
<span className="text-xs" style={{ color: 'var(--color-primary-600)' }}>
{t('dashboard:intelligent_system.prevented_issues', 'issues')}
</span>
</div>
{/* Savings Badge */}
<div
className="flex items-center gap-1 px-2 py-1 rounded-md"
style={{ backgroundColor: 'var(--color-success-100)', border: '1px solid var(--color-success-300)' }}
>
<Euro className="w-4 h-4" style={{ color: 'var(--color-success-600)' }} />
<span className="text-sm font-semibold" style={{ color: 'var(--color-success-700)' }}>
{totalSavings.toFixed(0)}
</span>
<span className="text-xs" style={{ color: 'var(--color-success-600)' }}>
saved
</span>
</div>
</div>
</div>
{/* Expand Button */}
<button
onClick={() => setPreventedIssuesExpanded(!preventedIssuesExpanded)}
className="flex-shrink-0 p-2 rounded-lg hover:bg-black/5 transition-colors"
>
{preventedIssuesExpanded ? (
<ChevronUp className="w-6 h-6" style={{ color: 'var(--text-secondary)' }} />
) : (
<ChevronDown className="w-6 h-6" style={{ color: 'var(--text-secondary)' }} />
)}
</button>
</div>
</div>
{/* Collapsible Section: Prevented Issues Details */}
{preventedIssuesExpanded && (
<div className="px-6 pb-6 pt-2 border-t" style={{ borderColor: 'var(--color-success-200)' }}>
{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!')}
</p>
</div>
) : (
<>
{/* Celebration Message */}
<div
className="rounded-lg p-3 mb-4"
style={{ backgroundColor: 'var(--color-success-100)', border: '1px solid var(--color-success-300)' }}
>
<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: allPreventedAlerts.length,
})}
</p>
</div>
{/* Prevented Issues List */}
<div className="space-y-2">
{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';
return (
<div
key={alert.id}
className="rounded-lg p-3 border"
style={{ backgroundColor: 'var(--bg-primary)', borderColor: 'var(--border-primary)' }}
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-start gap-2 flex-1">
<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)' }}>
{renderEventTitle(alert, t)}
</h4>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{renderEventMessage(alert, t)}
</p>
</div>
</div>
{savings > 0 && (
<Badge variant="success" className="ml-2 flex-shrink-0">
<Euro className="w-3 h-3 mr-1" />
{savings.toFixed(0)}
</Badge>
)}
</div>
<div className="flex items-center justify-between text-xs" style={{ color: 'var(--text-tertiary)' }}>
<div className="flex items-center gap-1">
<Zap className="w-3 h-3" />
<span>{actionTaken}</span>
</div>
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
<span>{timeAgo}</span>
</div>
</div>
</div>
);
})}
</div>
</>
)}
</div>
)}
{/* Collapsible Section: Latest Orchestration Run (Ultra Minimal) */}
<div className="border-t" style={{ borderColor: 'var(--color-success-200)' }}>
<button
onClick={() => setOrchestrationExpanded(!orchestrationExpanded)}
className="w-full flex items-center justify-between px-6 py-3 hover:bg-black/5 transition-colors"
>
<div className="flex items-center gap-2">
<Bot className="w-5 h-5" style={{ color: 'var(--color-primary)' }} />
<h3 className="text-base font-bold" style={{ color: 'var(--text-primary)' }}>
{t('dashboard:intelligent_system.orchestration_title', 'Latest Orchestration Run')}
</h3>
</div>
{orchestrationExpanded ? (
<ChevronUp className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
) : (
<ChevronDown className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
)}
</button>
{orchestrationExpanded && (
<div className="px-6 pb-4">
{orchestrationSummary && orchestrationSummary.status !== 'no_runs' ? (
<div className="space-y-2">
{/* Run Info Line */}
<div className="flex items-center gap-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
<Clock className="w-4 h-4" />
<span>
{t('reasoning:jtbd.orchestration_summary.run_info', 'Run #{runNumber}', {
runNumber: orchestrationSummary.runNumber || 0,
})}{' '}
{' '}
{orchestrationSummary.runTimestamp
? formatRelativeTime(orchestrationSummary.runTimestamp) || 'recently'
: 'recently'}
{orchestrationSummary.durationSeconds && `${orchestrationSummary.durationSeconds}s`}
</span>
</div>
{/* Summary Line */}
<div className="text-sm" style={{ color: 'var(--text-primary)' }}>
{orchestrationSummary.purchaseOrdersCreated > 0 && (
<span>
<span className="font-semibold">{orchestrationSummary.purchaseOrdersCreated}</span>{' '}
{orchestrationSummary.purchaseOrdersCreated === 1 ? 'purchase order' : 'purchase orders'}
{orchestrationSummary.purchaseOrdersSummary && orchestrationSummary.purchaseOrdersSummary.length > 0 && (
<span>
{' '}(
{orchestrationSummary.purchaseOrdersSummary
.reduce((sum, po) => sum + (po.totalAmount || 0), 0)
.toFixed(0)}
)
</span>
)}
</span>
)}
{orchestrationSummary.purchaseOrdersCreated > 0 && orchestrationSummary.productionBatchesCreated > 0 && ' • '}
{orchestrationSummary.productionBatchesCreated > 0 && (
<span>
<span className="font-semibold">{orchestrationSummary.productionBatchesCreated}</span>{' '}
{orchestrationSummary.productionBatchesCreated === 1 ? 'production batch' : 'production batches'}
{orchestrationSummary.productionBatchesSummary && orchestrationSummary.productionBatchesSummary.length > 0 && (
<span>
{' '}(
{orchestrationSummary.productionBatchesSummary[0].readyByTime
? formatTime(orchestrationSummary.productionBatchesSummary[0].readyByTime, 'HH:mm')
: 'TBD'}
{orchestrationSummary.productionBatchesSummary.length > 1 &&
orchestrationSummary.productionBatchesSummary[orchestrationSummary.productionBatchesSummary.length - 1]
.readyByTime &&
` - ${formatTime(
orchestrationSummary.productionBatchesSummary[orchestrationSummary.productionBatchesSummary.length - 1]
.readyByTime,
'HH:mm'
)}`}
)
</span>
)}
</span>
)}
{orchestrationSummary.purchaseOrdersCreated === 0 && orchestrationSummary.productionBatchesCreated === 0 && (
<span style={{ color: 'var(--text-secondary)' }}>
{t('reasoning:jtbd.orchestration_summary.no_actions', 'No actions created')}
</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)' }}>
{t('dashboard:orchestration.no_runs_message', 'No orchestration has been run yet.')}
</div>
)}
</div>
)}
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,286 @@
/**
* PendingDeliveriesBlock - Block 3: "Entregas Pendientes"
*
* Displays today's delivery status:
* - Overdue deliveries with alert styling
* - Pending deliveries expected today
* - Actions: Call Supplier, Mark Received
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
AlertTriangle,
CheckCircle2,
Clock,
Package,
Phone,
Truck,
} from 'lucide-react';
interface PendingDeliveriesBlockProps {
overdueDeliveries?: any[];
pendingDeliveries?: any[];
onCallSupplier?: (delivery: any) => void;
onMarkReceived?: (poId: string) => void;
loading?: boolean;
}
export function PendingDeliveriesBlock({
overdueDeliveries = [],
pendingDeliveries = [],
onCallSupplier,
onMarkReceived,
loading,
}: PendingDeliveriesBlockProps) {
const { t } = useTranslation(['dashboard', 'common']);
if (loading) {
return (
<div className="rounded-xl shadow-lg p-6 border border-[var(--border-primary)] bg-[var(--bg-primary)] animate-pulse">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 bg-[var(--bg-secondary)] rounded-full"></div>
<div className="flex-1 space-y-2">
<div className="h-5 bg-[var(--bg-secondary)] rounded w-1/3"></div>
<div className="h-4 bg-[var(--bg-secondary)] rounded w-1/4"></div>
</div>
</div>
<div className="space-y-3">
<div className="h-16 bg-[var(--bg-secondary)] rounded"></div>
<div className="h-16 bg-[var(--bg-secondary)] rounded"></div>
</div>
</div>
);
}
const hasOverdue = overdueDeliveries.length > 0;
const hasPending = pendingDeliveries.length > 0;
const hasAnyDeliveries = hasOverdue || hasPending;
const totalCount = overdueDeliveries.length + pendingDeliveries.length;
// Determine header status
const status = hasOverdue ? 'error' : hasPending ? 'warning' : 'success';
const statusStyles = {
success: {
iconBg: 'bg-[var(--color-success-100)]',
iconColor: 'text-[var(--color-success-600)]',
},
warning: {
iconBg: 'bg-[var(--color-warning-100)]',
iconColor: 'text-[var(--color-warning-600)]',
},
error: {
iconBg: 'bg-[var(--color-error-100)]',
iconColor: 'text-[var(--color-error-600)]',
},
};
const styles = statusStyles[status];
// Format hours display
const formatHours = (hours: number) => {
if (hours < 1) return t('common:time.less_than_hour', '< 1h');
if (hours === 1) return t('common:time.one_hour', '1h');
return `${hours}h`;
};
return (
<div className="rounded-xl shadow-lg border border-[var(--border-primary)] bg-[var(--bg-primary)] overflow-hidden">
{/* Header */}
<div className="p-6 pb-4">
<div className="flex items-center gap-4">
{/* Icon */}
<div className={`w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 ${styles.iconBg}`}>
{hasOverdue ? (
<AlertTriangle className={`w-6 h-6 ${styles.iconColor}`} />
) : hasAnyDeliveries ? (
<Truck className={`w-6 h-6 ${styles.iconColor}`} />
) : (
<CheckCircle2 className={`w-6 h-6 ${styles.iconColor}`} />
)}
</div>
{/* Title & Count */}
<div className="flex-1">
<h2 className="text-xl font-bold text-[var(--text-primary)]">
{t('dashboard:new_dashboard.pending_deliveries.title')}
</h2>
<p className="text-sm text-[var(--text-secondary)]">
{hasAnyDeliveries
? t('dashboard:new_dashboard.pending_deliveries.count', { count: totalCount })
: t('dashboard:new_dashboard.pending_deliveries.no_deliveries')}
</p>
</div>
{/* Count Badges */}
<div className="flex items-center gap-2">
{hasOverdue && (
<div className="px-3 py-1 rounded-full bg-[var(--color-error-100)] text-[var(--color-error-700)] font-semibold text-sm">
{overdueDeliveries.length} {t('dashboard:new_dashboard.pending_deliveries.overdue_badge')}
</div>
)}
{hasPending && (
<div className="px-3 py-1 rounded-full bg-[var(--color-warning-100)] text-[var(--color-warning-700)] font-semibold text-sm">
{pendingDeliveries.length}
</div>
)}
</div>
</div>
</div>
{/* Content */}
{hasAnyDeliveries ? (
<div className="border-t border-[var(--border-primary)]">
{/* Overdue Section */}
{hasOverdue && (
<div className="bg-[var(--color-error-50)]">
<div className="px-6 py-3 border-b border-[var(--color-error-100)]">
<h3 className="text-sm font-semibold text-[var(--color-error-700)] flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
{t('dashboard:new_dashboard.pending_deliveries.overdue_section')}
</h3>
</div>
{overdueDeliveries.map((delivery, index) => (
<div
key={delivery.po_id || index}
className={`p-4 ${
index < overdueDeliveries.length - 1 ? 'border-b border-[var(--color-error-100)]' : ''
}`}
>
<div className="flex items-start gap-4">
{/* Delivery Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Package className="w-4 h-4 text-[var(--color-error-600)]" />
<span className="font-semibold text-[var(--text-primary)]">
{delivery.supplier_name || 'Unknown Supplier'}
</span>
</div>
<p className="text-sm text-[var(--text-secondary)] mb-1">
{t('dashboard:new_dashboard.pending_deliveries.po_ref', {
number: delivery.po_number || delivery.po_id?.slice(0, 8),
})}
</p>
{/* Overdue Badge */}
<div className="inline-flex items-center gap-1 px-2 py-1 rounded bg-[var(--color-error-100)] text-[var(--color-error-700)] text-xs font-medium">
<Clock className="w-3 h-3" />
{t('dashboard:new_dashboard.pending_deliveries.overdue_by', {
hours: formatHours(delivery.hoursOverdue || 0),
})}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 flex-shrink-0">
{delivery.supplier_phone && onCallSupplier && (
<button
onClick={() => onCallSupplier(delivery)}
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-[var(--border-primary)] text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] transition-colors"
>
<Phone className="w-4 h-4" />
<span className="text-sm font-medium hidden sm:inline">
{t('dashboard:new_dashboard.pending_deliveries.call_supplier')}
</span>
</button>
)}
{onMarkReceived && (
<button
onClick={() => onMarkReceived(delivery.po_id)}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[var(--color-success-600)] text-white hover:bg-[var(--color-success-700)] transition-colors"
>
<CheckCircle2 className="w-4 h-4" />
<span className="text-sm font-medium">
{t('dashboard:new_dashboard.pending_deliveries.mark_received')}
</span>
</button>
)}
</div>
</div>
</div>
))}
</div>
)}
{/* Pending Today Section */}
{hasPending && (
<div>
<div className="px-6 py-3 border-b border-[var(--border-primary)] bg-[var(--bg-secondary)]">
<h3 className="text-sm font-semibold text-[var(--text-secondary)] flex items-center gap-2">
<Truck className="w-4 h-4" />
{t('dashboard:new_dashboard.pending_deliveries.today_section')}
</h3>
</div>
{pendingDeliveries.map((delivery, index) => (
<div
key={delivery.po_id || index}
className={`p-4 ${
index < pendingDeliveries.length - 1 ? 'border-b border-[var(--border-primary)]' : ''
}`}
>
<div className="flex items-start gap-4">
{/* Delivery Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Package className="w-4 h-4 text-[var(--text-tertiary)]" />
<span className="font-semibold text-[var(--text-primary)]">
{delivery.supplier_name || 'Unknown Supplier'}
</span>
</div>
<p className="text-sm text-[var(--text-secondary)] mb-1">
{t('dashboard:new_dashboard.pending_deliveries.po_ref', {
number: delivery.po_number || delivery.po_id?.slice(0, 8),
})}
</p>
{/* Arriving Badge */}
<div className="inline-flex items-center gap-1 px-2 py-1 rounded bg-[var(--color-info-50)] text-[var(--color-info-700)] text-xs font-medium">
<Clock className="w-3 h-3" />
{t('dashboard:new_dashboard.pending_deliveries.arriving_in', {
hours: formatHours(delivery.hoursUntil || 0),
})}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 flex-shrink-0">
{onMarkReceived && (
<button
onClick={() => onMarkReceived(delivery.po_id)}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[var(--color-success-600)] text-white hover:bg-[var(--color-success-700)] transition-colors"
>
<CheckCircle2 className="w-4 h-4" />
<span className="text-sm font-medium">
{t('dashboard:new_dashboard.pending_deliveries.mark_received')}
</span>
</button>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
) : (
/* Empty State */
<div className="px-6 pb-6">
<div className="flex items-center gap-3 p-4 rounded-lg bg-[var(--color-success-50)] border border-[var(--color-success-100)]">
<CheckCircle2 className="w-6 h-6 text-[var(--color-success-600)]" />
<p className="text-sm text-[var(--color-success-700)]">
{t('dashboard:new_dashboard.pending_deliveries.all_clear')}
</p>
</div>
</div>
)}
</div>
);
}
export default PendingDeliveriesBlock;

View File

@@ -0,0 +1,321 @@
/**
* PendingPurchasesBlock - Block 2: "Compras Pendientes"
*
* Displays pending purchase orders awaiting approval:
* - PO number, supplier, amount
* - AI reasoning for why the PO was created
* - Inline actions: Approve, Reject, View Details
*/
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Brain,
Check,
CheckCircle2,
ChevronDown,
ChevronUp,
Eye,
ShoppingCart,
X,
} from 'lucide-react';
interface PendingPurchasesBlockProps {
pendingPOs: any[];
onApprove?: (poId: string) => Promise<void>;
onReject?: (poId: string, reason: string) => Promise<void>;
onViewDetails?: (poId: string) => void;
loading?: boolean;
}
export function PendingPurchasesBlock({
pendingPOs = [],
onApprove,
onReject,
onViewDetails,
loading,
}: PendingPurchasesBlockProps) {
const { t } = useTranslation(['dashboard', 'common']);
const [expandedReasoningId, setExpandedReasoningId] = useState<string | null>(null);
const [processingId, setProcessingId] = useState<string | null>(null);
if (loading) {
return (
<div className="rounded-xl shadow-lg p-6 border border-[var(--border-primary)] bg-[var(--bg-primary)] animate-pulse">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 bg-[var(--bg-secondary)] rounded-full"></div>
<div className="flex-1 space-y-2">
<div className="h-5 bg-[var(--bg-secondary)] rounded w-1/3"></div>
<div className="h-4 bg-[var(--bg-secondary)] rounded w-1/4"></div>
</div>
</div>
<div className="space-y-3">
<div className="h-20 bg-[var(--bg-secondary)] rounded"></div>
<div className="h-20 bg-[var(--bg-secondary)] rounded"></div>
</div>
</div>
);
}
const hasPendingPOs = pendingPOs.length > 0;
// Handle approve action
const handleApprove = async (poId: string) => {
if (!onApprove || processingId) return;
setProcessingId(poId);
try {
await onApprove(poId);
} finally {
setProcessingId(null);
}
};
// Handle reject action
const handleReject = async (poId: string) => {
if (!onReject || processingId) return;
setProcessingId(poId);
try {
await onReject(poId, 'Rejected from dashboard');
} finally {
setProcessingId(null);
}
};
// Toggle reasoning expansion
const toggleReasoning = (poId: string) => {
setExpandedReasoningId(expandedReasoningId === poId ? null : poId);
};
// Format AI reasoning from reasoning_data
const formatReasoning = (po: any): string | null => {
const reasoningData = po.reasoning_data || po.ai_reasoning;
// If no structured reasoning data, try the summary field
if (!reasoningData) {
return po.ai_reasoning_summary || null;
}
if (typeof reasoningData === 'string') return reasoningData;
// Handle structured reasoning data
if (reasoningData.type === 'low_stock_forecast' || reasoningData.type === 'low_stock_detection') {
const params = reasoningData.parameters || {};
const productNames = params.product_names || params.critical_products || [];
const productDetails = params.product_details || [];
const criticalCount = params.critical_product_count || productNames.length;
const minDepletionDays = Math.ceil(params.min_depletion_days || 0);
const affectedBatchesCount = params.affected_batches_count || 0;
const potentialLoss = params.potential_loss_eur || 0;
// If we have detailed data (multiple products), show comprehensive message
if (criticalCount > 1 && productNames.length > 0) {
const productsStr = productNames.slice(0, 3).join(', ') + (productNames.length > 3 ? '...' : '');
return t('dashboard:new_dashboard.pending_purchases.reasoning.low_stock_detailed', {
count: criticalCount,
products: productsStr,
days: minDepletionDays,
batches: affectedBatchesCount,
loss: potentialLoss.toFixed(2),
});
}
// Simple version for single product
const firstProduct = productDetails[0] || {};
const ingredient = firstProduct.product_name || productNames[0] || 'ingredient';
const days = Math.ceil(firstProduct.days_until_depletion || minDepletionDays);
return t('dashboard:new_dashboard.pending_purchases.reasoning.low_stock', {
ingredient,
days,
});
}
if (reasoningData.type === 'demand_forecast') {
return t('dashboard:new_dashboard.pending_purchases.reasoning.demand_forecast', {
product: reasoningData.parameters?.product_name || 'product',
increase: reasoningData.parameters?.demand_increase_percent || 0,
});
}
if (reasoningData.summary) return reasoningData.summary;
// Fallback to ai_reasoning_summary if structured data doesn't have a matching type
return po.ai_reasoning_summary || null;
};
return (
<div className="rounded-xl shadow-lg border border-[var(--border-primary)] bg-[var(--bg-primary)] overflow-hidden">
{/* Header */}
<div className="p-6 pb-4">
<div className="flex items-center gap-4">
{/* Icon */}
<div
className={`w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 ${
hasPendingPOs
? 'bg-[var(--color-warning-100)]'
: 'bg-[var(--color-success-100)]'
}`}
>
{hasPendingPOs ? (
<ShoppingCart className="w-6 h-6 text-[var(--color-warning-600)]" />
) : (
<CheckCircle2 className="w-6 h-6 text-[var(--color-success-600)]" />
)}
</div>
{/* Title & Count */}
<div className="flex-1">
<h2 className="text-xl font-bold text-[var(--text-primary)]">
{t('dashboard:new_dashboard.pending_purchases.title')}
</h2>
<p className="text-sm text-[var(--text-secondary)]">
{hasPendingPOs
? t('dashboard:new_dashboard.pending_purchases.count', {
count: pendingPOs.length,
})
: t('dashboard:new_dashboard.pending_purchases.no_pending')}
</p>
</div>
{/* Count Badge */}
{hasPendingPOs && (
<div className="px-3 py-1 rounded-full bg-[var(--color-warning-100)] text-[var(--color-warning-700)] font-semibold text-sm">
{pendingPOs.length}
</div>
)}
</div>
</div>
{/* PO List */}
{hasPendingPOs ? (
<div className="border-t border-[var(--border-primary)]">
{pendingPOs.map((po, index) => {
const poId = po.id || po.po_id;
const isProcessing = processingId === poId;
const isExpanded = expandedReasoningId === poId;
const reasoning = formatReasoning(po);
return (
<div
key={poId || index}
className={`p-4 ${
index < pendingPOs.length - 1 ? 'border-b border-[var(--border-primary)]' : ''
}`}
>
{/* PO Main Info */}
<div className="flex items-start gap-4">
{/* PO Details */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-[var(--text-primary)]">
{t('dashboard:new_dashboard.pending_purchases.po_number', {
number: po.po_number || po.id?.slice(0, 8),
})}
</span>
{reasoning && (
<button
onClick={() => toggleReasoning(poId)}
className="flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-[var(--color-primary-50)] text-[var(--color-primary-700)] hover:bg-[var(--color-primary-100)] transition-colors"
>
<Brain className="w-3 h-3" />
AI
{isExpanded ? (
<ChevronUp className="w-3 h-3" />
) : (
<ChevronDown className="w-3 h-3" />
)}
</button>
)}
</div>
<p className="text-sm text-[var(--text-secondary)] mb-1">
{t('dashboard:new_dashboard.pending_purchases.supplier', {
name: po.supplier_name || po.supplier?.name || 'Unknown',
})}
</p>
<p className="text-lg font-bold text-[var(--text-primary)]">
{(po.total_amount || po.total || 0).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</p>
</div>
{/* Actions */}
<div className="flex items-center gap-2 flex-shrink-0">
{/* View Details */}
{onViewDetails && (
<button
onClick={() => onViewDetails(poId)}
disabled={isProcessing}
className="p-2 rounded-lg border border-[var(--border-primary)] text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)] transition-colors disabled:opacity-50"
title={t('dashboard:new_dashboard.pending_purchases.view_details')}
>
<Eye className="w-5 h-5" />
</button>
)}
{/* Reject */}
{onReject && (
<button
onClick={() => handleReject(poId)}
disabled={isProcessing}
className="p-2 rounded-lg border border-[var(--color-error-200)] text-[var(--color-error-600)] hover:bg-[var(--color-error-50)] transition-colors disabled:opacity-50"
title={t('dashboard:new_dashboard.pending_purchases.reject')}
>
<X className="w-5 h-5" />
</button>
)}
{/* Approve */}
{onApprove && (
<button
onClick={() => handleApprove(poId)}
disabled={isProcessing}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--color-success-600)] text-white hover:bg-[var(--color-success-700)] transition-colors disabled:opacity-50"
>
<Check className="w-5 h-5" />
<span className="font-medium">
{t('dashboard:new_dashboard.pending_purchases.approve')}
</span>
</button>
)}
</div>
</div>
{/* AI Reasoning (Expanded) */}
{isExpanded && reasoning && (
<div className="mt-3 p-3 rounded-lg bg-[var(--color-primary-50)] border border-[var(--color-primary-100)]">
<div className="flex items-start gap-2">
<Brain className="w-4 h-4 text-[var(--color-primary-600)] mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-[var(--color-primary-700)] mb-1">
{t('dashboard:new_dashboard.pending_purchases.ai_reasoning')}
</p>
<p className="text-sm text-[var(--color-primary-600)]">{reasoning}</p>
</div>
</div>
</div>
)}
</div>
);
})}
</div>
) : (
/* Empty State */
<div className="px-6 pb-6">
<div className="flex items-center gap-3 p-4 rounded-lg bg-[var(--color-success-50)] border border-[var(--color-success-100)]">
<CheckCircle2 className="w-6 h-6 text-[var(--color-success-600)]" />
<p className="text-sm text-[var(--color-success-700)]">
{t('dashboard:new_dashboard.pending_purchases.all_clear')}
</p>
</div>
</div>
)}
</div>
);
}
export default PendingPurchasesBlock;

View File

@@ -0,0 +1,417 @@
/**
* ProductionStatusBlock - Block 4: "Estado de Produccion"
*
* Displays today's production overview:
* - Late to start batches (should have started but haven't)
* - Currently running batches (IN_PROGRESS)
* - Pending batches for today
* - AI reasoning for batch scheduling
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
AlertTriangle,
Brain,
CheckCircle2,
ChevronDown,
ChevronUp,
Clock,
Factory,
Play,
Timer,
} from 'lucide-react';
interface ProductionStatusBlockProps {
lateToStartBatches?: any[];
runningBatches?: any[];
pendingBatches?: any[];
onStartBatch?: (batchId: string) => Promise<void>;
onViewBatch?: (batchId: string) => void;
loading?: boolean;
}
export function ProductionStatusBlock({
lateToStartBatches = [],
runningBatches = [],
pendingBatches = [],
onStartBatch,
onViewBatch,
loading,
}: ProductionStatusBlockProps) {
const { t } = useTranslation(['dashboard', 'common', 'production']);
const [expandedReasoningId, setExpandedReasoningId] = useState<string | null>(null);
const [processingId, setProcessingId] = useState<string | null>(null);
if (loading) {
return (
<div className="rounded-xl shadow-lg p-6 border border-[var(--border-primary)] bg-[var(--bg-primary)] animate-pulse">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 bg-[var(--bg-secondary)] rounded-full"></div>
<div className="flex-1 space-y-2">
<div className="h-5 bg-[var(--bg-secondary)] rounded w-1/3"></div>
<div className="h-4 bg-[var(--bg-secondary)] rounded w-1/4"></div>
</div>
</div>
<div className="space-y-3">
<div className="h-20 bg-[var(--bg-secondary)] rounded"></div>
<div className="h-20 bg-[var(--bg-secondary)] rounded"></div>
</div>
</div>
);
}
const hasLate = lateToStartBatches.length > 0;
const hasRunning = runningBatches.length > 0;
const hasPending = pendingBatches.length > 0;
const hasAnyProduction = hasLate || hasRunning || hasPending;
const totalCount = lateToStartBatches.length + runningBatches.length + pendingBatches.length;
// Determine header status
const status = hasLate ? 'error' : hasRunning ? 'info' : hasPending ? 'warning' : 'success';
const statusStyles = {
success: {
iconBg: 'bg-[var(--color-success-100)]',
iconColor: 'text-[var(--color-success-600)]',
},
warning: {
iconBg: 'bg-[var(--color-warning-100)]',
iconColor: 'text-[var(--color-warning-600)]',
},
info: {
iconBg: 'bg-[var(--color-info-100)]',
iconColor: 'text-[var(--color-info-600)]',
},
error: {
iconBg: 'bg-[var(--color-error-100)]',
iconColor: 'text-[var(--color-error-600)]',
},
};
const styles = statusStyles[status];
// Handle start batch
const handleStartBatch = async (batchId: string) => {
if (!onStartBatch || processingId) return;
setProcessingId(batchId);
try {
await onStartBatch(batchId);
} finally {
setProcessingId(null);
}
};
// Toggle reasoning expansion
const toggleReasoning = (batchId: string) => {
setExpandedReasoningId(expandedReasoningId === batchId ? null : batchId);
};
// Format AI reasoning
const formatReasoning = (batch: any): string | null => {
const reasoningData = batch.reasoning_data;
if (!reasoningData) return null;
if (typeof reasoningData === 'string') return reasoningData;
if (reasoningData.type === 'forecast_demand') {
return t('dashboard:new_dashboard.production_status.reasoning.forecast_demand', {
product: reasoningData.parameters?.product_name || batch.product_name,
demand: reasoningData.parameters?.predicted_demand || batch.planned_quantity,
});
}
if (reasoningData.type === 'customer_order') {
return t('dashboard:new_dashboard.production_status.reasoning.customer_order', {
customer: reasoningData.parameters?.customer_name || 'customer',
});
}
if (reasoningData.summary) return reasoningData.summary;
return null;
};
// Format time
const formatTime = (isoString: string | null | undefined) => {
if (!isoString) return '--:--';
const date = new Date(isoString);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
// Calculate progress percentage for running batches
const calculateProgress = (batch: any): number => {
if (!batch.actual_start_time || !batch.planned_end_time) return 0;
const start = new Date(batch.actual_start_time).getTime();
const end = new Date(batch.planned_end_time).getTime();
const now = Date.now();
if (now >= end) return 100;
if (now <= start) return 0;
return Math.round(((now - start) / (end - start)) * 100);
};
// Render a batch item
const renderBatchItem = (batch: any, type: 'late' | 'running' | 'pending', index: number, total: number) => {
const batchId = batch.id || batch.batch_id;
const isProcessing = processingId === batchId;
const isExpanded = expandedReasoningId === batchId;
const reasoning = formatReasoning(batch);
const progress = type === 'running' ? calculateProgress(batch) : 0;
const typeStyles = {
late: {
timeBg: 'bg-[var(--color-error-100)]',
timeColor: 'text-[var(--color-error-700)]',
icon: <AlertTriangle className="w-4 h-4 text-[var(--color-error-600)]" />,
},
running: {
timeBg: 'bg-[var(--color-info-100)]',
timeColor: 'text-[var(--color-info-700)]',
icon: <Timer className="w-4 h-4 text-[var(--color-info-600)]" />,
},
pending: {
timeBg: 'bg-[var(--color-warning-100)]',
timeColor: 'text-[var(--color-warning-700)]',
icon: <Clock className="w-4 h-4 text-[var(--text-tertiary)]" />,
},
};
const batchStyles = typeStyles[type];
return (
<div
key={batchId || index}
className={`p-4 ${index < total - 1 ? 'border-b border-[var(--border-primary)]' : ''}`}
>
<div className="flex items-start gap-4">
{/* Batch Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{batchStyles.icon}
<span className="font-semibold text-[var(--text-primary)]">
{batch.product_name || 'Unknown Product'}
</span>
{reasoning && (
<button
onClick={() => toggleReasoning(batchId)}
className="flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-[var(--color-primary-50)] text-[var(--color-primary-700)] hover:bg-[var(--color-primary-100)] transition-colors"
>
<Brain className="w-3 h-3" />
AI
{isExpanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</button>
)}
</div>
<p className="text-sm text-[var(--text-secondary)] mb-2">
{t('dashboard:new_dashboard.production_status.batch_info', {
number: batch.batch_number || batchId?.slice(0, 8),
quantity: batch.planned_quantity || 0,
})}
</p>
{/* Time/Status Badge */}
<div className="flex items-center gap-2">
{type === 'late' && (
<div className={`inline-flex items-center gap-1 px-2 py-1 rounded ${batchStyles.timeBg} ${batchStyles.timeColor} text-xs font-medium`}>
<Clock className="w-3 h-3" />
{t('dashboard:new_dashboard.production_status.should_have_started', {
time: formatTime(batch.planned_start_time),
})}
</div>
)}
{type === 'running' && (
<>
<div className={`inline-flex items-center gap-1 px-2 py-1 rounded ${batchStyles.timeBg} ${batchStyles.timeColor} text-xs font-medium`}>
<Timer className="w-3 h-3" />
{t('dashboard:new_dashboard.production_status.started_at', {
time: formatTime(batch.actual_start_time),
})}
</div>
{/* Progress Bar */}
<div className="flex-1 max-w-[120px]">
<div className="h-2 bg-[var(--bg-tertiary)] rounded-full overflow-hidden">
<div
className="h-full bg-[var(--color-info-500)] transition-all"
style={{ width: `${progress}%` }}
/>
</div>
<span className="text-xs text-[var(--text-tertiary)]">{progress}%</span>
</div>
</>
)}
{type === 'pending' && batch.planned_start_time && (
<div className={`inline-flex items-center gap-1 px-2 py-1 rounded ${batchStyles.timeBg} ${batchStyles.timeColor} text-xs font-medium`}>
<Clock className="w-3 h-3" />
{t('dashboard:new_dashboard.production_status.starts_at', {
time: formatTime(batch.planned_start_time),
})}
</div>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 flex-shrink-0">
{(type === 'late' || type === 'pending') && onStartBatch && (
<button
onClick={() => handleStartBatch(batchId)}
disabled={isProcessing}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[var(--color-success-600)] text-white hover:bg-[var(--color-success-700)] transition-colors disabled:opacity-50"
>
<Play className="w-4 h-4" />
<span className="text-sm font-medium">
{t('dashboard:new_dashboard.production_status.start_batch')}
</span>
</button>
)}
{type === 'running' && onViewBatch && (
<button
onClick={() => onViewBatch(batchId)}
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-[var(--border-primary)] text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] transition-colors"
>
<span className="text-sm font-medium">
{t('dashboard:new_dashboard.production_status.view_details')}
</span>
</button>
)}
</div>
</div>
{/* AI Reasoning (Expanded) */}
{isExpanded && reasoning && (
<div className="mt-3 p-3 rounded-lg bg-[var(--color-primary-50)] border border-[var(--color-primary-100)]">
<div className="flex items-start gap-2">
<Brain className="w-4 h-4 text-[var(--color-primary-600)] mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-[var(--color-primary-700)] mb-1">
{t('dashboard:new_dashboard.production_status.ai_reasoning')}
</p>
<p className="text-sm text-[var(--color-primary-600)]">{reasoning}</p>
</div>
</div>
</div>
)}
</div>
);
};
return (
<div className="rounded-xl shadow-lg border border-[var(--border-primary)] bg-[var(--bg-primary)] overflow-hidden">
{/* Header */}
<div className="p-6 pb-4">
<div className="flex items-center gap-4">
{/* Icon */}
<div className={`w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 ${styles.iconBg}`}>
{hasLate ? (
<AlertTriangle className={`w-6 h-6 ${styles.iconColor}`} />
) : hasAnyProduction ? (
<Factory className={`w-6 h-6 ${styles.iconColor}`} />
) : (
<CheckCircle2 className={`w-6 h-6 ${styles.iconColor}`} />
)}
</div>
{/* Title & Count */}
<div className="flex-1">
<h2 className="text-xl font-bold text-[var(--text-primary)]">
{t('dashboard:new_dashboard.production_status.title')}
</h2>
<p className="text-sm text-[var(--text-secondary)]">
{hasAnyProduction
? t('dashboard:new_dashboard.production_status.count', { count: totalCount })
: t('dashboard:new_dashboard.production_status.no_production')}
</p>
</div>
{/* Count Badges */}
<div className="flex items-center gap-2">
{hasLate && (
<div className="px-3 py-1 rounded-full bg-[var(--color-error-100)] text-[var(--color-error-700)] font-semibold text-sm">
{lateToStartBatches.length} {t('dashboard:new_dashboard.production_status.late_badge')}
</div>
)}
{hasRunning && (
<div className="px-3 py-1 rounded-full bg-[var(--color-info-100)] text-[var(--color-info-700)] font-semibold text-sm">
{runningBatches.length} {t('dashboard:new_dashboard.production_status.running_badge')}
</div>
)}
{hasPending && (
<div className="px-3 py-1 rounded-full bg-[var(--color-warning-100)] text-[var(--color-warning-700)] font-semibold text-sm">
{pendingBatches.length}
</div>
)}
</div>
</div>
</div>
{/* Content */}
{hasAnyProduction ? (
<div className="border-t border-[var(--border-primary)]">
{/* Late to Start Section */}
{hasLate && (
<div className="bg-[var(--color-error-50)]">
<div className="px-6 py-3 border-b border-[var(--color-error-100)]">
<h3 className="text-sm font-semibold text-[var(--color-error-700)] flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
{t('dashboard:new_dashboard.production_status.late_section')}
</h3>
</div>
{lateToStartBatches.map((batch, index) =>
renderBatchItem(batch, 'late', index, lateToStartBatches.length)
)}
</div>
)}
{/* Running Section */}
{hasRunning && (
<div className="bg-[var(--color-info-50)]">
<div className="px-6 py-3 border-b border-[var(--color-info-100)]">
<h3 className="text-sm font-semibold text-[var(--color-info-700)] flex items-center gap-2">
<Timer className="w-4 h-4" />
{t('dashboard:new_dashboard.production_status.running_section')}
</h3>
</div>
{runningBatches.map((batch, index) =>
renderBatchItem(batch, 'running', index, runningBatches.length)
)}
</div>
)}
{/* Pending Section */}
{hasPending && (
<div>
<div className="px-6 py-3 border-b border-[var(--border-primary)] bg-[var(--bg-secondary)]">
<h3 className="text-sm font-semibold text-[var(--text-secondary)] flex items-center gap-2">
<Clock className="w-4 h-4" />
{t('dashboard:new_dashboard.production_status.pending_section')}
</h3>
</div>
{pendingBatches.map((batch, index) =>
renderBatchItem(batch, 'pending', index, pendingBatches.length)
)}
</div>
)}
</div>
) : (
/* Empty State */
<div className="px-6 pb-6">
<div className="flex items-center gap-3 p-4 rounded-lg bg-[var(--color-success-50)] border border-[var(--color-success-100)]">
<CheckCircle2 className="w-6 h-6 text-[var(--color-success-600)]" />
<p className="text-sm text-[var(--color-success-700)]">
{t('dashboard:new_dashboard.production_status.all_clear')}
</p>
</div>
</div>
)}
</div>
);
}
export default ProductionStatusBlock;

View File

@@ -0,0 +1,265 @@
/**
* SystemStatusBlock - Block 1: "Estado del Sistema"
*
* Displays system status including:
* - Issues requiring user action
* - Issues prevented by AI
* - Last intelligent system run timestamp
* - AI handling rate and savings
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Activity,
AlertTriangle,
Bot,
CheckCircle2,
ChevronDown,
ChevronUp,
Clock,
Sparkles,
TrendingUp,
} from 'lucide-react';
import type { DashboardData, OrchestrationSummary } from '../../../api/hooks/useDashboardData';
interface SystemStatusBlockProps {
data: DashboardData | undefined;
loading?: boolean;
}
export function SystemStatusBlock({ data, loading }: SystemStatusBlockProps) {
const { t } = useTranslation(['dashboard', 'common']);
const [isExpanded, setIsExpanded] = useState(false);
if (loading) {
return (
<div className="rounded-xl shadow-lg p-6 border border-[var(--border-primary)] bg-[var(--bg-primary)] animate-pulse">
<div className="flex items-center gap-4">
<div className="w-16 h-16 bg-[var(--bg-secondary)] rounded-full"></div>
<div className="flex-1 space-y-3">
<div className="h-6 bg-[var(--bg-secondary)] rounded w-1/3"></div>
<div className="h-4 bg-[var(--bg-secondary)] rounded w-1/2"></div>
</div>
</div>
</div>
);
}
const issuesRequiringAction = data?.issuesRequiringAction || 0;
const issuesPreventedByAI = data?.issuesPreventedByAI || 0;
const orchestrationSummary = data?.orchestrationSummary;
const preventedIssues = data?.preventedIssues || [];
// Determine status: green if no issues, yellow/red if issues exist
const hasIssues = issuesRequiringAction > 0;
const status = hasIssues ? 'warning' : 'success';
// Format last run time
const formatLastRun = (timestamp: string | null | undefined) => {
if (!timestamp) return t('dashboard:new_dashboard.system_status.never_run');
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffMinutes = Math.floor(diffMs / (1000 * 60));
if (diffMinutes < 1) return t('common:time.just_now', 'Just now');
if (diffMinutes < 60) return t('common:time.minutes_ago', '{{count}} min ago', { count: diffMinutes });
if (diffHours < 24) return t('common:time.hours_ago', '{{count}}h ago', { count: diffHours });
return date.toLocaleDateString();
};
// Status styling
const statusStyles = {
success: {
bg: 'bg-[var(--color-success-50)]',
border: 'border-[var(--color-success-200)]',
iconBg: 'bg-[var(--color-success-100)]',
iconColor: 'text-[var(--color-success-600)]',
},
warning: {
bg: 'bg-[var(--color-warning-50)]',
border: 'border-[var(--color-warning-200)]',
iconBg: 'bg-[var(--color-warning-100)]',
iconColor: 'text-[var(--color-warning-600)]',
},
};
const styles = statusStyles[status];
return (
<div className={`rounded-xl shadow-lg border ${styles.border} ${styles.bg} overflow-hidden`}>
{/* Main Content */}
<div className="p-6">
<div className="flex items-start gap-4">
{/* Status Icon */}
<div className={`w-16 h-16 rounded-full ${styles.iconBg} flex items-center justify-center flex-shrink-0`}>
{hasIssues ? (
<AlertTriangle className={`w-8 h-8 ${styles.iconColor}`} />
) : (
<CheckCircle2 className={`w-8 h-8 ${styles.iconColor}`} />
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{/* Title */}
<h2 className="text-xl font-bold text-[var(--text-primary)] mb-1">
{t('dashboard:new_dashboard.system_status.title')}
</h2>
{/* Status Message */}
<p className="text-[var(--text-secondary)] mb-4">
{hasIssues
? t('dashboard:new_dashboard.system_status.issues_requiring_action', {
count: issuesRequiringAction,
})
: t('dashboard:new_dashboard.system_status.all_clear')}
</p>
{/* Stats Row */}
<div className="flex flex-wrap gap-4">
{/* Issues Requiring Action */}
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[var(--bg-primary)] border border-[var(--border-primary)]">
<AlertTriangle
className={`w-5 h-5 ${
issuesRequiringAction > 0 ? 'text-[var(--color-warning-500)]' : 'text-[var(--text-tertiary)]'
}`}
/>
<span className="text-sm font-medium text-[var(--text-primary)]">
{issuesRequiringAction}
</span>
<span className="text-sm text-[var(--text-secondary)]">
{t('dashboard:new_dashboard.system_status.action_needed_label')}
</span>
</div>
{/* Issues Prevented by AI */}
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[var(--bg-primary)] border border-[var(--border-primary)]">
<Bot className="w-5 h-5 text-[var(--color-primary)]" />
<span className="text-sm font-medium text-[var(--text-primary)]">
{issuesPreventedByAI}
</span>
<span className="text-sm text-[var(--text-secondary)]">
{t('dashboard:new_dashboard.system_status.ai_prevented_label')}
</span>
</div>
{/* Last Run */}
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[var(--bg-primary)] border border-[var(--border-primary)]">
<Clock className="w-5 h-5 text-[var(--text-tertiary)]" />
<span className="text-sm text-[var(--text-secondary)]">
{t('dashboard:new_dashboard.system_status.last_run_label')}:
</span>
<span className="text-sm font-medium text-[var(--text-primary)]">
{formatLastRun(orchestrationSummary?.runTimestamp)}
</span>
</div>
</div>
</div>
{/* Expand Button (if there are prevented issues to show) */}
{issuesPreventedByAI > 0 && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="p-2 rounded-lg hover:bg-[var(--bg-secondary)] transition-colors"
aria-label={isExpanded ? 'Collapse' : 'Expand'}
>
{isExpanded ? (
<ChevronUp className="w-5 h-5 text-[var(--text-tertiary)]" />
) : (
<ChevronDown className="w-5 h-5 text-[var(--text-tertiary)]" />
)}
</button>
)}
</div>
</div>
{/* Expanded AI Details Section */}
{isExpanded && issuesPreventedByAI > 0 && (
<div className="border-t border-[var(--border-primary)] bg-[var(--bg-primary)] p-6">
<h3 className="text-sm font-semibold text-[var(--text-primary)] mb-4 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-[var(--color-primary)]" />
{t('dashboard:new_dashboard.system_status.ai_prevented_details')}
</h3>
{/* AI Stats */}
{orchestrationSummary && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
{orchestrationSummary.aiHandlingRate !== undefined && (
<div className="p-3 rounded-lg bg-[var(--bg-secondary)]">
<div className="flex items-center gap-2 text-sm text-[var(--text-secondary)] mb-1">
<Activity className="w-4 h-4" />
{t('dashboard:new_dashboard.system_status.ai_handling_rate')}
</div>
<div className="text-xl font-bold text-[var(--color-primary)]">
{Math.round(orchestrationSummary.aiHandlingRate)}%
</div>
</div>
)}
{orchestrationSummary.estimatedSavingsEur !== undefined && orchestrationSummary.estimatedSavingsEur > 0 && (
<div className="p-3 rounded-lg bg-[var(--bg-secondary)]">
<div className="flex items-center gap-2 text-sm text-[var(--text-secondary)] mb-1">
<TrendingUp className="w-4 h-4" />
{t('dashboard:new_dashboard.system_status.estimated_savings')}
</div>
<div className="text-xl font-bold text-[var(--color-success-600)]">
{orchestrationSummary.estimatedSavingsEur.toLocaleString()}
</div>
</div>
)}
<div className="p-3 rounded-lg bg-[var(--bg-secondary)]">
<div className="flex items-center gap-2 text-sm text-[var(--text-secondary)] mb-1">
<Bot className="w-4 h-4" />
{t('dashboard:new_dashboard.system_status.issues_prevented')}
</div>
<div className="text-xl font-bold text-[var(--color-primary)]">
{issuesPreventedByAI}
</div>
</div>
</div>
)}
{/* Prevented Issues List */}
{preventedIssues.length > 0 && (
<div className="space-y-2">
{preventedIssues.slice(0, 5).map((issue: any, index: number) => (
<div
key={issue.id || index}
className="flex items-center gap-3 p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-primary)]"
>
<CheckCircle2 className="w-5 h-5 text-[var(--color-success-500)] flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[var(--text-primary)] truncate">
{issue.title || issue.message || t('dashboard:new_dashboard.system_status.issue_prevented')}
</p>
{issue.business_impact?.financial_impact_eur && (
<p className="text-xs text-[var(--text-secondary)]">
{t('dashboard:new_dashboard.system_status.saved')}: {issue.business_impact.financial_impact_eur.toLocaleString()}
</p>
)}
</div>
</div>
))}
{preventedIssues.length > 5 && (
<p className="text-sm text-[var(--text-secondary)] text-center py-2">
{t('dashboard:new_dashboard.system_status.and_more', {
count: preventedIssues.length - 5,
})}
</p>
)}
</div>
)}
</div>
)}
</div>
);
}
export default SystemStatusBlock;

View File

@@ -0,0 +1,10 @@
/**
* Dashboard Blocks - Barrel Export
*
* Export all dashboard block components for the new Panel de Control design.
*/
export { SystemStatusBlock } from './SystemStatusBlock';
export { PendingPurchasesBlock } from './PendingPurchasesBlock';
export { PendingDeliveriesBlock } from './PendingDeliveriesBlock';
export { ProductionStatusBlock } from './ProductionStatusBlock';

View File

@@ -3,14 +3,16 @@
// ================================================================
/**
* Dashboard Components Export
* Barrel export for all JTBD dashboard components
* Barrel export for all dashboard components
*/
// Core Dashboard Components (JTBD-Aligned)
export { GlanceableHealthHero } from './GlanceableHealthHero';
export { UnifiedActionQueueCard } from './UnifiedActionQueueCard';
export { ExecutionProgressTracker } from './ExecutionProgressTracker';
export { IntelligentSystemSummaryCard } from './IntelligentSystemSummaryCard';
// New Dashboard Blocks (4 focused blocks for Panel de Control)
export {
SystemStatusBlock,
PendingPurchasesBlock,
PendingDeliveriesBlock,
ProductionStatusBlock,
} from './blocks';
// Setup Flow Components
export { SetupWizardBlocker } from './SetupWizardBlocker';