New alert system and panel de control page

This commit is contained in:
Urtzi Alfaro
2025-11-27 15:52:40 +01:00
parent 1a2f4602f3
commit e902419b6e
178 changed files with 20982 additions and 6944 deletions

View File

@@ -1,665 +0,0 @@
// ================================================================
// frontend/src/components/dashboard/ActionQueueCard.tsx
// ================================================================
/**
* Action Queue Card - What needs your attention right now
*
* Prioritized list of actions the user needs to take, with context
* about why each action is needed and what happens if they don't do it.
*/
import React, { useState, useMemo } from 'react';
import {
FileText,
AlertCircle,
CheckCircle2,
Eye,
Edit,
Clock,
Euro,
ChevronDown,
ChevronUp,
X,
Package,
Building2,
Calendar,
Truck,
} from 'lucide-react';
import { ActionItem, ActionQueue } from '../../api/hooks/newDashboard';
import { useReasoningFormatter } from '../../hooks/useReasoningTranslation';
import { useTranslation } from 'react-i18next';
import { usePurchaseOrder } from '../../api/hooks/purchase-orders';
interface ActionQueueCardProps {
actionQueue: ActionQueue;
loading?: boolean;
onApprove?: (actionId: string) => void;
onReject?: (actionId: string, reason: string) => void;
onViewDetails?: (actionId: string) => void;
onModify?: (actionId: string) => void;
tenantId?: string;
}
const urgencyConfig = {
critical: {
bgColor: 'var(--color-error-50)',
borderColor: 'var(--color-error-300)',
badgeBgColor: 'var(--color-error-100)',
badgeTextColor: 'var(--color-error-800)',
icon: AlertCircle,
iconColor: 'var(--color-error-700)',
},
important: {
bgColor: 'var(--color-warning-50)',
borderColor: 'var(--color-warning-300)',
badgeBgColor: 'var(--color-warning-100)',
badgeTextColor: 'var(--color-warning-900)',
icon: AlertCircle,
iconColor: 'var(--color-warning-700)',
},
normal: {
bgColor: 'var(--color-info-50)',
borderColor: 'var(--color-info-300)',
badgeBgColor: 'var(--color-info-100)',
badgeTextColor: 'var(--color-info-800)',
icon: FileText,
iconColor: 'var(--color-info-700)',
},
};
/**
* Helper function to translate keys with proper namespace handling
* Maps backend key formats to correct i18next namespaces
*/
function translateKey(
key: string,
params: Record<string, any>,
tDashboard: any,
tReasoning: any
): string {
// Preprocess parameters - join arrays into strings
const processedParams = { ...params };
// Convert product_names array to product_names_joined string
if ('product_names' in processedParams && Array.isArray(processedParams.product_names)) {
processedParams.product_names_joined = processedParams.product_names.join(', ');
}
// Convert affected_products array to affected_products_joined string
if ('affected_products' in processedParams && Array.isArray(processedParams.affected_products)) {
processedParams.affected_products_joined = processedParams.affected_products.join(', ');
}
// Determine namespace based on key prefix
if (key.startsWith('reasoning.')) {
// Remove 'reasoning.' prefix and use reasoning namespace
const translationKey = key.substring('reasoning.'.length);
// Use i18next-icu for interpolation with {variable} syntax
return String(tReasoning(translationKey, processedParams));
} else if (key.startsWith('action_queue.') || key.startsWith('dashboard.') || key.startsWith('health.')) {
// Use dashboard namespace
return String(tDashboard(key, processedParams));
}
// Default to dashboard
return String(tDashboard(key, processedParams));
}
function ActionItemCard({
action,
onApprove,
onReject,
onViewDetails,
onModify,
tenantId,
}: {
action: ActionItem;
onApprove?: (id: string) => void;
onReject?: (id: string, reason: string) => void;
onViewDetails?: (id: string) => void;
onModify?: (id: string) => void;
tenantId?: string;
}) {
const [expanded, setExpanded] = useState(false);
const [showDetails, setShowDetails] = useState(false);
const [showRejectModal, setShowRejectModal] = useState(false);
const [rejectionReason, setRejectionReason] = useState('');
const config = urgencyConfig[action.urgency as keyof typeof urgencyConfig] || urgencyConfig.normal;
const UrgencyIcon = config.icon;
const { formatPOAction } = useReasoningFormatter();
const { t: tReasoning } = useTranslation('reasoning');
const { t: tDashboard } = useTranslation('dashboard');
// Fetch PO details if this is a PO action and details are expanded
const { data: poDetail } = usePurchaseOrder(
tenantId || '',
action.id,
{ enabled: !!tenantId && showDetails && action.type === 'po_approval' }
);
// Translate i18n fields (or fallback to deprecated text fields or reasoning_data for alerts)
const reasoning = useMemo(() => {
if (action.reasoning_i18n) {
return translateKey(action.reasoning_i18n.key, action.reasoning_i18n.params, tDashboard, tReasoning);
}
if (action.reasoning_data) {
const formatted = formatPOAction(action.reasoning_data);
return formatted.reasoning;
}
return action.reasoning || '';
}, [action.reasoning_i18n, action.reasoning_data, action.reasoning, tDashboard, tReasoning, formatPOAction]);
const consequence = useMemo(() => {
if (action.consequence_i18n) {
return translateKey(action.consequence_i18n.key, action.consequence_i18n.params, tDashboard, tReasoning);
}
if (action.reasoning_data) {
const formatted = formatPOAction(action.reasoning_data);
return formatted.consequence;
}
return '';
}, [action.consequence_i18n, action.reasoning_data, tDashboard, tReasoning, formatPOAction]);
return (
<div
className="border-2 rounded-lg p-4 md:p-5 transition-all duration-200 hover:shadow-md"
style={{
backgroundColor: config.bgColor,
borderColor: config.borderColor,
}}
>
{/* Header */}
<div className="flex items-start gap-3 mb-3">
<UrgencyIcon className="w-6 h-6 flex-shrink-0" style={{ color: config.iconColor }} />
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-1">
<h3 className="font-bold text-lg" style={{ color: 'var(--text-primary)' }}>
{action.title_i18n ? translateKey(action.title_i18n.key, action.title_i18n.params, tDashboard, tReasoning) : (action.title || 'Action Required')}
</h3>
<span
className="px-2 py-1 rounded text-xs font-semibold uppercase flex-shrink-0"
style={{
backgroundColor: config.badgeBgColor,
color: config.badgeTextColor,
}}
>
{action.urgency || 'normal'}
</span>
</div>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{action.subtitle_i18n ? translateKey(action.subtitle_i18n.key, action.subtitle_i18n.params, tDashboard, tReasoning) : (action.subtitle || '')}
</p>
</div>
</div>
{/* Amount (for POs) */}
{action.amount && (
<div className="flex items-center gap-2 mb-3 text-lg font-bold" style={{ color: 'var(--text-primary)' }}>
<Euro className="w-5 h-5" />
<span>
{action.amount.toFixed(2)} {action.currency}
</span>
</div>
)}
{/* Reasoning (always visible) */}
<div className="rounded-md p-3 mb-3" style={{ backgroundColor: 'var(--bg-primary)' }}>
<p className="text-sm font-medium mb-1" style={{ color: 'var(--text-primary)' }}>
{tReasoning('jtbd.action_queue.why_needed')}
</p>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{reasoning}</p>
</div>
{/* Consequence (expandable) */}
{consequence && (
<>
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-2 text-sm transition-colors mb-3 w-full"
style={{ color: 'var(--text-secondary)' }}
>
{expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
<span className="font-medium">{tReasoning('jtbd.action_queue.what_if_not')}</span>
</button>
{expanded && (
<div
className="border rounded-md p-3 mb-3"
style={{
backgroundColor: 'var(--color-warning-50)',
borderColor: 'var(--color-warning-200)',
}}
>
<p className="text-sm" style={{ color: 'var(--color-warning-900)' }}>{consequence}</p>
</div>
)}
</>
)}
{/* Inline PO Details (expandable) */}
{action.type === 'po_approval' && (
<>
<button
onClick={() => setShowDetails(!showDetails)}
className="flex items-center gap-2 text-sm font-medium transition-colors mb-3 w-full"
style={{ color: 'var(--color-info-700)' }}
>
{showDetails ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
<Package className="w-4 h-4" />
<span>View Order Details</span>
</button>
{showDetails && poDetail && (
<div
className="border rounded-md p-4 mb-3 space-y-3"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
}}
>
{/* Supplier Info */}
<div className="flex items-start gap-2">
<Building2 className="w-5 h-5 flex-shrink-0" style={{ color: 'var(--color-info-600)' }} />
<div className="flex-1">
<p className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
{poDetail.supplier?.name || 'Supplier'}
</p>
{poDetail.supplier?.contact_person && (
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
Contact: {poDetail.supplier.contact_person}
</p>
)}
{poDetail.supplier?.email && (
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{poDetail.supplier.email}
</p>
)}
</div>
</div>
{/* Delivery Date & Tracking */}
{poDetail.required_delivery_date && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Calendar className="w-5 h-5" style={{ color: 'var(--color-warning-600)' }} />
<div className="flex-1">
<p className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
Required Delivery
</p>
<p className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
{new Date(poDetail.required_delivery_date).toLocaleDateString()}
</p>
</div>
</div>
{/* Estimated Delivery Date (shown after approval) */}
{poDetail.estimated_delivery_date && (
<div className="flex items-center gap-2 ml-7">
<Truck className="w-4 h-4" style={{ color: 'var(--color-info-600)' }} />
<div className="flex-1">
<p className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
Expected Arrival
</p>
<p className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
{new Date(poDetail.estimated_delivery_date).toLocaleDateString()}
</p>
</div>
{(() => {
const now = new Date();
const estimatedDate = new Date(poDetail.estimated_delivery_date);
const daysUntil = Math.ceil((estimatedDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
let statusColor = 'var(--color-success-600)';
let statusText = 'On Track';
if (daysUntil < 0) {
statusColor = 'var(--color-error-600)';
statusText = `${Math.abs(daysUntil)}d Overdue`;
} else if (daysUntil === 0) {
statusColor = 'var(--color-warning-600)';
statusText = 'Due Today';
} else if (daysUntil <= 2) {
statusColor = 'var(--color-warning-600)';
statusText = `${daysUntil}d Left`;
} else {
statusText = `${daysUntil}d Left`;
}
return (
<span
className="px-2 py-1 rounded text-xs font-semibold"
style={{
backgroundColor: statusColor.replace('600', '100'),
color: statusColor,
}}
>
{statusText}
</span>
);
})()}
</div>
)}
</div>
)}
{/* Line Items */}
{poDetail.items && poDetail.items.length > 0 && (
<div>
<p className="text-xs font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>
Order Items ({poDetail.items.length})
</p>
<div className="space-y-2 max-h-48 overflow-y-auto">
{poDetail.items.map((item, idx) => (
<div
key={idx}
className="flex justify-between items-start p-2 rounded"
style={{ backgroundColor: 'var(--bg-secondary)' }}
>
<div className="flex-1">
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
{item.product_name || item.product_code || 'Product'}
</p>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{item.ordered_quantity} {item.unit_of_measure} × {parseFloat(item.unit_price).toFixed(2)}
</p>
</div>
<p className="text-sm font-bold" style={{ color: 'var(--text-primary)' }}>
{parseFloat(item.line_total).toFixed(2)}
</p>
</div>
))}
</div>
</div>
)}
{/* Total Amount */}
<div
className="border-t pt-2 flex justify-between items-center"
style={{ borderColor: 'var(--border-primary)' }}
>
<p className="text-sm font-bold" style={{ color: 'var(--text-primary)' }}>Total Amount</p>
<p className="text-lg font-bold" style={{ color: 'var(--color-info-700)' }}>
{parseFloat(poDetail.total_amount).toFixed(2)}
</p>
</div>
</div>
)}
</>
)}
{/* Time Estimate */}
<div className="flex items-center gap-2 text-xs mb-4" style={{ color: 'var(--text-tertiary)' }}>
<Clock className="w-4 h-4" />
<span>
{tReasoning('jtbd.action_queue.estimated_time')}: {action.estimatedTimeMinutes || 5} min
</span>
</div>
{/* Rejection Modal */}
{showRejectModal && (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
onClick={() => setShowRejectModal(false)}
>
<div
className="rounded-lg p-6 max-w-md w-full"
style={{ backgroundColor: 'var(--bg-primary)' }}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold" style={{ color: 'var(--text-primary)' }}>
Reject Purchase Order
</h3>
<button
onClick={() => setShowRejectModal(false)}
className="p-1 rounded hover:bg-opacity-10 hover:bg-black"
>
<X className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
</button>
</div>
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
Please provide a reason for rejecting this purchase order:
</p>
<textarea
value={rejectionReason}
onChange={(e) => setRejectionReason(e.target.value)}
placeholder="Enter rejection reason..."
className="w-full p-3 border rounded-lg mb-4 min-h-24"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
/>
<div className="flex gap-3 justify-end">
<button
onClick={() => setShowRejectModal(false)}
className="px-4 py-2 rounded-lg font-semibold transition-colors"
style={{
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-primary)',
}}
>
Cancel
</button>
<button
onClick={() => {
if (onReject && rejectionReason.trim()) {
onReject(action.id, rejectionReason);
setShowRejectModal(false);
setRejectionReason('');
}
}}
disabled={!rejectionReason.trim()}
className="px-4 py-2 rounded-lg font-semibold transition-colors"
style={{
backgroundColor: rejectionReason.trim() ? 'var(--color-error-600)' : 'var(--bg-quaternary)',
color: rejectionReason.trim() ? 'white' : 'var(--text-tertiary)',
cursor: rejectionReason.trim() ? 'pointer' : 'not-allowed',
}}
>
Reject Order
</button>
</div>
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex flex-wrap gap-2">
{(action.actions || []).map((button, index) => {
const buttonStyles: Record<string, React.CSSProperties> = {
primary: {
backgroundColor: 'var(--color-info-600)',
color: 'var(--text-inverse, #ffffff)',
},
secondary: {
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-primary)',
},
tertiary: {
backgroundColor: 'var(--bg-primary)',
color: 'var(--text-primary)',
border: '1px solid var(--border-primary)',
},
};
const hoverStyles: Record<string, React.CSSProperties> = {
primary: {
backgroundColor: 'var(--color-info-700)',
},
secondary: {
backgroundColor: 'var(--bg-quaternary)',
},
tertiary: {
backgroundColor: 'var(--bg-secondary)',
},
};
const [isHovered, setIsHovered] = useState(false);
const handleClick = () => {
if (button.action === 'approve' && onApprove) {
onApprove(action.id);
} else if (button.action === 'reject') {
setShowRejectModal(true);
} else if (button.action === 'view_details' && onViewDetails) {
onViewDetails(action.id);
} else if (button.action === 'modify' && onModify) {
onModify(action.id);
}
};
const currentStyle = {
...buttonStyles[button.type],
...(isHovered ? hoverStyles[button.type] : {}),
};
return (
<button
key={index}
onClick={handleClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className="px-4 py-2 rounded-lg font-semibold text-sm transition-colors duration-200 flex items-center gap-2 min-h-[44px]"
style={currentStyle}
>
{button.action === 'approve' && <CheckCircle2 className="w-4 h-4" />}
{button.action === 'reject' && <X className="w-4 h-4" />}
{button.action === 'view_details' && <Eye className="w-4 h-4" />}
{button.action === 'modify' && <Edit className="w-4 h-4" />}
{translateKey(button.label_i18n.key, button.label_i18n.params, tDashboard, tReasoning)}
</button>
);
})}
</div>
</div>
);
}
export function ActionQueueCard({
actionQueue,
loading,
onApprove,
onReject,
onViewDetails,
onModify,
tenantId,
}: ActionQueueCardProps) {
const [showAll, setShowAll] = useState(false);
const { t: tReasoning } = useTranslation('reasoning');
if (loading || !actionQueue) {
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/2" 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 (!actionQueue.actions || actionQueue.actions.length === 0) {
return (
<div
className="border-2 rounded-xl p-8 text-center shadow-lg"
style={{
backgroundColor: 'var(--color-success-50)',
borderColor: 'var(--color-success-200)',
}}
>
<CheckCircle2 className="w-16 h-16 mx-auto mb-4" style={{ color: 'var(--color-success-600)' }} />
<h3 className="text-xl font-bold mb-2" style={{ color: 'var(--color-success-900)' }}>
{tReasoning('jtbd.action_queue.all_caught_up')}
</h3>
<p style={{ color: 'var(--color-success-700)' }}>{tReasoning('jtbd.action_queue.no_actions')}</p>
</div>
);
}
const displayedActions = showAll ? actionQueue.actions : actionQueue.actions.slice(0, 3);
return (
<div className="rounded-xl shadow-lg p-6 border" style={{ backgroundColor: 'var(--bg-primary)', borderColor: 'var(--border-primary)' }}>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>{tReasoning('jtbd.action_queue.title')}</h2>
{(actionQueue.totalActions || 0) > 3 && (
<span
className="px-3 py-1 rounded-full text-sm font-semibold"
style={{
backgroundColor: 'var(--color-error-100)',
color: 'var(--color-error-800)',
}}
>
{actionQueue.totalActions || 0} {tReasoning('jtbd.action_queue.total')}
</span>
)}
</div>
{/* Summary Badges */}
{((actionQueue.criticalCount || 0) > 0 || (actionQueue.importantCount || 0) > 0) && (
<div className="flex flex-wrap gap-2 mb-6">
{(actionQueue.criticalCount || 0) > 0 && (
<span
className="px-3 py-1 rounded-full text-sm font-semibold"
style={{
backgroundColor: 'var(--color-error-100)',
color: 'var(--color-error-800)',
}}
>
{actionQueue.criticalCount || 0} {tReasoning('jtbd.action_queue.critical')}
</span>
)}
{(actionQueue.importantCount || 0) > 0 && (
<span
className="px-3 py-1 rounded-full text-sm font-semibold"
style={{
backgroundColor: 'var(--color-warning-100)',
color: 'var(--color-warning-800)',
}}
>
{actionQueue.importantCount || 0} {tReasoning('jtbd.action_queue.important')}
</span>
)}
</div>
)}
{/* Action Items */}
<div className="space-y-4">
{displayedActions.map((action) => (
<ActionItemCard
key={action.id}
action={action}
onApprove={onApprove}
onReject={onReject}
onViewDetails={onViewDetails}
onModify={onModify}
tenantId={tenantId}
/>
))}
</div>
{/* Show More/Less */}
{(actionQueue.totalActions || 0) > 3 && (
<button
onClick={() => setShowAll(!showAll)}
className="w-full mt-4 py-3 rounded-lg font-semibold transition-colors duration-200"
style={{
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-primary)',
}}
>
{showAll
? tReasoning('jtbd.action_queue.show_less')
: tReasoning('jtbd.action_queue.show_more', { count: (actionQueue.totalActions || 3) - 3 })}
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,230 @@
// ================================================================
// frontend/src/components/dashboard/CollapsibleSetupBanner.tsx
// ================================================================
/**
* Collapsible Setup Banner - Recommended Configuration Reminder
*
* JTBD: "Remind me to complete optional setup without blocking my workflow"
*
* This banner appears at the top of the dashboard when:
* - Critical setup is complete (can operate bakery)
* - BUT recommended setup is incomplete (missing features)
* - Progress: 50-99%
*
* Features:
* - Collapsible (default: collapsed to minimize distraction)
* - Dismissible (persists in localStorage for 7 days)
* - Shows progress + remaining sections
* - One-click navigation to incomplete sections
*/
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { CheckCircle, ChevronDown, ChevronUp, X, ArrowRight } from 'lucide-react';
interface RemainingSection {
id: string;
title: string;
icon: React.ElementType;
path: string;
count: number;
recommended: number;
}
interface CollapsibleSetupBannerProps {
remainingSections: RemainingSection[];
progressPercentage: number;
onDismiss?: () => void;
}
const DISMISS_KEY = 'setup_banner_dismissed';
const DISMISS_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds
export function CollapsibleSetupBanner({ remainingSections, progressPercentage, onDismiss }: CollapsibleSetupBannerProps) {
const { t } = useTranslation(['dashboard']);
const navigate = useNavigate();
const [expanded, setExpanded] = useState(false);
const [dismissed, setDismissed] = useState(false);
// Check if banner was dismissed
useEffect(() => {
const dismissedUntil = localStorage.getItem(DISMISS_KEY);
if (dismissedUntil) {
const dismissedTime = parseInt(dismissedUntil, 10);
if (Date.now() < dismissedTime) {
setDismissed(true);
} else {
localStorage.removeItem(DISMISS_KEY);
}
}
}, []);
const handleDismiss = () => {
const dismissUntil = Date.now() + DISMISS_DURATION;
localStorage.setItem(DISMISS_KEY, dismissUntil.toString());
setDismissed(true);
if (onDismiss) {
onDismiss();
}
};
const handleSectionClick = (path: string) => {
navigate(path);
};
if (dismissed || remainingSections.length === 0) {
return null;
}
return (
<div
className="mb-6 rounded-xl border-2 shadow-md transition-all duration-300"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: expanded ? 'var(--color-info-300)' : 'var(--border-secondary)',
}}
>
{/* Compact Header (Always Visible) */}
<div
className="flex items-center gap-4 p-4 cursor-pointer hover:bg-[var(--bg-secondary)] transition-colors"
onClick={() => setExpanded(!expanded)}
>
{/* Progress Circle */}
<div className="relative w-12 h-12 flex-shrink-0">
<svg className="w-full h-full transform -rotate-90">
{/* Background circle */}
<circle
cx="24"
cy="24"
r="20"
fill="none"
stroke="var(--bg-tertiary)"
strokeWidth="4"
/>
{/* Progress circle */}
<circle
cx="24"
cy="24"
r="20"
fill="none"
stroke="var(--color-success)"
strokeWidth="4"
strokeDasharray={`${2 * Math.PI * 20}`}
strokeDashoffset={`${2 * Math.PI * 20 * (1 - progressPercentage / 100)}`}
strokeLinecap="round"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xs font-bold" style={{ color: 'var(--color-success)' }}>
{progressPercentage}%
</span>
</div>
</div>
{/* Text */}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-base mb-1" style={{ color: 'var(--text-primary)' }}>
📋 {t('dashboard:setup_banner.title', '{{count}} paso(s) más para desbloquear todas las funciones', { count: remainingSections.length })}
</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{remainingSections.map(s => s.title).join(', ')} {t('dashboard:setup_banner.recommended', '(recomendado)')}
</p>
</div>
{/* Expand/Collapse Button */}
<button
onClick={(e) => {
e.stopPropagation();
setExpanded(!expanded);
}}
className="flex-shrink-0 p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
title={expanded ? 'Ocultar' : 'Expandir'}
>
{expanded ? (
<ChevronUp className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
) : (
<ChevronDown className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
)}
</button>
{/* Dismiss Button */}
<button
onClick={(e) => {
e.stopPropagation();
handleDismiss();
}}
className="flex-shrink-0 p-2 rounded-lg hover:bg-[var(--color-error)]/10 transition-colors"
title={t('dashboard:setup_banner.dismiss', 'Ocultar por 7 días')}
>
<X className="w-5 h-5" style={{ color: 'var(--text-tertiary)' }} />
</button>
</div>
{/* Expanded Content */}
{expanded && (
<div
className="px-4 pb-4 border-t"
style={{ borderColor: 'var(--border-secondary)' }}
>
<div className="space-y-3 mt-4">
{remainingSections.map((section) => {
const Icon = section.icon || (() => <div></div>);
return (
<button
key={section.id}
onClick={() => handleSectionClick(section.path)}
className="w-full flex items-center gap-4 p-4 rounded-lg border border-[var(--border-secondary)] hover:border-[var(--color-primary)] hover:bg-[var(--bg-secondary)] transition-all group text-left"
>
{/* Icon */}
<div className="w-10 h-10 rounded-full flex items-center justify-center bg-[var(--bg-tertiary)] group-hover:bg-[var(--color-primary)]/10 transition-colors">
<Icon className="w-5 h-5 text-[var(--text-secondary)] group-hover:text-[var(--color-primary)] transition-colors" />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<h4 className="font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
{section.title}
</h4>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{section.count} {t('dashboard:setup_banner.added', 'agregado(s)')} {t('dashboard:setup_banner.recommended_count', 'Recomendado')}: {section.recommended}
</p>
</div>
{/* Arrow */}
<ArrowRight className="w-5 h-5 text-[var(--text-tertiary)] group-hover:text-[var(--color-primary)] group-hover:translate-x-1 transition-all" />
</button>
);
})}
</div>
{/* Benefits of Completion */}
<div
className="mt-4 p-4 rounded-lg"
style={{ backgroundColor: 'var(--color-info)]/5', border: '1px solid var(--color-info)/20' }}
>
<div className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 flex-shrink-0 mt-0.5" style={{ color: 'var(--color-info)' }} />
<div>
<h5 className="font-semibold text-sm mb-1" style={{ color: 'var(--text-primary)' }}>
{t('dashboard:setup_banner.benefits_title', '✨ Al completar estos pasos, desbloquearás')}:
</h5>
<ul className="text-sm space-y-1" style={{ color: 'var(--text-secondary)' }}>
<li> {t('dashboard:setup_banner.benefit_1', 'Análisis de costos más preciso')}</li>
<li> {t('dashboard:setup_banner.benefit_2', 'Recomendaciones de IA mejoradas')}</li>
<li> {t('dashboard:setup_banner.benefit_3', 'Planificación de producción optimizada')}</li>
</ul>
</div>
</div>
</div>
{/* Dismiss Info */}
<p className="text-xs text-center mt-4" style={{ color: 'var(--text-tertiary)' }}>
💡 {t('dashboard:setup_banner.dismiss_info', 'Puedes ocultar este banner por 7 días haciendo clic en la X')}
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,371 @@
// ================================================================
// 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/newDashboard';
import { formatDistanceToNow } from 'date-fns';
import { es, eu, enUS } from 'date-fns/locale';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useNotifications } from '../../hooks/useNotifications';
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 } = useNotifications();
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;
// 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 */}
{healthStatus.aiPreventedIssues && healthStatus.aiPreventedIssues > 0 && (
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md" style={{ backgroundColor: 'var(--color-info-100)', color: 'var(--color-info-800)' }}>
<Zap className="w-4 h-4" />
<span className="font-semibold">{healthStatus.aiPreventedIssues} evitado{healthStatus.aiPreventedIssues > 1 ? 's' : ''}</span>
</div>
)}
</div>
</div>
{/* Expand/Collapse Button */}
<button
onClick={() => setDetailsExpanded(!detailsExpanded)}
className="flex-shrink-0 p-2 rounded-lg transition-colors hover:bg-[var(--bg-tertiary)]"
title={detailsExpanded ? 'Ocultar detalles' : 'Ver detalles'}
>
{detailsExpanded ? (
<ChevronUp className="w-6 h-6" style={{ color: config.textColor }} />
) : (
<ChevronDown className="w-6 h-6" style={{ color: config.textColor }} />
)}
</button>
</div>
</div>
{/* Detailed Checklist (Collapsible) */}
{detailsExpanded && (
<div
className="px-6 pb-6 pt-2 border-t border-[var(--border-primary)]"
style={{ borderColor: config.borderColor }}
>
{/* Full Headline */}
<p className="text-base mb-4 text-[var(--text-secondary)]">
{typeof healthStatus.headline === 'object' && healthStatus.headline?.key
? translateKey(healthStatus.headline.key, healthStatus.headline.params || {}, t)
: healthStatus.headline}
</p>
{/* Checklist */}
{updatedChecklistItems && updatedChecklistItems.length > 0 && (
<div className="space-y-2">
{updatedChecklistItems.map((item, index) => {
// Safely get the icon with proper validation
const SafeIconComponent = iconMap[item.icon];
const ItemIcon = SafeIconComponent || AlertCircle;
const getStatusStyles = () => {
switch (item.status) {
case 'good':
return {
iconColor: 'var(--color-success-600)',
bgColor: 'var(--color-success-50)',
borderColor: 'transparent',
};
case 'ai_handled':
return {
iconColor: 'var(--color-info-600)',
bgColor: 'var(--color-info-50)',
borderColor: 'var(--color-info-300)',
};
case 'needs_you':
return {
iconColor: 'var(--color-warning-600)',
bgColor: 'var(--color-warning-50)',
borderColor: 'var(--color-warning-300)',
};
default:
return {
iconColor: item.actionRequired ? 'var(--color-warning-600)' : 'var(--color-success-600)',
bgColor: item.actionRequired ? 'var(--bg-primary)' : 'var(--bg-secondary)',
borderColor: 'transparent',
};
}
};
const styles = getStatusStyles();
const displayText = item.textKey
? translateKey(item.textKey, item.textParams || {}, t)
: item.text || '';
const handleClick = () => {
if (item.actionPath && (item.status === 'needs_you' || item.actionRequired)) {
navigate(item.actionPath);
}
};
const isClickable = Boolean(item.actionPath && (item.status === 'needs_you' || item.actionRequired));
return (
<div
key={index}
className={`flex items-center gap-3 p-3 rounded-lg border transition-all ${
isClickable ? 'cursor-pointer hover:shadow-md hover:scale-[1.01] bg-[var(--bg-tertiary)]' : ''
}`}
style={{
backgroundColor: styles.bgColor,
borderColor: styles.borderColor,
}}
onClick={handleClick}
>
<ItemIcon className="w-5 h-5 flex-shrink-0" style={{ color: styles.iconColor }} />
<span
className={`flex-1 text-sm ${
item.status === 'needs_you' || item.actionRequired ? 'font-semibold' : ''
} text-[var(--text-primary)]`}
>
{displayText}
</span>
{isClickable && (
<ChevronRight className="w-4 h-4 flex-shrink-0 text-[var(--text-tertiary)]" />
)}
</div>
);
})}
</div>
)}
{/* Next Check */}
{healthStatus.nextScheduledRun && (
<div className="flex items-center gap-2 text-sm mt-4 p-3 rounded-lg bg-[var(--bg-secondary)] text-[var(--text-secondary)]">
<RefreshCw className="w-4 h-4" />
<span>
{t('jtbd.health_status.next_check', { ns: 'reasoning' })}:{' '}
{formatDistanceToNow(new Date(healthStatus.nextScheduledRun), { addSuffix: true, locale: dateLocale })}
</span>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -1,221 +0,0 @@
// ================================================================
// frontend/src/components/dashboard/HealthStatusCard.tsx
// ================================================================
/**
* Health Status Card - Top-level bakery status indicator
*
* Shows if the bakery is running smoothly (green), needs attention (yellow),
* or has critical issues (red). This is the first thing users see.
*/
import React from 'react';
import { CheckCircle, AlertTriangle, AlertCircle, Clock, RefreshCw } from 'lucide-react';
import { BakeryHealthStatus } from '../../api/hooks/newDashboard';
import { formatDistanceToNow } from 'date-fns';
import { es, eu, enUS } from 'date-fns/locale';
import { useTranslation } from 'react-i18next';
interface HealthStatusCardProps {
healthStatus: BakeryHealthStatus;
loading?: boolean;
}
const statusConfig = {
green: {
bgColor: 'var(--color-success-50)',
borderColor: 'var(--color-success-200)',
textColor: 'var(--color-success-800)',
icon: CheckCircle,
iconColor: 'var(--color-success-600)',
},
yellow: {
bgColor: 'var(--color-warning-50)',
borderColor: 'var(--color-warning-200)',
textColor: 'var(--color-warning-900)',
icon: AlertTriangle,
iconColor: 'var(--color-warning-600)',
},
red: {
bgColor: 'var(--color-error-50)',
borderColor: 'var(--color-error-200)',
textColor: 'var(--color-error-900)',
icon: AlertCircle,
iconColor: 'var(--color-error-600)',
},
};
const iconMap = {
check: CheckCircle,
warning: AlertTriangle,
alert: AlertCircle,
};
/**
* Helper function to translate keys with proper namespace handling
* Maps backend key formats to correct i18next namespaces
*/
function translateKey(
key: string,
params: Record<string, any>,
t: any
): string {
// Map key prefixes to their correct namespaces
const namespaceMap: Record<string, string> = {
'health.': 'dashboard',
'dashboard.health.': 'dashboard',
'dashboard.': 'dashboard',
'reasoning.': 'reasoning',
'production.': 'production',
'jtbd.': 'reasoning',
};
// Find the matching namespace
let namespace = 'common';
let translationKey = key;
for (const [prefix, ns] of Object.entries(namespaceMap)) {
if (key.startsWith(prefix)) {
namespace = ns;
// Remove the first segment if it matches the namespace
// e.g., "dashboard.health.production_on_schedule" -> "health.production_on_schedule"
// e.g., "reasoning.types.xyz" -> "types.xyz" for reasoning namespace
if (prefix === 'reasoning.') {
translationKey = key.substring(prefix.length);
} else if (prefix === 'dashboard.health.') {
// Keep "health." prefix but remove "dashboard."
translationKey = key.substring('dashboard.'.length);
} else if (prefix === 'dashboard.' && !key.startsWith('dashboard.health.')) {
// For other dashboard keys, remove "dashboard." prefix
translationKey = key.substring('dashboard.'.length);
}
break;
}
}
return t(translationKey, { ...params, ns: namespace, defaultValue: key });
}
export function HealthStatusCard({ healthStatus, loading }: HealthStatusCardProps) {
const { t, i18n } = useTranslation(['dashboard', 'reasoning', 'production']);
// Get date-fns locale based on current language
const dateLocale = i18n.language === 'es' ? es : i18n.language === 'eu' ? eu : enUS;
if (loading || !healthStatus) {
return (
<div className="animate-pulse rounded-lg shadow-md p-6" style={{ backgroundColor: 'var(--bg-primary)' }}>
<div className="h-8 rounded w-3/4 mb-4" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
<div className="h-4 rounded w-1/2" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
</div>
);
}
const status = healthStatus.status || 'green';
const config = statusConfig[status];
const StatusIcon = config.icon;
return (
<div
className="border-2 rounded-xl p-6 shadow-lg transition-all duration-300 hover:shadow-xl"
style={{
backgroundColor: config.bgColor,
borderColor: config.borderColor,
}}
>
{/* Header with Status Icon */}
<div className="flex items-start gap-4 mb-4">
<div className="flex-shrink-0">
<StatusIcon className="w-10 h-10 md:w-12 md:h-12" strokeWidth={2} style={{ color: config.iconColor }} />
</div>
<div className="flex-1 min-w-0">
<h2 className="text-xl md:text-2xl font-bold mb-2" style={{ color: config.textColor }}>
{typeof healthStatus.headline === 'object' && healthStatus.headline?.key
? translateKey(healthStatus.headline.key, healthStatus.headline.params || {}, t)
: healthStatus.headline || t(`jtbd.health_status.${status}`, { ns: 'reasoning' })}
</h2>
{/* Last Update */}
<div className="flex items-center gap-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
<Clock className="w-4 h-4" />
<span>
{t('jtbd.health_status.last_updated', { ns: 'reasoning' })}:{' '}
{healthStatus.lastOrchestrationRun
? formatDistanceToNow(new Date(healthStatus.lastOrchestrationRun), {
addSuffix: true,
locale: dateLocale,
})
: t('jtbd.health_status.never', { ns: 'reasoning' })}
</span>
</div>
{/* Next Check */}
{healthStatus.nextScheduledRun && (
<div className="flex items-center gap-2 text-sm mt-1" style={{ color: '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>
{/* Status Checklist */}
{healthStatus.checklistItems && healthStatus.checklistItems.length > 0 && (
<div className="space-y-3 mt-6">
{healthStatus.checklistItems.map((item, index) => {
const ItemIcon = iconMap[item.icon];
const iconColor = item.actionRequired ? 'var(--color-warning-600)' : 'var(--color-success-600)';
const bgColor = item.actionRequired ? 'var(--bg-primary)' : 'rgba(255, 255, 255, 0.5)';
// Translate using textKey if available, otherwise use text
const displayText = item.textKey
? translateKey(item.textKey, item.textParams || {}, t)
: item.text || '';
return (
<div
key={index}
className="flex items-center gap-3 p-3 rounded-lg"
style={{ backgroundColor: bgColor }}
>
<ItemIcon className="w-5 h-5 flex-shrink-0" style={{ color: iconColor }} />
<span
className={`text-sm md:text-base ${item.actionRequired ? 'font-semibold' : ''}`}
style={{ color: 'var(--text-primary)' }}
>
{displayText}
</span>
</div>
);
})}
</div>
)}
{/* Summary Footer */}
{(healthStatus.criticalIssues > 0 || healthStatus.pendingActions > 0) && (
<div className="mt-6 pt-4" style={{ borderTop: '1px solid var(--border-primary)' }}>
<div className="flex flex-wrap gap-4 text-sm">
{healthStatus.criticalIssues > 0 && (
<div className="flex items-center gap-2">
<AlertCircle className="w-4 h-4" style={{ color: 'var(--color-error-600)' }} />
<span className="font-semibold" style={{ color: 'var(--color-error-800)' }}>
{t('jtbd.health_status.critical_issues', { count: healthStatus.criticalIssues, ns: 'reasoning' })}
</span>
</div>
)}
{healthStatus.pendingActions > 0 && (
<div className="flex items-center gap-2">
<AlertTriangle className="w-4 h-4" style={{ color: 'var(--color-warning-600)' }} />
<span className="font-semibold" style={{ color: 'var(--color-warning-800)' }}>
{t('jtbd.health_status.actions_needed', { count: healthStatus.pendingActions, ns: 'reasoning' })}
</span>
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -1,129 +0,0 @@
// ================================================================
// frontend/src/components/dashboard/InsightsGrid.tsx
// ================================================================
/**
* Insights Grid - Key metrics at a glance
*
* 2x2 grid of important metrics: savings, inventory, waste, deliveries.
* Mobile-first design with large touch targets.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Insights } from '../../api/hooks/newDashboard';
interface InsightsGridProps {
insights: Insights;
loading?: boolean;
}
const colorConfig = {
green: {
bgColor: 'var(--color-success-50)',
borderColor: 'var(--color-success-200)',
textColor: 'var(--color-success-800)',
detailColor: 'var(--color-success-600)',
},
amber: {
bgColor: 'var(--color-warning-50)',
borderColor: 'var(--color-warning-200)',
textColor: 'var(--color-warning-900)',
detailColor: 'var(--color-warning-600)',
},
red: {
bgColor: 'var(--color-error-50)',
borderColor: 'var(--color-error-200)',
textColor: 'var(--color-error-900)',
detailColor: 'var(--color-error-600)',
},
};
function InsightCard({
color,
i18n,
}: {
color: 'green' | 'amber' | 'red';
i18n: {
label: {
key: string;
params?: Record<string, any>;
};
value: {
key: string;
params?: Record<string, any>;
};
detail: {
key: string;
params?: Record<string, any>;
} | null;
};
}) {
const { t } = useTranslation('dashboard');
const config = colorConfig[color];
// Translate using i18n keys
const displayLabel = t(i18n.label.key, i18n.label.params);
const displayValue = t(i18n.value.key, i18n.value.params);
const displayDetail = i18n.detail ? t(i18n.detail.key, i18n.detail.params) : '';
return (
<div
className="border-2 rounded-xl p-4 md:p-6 transition-all duration-200 hover:shadow-lg cursor-pointer"
style={{
backgroundColor: config.bgColor,
borderColor: config.borderColor,
}}
>
{/* Label */}
<div className="text-sm md:text-base font-bold mb-2" style={{ color: 'var(--text-primary)' }}>{displayLabel}</div>
{/* Value */}
<div className="text-xl md:text-2xl font-bold mb-1" style={{ color: config.textColor }}>{displayValue}</div>
{/* Detail */}
<div className="text-sm font-medium" style={{ color: config.detailColor }}>{displayDetail}</div>
</div>
);
}
export function InsightsGrid({ insights, loading }: InsightsGridProps) {
if (loading) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="animate-pulse rounded-xl p-6" style={{ backgroundColor: 'var(--bg-tertiary)' }}>
<div className="h-4 rounded w-1/2 mb-3" style={{ backgroundColor: 'var(--bg-quaternary)' }}></div>
<div className="h-8 rounded w-3/4 mb-2" style={{ backgroundColor: 'var(--bg-quaternary)' }}></div>
<div className="h-4 rounded w-2/3" style={{ backgroundColor: 'var(--bg-quaternary)' }}></div>
</div>
))}
</div>
);
}
// Guard against undefined values
if (!insights || !insights.savings || !insights.inventory || !insights.waste || !insights.deliveries) {
return null;
}
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<InsightCard
color={insights.savings.color}
i18n={insights.savings.i18n}
/>
<InsightCard
color={insights.inventory.color}
i18n={insights.inventory.i18n}
/>
<InsightCard
color={insights.waste.color}
i18n={insights.waste.i18n}
/>
<InsightCard
color={insights.deliveries.color}
i18n={insights.deliveries.i18n}
/>
</div>
);
}

View File

@@ -0,0 +1,432 @@
// ================================================================
// 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,
} from 'lucide-react';
import { OrchestrationSummary } from '../../api/hooks/newDashboard';
import { useTranslation } from 'react-i18next';
import { formatTime, formatRelativeTime } from '../../utils/date';
import { useTenant } from '../../stores/tenant.store';
import { useNotifications } from '../../hooks/useNotifications';
import { EnrichedAlert } from '../../types/alerts';
import { 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 } = useNotifications();
const [analytics, setAnalytics] = useState<DashboardAnalytics | null>(null);
const [preventedAlerts, setPreventedAlerts] = useState<EnrichedAlert[]>([]);
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: EnrichedAlert[] }>(
`/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
const preventedIssuesKey = useMemo(() => {
if (!notifications || notifications.length === 0) return 'empty';
return notifications
.filter((n) => n.type_class === 'prevented_issue' && !n.read)
.map((n) => n.id)
.sort()
.join(',');
}, [notifications]);
// 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 */}
<div
className="flex items-center gap-1 px-2 py-1 rounded-md"
style={{ backgroundColor: 'var(--color-primary-100)', border: '1px solid var(--color-primary-300)' }}
>
<ShieldCheck className="w-4 h-4" style={{ color: 'var(--color-primary-600)' }} />
<span className="text-sm font-semibold" style={{ color: 'var(--color-primary-700)' }}>
{analytics?.prevented_issues_count || 0}
</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)' }}>
{preventedAlerts.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: preventedAlerts.length,
})}
</p>
</div>
{/* Prevented Issues List */}
<div className="space-y-2">
{preventedAlerts.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)' }}>
{alert.title}
</h4>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{alert.message}
</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>
</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>
);
}

View File

@@ -1,316 +0,0 @@
// ================================================================
// frontend/src/components/dashboard/OrchestrationSummaryCard.tsx
// ================================================================
/**
* Orchestration Summary Card - What the system did for you
*
* Builds trust by showing transparency into automation decisions.
* Narrative format makes it feel like a helpful assistant.
*/
import React, { useState } from 'react';
import {
Bot,
TrendingUp,
Package,
Clock,
CheckCircle,
FileText,
Users,
Brain,
ChevronDown,
ChevronUp,
Loader2,
} from 'lucide-react';
import { OrchestrationSummary } from '../../api/hooks/newDashboard';
import { runDailyWorkflow } from '../../api/services/orchestrator';
import { formatDistanceToNow } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { useTenant } from '../../stores/tenant.store';
import toast from 'react-hot-toast';
interface OrchestrationSummaryCardProps {
summary: OrchestrationSummary;
loading?: boolean;
onWorkflowComplete?: () => void;
}
export function OrchestrationSummaryCard({ summary, loading, onWorkflowComplete }: OrchestrationSummaryCardProps) {
const [expanded, setExpanded] = useState(false);
const [isRunning, setIsRunning] = useState(false);
const { t } = useTranslation('reasoning');
const { currentTenant } = useTenant();
const handleRunPlanning = async () => {
if (!currentTenant?.id) {
toast.error(t('jtbd.orchestration_summary.no_tenant_error') || 'No tenant ID found');
return;
}
setIsRunning(true);
try {
const result = await runDailyWorkflow(currentTenant.id);
if (result.success) {
toast.success(t('jtbd.orchestration_summary.planning_started') || 'Planning started successfully');
// Call callback to refresh the orchestration summary
if (onWorkflowComplete) {
onWorkflowComplete();
}
} else {
toast.error(result.message || t('jtbd.orchestration_summary.planning_failed') || 'Failed to start planning');
}
} catch (error) {
console.error('Error running daily workflow:', error);
toast.error(t('jtbd.orchestration_summary.planning_error') || 'An error occurred while starting planning');
} finally {
setIsRunning(false);
}
};
if (loading || !summary) {
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="flex items-center gap-3">
<div className="w-10 h-10 rounded-full" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
<div className="h-6 rounded w-1/2" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
</div>
<div className="space-y-2">
<div className="h-4 rounded" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
<div className="h-4 rounded w-5/6" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
</div>
</div>
</div>
);
}
// Handle case where no orchestration has run yet
if (summary.status === 'no_runs') {
return (
<div
className="border-2 rounded-xl p-6 shadow-lg"
style={{
backgroundColor: 'var(--surface-secondary)',
borderColor: 'var(--color-info-300)',
}}
>
<div className="flex items-start gap-4">
<Bot className="w-10 h-10 flex-shrink-0" style={{ color: 'var(--color-info)' }} />
<div>
<h3 className="text-lg font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
{t('jtbd.orchestration_summary.ready_to_plan')}
</h3>
<p className="mb-4" style={{ color: 'var(--text-secondary)' }}>{summary.message || ''}</p>
<button
onClick={handleRunPlanning}
disabled={isRunning}
className="px-4 py-2 rounded-lg font-semibold transition-colors duration-200 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed hover:opacity-90"
style={{
backgroundColor: 'var(--color-info)',
color: 'var(--bg-primary)',
}}
>
{isRunning && <Loader2 className="w-4 h-4 animate-spin" />}
{t('jtbd.orchestration_summary.run_planning')}
</button>
</div>
</div>
</div>
);
}
const runTime = summary.runTimestamp
? formatDistanceToNow(new Date(summary.runTimestamp), { addSuffix: true })
: 'recently';
return (
<div
className="rounded-xl shadow-lg p-6 border"
style={{
background: 'linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%)',
borderColor: 'var(--border-primary)',
}}
>
{/* Header */}
<div className="flex items-start gap-4 mb-6">
<div className="p-3 rounded-full" style={{ backgroundColor: 'var(--bg-tertiary)' }}>
<Bot className="w-8 h-8" style={{ color: 'var(--color-primary)' }} />
</div>
<div className="flex-1">
<h2 className="text-2xl font-bold mb-1" style={{ color: 'var(--text-primary)' }}>
{t('jtbd.orchestration_summary.title')}
</h2>
<div className="flex items-center gap-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
<Clock className="w-4 h-4" />
<span>
{t('jtbd.orchestration_summary.run_info', { runNumber: summary.runNumber || 0 })} {runTime}
</span>
{summary.durationSeconds && (
<span style={{ color: 'var(--text-tertiary)' }}>
{t('jtbd.orchestration_summary.took', { seconds: summary.durationSeconds })}
</span>
)}
</div>
</div>
</div>
{/* Purchase Orders Created */}
{summary.purchaseOrdersCreated > 0 && (
<div className="rounded-lg p-4 mb-4" style={{ backgroundColor: 'var(--bg-primary)' }}>
<div className="flex items-center gap-3 mb-3">
<CheckCircle className="w-5 h-5" style={{ color: 'var(--color-success-600)' }} />
<h3 className="font-bold" style={{ color: 'var(--text-primary)' }}>
{t('jtbd.orchestration_summary.created_pos', { count: summary.purchaseOrdersCreated })}
</h3>
</div>
{summary.purchaseOrdersSummary && summary.purchaseOrdersSummary.length > 0 && (
<ul className="space-y-2 ml-8">
{summary.purchaseOrdersSummary.map((po, index) => (
<li key={index} className="text-sm" style={{ color: 'var(--text-secondary)' }}>
<span className="font-medium">{po.supplierName || 'Unknown Supplier'}</span>
{' • '}
{(po.itemCategories || []).slice(0, 2).join(', ') || 'Items'}
{(po.itemCategories || []).length > 2 && ` +${po.itemCategories.length - 2} more`}
{' • '}
<span className="font-semibold">{(po.totalAmount || 0).toFixed(2)}</span>
</li>
))}
</ul>
)}
</div>
)}
{/* Production Batches Created */}
{summary.productionBatchesCreated > 0 && (
<div className="rounded-lg p-4 mb-4" style={{ backgroundColor: 'var(--bg-primary)' }}>
<div className="flex items-center gap-3 mb-3">
<CheckCircle className="w-5 h-5" style={{ color: 'var(--color-success-600)' }} />
<h3 className="font-bold" style={{ color: 'var(--text-primary)' }}>
{t('jtbd.orchestration_summary.scheduled_batches', {
count: summary.productionBatchesCreated,
})}
</h3>
</div>
{summary.productionBatchesSummary && summary.productionBatchesSummary.length > 0 && (
<ul className="space-y-2 ml-8">
{summary.productionBatchesSummary.slice(0, expanded ? undefined : 3).map((batch, index) => (
<li key={index} className="text-sm" style={{ color: 'var(--text-secondary)' }}>
<span className="font-semibold">{batch.quantity || 0}</span> {batch.productName || 'Product'}
{' • '}
<span style={{ color: 'var(--text-tertiary)' }}>
ready by {batch.readyByTime ? new Date(batch.readyByTime).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
}) : 'TBD'}
</span>
</li>
))}
</ul>
)}
{summary.productionBatchesSummary && summary.productionBatchesSummary.length > 3 && (
<button
onClick={() => setExpanded(!expanded)}
className="ml-8 mt-2 flex items-center gap-1 text-sm font-medium"
style={{ color: 'var(--color-primary-600)' }}
>
{expanded ? (
<>
<ChevronUp className="w-4 h-4" />
{t('jtbd.orchestration_summary.show_less')}
</>
) : (
<>
<ChevronDown className="w-4 h-4" />
{t('jtbd.orchestration_summary.show_more', {
count: summary.productionBatchesSummary.length - 3,
})}
</>
)}
</button>
)}
</div>
)}
{/* No actions created */}
{summary.purchaseOrdersCreated === 0 && summary.productionBatchesCreated === 0 && (
<div className="rounded-lg p-4 mb-4" style={{ backgroundColor: 'var(--bg-primary)' }}>
<div className="flex items-center gap-3">
<CheckCircle className="w-5 h-5" style={{ color: 'var(--text-tertiary)' }} />
<p style={{ color: 'var(--text-secondary)' }}>{t('jtbd.orchestration_summary.no_actions')}</p>
</div>
</div>
)}
{/* Reasoning Inputs (How decisions were made) */}
<div className="rounded-lg p-4" style={{ backgroundColor: 'var(--surface-secondary)', border: '1px solid var(--border-primary)' }}>
<div className="flex items-center gap-2 mb-3">
<Brain className="w-5 h-5" style={{ color: 'var(--color-primary)' }} />
<h3 className="font-bold" style={{ color: 'var(--text-primary)' }}>{t('jtbd.orchestration_summary.based_on')}</h3>
</div>
<div className="grid grid-cols-2 gap-3 ml-7">
{summary.reasoningInputs.customerOrders > 0 && (
<div className="flex items-center gap-2 text-sm">
<Users className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />
<span style={{ color: 'var(--text-secondary)' }}>
{t('jtbd.orchestration_summary.customer_orders', {
count: summary.reasoningInputs.customerOrders,
})}
</span>
</div>
)}
{summary.reasoningInputs.historicalDemand && (
<div className="flex items-center gap-2 text-sm">
<TrendingUp className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />
<span style={{ color: 'var(--text-secondary)' }}>
{t('jtbd.orchestration_summary.historical_demand')}
</span>
</div>
)}
{summary.reasoningInputs.inventoryLevels && (
<div className="flex items-center gap-2 text-sm">
<Package className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />
<span style={{ color: 'var(--text-secondary)' }}>{t('jtbd.orchestration_summary.inventory_levels')}</span>
</div>
)}
{summary.reasoningInputs.aiInsights && (
<div className="flex items-center gap-2 text-sm">
<Brain className="w-4 h-4" style={{ color: 'var(--color-primary-600)' }} />
<span className="font-medium" style={{ color: 'var(--text-secondary)' }}>
{t('jtbd.orchestration_summary.ai_optimization')}
</span>
</div>
)}
</div>
</div>
{/* Actions Required Footer */}
{summary.userActionsRequired > 0 && (
<div
className="mt-4 p-4 border rounded-lg"
style={{
backgroundColor: 'var(--bg-tertiary)',
borderColor: 'var(--color-warning)',
}}
>
<div className="flex items-center gap-2">
<FileText className="w-5 h-5" style={{ color: 'var(--color-warning)' }} />
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
{t('jtbd.orchestration_summary.actions_required', {
count: summary.userActionsRequired,
})}
</p>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,324 +0,0 @@
// ================================================================
// frontend/src/components/dashboard/ProductionTimelineCard.tsx
// ================================================================
/**
* Production Timeline Card - Today's production schedule
*
* Chronological view of what's being made today with real-time progress.
*/
import React, { useMemo } from 'react';
import { Factory, Clock, Play, Pause, CheckCircle2 } from 'lucide-react';
import { ProductionTimeline, ProductionTimelineItem } from '../../api/hooks/newDashboard';
import { useReasoningFormatter } from '../../hooks/useReasoningTranslation';
import { useTranslation } from 'react-i18next';
interface ProductionTimelineCardProps {
timeline: ProductionTimeline;
loading?: boolean;
onStart?: (batchId: string) => void;
onPause?: (batchId: string) => void;
}
const priorityColors = {
URGENT: 'var(--color-error-600)',
HIGH: 'var(--color-warning-600)',
MEDIUM: 'var(--color-info-600)',
LOW: 'var(--text-tertiary)',
};
function TimelineItemCard({
item,
onStart,
onPause,
}: {
item: ProductionTimelineItem;
onStart?: (id: string) => void;
onPause?: (id: string) => void;
}) {
const priorityColor = priorityColors[item.priority as keyof typeof priorityColors] || 'var(--text-tertiary)';
const { formatBatchAction } = useReasoningFormatter();
const { t } = useTranslation(['reasoning', 'dashboard', 'production']);
// Translate reasoning_data (or use new reasoning_i18n or fallback to deprecated text field)
// Memoize to prevent undefined values from being created on each render
const { reasoning } = useMemo(() => {
if (item.reasoning_i18n) {
// Use new i18n structure if available
const { key, params = {} } = item.reasoning_i18n;
// Handle namespace - remove "reasoning." prefix for reasoning namespace
let translationKey = key;
let namespace = 'reasoning';
if (key.startsWith('reasoning.')) {
translationKey = key.substring('reasoning.'.length);
} else if (key.startsWith('production.')) {
translationKey = key.substring('production.'.length);
namespace = 'production';
}
// Use i18next-icu for interpolation with {variable} syntax
const fullKey = `${namespace}:${translationKey}`;
const result = t(fullKey, params);
return { reasoning: String(result || item.reasoning || '') };
} else if (item.reasoning_data) {
return formatBatchAction(item.reasoning_data);
}
return { reasoning: item.reasoning || '' };
}, [item.reasoning_i18n, item.reasoning_data, item.reasoning, formatBatchAction, t]);
const startTime = item.plannedStartTime
? new Date(item.plannedStartTime).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
})
: 'N/A';
const readyByTime = item.readyBy
? new Date(item.readyBy).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
})
: 'N/A';
return (
<div
className="flex gap-4 p-4 md:p-5 rounded-lg border-2 hover:shadow-md transition-all duration-200"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
}}
>
{/* Timeline icon and connector */}
<div className="flex flex-col items-center flex-shrink-0">
<div className="text-2xl">{item.statusIcon || '🔵'}</div>
<div className="text-xs font-mono mt-1" style={{ color: 'var(--text-tertiary)' }}>{startTime}</div>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{/* Header */}
<div className="flex items-start justify-between gap-2 mb-2">
<div>
<h3 className="font-bold text-lg" style={{ color: 'var(--text-primary)' }}>{item.productName || 'Product'}</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{item.quantity || 0} {item.unit || 'units'} Batch #{item.batchNumber || 'N/A'}
</p>
</div>
<span className="text-xs font-semibold uppercase" style={{ color: priorityColor }}>
{item.priority || 'NORMAL'}
</span>
</div>
{/* Status and Progress */}
<div className="mb-3">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
{item.status_i18n
? (() => {
const statusKey = item.status_i18n.key;
const statusNamespace = statusKey.startsWith('production.') ? 'production' : 'reasoning';
const statusTranslationKey = statusKey.startsWith('production.')
? statusKey.substring('production.'.length)
: (statusKey.startsWith('reasoning.') ? statusKey.substring('reasoning.'.length) : statusKey);
const fullStatusKey = `${statusNamespace}:${statusTranslationKey}`;
return String(t(fullStatusKey, item.status_i18n.params) || item.statusText || 'Status');
})()
: item.statusText || 'Status'}
</span>
{item.status === 'IN_PROGRESS' && (
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>{item.progress || 0}%</span>
)}
</div>
{/* Progress Bar */}
{item.status === 'IN_PROGRESS' && (
<div className="w-full rounded-full h-2" style={{ backgroundColor: 'var(--bg-tertiary)' }}>
<div
className="h-2 rounded-full transition-all duration-500"
style={{ width: `${item.progress || 0}%`, backgroundColor: 'var(--color-info-600)' }}
/>
</div>
)}
</div>
{/* Ready By Time */}
{item.status !== 'COMPLETED' && (
<div className="flex items-center gap-2 text-sm mb-2" style={{ color: 'var(--text-secondary)' }}>
<Clock className="w-4 h-4" />
<span>
{t('jtbd.production_timeline.ready_by')}: {readyByTime}
</span>
</div>
)}
{/* Reasoning */}
{reasoning && (
<p className="text-sm italic mb-3" style={{ color: 'var(--text-secondary)' }}>"{reasoning}"</p>
)}
{/* Actions */}
{item.status === 'PENDING' && onStart && (
<button
onClick={() => onStart(item.id)}
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-semibold transition-colors duration-200"
style={{
backgroundColor: 'var(--color-success-600)',
color: 'var(--text-inverse)',
}}
>
<Play className="w-4 h-4" />
{t('jtbd.production_timeline.start_batch')}
</button>
)}
{item.status === 'IN_PROGRESS' && onPause && (
<button
onClick={() => onPause(item.id)}
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-semibold transition-colors duration-200"
style={{
backgroundColor: 'var(--color-warning-600)',
color: 'var(--text-inverse)',
}}
>
<Pause className="w-4 h-4" />
{t('jtbd.production_timeline.pause_batch')}
</button>
)}
{item.status === 'COMPLETED' && (
<div className="flex items-center gap-2 text-sm font-medium" style={{ color: 'var(--color-success-600)' }}>
<CheckCircle2 className="w-4 h-4" />
{t('jtbd.production_timeline.completed')}
</div>
)}
</div>
</div>
);
}
export function ProductionTimelineCard({
timeline,
loading,
onStart,
onPause,
}: ProductionTimelineCardProps) {
const { t } = useTranslation('reasoning');
// Filter for today's PENDING/ON_HOLD batches only
const filteredTimeline = useMemo(() => {
if (!timeline?.timeline) return null;
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const filteredItems = timeline.timeline.filter(item => {
// Only show PENDING or ON_HOLD status
if (item.status !== 'PENDING' && item.status !== 'ON_HOLD') {
return false;
}
// Check if plannedStartTime is today
if (item.plannedStartTime) {
const startTime = new Date(item.plannedStartTime);
return startTime >= today && startTime < tomorrow;
}
return false;
});
return {
...timeline,
timeline: filteredItems,
totalBatches: filteredItems.length,
pendingBatches: filteredItems.filter(item => item.status === 'PENDING').length,
inProgressBatches: 0, // Filtered out
completedBatches: 0, // Filtered out
};
}, [timeline]);
if (loading || !filteredTimeline) {
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/2" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
<div className="space-y-3">
<div className="h-24 rounded" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
<div className="h-24 rounded" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
</div>
</div>
</div>
);
}
if (!filteredTimeline.timeline || filteredTimeline.timeline.length === 0) {
return (
<div className="rounded-xl shadow-lg p-8 text-center border" style={{ backgroundColor: 'var(--bg-primary)', borderColor: 'var(--border-primary)' }}>
<Factory className="w-16 h-16 mx-auto mb-4" style={{ color: 'var(--text-tertiary)' }} />
<h3 className="text-xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
{t('jtbd.production_timeline.no_production')}
</h3>
<p style={{ color: 'var(--text-secondary)' }}>{t('jtbd.production_timeline.no_batches')}</p>
</div>
);
}
return (
<div className="rounded-xl shadow-lg p-6 border" style={{ backgroundColor: 'var(--bg-primary)', borderColor: 'var(--border-primary)' }}>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Factory className="w-8 h-8" style={{ color: 'var(--color-info-600)' }} />
<h2 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>{t('jtbd.production_timeline.title')}</h2>
</div>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-4 gap-4 mb-6">
<div className="rounded-lg p-3 text-center" style={{ backgroundColor: 'var(--bg-tertiary)' }}>
<div className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>{filteredTimeline.totalBatches}</div>
<div className="text-xs uppercase" style={{ color: 'var(--text-secondary)' }}>{t('jtbd.production_timeline.total')}</div>
</div>
<div className="rounded-lg p-3 text-center" style={{ backgroundColor: 'var(--color-success-50)' }}>
<div className="text-2xl font-bold" style={{ color: 'var(--color-success-600)' }}>{filteredTimeline.completedBatches}</div>
<div className="text-xs uppercase" style={{ color: 'var(--color-success-700)' }}>{t('jtbd.production_timeline.done')}</div>
</div>
<div className="rounded-lg p-3 text-center" style={{ backgroundColor: 'var(--color-info-50)' }}>
<div className="text-2xl font-bold" style={{ color: 'var(--color-info-600)' }}>{filteredTimeline.inProgressBatches}</div>
<div className="text-xs uppercase" style={{ color: 'var(--color-info-700)' }}>{t('jtbd.production_timeline.active')}</div>
</div>
<div className="rounded-lg p-3 text-center" style={{ backgroundColor: 'var(--bg-tertiary)' }}>
<div className="text-2xl font-bold" style={{ color: 'var(--text-secondary)' }}>{filteredTimeline.pendingBatches}</div>
<div className="text-xs uppercase" style={{ color: 'var(--text-secondary)' }}>{t('jtbd.production_timeline.pending')}</div>
</div>
</div>
{/* Timeline */}
<div className="space-y-4">
{filteredTimeline.timeline.map((item) => (
<TimelineItemCard
key={item.id}
item={item}
onStart={onStart}
onPause={onPause}
/>
))}
</div>
{/* View Full Schedule Link */}
{filteredTimeline.totalBatches > 5 && (
<button
className="w-full mt-6 py-3 rounded-lg font-semibold transition-colors duration-200"
style={{
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-primary)',
}}
>
{t('jtbd.production_timeline.view_full_schedule')}
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,237 @@
// ================================================================
// frontend/src/components/dashboard/SetupWizardBlocker.tsx
// ================================================================
/**
* Setup Wizard Blocker - Critical Path Onboarding
*
* JTBD: "I cannot operate my bakery without basic configuration"
*
* This component blocks the entire dashboard when critical setup is incomplete.
* Shows a full-page wizard to guide users through essential configuration.
*
* Triggers when:
* - 0-2 critical sections complete (<50% progress)
* - Missing: Ingredients (<3) OR Suppliers (<1) OR Recipes (<1)
*/
import React, { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { AlertCircle, Package, Users, BookOpen, ChevronRight, CheckCircle2, Circle } from 'lucide-react';
interface SetupSection {
id: string;
title: string;
description: string;
icon: React.ElementType;
path: string;
isComplete: boolean;
count: number;
minimum: number;
}
interface SetupWizardBlockerProps {
criticalSections: SetupSection[];
onComplete?: () => void;
}
export function SetupWizardBlocker({ criticalSections = [], onComplete }: SetupWizardBlockerProps) {
const { t } = useTranslation(['dashboard', 'common']);
const navigate = useNavigate();
// Calculate progress
const { completedCount, totalCount, progressPercentage, nextSection } = useMemo(() => {
// Guard against undefined or invalid criticalSections
if (!criticalSections || !Array.isArray(criticalSections) || criticalSections.length === 0) {
return {
completedCount: 0,
totalCount: 0,
progressPercentage: 0,
nextSection: undefined,
};
}
const completed = criticalSections.filter(s => s.isComplete).length;
const total = criticalSections.length;
const percentage = Math.round((completed / total) * 100);
const next = criticalSections.find(s => !s.isComplete);
return {
completedCount: completed,
totalCount: total,
progressPercentage: percentage,
nextSection: next,
};
}, [criticalSections]);
const handleSectionClick = (path: string) => {
navigate(path);
};
return (
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="w-full max-w-3xl">
{/* Warning Header */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full mb-4" style={{ backgroundColor: 'var(--color-warning-100)' }}>
<AlertCircle className="w-10 h-10" style={{ color: 'var(--color-warning-600)' }} />
</div>
<h1 className="text-3xl md:text-4xl font-bold mb-3" style={{ color: 'var(--text-primary)' }}>
{t('dashboard:setup_blocker.title', 'Configuración Requerida')}
</h1>
<p className="text-lg" style={{ color: 'var(--text-secondary)' }}>
{t('dashboard:setup_blocker.subtitle', 'Necesitas completar la configuración básica antes de usar el panel de control')}
</p>
</div>
{/* Progress Card */}
<div className="bg-[var(--bg-primary)] border-2 border-[var(--border-primary)] rounded-xl shadow-xl p-8">
{/* Progress Bar */}
<div className="mb-8">
<div className="flex items-center justify-between text-sm mb-3">
<span className="font-semibold" style={{ color: 'var(--text-primary)' }}>
{t('dashboard:setup_blocker.progress', 'Progreso de Configuración')}
</span>
<span className="font-bold" style={{ color: 'var(--color-primary)' }}>
{completedCount}/{totalCount} ({progressPercentage}%)
</span>
</div>
<div className="w-full h-3 bg-[var(--bg-tertiary)] rounded-full overflow-hidden">
<div
className="h-full transition-all duration-500 ease-out"
style={{
width: `${progressPercentage}%`,
background: 'linear-gradient(90deg, var(--color-primary), var(--color-success))',
}}
/>
</div>
</div>
{/* Critical Sections List */}
<div className="space-y-4 mb-8">
<h3 className="text-lg font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
{t('dashboard:setup_blocker.required_steps', 'Pasos Requeridos')}
</h3>
{criticalSections.map((section, index) => {
const Icon = section.icon || (() => <div></div>);
const isNext = section === nextSection;
return (
<button
key={section.id}
onClick={() => handleSectionClick(section.path)}
className={`w-full p-5 rounded-lg border-2 transition-all duration-200 text-left group ${
section.isComplete
? 'border-[var(--color-success)]/30 bg-[var(--color-success)]/5'
: isNext
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/5 hover:bg-[var(--color-primary)]/10 shadow-md'
: 'border-[var(--border-secondary)] bg-[var(--bg-secondary)] hover:border-[var(--color-primary)] hover:bg-[var(--bg-primary)]'
}`}
>
<div className="flex items-start gap-4">
{/* Step Number / Status */}
<div className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm ${
section.isComplete
? 'bg-[var(--color-success)] text-white'
: isNext
? 'bg-[var(--color-primary)] text-white'
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'
}`}>
{section.isComplete ? (
<CheckCircle2 className="w-5 h-5" />
) : (
<span>{index + 1}</span>
)}
</div>
{/* Section Icon */}
<div className={`w-12 h-12 rounded-full flex items-center justify-center ${
section.isComplete
? 'bg-[var(--color-success)]/20 text-[var(--color-success)]'
: isNext
? 'bg-[var(--color-primary)]/20 text-[var(--color-primary)]'
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'
}`}>
<Icon className="w-6 h-6" />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-bold text-lg" style={{ color: 'var(--text-primary)' }}>
{section.title}
</h4>
{isNext && !section.isComplete && (
<span className="px-2 py-0.5 bg-[var(--color-primary)] text-white rounded-full text-xs font-semibold">
{t('dashboard:setup_blocker.next', 'Siguiente')}
</span>
)}
</div>
<p className="text-sm mb-2" style={{ color: 'var(--text-secondary)' }}>
{section.description}
</p>
<div className="flex items-center gap-2 text-sm">
{section.isComplete ? (
<span className="font-semibold" style={{ color: 'var(--color-success)' }}>
{section.count} {t('dashboard:setup_blocker.added', 'agregado(s)')}
</span>
) : (
<span className="font-semibold" style={{ color: 'var(--color-warning-700)' }}>
{t('dashboard:setup_blocker.minimum_required', 'Mínimo requerido')}: {section.minimum}
</span>
)}
</div>
</div>
{/* Chevron */}
<ChevronRight className={`w-6 h-6 flex-shrink-0 transition-transform group-hover:translate-x-1 ${
isNext ? 'text-[var(--color-primary)]' : 'text-[var(--text-tertiary)]'
}`} />
</div>
</button>
);
})}
</div>
{/* Next Step CTA */}
{nextSection && (
<div className="p-5 rounded-xl border-2 border-[var(--color-primary)]/30" style={{ backgroundColor: 'var(--color-primary)]/5' }}>
<div className="flex items-start gap-4">
<AlertCircle className="w-6 h-6 flex-shrink-0" style={{ color: 'var(--color-primary)' }} />
<div className="flex-1">
<h4 className="font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
👉 {t('dashboard:setup_blocker.start_with', 'Empieza por')}: {nextSection.title}
</h4>
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
{nextSection.description}
</p>
<button
onClick={() => handleSectionClick(nextSection.path)}
className="px-6 py-3 rounded-lg font-semibold transition-all duration-200 hover:scale-105 active:scale-95 shadow-lg hover:shadow-xl inline-flex items-center gap-2"
style={{
background: 'linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-dark) 100%)',
color: 'white',
}}
>
{t('dashboard:setup_blocker.configure_now', 'Configurar Ahora')} {nextSection.title}
<ChevronRight className="w-5 h-5" />
</button>
</div>
</div>
</div>
)}
{/* Help Text */}
<div className="mt-6 pt-6 border-t" style={{ borderColor: 'var(--border-secondary)' }}>
<p className="text-sm text-center" style={{ color: 'var(--text-tertiary)' }}>
💡 {t('dashboard:setup_blocker.help_text', 'Una vez completes estos pasos, podrás acceder al panel de control completo y comenzar a usar todas las funciones de IA')}
</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,677 @@
// ================================================================
// frontend/src/components/dashboard/StockReceiptModal.tsx
// ================================================================
/**
* Stock Receipt Modal - Lot-Level Tracking
*
* Complete workflow for receiving deliveries with lot-level expiration tracking.
* Critical for food safety compliance.
*
* Features:
* - Multi-line item support (one per ingredient)
* - Lot splitting (e.g., 50kg → 2×25kg lots with different expiration dates)
* - Mandatory expiration dates
* - Quantity validation (lot quantities must sum to actual quantity)
* - Discrepancy tracking (expected vs actual)
* - Draft save functionality
* - Warehouse location tracking
*/
import React, { useState, useEffect } from 'react';
import {
X,
Plus,
Trash2,
Save,
CheckCircle,
AlertTriangle,
Package,
Calendar,
MapPin,
FileText,
Truck,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '../ui/Button';
// ============================================================
// Types
// ============================================================
export interface StockLot {
id?: string;
lot_number?: string;
supplier_lot_number?: string;
quantity: number;
unit_of_measure: string;
expiration_date: string; // ISO date string (YYYY-MM-DD)
best_before_date?: string;
warehouse_location?: string;
storage_zone?: string;
quality_notes?: string;
}
export interface StockReceiptLineItem {
id?: string;
ingredient_id: string;
ingredient_name: string;
po_line_id?: string;
expected_quantity: number;
actual_quantity: number;
unit_of_measure: string;
has_discrepancy: boolean;
discrepancy_reason?: string;
unit_cost?: number;
total_cost?: number;
lots: StockLot[];
}
export interface StockReceipt {
id?: string;
tenant_id: string;
po_id: string;
po_number?: string;
received_at?: string;
received_by_user_id: string;
status?: 'draft' | 'confirmed' | 'cancelled';
supplier_id?: string;
supplier_name?: string;
notes?: string;
has_discrepancies?: boolean;
line_items: StockReceiptLineItem[];
}
interface StockReceiptModalProps {
isOpen: boolean;
onClose: () => void;
receipt: Partial<StockReceipt>;
mode?: 'create' | 'edit';
onSaveDraft?: (receipt: StockReceipt) => Promise<void>;
onConfirm?: (receipt: StockReceipt) => Promise<void>;
}
// ============================================================
// Helper Functions
// ============================================================
function calculateLotQuantitySum(lots: StockLot[]): number {
return lots.reduce((sum, lot) => sum + (lot.quantity || 0), 0);
}
function hasDiscrepancy(expected: number, actual: number): boolean {
return Math.abs(expected - actual) > 0.01;
}
// ============================================================
// Sub-Components
// ============================================================
interface LotInputProps {
lot: StockLot;
lineItemUoM: string;
onChange: (updatedLot: StockLot) => void;
onRemove: () => void;
canRemove: boolean;
}
function LotInput({ lot, lineItemUoM, onChange, onRemove, canRemove }: LotInputProps) {
const { t } = useTranslation('inventory');
return (
<div
className="p-4 rounded-lg border-2"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border-secondary)',
}}
>
{/* Lot Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Package className="w-4 h-4" style={{ color: 'var(--color-info)' }} />
<span className="font-semibold text-sm" style={{ color: 'var(--text-primary)' }}>
{t('lot_details')}
</span>
</div>
{canRemove && (
<button
onClick={onRemove}
className="p-1 rounded hover:bg-red-100 transition-colors"
style={{ color: 'var(--color-error)' }}
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{/* Quantity (Required) */}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
{t('quantity')} ({lineItemUoM}) *
</label>
<input
type="number"
step="0.01"
min="0"
value={lot.quantity || ''}
onChange={(e) => onChange({ ...lot, quantity: parseFloat(e.target.value) || 0 })}
className="w-full px-3 py-2 rounded-lg border"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
required
/>
</div>
{/* Expiration Date (Required) */}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
<Calendar className="w-3 h-3 inline mr-1" />
{t('expiration_date')} *
</label>
<input
type="date"
value={lot.expiration_date || ''}
onChange={(e) => onChange({ ...lot, expiration_date: e.target.value })}
className="w-full px-3 py-2 rounded-lg border"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
required
/>
</div>
{/* Lot Number (Optional) */}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
{t('lot_number')}
</label>
<input
type="text"
value={lot.lot_number || ''}
onChange={(e) => onChange({ ...lot, lot_number: e.target.value })}
placeholder="LOT-2024-001"
className="w-full px-3 py-2 rounded-lg border"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
/>
</div>
{/* Supplier Lot Number (Optional) */}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
{t('supplier_lot_number')}
</label>
<input
type="text"
value={lot.supplier_lot_number || ''}
onChange={(e) => onChange({ ...lot, supplier_lot_number: e.target.value })}
placeholder="SUPP-LOT-123"
className="w-full px-3 py-2 rounded-lg border"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
/>
</div>
{/* Warehouse Location (Optional) */}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
<MapPin className="w-3 h-3 inline mr-1" />
{t('warehouse_location')}
</label>
<input
type="text"
value={lot.warehouse_location || ''}
onChange={(e) => onChange({ ...lot, warehouse_location: e.target.value })}
placeholder="A-01-03"
className="w-full px-3 py-2 rounded-lg border"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
/>
</div>
{/* Storage Zone (Optional) */}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
{t('storage_zone')}
</label>
<input
type="text"
value={lot.storage_zone || ''}
onChange={(e) => onChange({ ...lot, storage_zone: e.target.value })}
placeholder="Cold Storage"
className="w-full px-3 py-2 rounded-lg border"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
/>
</div>
</div>
{/* Quality Notes (Optional, full width) */}
<div className="mt-3">
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
<FileText className="w-3 h-3 inline mr-1" />
{t('quality_notes')}
</label>
<textarea
value={lot.quality_notes || ''}
onChange={(e) => onChange({ ...lot, quality_notes: e.target.value })}
placeholder={t('quality_notes_placeholder')}
rows={2}
className="w-full px-3 py-2 rounded-lg border resize-none"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
/>
</div>
</div>
);
}
// ============================================================
// Main Component
// ============================================================
export function StockReceiptModal({
isOpen,
onClose,
receipt: initialReceipt,
mode = 'create',
onSaveDraft,
onConfirm,
}: StockReceiptModalProps) {
const { t } = useTranslation(['inventory', 'common']);
const [receipt, setReceipt] = useState<Partial<StockReceipt>>(initialReceipt);
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
setReceipt(initialReceipt);
}, [initialReceipt]);
if (!isOpen) return null;
// ============================================================
// Handlers
// ============================================================
const updateLineItem = (index: number, updates: Partial<StockReceiptLineItem>) => {
const newLineItems = [...(receipt.line_items || [])];
newLineItems[index] = { ...newLineItems[index], ...updates };
// Auto-detect discrepancy
if (updates.actual_quantity !== undefined || updates.expected_quantity !== undefined) {
const item = newLineItems[index];
item.has_discrepancy = hasDiscrepancy(item.expected_quantity, item.actual_quantity);
}
setReceipt({ ...receipt, line_items: newLineItems });
};
const updateLot = (lineItemIndex: number, lotIndex: number, updatedLot: StockLot) => {
const newLineItems = [...(receipt.line_items || [])];
newLineItems[lineItemIndex].lots[lotIndex] = updatedLot;
setReceipt({ ...receipt, line_items: newLineItems });
};
const addLot = (lineItemIndex: number) => {
const lineItem = receipt.line_items?.[lineItemIndex];
if (!lineItem) return;
const newLot: StockLot = {
quantity: 0,
unit_of_measure: lineItem.unit_of_measure,
expiration_date: '',
};
const newLineItems = [...(receipt.line_items || [])];
newLineItems[lineItemIndex].lots.push(newLot);
setReceipt({ ...receipt, line_items: newLineItems });
};
const removeLot = (lineItemIndex: number, lotIndex: number) => {
const newLineItems = [...(receipt.line_items || [])];
newLineItems[lineItemIndex].lots.splice(lotIndex, 1);
setReceipt({ ...receipt, line_items: newLineItems });
};
const validate = (): boolean => {
const errors: Record<string, string> = {};
if (!receipt.line_items || receipt.line_items.length === 0) {
errors.general = t('inventory:validation.no_line_items');
setValidationErrors(errors);
return false;
}
receipt.line_items.forEach((item, idx) => {
// Check lots exist
if (!item.lots || item.lots.length === 0) {
errors[`line_${idx}_lots`] = t('inventory:validation.at_least_one_lot');
}
// Check lot quantities sum to actual quantity
const lotSum = calculateLotQuantitySum(item.lots);
if (Math.abs(lotSum - item.actual_quantity) > 0.01) {
errors[`line_${idx}_quantity`] = t('inventory:validation.lot_quantity_mismatch', {
expected: item.actual_quantity,
actual: lotSum,
});
}
// Check expiration dates
item.lots.forEach((lot, lotIdx) => {
if (!lot.expiration_date) {
errors[`line_${idx}_lot_${lotIdx}_exp`] = t('inventory:validation.expiration_required');
}
if (!lot.quantity || lot.quantity <= 0) {
errors[`line_${idx}_lot_${lotIdx}_qty`] = t('inventory:validation.quantity_required');
}
});
});
setValidationErrors(errors);
return Object.keys(errors).length === 0;
};
const handleSaveDraft = async () => {
if (!onSaveDraft) return;
setIsSaving(true);
try {
await onSaveDraft(receipt as StockReceipt);
onClose();
} catch (error) {
console.error('Failed to save draft:', error);
setValidationErrors({ general: t('inventory:errors.save_failed') });
} finally {
setIsSaving(false);
}
};
const handleConfirm = async () => {
if (!validate()) return;
if (!onConfirm) return;
setIsSaving(true);
try {
await onConfirm(receipt as StockReceipt);
onClose();
} catch (error) {
console.error('Failed to confirm receipt:', error);
setValidationErrors({ general: t('inventory:errors.confirm_failed') });
} finally {
setIsSaving(false);
}
};
// ============================================================
// Render
// ============================================================
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div
className="w-full max-w-5xl max-h-[90vh] overflow-hidden rounded-xl shadow-2xl flex flex-col"
style={{ backgroundColor: 'var(--bg-primary)' }}
>
{/* Header */}
<div
className="flex items-center justify-between p-6 border-b"
style={{ borderColor: 'var(--border-primary)' }}
>
<div className="flex items-center gap-3">
<Truck className="w-6 h-6" style={{ color: 'var(--color-primary)' }} />
<div>
<h2 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>
{t('inventory:stock_receipt_title')}
</h2>
{receipt.po_number && (
<p className="text-sm mt-1" style={{ color: 'var(--text-secondary)' }}>
{t('inventory:purchase_order')}: {receipt.po_number}
</p>
)}
</div>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
style={{ color: 'var(--text-secondary)' }}
>
<X className="w-6 h-6" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{/* Supplier Info */}
{receipt.supplier_name && (
<div
className="mb-6 p-4 rounded-lg border"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border-secondary)',
}}
>
<h3 className="font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
{t('inventory:supplier_info')}
</h3>
<p style={{ color: 'var(--text-secondary)' }}>{receipt.supplier_name}</p>
</div>
)}
{/* Validation Errors */}
{validationErrors.general && (
<div
className="mb-6 p-4 rounded-lg border-2 flex items-start gap-3"
style={{
backgroundColor: 'var(--color-error-50)',
borderColor: 'var(--color-error-300)',
}}
>
<AlertTriangle className="w-5 h-5 flex-shrink-0 mt-0.5" style={{ color: 'var(--color-error-700)' }} />
<div>
<p className="font-semibold" style={{ color: 'var(--color-error-900)' }}>
{validationErrors.general}
</p>
</div>
</div>
)}
{/* Line Items */}
{receipt.line_items?.map((item, idx) => (
<div
key={idx}
className="mb-6 p-6 rounded-xl border-2"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: item.has_discrepancy ? 'var(--color-warning-300)' : 'var(--border-primary)',
}}
>
{/* Line Item Header */}
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-lg font-bold mb-1" style={{ color: 'var(--text-primary)' }}>
{item.ingredient_name}
</h3>
<div className="flex items-center gap-4 text-sm" style={{ color: 'var(--text-secondary)' }}>
<span>
{t('inventory:expected')}: {item.expected_quantity} {item.unit_of_measure}
</span>
<span></span>
<span className={item.has_discrepancy ? 'font-semibold' : ''}>
{t('inventory:actual')}: {item.actual_quantity} {item.unit_of_measure}
</span>
</div>
{item.has_discrepancy && (
<div className="mt-2 flex items-center gap-2">
<AlertTriangle className="w-4 h-4" style={{ color: 'var(--color-warning-700)' }} />
<span className="text-sm font-semibold" style={{ color: 'var(--color-warning-900)' }}>
{t('inventory:discrepancy_detected')}
</span>
</div>
)}
</div>
{/* Lot Quantity Summary */}
<div className="text-right">
<div className="text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
{t('inventory:lot_total')}
</div>
<div
className="text-2xl font-bold"
style={{
color:
Math.abs(calculateLotQuantitySum(item.lots) - item.actual_quantity) < 0.01
? 'var(--color-success)'
: 'var(--color-error)',
}}
>
{calculateLotQuantitySum(item.lots).toFixed(2)} {item.unit_of_measure}
</div>
</div>
</div>
{/* Discrepancy Reason */}
{item.has_discrepancy && (
<div className="mb-4">
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>
{t('inventory:discrepancy_reason')}
</label>
<textarea
value={item.discrepancy_reason || ''}
onChange={(e) => updateLineItem(idx, { discrepancy_reason: e.target.value })}
placeholder={t('inventory:discrepancy_reason_placeholder')}
rows={2}
className="w-full px-3 py-2 rounded-lg border resize-none"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
/>
</div>
)}
{/* Lots */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="font-semibold" style={{ color: 'var(--text-primary)' }}>
{t('inventory:lots')} ({item.lots.length})
</h4>
</div>
{item.lots.map((lot, lotIdx) => (
<LotInput
key={lotIdx}
lot={lot}
lineItemUoM={item.unit_of_measure}
onChange={(updatedLot) => updateLot(idx, lotIdx, updatedLot)}
onRemove={() => removeLot(idx, lotIdx)}
canRemove={item.lots.length > 1}
/>
))}
{/* Add Lot Button */}
<button
onClick={() => addLot(idx)}
className="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg border-2 border-dashed transition-colors hover:border-solid"
style={{
borderColor: 'var(--border-primary)',
color: 'var(--color-primary)',
backgroundColor: 'transparent',
}}
>
<Plus className="w-4 h-4" />
<span className="font-semibold">{t('inventory:add_lot')}</span>
</button>
</div>
{/* Validation Error for this line */}
{(validationErrors[`line_${idx}_lots`] ||
validationErrors[`line_${idx}_quantity`]) && (
<div
className="mt-4 p-3 rounded-lg border flex items-start gap-2"
style={{
backgroundColor: 'var(--color-error-50)',
borderColor: 'var(--color-error-300)',
}}
>
<AlertTriangle className="w-4 h-4 flex-shrink-0 mt-0.5" style={{ color: 'var(--color-error-700)' }} />
<p className="text-sm" style={{ color: 'var(--color-error-900)' }}>
{validationErrors[`line_${idx}_lots`] ||
validationErrors[`line_${idx}_quantity`]}
</p>
</div>
)}
</div>
))}
{/* General Notes */}
<div className="mt-6">
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>
{t('inventory:receipt_notes')}
</label>
<textarea
value={receipt.notes || ''}
onChange={(e) => setReceipt({ ...receipt, notes: e.target.value })}
placeholder={t('inventory:receipt_notes_placeholder')}
rows={3}
className="w-full px-3 py-2 rounded-lg border resize-none"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
/>
</div>
</div>
{/* Footer */}
<div
className="flex items-center justify-between p-6 border-t"
style={{ borderColor: 'var(--border-primary)' }}
>
<Button variant="ghost" onClick={onClose} disabled={isSaving}>
{t('common:actions.cancel')}
</Button>
<div className="flex items-center gap-3">
{onSaveDraft && (
<Button variant="secondary" onClick={handleSaveDraft} disabled={isSaving}>
<Save className="w-4 h-4 mr-2" />
{t('common:actions.save_draft')}
</Button>
)}
{onConfirm && (
<Button variant="default" onClick={handleConfirm} disabled={isSaving}>
<CheckCircle className="w-4 h-4 mr-2" />
{t('common:actions.confirm_receipt')}
</Button>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -95,11 +95,7 @@ function EscalationBadge({ alert }: { alert: EnrichedAlert }) {
return (
<div
className="flex items-center gap-1 px-2 py-1 rounded-md text-xs font-semibold"
style={{
backgroundColor: 'var(--color-warning-100)',
color: 'var(--color-warning-900)',
}}
className="flex items-center gap-1 px-2 py-1 rounded-md text-xs font-semibold bg-[var(--color-warning-100)] text-[var(--color-warning-900)]"
>
<AlertTriangle className="w-3 h-3" />
<span>
@@ -267,7 +263,7 @@ function ActionCard({ alert, showEscalationBadge = false, onActionSuccess, onAct
{showEscalationBadge && <EscalationBadge alert={alert} />}
{/* Message */}
<p className="text-sm mb-3" style={{ color: 'var(--text-secondary)' }}>
<p className="text-sm mb-3 text-[var(--text-secondary)]">
{alert.message}
</p>
@@ -276,11 +272,7 @@ function ActionCard({ alert, showEscalationBadge = false, onActionSuccess, onAct
<div className="flex flex-wrap gap-2 mb-3">
{alert.business_impact?.financial_impact_eur && (
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-semibold"
style={{
backgroundColor: 'var(--color-warning-100)',
color: 'var(--color-warning-800)',
}}
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-semibold bg-[var(--color-warning-100)] text-[var(--color-warning-800)]"
>
<TrendingUp className="w-4 h-4" />
<span>{alert.business_impact.financial_impact_eur.toFixed(0)} at risk</span>
@@ -290,11 +282,7 @@ function ActionCard({ alert, showEscalationBadge = false, onActionSuccess, onAct
<div
className={`flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-semibold ${
alert.urgency_context.time_until_consequence_hours < 6 ? 'animate-pulse' : ''
}`}
style={{
backgroundColor: 'var(--color-error-100)',
color: 'var(--color-error-800)',
}}
} bg-[var(--color-error-100)] text-[var(--color-error-800)]`}
>
<Clock className="w-4 h-4" />
<span>{Math.round(alert.urgency_context.time_until_consequence_hours)}h left</span>
@@ -302,11 +290,7 @@ function ActionCard({ alert, showEscalationBadge = false, onActionSuccess, onAct
)}
{alert.type_class === 'prevented_issue' && (
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-semibold"
style={{
backgroundColor: 'var(--color-success-100)',
color: 'var(--color-success-800)',
}}
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-semibold bg-[var(--color-success-100)] text-[var(--color-success-800)]"
>
<Bot className="w-4 h-4" />
<span>AI handled</span>
@@ -320,8 +304,7 @@ function ActionCard({ alert, showEscalationBadge = false, onActionSuccess, onAct
<>
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-2 text-sm font-medium transition-colors mb-2"
style={{ color: 'var(--color-info-700)' }}
className="flex items-center gap-2 text-sm font-medium transition-colors mb-2 text-[var(--color-info-700)]"
>
{expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
<Zap className="w-4 h-4" />
@@ -330,15 +313,11 @@ function ActionCard({ alert, showEscalationBadge = false, onActionSuccess, onAct
{expanded && (
<div
className="rounded-md p-3 mb-3"
style={{
backgroundColor: 'var(--bg-secondary)',
borderLeft: '3px solid var(--color-info-600)',
}}
className="rounded-md p-3 mb-3 bg-[var(--bg-secondary)] border-l-4 border-l-[var(--color-info-600)]"
>
<div className="flex items-start gap-2">
<Bot className="w-4 h-4 flex-shrink-0 mt-0.5" style={{ color: 'var(--color-info-700)' }} />
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
<Bot className="w-4 h-4 flex-shrink-0 mt-0.5 text-[var(--color-info-700)]" />
<p className="text-sm text-[var(--text-secondary)]">
{alert.ai_reasoning_summary}
</p>
</div>
@@ -386,11 +365,7 @@ function ActionCard({ alert, showEscalationBadge = false, onActionSuccess, onAct
{/* Action Completed State */}
{actionCompleted && (
<div
className="flex items-center gap-2 mt-3 px-3 py-2 rounded-md"
style={{
backgroundColor: 'var(--color-success-100)',
color: 'var(--color-success-800)',
}}
className="flex items-center gap-2 mt-3 px-3 py-2 rounded-md bg-[var(--color-success-100)] text-[var(--color-success-800)]"
>
<CheckCircle className="w-5 h-5" />
<span className="font-semibold">Action completed successfully</span>
@@ -443,7 +418,7 @@ function ActionSection({
>
<div className="flex items-center gap-2">
<Icon className="w-5 h-5" style={{ color: iconColor }} />
<h3 className="text-lg font-bold" style={{ color: 'var(--text-primary)' }}>
<h3 className="text-lg font-bold text-[var(--text-primary)]">
{title}
</h3>
<span
@@ -457,9 +432,9 @@ function ActionSection({
</span>
</div>
{expanded ? (
<ChevronUp className="w-5 h-5 transition-transform" style={{ color: 'var(--text-secondary)' }} />
<ChevronUp className="w-5 h-5 transition-transform text-[var(--text-secondary)]" />
) : (
<ChevronDown className="w-5 h-5 transition-transform" style={{ color: 'var(--text-secondary)' }} />
<ChevronDown className="w-5 h-5 transition-transform text-[var(--text-secondary)]" />
)}
</button>
@@ -674,9 +649,9 @@ export function UnifiedActionQueueCard({
}}
>
<div className="animate-pulse space-y-4">
<div className="h-6 rounded w-1/2" 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-6 rounded w-1/2 bg-[var(--bg-tertiary)]"></div>
<div className="h-32 rounded bg-[var(--bg-tertiary)]"></div>
<div className="h-32 rounded bg-[var(--bg-tertiary)]"></div>
</div>
</div>
);
@@ -685,19 +660,15 @@ export function UnifiedActionQueueCard({
if (!displayQueue || displayQueue.totalActions === 0) {
return (
<div
className="border-2 rounded-xl p-8 text-center shadow-lg"
style={{
backgroundColor: 'var(--color-success-50)',
borderColor: 'var(--color-success-200)',
}}
className="border-2 rounded-xl p-8 text-center shadow-lg bg-[var(--color-success-50)] border-[var(--color-success-200)]"
>
<div className="w-16 h-16 mx-auto mb-4 rounded-full flex items-center justify-center" style={{ backgroundColor: 'var(--color-success-100)' }}>
<Zap className="w-8 h-8" style={{ color: 'var(--color-success-600)' }} />
<div className="w-16 h-16 mx-auto mb-4 rounded-full flex items-center justify-center bg-[var(--color-success-100)]">
<Zap className="w-8 h-8 text-[var(--color-success-600)]" />
</div>
<h3 className="text-xl font-bold mb-2" style={{ color: 'var(--color-success-900)' }}>
<h3 className="text-xl font-bold mb-2 text-[var(--color-success-900)]">
{t('dashboard:all_caught_up')}
</h3>
<p style={{ color: 'var(--color-success-700)' }}>
<p className="text-[var(--color-success-700)]">
{t('dashboard:no_actions_needed')}
</p>
</div>
@@ -706,11 +677,7 @@ export function UnifiedActionQueueCard({
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)',
}}
className="rounded-xl shadow-xl p-6 border-2 transition-all duration-300 hover:shadow-2xl bg-[var(--bg-primary)] border-[var(--border-primary)]"
>
{/* Header with Hero Icon */}
<div className="flex items-center gap-4 mb-6">
@@ -728,7 +695,7 @@ export function UnifiedActionQueueCard({
{/* Title + Inline Metrics */}
<div className="flex-1 min-w-0">
<h2 className="text-2xl md:text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
<h2 className="text-2xl md:text-3xl font-bold mb-2 text-[var(--text-primary)]">
{t('dashboard:action_queue_title')}
</h2>
@@ -736,12 +703,11 @@ export function UnifiedActionQueueCard({
<div className="flex flex-wrap items-center gap-3">
{/* Total Actions Badge */}
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md"
style={{
backgroundColor: displayQueue.totalActions > 5 ? 'var(--color-error-100)' : 'var(--color-info-100)',
color: displayQueue.totalActions > 5 ? 'var(--color-error-800)' : 'var(--color-info-800)',
border: `1px solid ${displayQueue.totalActions > 5 ? 'var(--color-error-300)' : 'var(--color-info-300)'}`,
}}
className={`flex items-center gap-1.5 px-2 py-1 rounded-md ${
displayQueue.totalActions > 5
? 'bg-[var(--color-error-100)] text-[var(--color-error-800)] border border-[var(--color-error-300)]'
: 'bg-[var(--color-info-100)] text-[var(--color-info-800)] border border-[var(--color-info-300)]'
}`}
>
<span className="font-semibold">{displayQueue.totalActions}</span>
<span className="text-xs">{t('dashboard:total_actions')}</span>
@@ -749,12 +715,11 @@ export function UnifiedActionQueueCard({
{/* SSE Connection Badge */}
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs"
style={{
backgroundColor: isConnected ? 'var(--color-success-100)' : 'var(--color-error-100)',
color: isConnected ? 'var(--color-success-800)' : 'var(--color-error-800)',
border: `1px solid ${isConnected ? 'var(--color-success-300)' : 'var(--color-error-300)'}`,
}}
className={`flex items-center gap-1.5 px-2 py-1 rounded-md text-xs ${
isConnected
? 'bg-[var(--color-success-100)] text-[var(--color-success-800)] border border-[var(--color-success-300)]'
: 'bg-[var(--color-error-100)] text-[var(--color-error-800)] border border-[var(--color-error-300)]'
}`}
>
{isConnected ? (
<>
@@ -775,11 +740,11 @@ export function UnifiedActionQueueCard({
{/* Toast Notification */}
{toastMessage && (
<div
className="fixed top-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 animate-slide-in-right"
style={{
backgroundColor: toastMessage.type === 'success' ? 'var(--color-success-600)' : 'var(--color-error-600)',
color: 'white',
}}
className={`fixed top-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 animate-slide-in-right text-white ${
toastMessage.type === 'success'
? 'bg-[var(--color-success-600)]'
: 'bg-[var(--color-error-600)]'
}`}
>
{toastMessage.type === 'success' ? (
<CheckCircle className="w-5 h-5" />

View File

@@ -6,8 +6,15 @@
* Barrel export for all JTBD dashboard components
*/
export { HealthStatusCard } from './HealthStatusCard';
export { ActionQueueCard } from './ActionQueueCard';
export { OrchestrationSummaryCard } from './OrchestrationSummaryCard';
export { ProductionTimelineCard } from './ProductionTimelineCard';
export { InsightsGrid } from './InsightsGrid';
// Core Dashboard Components (JTBD-Aligned)
export { GlanceableHealthHero } from './GlanceableHealthHero';
export { UnifiedActionQueueCard } from './UnifiedActionQueueCard';
export { ExecutionProgressTracker } from './ExecutionProgressTracker';
export { IntelligentSystemSummaryCard } from './IntelligentSystemSummaryCard';
// Setup Flow Components
export { SetupWizardBlocker } from './SetupWizardBlocker';
export { CollapsibleSetupBanner } from './CollapsibleSetupBanner';
// Modals & Utilities
export { StockReceiptModal } from './StockReceiptModal';