Fix and UI imporvements 3
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
265
frontend/src/components/dashboard/blocks/SystemStatusBlock.tsx
Normal file
265
frontend/src/components/dashboard/blocks/SystemStatusBlock.tsx
Normal 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;
|
||||
10
frontend/src/components/dashboard/blocks/index.ts
Normal file
10
frontend/src/components/dashboard/blocks/index.ts
Normal 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';
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user