New alert system and panel de control page
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
230
frontend/src/components/dashboard/CollapsibleSetupBanner.tsx
Normal file
230
frontend/src/components/dashboard/CollapsibleSetupBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
371
frontend/src/components/dashboard/GlanceableHealthHero.tsx
Normal file
371
frontend/src/components/dashboard/GlanceableHealthHero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
237
frontend/src/components/dashboard/SetupWizardBlocker.tsx
Normal file
237
frontend/src/components/dashboard/SetupWizardBlocker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
677
frontend/src/components/dashboard/StockReceiptModal.tsx
Normal file
677
frontend/src/components/dashboard/StockReceiptModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Check, Trash2, Clock, X } from 'lucide-react';
|
||||
import { Button } from '../../ui/Button';
|
||||
import AlertSnoozeMenu from './AlertSnoozeMenu';
|
||||
|
||||
export interface AlertBulkActionsProps {
|
||||
selectedCount: number;
|
||||
onMarkAsRead: () => void;
|
||||
onRemove: () => void;
|
||||
onSnooze: (duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => void;
|
||||
onDeselectAll: () => void;
|
||||
onSelectAll: () => void;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
const AlertBulkActions: React.FC<AlertBulkActionsProps> = ({
|
||||
selectedCount,
|
||||
onMarkAsRead,
|
||||
onRemove,
|
||||
onSnooze,
|
||||
onDeselectAll,
|
||||
onSelectAll,
|
||||
totalCount,
|
||||
}) => {
|
||||
const [showSnoozeMenu, setShowSnoozeMenu] = useState(false);
|
||||
|
||||
if (selectedCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSnooze = (duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => {
|
||||
onSnooze(duration);
|
||||
setShowSnoozeMenu(false);
|
||||
};
|
||||
|
||||
const allSelected = selectedCount === totalCount;
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-20 bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary-dark)] text-white px-4 py-3 rounded-xl shadow-xl flex items-center justify-between gap-3 animate-in slide-in-from-top-2 duration-300">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-white/20 backdrop-blur-sm rounded-lg">
|
||||
<span className="text-sm font-bold">{selectedCount}</span>
|
||||
<span className="text-xs font-medium opacity-90">
|
||||
{selectedCount === 1 ? 'seleccionado' : 'seleccionados'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!allSelected && totalCount > selectedCount && (
|
||||
<button
|
||||
onClick={onSelectAll}
|
||||
className="text-sm font-medium hover:underline opacity-90 hover:opacity-100 transition-opacity whitespace-nowrap"
|
||||
aria-label={`Select all ${totalCount} alerts`}
|
||||
>
|
||||
Seleccionar todos ({totalCount})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 relative flex-shrink-0">
|
||||
{/* Quick Actions */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onMarkAsRead}
|
||||
className="bg-white/15 text-white border-white/30 hover:bg-white/25 backdrop-blur-sm h-9 px-3"
|
||||
aria-label="Mark all selected as read"
|
||||
>
|
||||
<Check className="w-4 h-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Marcar leídos</span>
|
||||
</Button>
|
||||
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowSnoozeMenu(!showSnoozeMenu)}
|
||||
className="bg-white/15 text-white border-white/30 hover:bg-white/25 backdrop-blur-sm h-9 px-3"
|
||||
aria-label="Snooze selected alerts"
|
||||
>
|
||||
<Clock className="w-4 h-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Posponer</span>
|
||||
</Button>
|
||||
|
||||
{showSnoozeMenu && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-20"
|
||||
onClick={() => setShowSnoozeMenu(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="absolute right-0 top-full mt-2 z-30">
|
||||
<AlertSnoozeMenu
|
||||
onSnooze={handleSnooze}
|
||||
onCancel={() => setShowSnoozeMenu(false)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRemove}
|
||||
className="bg-red-500/25 text-white border-red-300/40 hover:bg-red-500/40 backdrop-blur-sm h-9 px-3"
|
||||
aria-label="Delete selected alerts"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Eliminar</span>
|
||||
</Button>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onDeselectAll}
|
||||
className="ml-1 p-2 hover:bg-white/15 rounded-lg transition-colors"
|
||||
aria-label="Deselect all"
|
||||
title="Cerrar selección"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertBulkActions;
|
||||
@@ -1,446 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlertTriangle,
|
||||
AlertCircle,
|
||||
Info,
|
||||
CheckCircle,
|
||||
Check,
|
||||
Trash2,
|
||||
Clock,
|
||||
MoreVertical,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '../../ui/Badge';
|
||||
import { Button } from '../../ui/Button';
|
||||
import type { NotificationData } from '../../../hooks/useNotifications';
|
||||
import { getSnoozedTimeRemaining, categorizeAlert } from '../../../utils/alertHelpers';
|
||||
import AlertContextActions from './AlertContextActions';
|
||||
import AlertSnoozeMenu from './AlertSnoozeMenu';
|
||||
|
||||
export interface AlertCardProps {
|
||||
alert: NotificationData;
|
||||
isExpanded: boolean;
|
||||
isSelected: boolean;
|
||||
isSnoozed: boolean;
|
||||
snoozedUntil?: number;
|
||||
onToggleExpand: () => void;
|
||||
onToggleSelect: () => void;
|
||||
onMarkAsRead: () => void;
|
||||
onRemove: () => void;
|
||||
onSnooze: (duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => void;
|
||||
onUnsnooze: () => void;
|
||||
showCheckbox?: boolean;
|
||||
}
|
||||
|
||||
const getSeverityIcon = (severity: string) => {
|
||||
switch (severity) {
|
||||
case 'urgent':
|
||||
return AlertTriangle;
|
||||
case 'high':
|
||||
return AlertCircle;
|
||||
case 'medium':
|
||||
return Info;
|
||||
case 'low':
|
||||
return CheckCircle;
|
||||
default:
|
||||
return Info;
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityColor = (severity: string) => {
|
||||
switch (severity) {
|
||||
case 'urgent':
|
||||
return 'var(--color-error)';
|
||||
case 'high':
|
||||
return 'var(--color-warning)';
|
||||
case 'medium':
|
||||
return 'var(--color-info)';
|
||||
case 'low':
|
||||
return 'var(--color-success)';
|
||||
default:
|
||||
return 'var(--color-info)';
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityBadge = (severity: string): 'error' | 'warning' | 'info' | 'success' => {
|
||||
switch (severity) {
|
||||
case 'urgent':
|
||||
return 'error';
|
||||
case 'high':
|
||||
return 'warning';
|
||||
case 'medium':
|
||||
return 'info';
|
||||
case 'low':
|
||||
return 'success';
|
||||
default:
|
||||
return 'info';
|
||||
}
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: string, t: any) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||
|
||||
if (diffMins < 1) return t('dashboard:alerts.time.now', 'Ahora');
|
||||
if (diffMins < 60) return t('dashboard:alerts.time.minutes_ago', 'hace {{count}} min', { count: diffMins });
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return t('dashboard:alerts.time.hours_ago', 'hace {{count}} h', { count: diffHours });
|
||||
return date.toLocaleDateString() === new Date(now.getTime() - 24 * 60 * 60 * 1000).toLocaleDateString()
|
||||
? t('dashboard:alerts.time.yesterday', 'Ayer')
|
||||
: date.toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' });
|
||||
};
|
||||
|
||||
const AlertCard: React.FC<AlertCardProps> = ({
|
||||
alert,
|
||||
isExpanded,
|
||||
isSelected,
|
||||
isSnoozed,
|
||||
snoozedUntil,
|
||||
onToggleExpand,
|
||||
onToggleSelect,
|
||||
onMarkAsRead,
|
||||
onRemove,
|
||||
onSnooze,
|
||||
onUnsnooze,
|
||||
showCheckbox = false,
|
||||
}) => {
|
||||
const { t } = useTranslation(['dashboard']);
|
||||
const [showSnoozeMenu, setShowSnoozeMenu] = useState(false);
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const SeverityIcon = getSeverityIcon(alert.severity);
|
||||
const severityColor = getSeverityColor(alert.severity);
|
||||
|
||||
const handleSnooze = (duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => {
|
||||
onSnooze(duration);
|
||||
setShowSnoozeMenu(false);
|
||||
};
|
||||
|
||||
const category = categorizeAlert(alert);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
rounded-lg transition-all duration-200 relative overflow-hidden
|
||||
${isExpanded ? 'shadow-md' : 'hover:shadow-md'}
|
||||
${isSelected ? 'ring-2 ring-[var(--color-primary)] ring-offset-2' : ''}
|
||||
${isSnoozed ? 'opacity-75' : ''}
|
||||
`}
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
border: '1px solid var(--border-primary)',
|
||||
...(isExpanded && {
|
||||
backgroundColor: 'var(--bg-secondary)',
|
||||
}),
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* Left severity accent border */}
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 w-1"
|
||||
style={{ backgroundColor: severityColor }}
|
||||
/>
|
||||
|
||||
{/* Compact Card Header */}
|
||||
<div className="flex items-start gap-3 p-4 pl-5">
|
||||
{/* Checkbox for selection */}
|
||||
{showCheckbox && (
|
||||
<div className="flex-shrink-0 pt-0.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleSelect();
|
||||
}}
|
||||
className="w-4 h-4 rounded border-[var(--border-primary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)] focus:ring-offset-0 cursor-pointer"
|
||||
aria-label={`Select alert: ${alert.title}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Severity Icon */}
|
||||
<div
|
||||
className="flex-shrink-0 p-2 rounded-lg cursor-pointer hover:scale-105 transition-transform"
|
||||
style={{ backgroundColor: severityColor + '15' }}
|
||||
onClick={onToggleExpand}
|
||||
aria-label="Toggle alert details"
|
||||
>
|
||||
<SeverityIcon
|
||||
className="w-5 h-5"
|
||||
style={{ color: severityColor }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Alert Content */}
|
||||
<div className="flex-1 min-w-0 cursor-pointer" onClick={onToggleExpand}>
|
||||
{/* Title and Status */}
|
||||
<div className="flex items-start justify-between gap-3 mb-1.5">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-base font-semibold leading-snug mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||
{alert.title}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* Single primary severity badge */}
|
||||
<Badge variant={getSeverityBadge(alert.severity)} size="sm" className="font-semibold px-2.5 py-1 min-h-[1.375rem]">
|
||||
{t(`dashboard:alerts.severity.${alert.severity}`, alert.severity.toUpperCase())}
|
||||
</Badge>
|
||||
|
||||
{/* Unread indicator */}
|
||||
{!alert.read && (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium px-2 py-1 rounded-md bg-blue-50 dark:bg-blue-900/20 min-h-[1.375rem]" style={{ color: 'var(--color-info)' }}>
|
||||
<span className="w-2 h-2 rounded-full bg-[var(--color-info)] animate-pulse flex-shrink-0" />
|
||||
{t('dashboard:alerts.status.new', 'Nuevo')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Snoozed indicator */}
|
||||
{isSnoozed && snoozedUntil && (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium px-2 py-1 rounded-md bg-gray-50 dark:bg-gray-800 min-h-[1.375rem]" style={{ color: 'var(--text-secondary)' }}>
|
||||
<Clock className="w-3 h-3 flex-shrink-0" />
|
||||
<span className="whitespace-nowrap">{getSnoozedTimeRemaining(alert.id, new Map([[alert.id, { alertId: alert.id, until: snoozedUntil }]]))}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
<span className="text-xs font-medium flex-shrink-0 pt-0.5" style={{ color: 'var(--text-secondary)' }}>
|
||||
{formatTimestamp(alert.timestamp, t)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Preview message when collapsed */}
|
||||
{!isExpanded && alert.message && (
|
||||
<p
|
||||
className="text-sm leading-relaxed mt-2 overflow-hidden"
|
||||
style={{
|
||||
color: 'var(--text-secondary)',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{alert.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions - shown on hover or when expanded */}
|
||||
<div className={`flex-shrink-0 flex items-center gap-1 transition-opacity ${isHovered || isExpanded || showActions ? 'opacity-100' : 'opacity-0'}`}>
|
||||
{/* Quick action buttons */}
|
||||
{!alert.read && !isExpanded && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMarkAsRead();
|
||||
}}
|
||||
className="p-1.5 rounded-md hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
title={t('dashboard:alerts.mark_as_read', 'Marcar como leído')}
|
||||
aria-label="Mark as read"
|
||||
>
|
||||
<Check className="w-4 h-4" style={{ color: 'var(--color-success)' }} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowActions(!showActions);
|
||||
}}
|
||||
className="p-1.5 rounded-md hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
aria-label="More actions"
|
||||
title="More actions"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleExpand();
|
||||
}}
|
||||
className="p-1.5 rounded-md hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
aria-label={isExpanded ? "Collapse" : "Expand"}
|
||||
title={isExpanded ? "Collapse" : "Expand"}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions Menu - Better positioning */}
|
||||
{showActions && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-20"
|
||||
onClick={() => setShowActions(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="absolute right-3 top-16 z-30 bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg shadow-xl py-1 min-w-[180px]">
|
||||
{!alert.read && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMarkAsRead();
|
||||
setShowActions(false);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-[var(--bg-secondary)] transition-colors flex items-center gap-2"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<Check className="w-4 h-4" style={{ color: 'var(--color-success)' }} />
|
||||
Marcar como leído
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowSnoozeMenu(true);
|
||||
setShowActions(false);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-[var(--bg-secondary)] transition-colors flex items-center gap-2"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<Clock className="w-4 h-4" />
|
||||
{isSnoozed ? 'Cambiar tiempo' : 'Posponer'}
|
||||
</button>
|
||||
{isSnoozed && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUnsnooze();
|
||||
setShowActions(false);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-[var(--bg-secondary)] transition-colors flex items-center gap-2"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<Clock className="w-4 h-4" />
|
||||
Reactivar ahora
|
||||
</button>
|
||||
)}
|
||||
<div className="my-1 border-t border-[var(--border-primary)]" />
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
setShowActions(false);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-red-50 dark:hover:bg-red-900/20 text-red-600 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Eliminar
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Snooze Menu - Better positioning */}
|
||||
{showSnoozeMenu && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-20"
|
||||
onClick={() => setShowSnoozeMenu(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="absolute right-3 top-16 z-30">
|
||||
<AlertSnoozeMenu
|
||||
onSnooze={handleSnooze}
|
||||
onCancel={() => setShowSnoozeMenu(false)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Expanded Details */}
|
||||
{isExpanded && (
|
||||
<div className="px-5 pb-4 border-t pt-4" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
{/* Full Message */}
|
||||
<div className="mb-4">
|
||||
<p className="text-sm leading-relaxed" style={{ color: 'var(--text-primary)' }}>
|
||||
{alert.message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
{alert.metadata && Object.keys(alert.metadata).length > 0 && (
|
||||
<div className="mb-4 p-3 rounded-lg border" style={{
|
||||
backgroundColor: 'var(--bg-tertiary)',
|
||||
borderColor: 'var(--border-primary)'
|
||||
}}>
|
||||
<p className="text-xs font-semibold mb-2 uppercase tracking-wide" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('dashboard:alerts.additional_details', 'Detalles Adicionales')}
|
||||
</p>
|
||||
<div className="text-sm space-y-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
{Object.entries(alert.metadata).map(([key, value]) => (
|
||||
<div key={key} className="flex justify-between gap-4">
|
||||
<span className="font-medium capitalize text-[var(--text-primary)]">{key.replace(/_/g, ' ')}:</span>
|
||||
<span className="text-right">{String(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contextual Actions */}
|
||||
<div className="mb-4">
|
||||
<AlertContextActions alert={alert} />
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{!alert.read && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMarkAsRead();
|
||||
}}
|
||||
className="h-9 px-4 text-sm font-medium"
|
||||
>
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
{t('dashboard:alerts.mark_as_read', 'Marcar como leído')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowSnoozeMenu(true);
|
||||
}}
|
||||
className="h-9 px-4 text-sm font-medium"
|
||||
>
|
||||
<Clock className="w-4 h-4 mr-2" />
|
||||
{isSnoozed ? 'Cambiar' : 'Posponer'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
className="h-9 px-4 text-sm font-medium text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{t('dashboard:alerts.remove', 'Eliminar')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertCard;
|
||||
@@ -1,49 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Button } from '../../ui/Button';
|
||||
import type { NotificationData } from '../../../hooks/useNotifications';
|
||||
import { useAlertActions } from '../../../hooks/useAlertActions';
|
||||
|
||||
export interface AlertContextActionsProps {
|
||||
alert: NotificationData;
|
||||
}
|
||||
|
||||
const AlertContextActions: React.FC<AlertContextActionsProps> = ({ alert }) => {
|
||||
const { getActions, executeAction } = useAlertActions();
|
||||
const actions = getActions(alert);
|
||||
|
||||
if (actions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<p className="text-xs font-semibold mb-2 uppercase tracking-wide text-[var(--text-primary)]">
|
||||
Acciones Recomendadas
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{actions.map((action, index) => {
|
||||
const variantMap: Record<string, 'primary' | 'secondary' | 'outline'> = {
|
||||
primary: 'primary',
|
||||
secondary: 'secondary',
|
||||
outline: 'outline',
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={index}
|
||||
variant={variantMap[action.variant] || 'outline'}
|
||||
size="sm"
|
||||
onClick={() => executeAction(alert, action)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span>{action.icon}</span>
|
||||
<span>{action.label}</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertContextActions;
|
||||
@@ -1,306 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Search, X, Filter, ChevronDown } from 'lucide-react';
|
||||
import { Badge } from '../../ui/Badge';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { Input } from '../../ui/Input';
|
||||
import type { AlertSeverity, AlertCategory, TimeGroup } from '../../../utils/alertHelpers';
|
||||
import { getCategoryName, getCategoryIcon } from '../../../utils/alertHelpers';
|
||||
|
||||
export interface AlertFiltersProps {
|
||||
selectedSeverities: AlertSeverity[];
|
||||
selectedCategories: AlertCategory[];
|
||||
selectedTimeRange: TimeGroup | 'all';
|
||||
searchQuery: string;
|
||||
showSnoozed: boolean;
|
||||
onToggleSeverity: (severity: AlertSeverity) => void;
|
||||
onToggleCategory: (category: AlertCategory) => void;
|
||||
onSetTimeRange: (range: TimeGroup | 'all') => void;
|
||||
onSearchChange: (query: string) => void;
|
||||
onToggleShowSnoozed: () => void;
|
||||
onClearFilters: () => void;
|
||||
hasActiveFilters: boolean;
|
||||
activeFilterCount: number;
|
||||
}
|
||||
|
||||
const SEVERITY_CONFIG: Record<AlertSeverity, { label: string; color: string; variant: 'error' | 'warning' | 'info' | 'success' }> = {
|
||||
urgent: { label: 'Urgente', color: 'bg-red-500', variant: 'error' },
|
||||
high: { label: 'Alta', color: 'bg-orange-500', variant: 'warning' },
|
||||
medium: { label: 'Media', color: 'bg-blue-500', variant: 'info' },
|
||||
low: { label: 'Baja', color: 'bg-green-500', variant: 'success' },
|
||||
};
|
||||
|
||||
const TIME_RANGES: Array<{ value: TimeGroup | 'all'; label: string }> = [
|
||||
{ value: 'all', label: 'Todos' },
|
||||
{ value: 'today', label: 'Hoy' },
|
||||
{ value: 'yesterday', label: 'Ayer' },
|
||||
{ value: 'this_week', label: 'Esta semana' },
|
||||
{ value: 'older', label: 'Anteriores' },
|
||||
];
|
||||
|
||||
const CATEGORIES: AlertCategory[] = ['inventory', 'production', 'orders', 'equipment', 'quality', 'suppliers'];
|
||||
|
||||
const AlertFilters: React.FC<AlertFiltersProps> = ({
|
||||
selectedSeverities,
|
||||
selectedCategories,
|
||||
selectedTimeRange,
|
||||
searchQuery,
|
||||
showSnoozed,
|
||||
onToggleSeverity,
|
||||
onToggleCategory,
|
||||
onSetTimeRange,
|
||||
onSearchChange,
|
||||
onToggleShowSnoozed,
|
||||
onClearFilters,
|
||||
hasActiveFilters,
|
||||
activeFilterCount,
|
||||
}) => {
|
||||
const { t, i18n } = useTranslation(['dashboard']);
|
||||
// Start collapsed by default for cleaner UI
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Search and Filter Toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t('dashboard:alerts.filters.search_placeholder', 'Buscar alertas...')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
leftIcon={<Search className="w-4 h-4" />}
|
||||
rightIcon={
|
||||
searchQuery ? (
|
||||
<button
|
||||
onClick={() => onSearchChange('')}
|
||||
className="p-1 hover:bg-[var(--bg-secondary)] rounded-full transition-colors"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
className="pr-8 h-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant={showFilters || hasActiveFilters ? 'primary' : 'outline'}
|
||||
size="md"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="flex items-center gap-2 relative h-10 px-4"
|
||||
aria-expanded={showFilters}
|
||||
aria-label="Toggle filters"
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
<span className="hidden sm:inline font-medium">Filtros</span>
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="absolute -top-1.5 -right-1.5 w-5 h-5 bg-[var(--color-error)] text-white text-xs font-bold rounded-full flex items-center justify-center">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className={`w-4 h-4 transition-transform duration-200 ${showFilters ? 'rotate-180' : ''}`} />
|
||||
</Button>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClearFilters}
|
||||
className="text-red-500 hover:text-red-600 h-10 px-3"
|
||||
title="Clear all filters"
|
||||
aria-label="Clear all filters"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
<span className="sr-only">Limpiar filtros</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expandable Filters Panel - Animated */}
|
||||
{showFilters && (
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] space-y-5 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
{/* Severity Filters */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-[var(--text-primary)] mb-3">
|
||||
{t('dashboard:alerts.filters.severity', 'Severidad')}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(Object.keys(SEVERITY_CONFIG) as AlertSeverity[]).map((severity) => {
|
||||
const config = SEVERITY_CONFIG[severity];
|
||||
const isSelected = selectedSeverities.includes(severity);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={severity}
|
||||
onClick={() => onToggleSeverity(severity)}
|
||||
className={`
|
||||
px-4 py-2 rounded-lg text-sm font-medium transition-all
|
||||
${isSelected
|
||||
? 'ring-2 ring-[var(--color-primary)] ring-offset-2 ring-offset-[var(--bg-secondary)] scale-105'
|
||||
: 'opacity-70 hover:opacity-100 hover:scale-105'
|
||||
}
|
||||
`}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
<Badge variant={config.variant} size="sm" className="pointer-events-none">
|
||||
{config.label}
|
||||
</Badge>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Filters */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-[var(--text-primary)] mb-3">
|
||||
{t('dashboard:alerts.filters.category', 'Categoría')}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CATEGORIES.map((category) => {
|
||||
const isSelected = selectedCategories.includes(category);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => onToggleCategory(category)}
|
||||
className={`
|
||||
px-4 py-2 rounded-lg text-sm font-medium transition-all border-2
|
||||
${isSelected
|
||||
? 'bg-[var(--color-primary)] text-white border-[var(--color-primary)] scale-105'
|
||||
: 'bg-[var(--bg-primary)] text-[var(--text-secondary)] border-[var(--border-primary)] hover:border-[var(--color-primary)] hover:text-[var(--text-primary)] hover:scale-105'
|
||||
}
|
||||
`}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
<span className="mr-1.5">{getCategoryIcon(category)}</span>
|
||||
{getCategoryName(category, i18n.language)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Range Filters */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-[var(--text-primary)] mb-3">
|
||||
{t('dashboard:alerts.filters.time_range', 'Periodo')}
|
||||
</label>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-5 gap-2">
|
||||
{TIME_RANGES.map((range) => {
|
||||
const isSelected = selectedTimeRange === range.value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={range.value}
|
||||
onClick={() => onSetTimeRange(range.value)}
|
||||
className={`
|
||||
px-4 py-2 rounded-lg text-sm font-medium transition-all
|
||||
${isSelected
|
||||
? 'bg-[var(--color-primary)] text-white shadow-md scale-105'
|
||||
: 'bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-primary)] hover:scale-105'
|
||||
}
|
||||
`}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
{range.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Show Snoozed Toggle */}
|
||||
<div className="flex items-center justify-between pt-3 border-t border-[var(--border-primary)]">
|
||||
<label htmlFor="show-snoozed-toggle" className="text-sm font-medium text-[var(--text-primary)] cursor-pointer">
|
||||
{t('dashboard:alerts.filters.show_snoozed', 'Mostrar pospuestos')}
|
||||
</label>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
id="show-snoozed-toggle"
|
||||
type="checkbox"
|
||||
checked={showSnoozed}
|
||||
onChange={onToggleShowSnoozed}
|
||||
className="sr-only peer"
|
||||
aria-label="Toggle show snoozed alerts"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-300 dark:bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[var(--color-primary)]/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[var(--color-primary)]"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Filters Summary - Chips */}
|
||||
{hasActiveFilters && !showFilters && (
|
||||
<div className="flex flex-wrap gap-2 animate-in fade-in slide-in-from-top-1 duration-200">
|
||||
<span className="text-xs font-medium text-[var(--text-secondary)] self-center">
|
||||
Filtros activos:
|
||||
</span>
|
||||
|
||||
{selectedSeverities.map((severity) => (
|
||||
<button
|
||||
key={severity}
|
||||
onClick={() => onToggleSeverity(severity)}
|
||||
className="group inline-flex items-center gap-1.5 transition-all hover:scale-105"
|
||||
aria-label={`Remove ${severity} filter`}
|
||||
>
|
||||
<Badge
|
||||
variant={SEVERITY_CONFIG[severity].variant}
|
||||
size="sm"
|
||||
className="flex items-center gap-1.5 pr-1"
|
||||
>
|
||||
{SEVERITY_CONFIG[severity].label}
|
||||
<span className="p-0.5 rounded-full hover:bg-black/10 transition-colors">
|
||||
<X className="w-3 h-3" />
|
||||
</span>
|
||||
</Badge>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{selectedCategories.map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => onToggleCategory(category)}
|
||||
className="group inline-flex items-center gap-1.5 transition-all hover:scale-105"
|
||||
aria-label={`Remove ${category} filter`}
|
||||
>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex items-center gap-1.5 pr-1"
|
||||
>
|
||||
{getCategoryIcon(category)} {getCategoryName(category, i18n.language)}
|
||||
<span className="p-0.5 rounded-full hover:bg-black/10 transition-colors">
|
||||
<X className="w-3 h-3" />
|
||||
</span>
|
||||
</Badge>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{selectedTimeRange !== 'all' && (
|
||||
<button
|
||||
onClick={() => onSetTimeRange('all')}
|
||||
className="group inline-flex items-center gap-1.5 transition-all hover:scale-105"
|
||||
aria-label="Remove time range filter"
|
||||
>
|
||||
<Badge
|
||||
variant="info"
|
||||
size="sm"
|
||||
className="flex items-center gap-1.5 pr-1"
|
||||
>
|
||||
{TIME_RANGES.find(r => r.value === selectedTimeRange)?.label}
|
||||
<span className="p-0.5 rounded-full hover:bg-black/10 transition-colors">
|
||||
<X className="w-3 h-3" />
|
||||
</span>
|
||||
</Badge>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertFilters;
|
||||
@@ -1,84 +0,0 @@
|
||||
import React from 'react';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Badge } from '../../ui/Badge';
|
||||
import type { AlertGroup } from '../../../utils/alertHelpers';
|
||||
|
||||
export interface AlertGroupHeaderProps {
|
||||
group: AlertGroup;
|
||||
isCollapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
}
|
||||
|
||||
const SEVERITY_COLORS: Record<string, string> = {
|
||||
urgent: 'text-red-600 bg-red-50 border-red-200',
|
||||
high: 'text-orange-600 bg-orange-50 border-orange-200',
|
||||
medium: 'text-blue-600 bg-blue-50 border-blue-200',
|
||||
low: 'text-green-600 bg-green-50 border-green-200',
|
||||
};
|
||||
|
||||
const SEVERITY_BADGE_VARIANTS: Record<string, 'error' | 'warning' | 'info' | 'success'> = {
|
||||
urgent: 'error',
|
||||
high: 'warning',
|
||||
medium: 'info',
|
||||
low: 'success',
|
||||
};
|
||||
|
||||
const AlertGroupHeader: React.FC<AlertGroupHeaderProps> = ({
|
||||
group,
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
}) => {
|
||||
const severityConfig = SEVERITY_COLORS[group.severity] || SEVERITY_COLORS.low;
|
||||
const badgeVariant = SEVERITY_BADGE_VARIANTS[group.severity] || 'info';
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className={`
|
||||
w-full flex items-center justify-between p-3.5 rounded-lg border-2 transition-all
|
||||
${severityConfig}
|
||||
hover:shadow-md cursor-pointer hover:scale-[1.01]
|
||||
`}
|
||||
aria-expanded={!isCollapsed}
|
||||
aria-label={`${isCollapsed ? 'Expand' : 'Collapse'} ${group.title}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="flex-shrink-0">
|
||||
{isCollapsed ? (
|
||||
<ChevronDown className="w-5 h-5" />
|
||||
) : (
|
||||
<ChevronUp className="w-5 h-5" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-left flex-1 min-w-0">
|
||||
<h3 className="font-bold text-sm truncate">
|
||||
{group.title}
|
||||
</h3>
|
||||
{group.type === 'similarity' && group.count > 1 && (
|
||||
<p className="text-xs opacity-75 mt-0.5">
|
||||
{group.alerts.length} alertas similares
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0 ml-3">
|
||||
{group.count > 1 && (
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1 bg-white/60 backdrop-blur-sm rounded-lg border border-current/20 min-h-[1.625rem]">
|
||||
<span className="text-xs font-bold leading-none">{group.count}</span>
|
||||
<span className="text-xs opacity-75 leading-none">alertas</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{group.severity && (
|
||||
<Badge variant={badgeVariant} size="sm" className="font-bold px-2.5 py-1 min-h-[1.625rem]">
|
||||
{group.severity.toUpperCase()}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertGroupHeader;
|
||||
@@ -1,118 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Clock, X } from 'lucide-react';
|
||||
import { Button } from '../../ui/Button';
|
||||
|
||||
export interface AlertSnoozeMenuProps {
|
||||
onSnooze: (duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const PRESET_DURATIONS = [
|
||||
{ value: '15min' as const, label: '15 minutos', icon: '⏰' },
|
||||
{ value: '1hr' as const, label: '1 hora', icon: '🕐' },
|
||||
{ value: '4hr' as const, label: '4 horas', icon: '🕓' },
|
||||
{ value: 'tomorrow' as const, label: 'Mañana (9 AM)', icon: '☀️' },
|
||||
];
|
||||
|
||||
const AlertSnoozeMenu: React.FC<AlertSnoozeMenuProps> = ({
|
||||
onSnooze,
|
||||
onCancel,
|
||||
}) => {
|
||||
const [showCustom, setShowCustom] = useState(false);
|
||||
const [customHours, setCustomHours] = useState(1);
|
||||
|
||||
const handleCustomSnooze = () => {
|
||||
const milliseconds = customHours * 60 * 60 * 1000;
|
||||
onSnooze(milliseconds);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg shadow-lg p-3 min-w-[240px]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3 pb-2 border-b border-[var(--border-primary)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-[var(--color-primary)]" />
|
||||
<span className="text-sm font-semibold text-[var(--text-primary)]">
|
||||
Posponer hasta
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="p-1 hover:bg-[var(--bg-secondary)] rounded transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4 text-[var(--text-secondary)]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!showCustom ? (
|
||||
<>
|
||||
{/* Preset Options */}
|
||||
<div className="space-y-1 mb-2">
|
||||
{PRESET_DURATIONS.map((preset) => (
|
||||
<button
|
||||
key={preset.value}
|
||||
onClick={() => onSnooze(preset.value)}
|
||||
className="w-full px-3 py-2 text-left text-sm rounded-lg hover:bg-[var(--bg-secondary)] transition-colors flex items-center gap-2 text-[var(--text-primary)]"
|
||||
>
|
||||
<span className="text-lg">{preset.icon}</span>
|
||||
<span>{preset.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Custom Option */}
|
||||
<button
|
||||
onClick={() => setShowCustom(true)}
|
||||
className="w-full px-3 py-2 text-left text-sm rounded-lg border border-dashed border-[var(--border-primary)] hover:bg-[var(--bg-secondary)] transition-colors text-[var(--text-secondary)]"
|
||||
>
|
||||
⚙️ Personalizado...
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Custom Time Input */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
||||
Número de horas
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="168"
|
||||
value={customHours}
|
||||
onChange={(e) => setCustomHours(parseInt(e.target.value) || 1)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-primary)] rounded-lg text-sm bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-[var(--text-tertiary)] mt-1">
|
||||
Máximo 168 horas (7 días)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowCustom(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
Atrás
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleCustomSnooze}
|
||||
className="flex-1"
|
||||
>
|
||||
Confirmar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertSnoozeMenu;
|
||||
@@ -1,179 +0,0 @@
|
||||
import React from 'react';
|
||||
import { TrendingUp, Clock, AlertTriangle, BarChart3 } from 'lucide-react';
|
||||
import { Badge } from '../../ui/Badge';
|
||||
import type { AlertAnalytics } from '../../../hooks/useAlertAnalytics';
|
||||
|
||||
export interface AlertTrendsProps {
|
||||
analytics: AlertAnalytics | undefined;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const AlertTrends: React.FC<AlertTrendsProps> = ({ analytics, className }) => {
|
||||
// Debug logging
|
||||
console.log('[AlertTrends] Received analytics:', analytics);
|
||||
console.log('[AlertTrends] Has trends?', analytics?.trends);
|
||||
console.log('[AlertTrends] Is array?', Array.isArray(analytics?.trends));
|
||||
|
||||
// Safety check: handle undefined or missing analytics data
|
||||
if (!analytics || !analytics.trends || !Array.isArray(analytics.trends)) {
|
||||
console.log('[AlertTrends] Showing loading state');
|
||||
return (
|
||||
<div className={`bg-[var(--bg-secondary)] rounded-lg p-4 border border-[var(--border-primary)] ${className}`}>
|
||||
<div className="flex items-center justify-center h-32 text-[var(--text-secondary)]">
|
||||
Cargando analíticas...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[AlertTrends] Rendering analytics with', analytics.trends.length, 'trends');
|
||||
|
||||
// Ensure we have valid trend data
|
||||
const validTrends = analytics.trends.filter(t => t && typeof t.count === 'number');
|
||||
const maxCount = validTrends.length > 0 ? Math.max(...validTrends.map(t => t.count), 1) : 1;
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
if (date.toDateString() === today.toDateString()) {
|
||||
return 'Hoy';
|
||||
}
|
||||
if (date.toDateString() === yesterday.toDateString()) {
|
||||
return 'Ayer';
|
||||
}
|
||||
return date.toLocaleDateString('es-ES', { weekday: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`bg-[var(--bg-secondary)] rounded-lg p-4 border border-[var(--border-primary)] ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-[var(--color-primary)]" />
|
||||
<h3 className="font-semibold text-[var(--text-primary)]">
|
||||
Tendencias (7 días)
|
||||
</h3>
|
||||
</div>
|
||||
<Badge variant="secondary" size="sm">
|
||||
{analytics.totalAlerts} total
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-end justify-between gap-1 h-32">
|
||||
{analytics.trends.map((trend, index) => {
|
||||
const heightPercentage = maxCount > 0 ? (trend.count / maxCount) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={trend.date}
|
||||
className="flex-1 flex flex-col items-center gap-1"
|
||||
>
|
||||
<div className="w-full flex flex-col justify-end" style={{ height: '100px' }}>
|
||||
{/* Bar */}
|
||||
<div
|
||||
className="w-full bg-gradient-to-t from-[var(--color-primary)] to-[var(--color-primary)]/60 rounded-t transition-all hover:opacity-80 cursor-pointer relative group"
|
||||
style={{ height: `${heightPercentage}%`, minHeight: trend.count > 0 ? '4px' : '0' }}
|
||||
title={`${trend.count} alertas`}
|
||||
>
|
||||
{/* Tooltip on hover */}
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
|
||||
<div className="bg-[var(--text-primary)] text-white text-xs rounded px-2 py-1 whitespace-nowrap">
|
||||
{trend.count} alertas
|
||||
<div className="text-[10px] opacity-75">
|
||||
🔴 {trend.urgentCount} • 🟠 {trend.highCount} • 🔵 {trend.mediumCount} • 🟢 {trend.lowCount}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<span className="text-[10px] text-[var(--text-secondary)] text-center">
|
||||
{formatDate(trend.date)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 gap-3 pt-3 border-t border-[var(--border-primary)]">
|
||||
{/* Average Response Time */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 bg-blue-500/10 rounded">
|
||||
<Clock className="w-4 h-4 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">Respuesta promedio</div>
|
||||
<div className="text-sm font-semibold text-[var(--text-primary)]">
|
||||
{analytics.averageResponseTime > 0 ? `${analytics.averageResponseTime} min` : 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Daily Average */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 bg-purple-500/10 rounded">
|
||||
<TrendingUp className="w-4 h-4 text-purple-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">Promedio diario</div>
|
||||
<div className="text-sm font-semibold text-[var(--text-primary)]">
|
||||
{analytics.predictedDailyAverage} alertas
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resolution Rate */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 bg-green-500/10 rounded">
|
||||
<BarChart3 className="w-4 h-4 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">Tasa de resolución</div>
|
||||
<div className="text-sm font-semibold text-[var(--text-primary)]">
|
||||
{analytics.resolutionRate}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Busiest Day */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 bg-orange-500/10 rounded">
|
||||
<AlertTriangle className="w-4 h-4 text-orange-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">Día más activo</div>
|
||||
<div className="text-sm font-semibold text-[var(--text-primary)]">
|
||||
{analytics.busiestDay}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Categories */}
|
||||
{analytics.topCategories.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-[var(--border-primary)]">
|
||||
<div className="text-xs font-semibold text-[var(--text-secondary)] mb-2">
|
||||
Categorías principales
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{analytics.topCategories.map((cat) => (
|
||||
<Badge key={cat.category} variant="secondary" size="sm">
|
||||
{cat.count} ({cat.percentage}%)
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertTrends;
|
||||
@@ -0,0 +1,286 @@
|
||||
// ================================================================
|
||||
// frontend/src/components/domain/dashboard/AutoActionCountdownComponent.tsx
|
||||
// ================================================================
|
||||
/**
|
||||
* Auto Action Countdown Component
|
||||
*
|
||||
* Displays countdown timer for ESCALATION type alerts where AI will
|
||||
* automatically take action unless user intervenes.
|
||||
*
|
||||
* Design Philosophy:
|
||||
* - High urgency visual design
|
||||
* - Clear countdown timer
|
||||
* - One-click cancel button
|
||||
* - Shows what action will be taken
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { AlertTriangle, Clock, XCircle, CheckCircle } from 'lucide-react';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { Badge } from '../../ui/Badge';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface AutoActionCountdownProps {
|
||||
actionDescription: string;
|
||||
countdownSeconds: number;
|
||||
onCancel: () => Promise<void>;
|
||||
financialImpactEur?: number;
|
||||
alertId: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AutoActionCountdownComponent({
|
||||
actionDescription,
|
||||
countdownSeconds,
|
||||
onCancel,
|
||||
financialImpactEur,
|
||||
alertId,
|
||||
className = '',
|
||||
}: AutoActionCountdownProps) {
|
||||
const { t } = useTranslation('alerts');
|
||||
const [timeRemaining, setTimeRemaining] = useState(countdownSeconds);
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
const [isCancelled, setIsCancelled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (timeRemaining <= 0 || isCancelled) return;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setTimeRemaining((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timer);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [timeRemaining, isCancelled]);
|
||||
|
||||
const handleCancel = async () => {
|
||||
setIsCancelling(true);
|
||||
try {
|
||||
await onCancel();
|
||||
setIsCancelled(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel auto-action:', error);
|
||||
} finally {
|
||||
setIsCancelling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const getUrgencyLevel = (seconds: number): 'critical' | 'warning' | 'info' => {
|
||||
if (seconds <= 60) return 'critical';
|
||||
if (seconds <= 300) return 'warning';
|
||||
return 'info';
|
||||
};
|
||||
|
||||
const urgency = getUrgencyLevel(timeRemaining);
|
||||
const progressPercentage = ((countdownSeconds - timeRemaining) / countdownSeconds) * 100;
|
||||
|
||||
// Cancelled state
|
||||
if (isCancelled) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg p-4 border ${className}`}
|
||||
style={{
|
||||
backgroundColor: 'var(--color-success-50)',
|
||||
borderColor: 'var(--color-success-300)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="w-6 h-6 flex-shrink-0" style={{ color: 'var(--color-success-600)' }} />
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold" style={{ color: 'var(--color-success-700)' }}>
|
||||
{t('auto_action.cancelled_title', 'Auto-action Cancelled')}
|
||||
</h4>
|
||||
<p className="text-sm mt-1" style={{ color: 'var(--color-success-600)' }}>
|
||||
{t('auto_action.cancelled_message', 'The automatic action has been prevented. You can now handle this manually.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Action completed (countdown reached 0)
|
||||
if (timeRemaining <= 0) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg p-4 border ${className}`}
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-secondary)',
|
||||
borderColor: 'var(--border-primary)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Clock className="w-6 h-6 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('auto_action.completed_title', 'Auto-action Executed')}
|
||||
</h4>
|
||||
<p className="text-sm mt-1" style={{ color: 'var(--text-secondary)' }}>
|
||||
{actionDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Active countdown
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg p-4 border-2 shadow-lg ${className}`}
|
||||
style={{
|
||||
backgroundColor:
|
||||
urgency === 'critical'
|
||||
? 'var(--color-error-50)'
|
||||
: urgency === 'warning'
|
||||
? 'var(--color-warning-50)'
|
||||
: 'var(--color-info-50)',
|
||||
borderColor:
|
||||
urgency === 'critical'
|
||||
? 'var(--color-error-400)'
|
||||
: urgency === 'warning'
|
||||
? 'var(--color-warning-400)'
|
||||
: 'var(--color-info-400)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div
|
||||
className={`p-2 rounded-full ${
|
||||
urgency === 'critical' ? 'animate-pulse' : ''
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor:
|
||||
urgency === 'critical'
|
||||
? 'var(--color-error-100)'
|
||||
: urgency === 'warning'
|
||||
? 'var(--color-warning-100)'
|
||||
: 'var(--color-info-100)',
|
||||
}}
|
||||
>
|
||||
<AlertTriangle
|
||||
className="w-5 h-5"
|
||||
style={{
|
||||
color:
|
||||
urgency === 'critical'
|
||||
? 'var(--color-error-600)'
|
||||
: urgency === 'warning'
|
||||
? 'var(--color-warning-600)'
|
||||
: 'var(--color-info-600)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-bold" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('auto_action.title', 'Auto-action Pending')}
|
||||
</h4>
|
||||
<Badge
|
||||
variant={urgency === 'critical' ? 'error' : urgency === 'warning' ? 'warning' : 'info'}
|
||||
size="sm"
|
||||
>
|
||||
{urgency === 'critical'
|
||||
? t('auto_action.urgency.critical', 'URGENT')
|
||||
: urgency === 'warning'
|
||||
? t('auto_action.urgency.warning', 'SOON')
|
||||
: t('auto_action.urgency.info', 'SCHEDULED')}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||
{actionDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Countdown Timer */}
|
||||
<div className="text-center flex-shrink-0">
|
||||
<div
|
||||
className="text-2xl font-bold tabular-nums"
|
||||
style={{
|
||||
color:
|
||||
urgency === 'critical'
|
||||
? 'var(--color-error-600)'
|
||||
: urgency === 'warning'
|
||||
? 'var(--color-warning-600)'
|
||||
: 'var(--color-info-600)',
|
||||
}}
|
||||
>
|
||||
{formatTime(timeRemaining)}
|
||||
</div>
|
||||
<div className="text-xs font-medium" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{t('auto_action.remaining', 'remaining')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-3 rounded-full overflow-hidden" style={{ backgroundColor: 'var(--bg-tertiary)', height: '6px' }}>
|
||||
<div
|
||||
className="h-full transition-all duration-1000 ease-linear"
|
||||
style={{
|
||||
width: `${progressPercentage}%`,
|
||||
backgroundColor:
|
||||
urgency === 'critical'
|
||||
? 'var(--color-error-500)'
|
||||
: urgency === 'warning'
|
||||
? 'var(--color-warning-500)'
|
||||
: 'var(--color-info-500)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Financial Impact & Cancel Button */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{financialImpactEur !== undefined && financialImpactEur > 0 && (
|
||||
<div className="text-sm">
|
||||
<span className="font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('auto_action.financial_impact', 'Impact:')}
|
||||
</span>{' '}
|
||||
<span className="font-bold" style={{ color: 'var(--text-primary)' }}>
|
||||
€{financialImpactEur.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCancel}
|
||||
disabled={isCancelling}
|
||||
className="ml-auto bg-white/80 hover:bg-white flex items-center gap-2"
|
||||
>
|
||||
<XCircle className="w-4 h-4" />
|
||||
{isCancelling
|
||||
? t('auto_action.cancelling', 'Cancelling...')
|
||||
: t('auto_action.cancel_button', 'Cancel Auto-action')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Help Text */}
|
||||
<div
|
||||
className="mt-3 text-xs p-2 rounded"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
color: 'var(--text-tertiary)',
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
'auto_action.help_text',
|
||||
'AI will automatically execute this action when the timer expires. Click "Cancel" to prevent it and handle manually.'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
import { useIngredients } from '../../../api/hooks/inventory';
|
||||
import { useSuppliers } from '../../../api/hooks/suppliers';
|
||||
import { useRecipes } from '../../../api/hooks/recipes';
|
||||
import { useQualityTemplates } from '../../../api/hooks/qualityTemplates';
|
||||
import { CheckCircle2, Circle, AlertCircle, ChevronRight, Package, Users, BookOpen, Shield } from 'lucide-react';
|
||||
|
||||
interface ConfigurationSection {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: React.ElementType;
|
||||
path: string;
|
||||
count: number;
|
||||
minimum: number;
|
||||
recommended: number;
|
||||
isOptional?: boolean;
|
||||
isComplete: boolean;
|
||||
nextAction?: string;
|
||||
}
|
||||
|
||||
export const ConfigurationProgressWidget: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
// Fetch configuration data
|
||||
const { data: ingredients = [], isLoading: loadingIngredients } = useIngredients(tenantId, {}, { enabled: !!tenantId });
|
||||
const { data: suppliersData, isLoading: loadingSuppliers } = useSuppliers(tenantId, { enabled: !!tenantId });
|
||||
const suppliers = suppliersData?.suppliers || [];
|
||||
const { data: recipesData, isLoading: loadingRecipes } = useRecipes(tenantId, { enabled: !!tenantId });
|
||||
const recipes = recipesData?.recipes || [];
|
||||
const { data: qualityData, isLoading: loadingQuality } = useQualityTemplates(tenantId, { enabled: !!tenantId });
|
||||
const qualityTemplates = qualityData?.templates || [];
|
||||
|
||||
const isLoading = loadingIngredients || loadingSuppliers || loadingRecipes || loadingQuality;
|
||||
|
||||
// Calculate configuration sections
|
||||
const sections: ConfigurationSection[] = useMemo(() => [
|
||||
{
|
||||
id: 'inventory',
|
||||
title: t('dashboard:config.inventory', 'Inventory'),
|
||||
icon: Package,
|
||||
path: '/app/operations/inventory',
|
||||
count: ingredients.length,
|
||||
minimum: 3,
|
||||
recommended: 10,
|
||||
isComplete: ingredients.length >= 3,
|
||||
nextAction: ingredients.length < 3 ? t('dashboard:config.add_ingredients', 'Add at least {{count}} ingredients', { count: 3 - ingredients.length }) : undefined
|
||||
},
|
||||
{
|
||||
id: 'suppliers',
|
||||
title: t('dashboard:config.suppliers', 'Suppliers'),
|
||||
icon: Users,
|
||||
path: '/app/operations/suppliers',
|
||||
count: suppliers.length,
|
||||
minimum: 1,
|
||||
recommended: 3,
|
||||
isComplete: suppliers.length >= 1,
|
||||
nextAction: suppliers.length < 1 ? t('dashboard:config.add_supplier', 'Add your first supplier') : undefined
|
||||
},
|
||||
{
|
||||
id: 'recipes',
|
||||
title: t('dashboard:config.recipes', 'Recipes'),
|
||||
icon: BookOpen,
|
||||
path: '/app/operations/recipes',
|
||||
count: recipes.length,
|
||||
minimum: 1,
|
||||
recommended: 3,
|
||||
isComplete: recipes.length >= 1,
|
||||
nextAction: recipes.length < 1 ? t('dashboard:config.add_recipe', 'Create your first recipe') : undefined
|
||||
},
|
||||
{
|
||||
id: 'quality',
|
||||
title: t('dashboard:config.quality', 'Quality Standards'),
|
||||
icon: Shield,
|
||||
path: '/app/operations/production/quality',
|
||||
count: qualityTemplates.length,
|
||||
minimum: 0,
|
||||
recommended: 2,
|
||||
isOptional: true,
|
||||
isComplete: true, // Optional, so always "complete"
|
||||
nextAction: qualityTemplates.length < 2 ? t('dashboard:config.add_quality', 'Add quality checks (optional)') : undefined
|
||||
}
|
||||
], [ingredients.length, suppliers.length, recipes.length, qualityTemplates.length, t]);
|
||||
|
||||
// Calculate overall progress
|
||||
const { completedSections, totalSections, progressPercentage, nextIncompleteSection } = useMemo(() => {
|
||||
const requiredSections = sections.filter(s => !s.isOptional);
|
||||
const completed = requiredSections.filter(s => s.isComplete).length;
|
||||
const total = requiredSections.length;
|
||||
const percentage = Math.round((completed / total) * 100);
|
||||
const nextIncomplete = sections.find(s => !s.isComplete && !s.isOptional);
|
||||
|
||||
return {
|
||||
completedSections: completed,
|
||||
totalSections: total,
|
||||
progressPercentage: percentage,
|
||||
nextIncompleteSection: nextIncomplete
|
||||
};
|
||||
}, [sections]);
|
||||
|
||||
const isFullyConfigured = progressPercentage === 100;
|
||||
|
||||
// Determine unlocked features
|
||||
const unlockedFeatures = useMemo(() => {
|
||||
const features: string[] = [];
|
||||
if (ingredients.length >= 3) features.push(t('dashboard:config.features.inventory_tracking', 'Inventory Tracking'));
|
||||
if (suppliers.length >= 1 && ingredients.length >= 3) features.push(t('dashboard:config.features.purchase_orders', 'Purchase Orders'));
|
||||
if (recipes.length >= 1 && ingredients.length >= 3) features.push(t('dashboard:config.features.production_planning', 'Production Planning'));
|
||||
if (recipes.length >= 1 && ingredients.length >= 3 && suppliers.length >= 1) features.push(t('dashboard:config.features.cost_analysis', 'Cost Analysis'));
|
||||
return features;
|
||||
}, [ingredients.length, suppliers.length, recipes.length, t]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-[var(--color-primary)]"></div>
|
||||
<span className="text-sm text-[var(--text-secondary)]">{t('common:loading', 'Loading configuration...')}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Don't show widget if fully configured
|
||||
if (isFullyConfigured) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-[var(--bg-primary)] to-[var(--bg-secondary)] border-2 border-[var(--color-primary)]/20 rounded-xl shadow-lg overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-6 pb-4 border-b border-[var(--border-secondary)]">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-[var(--color-primary)]/10 flex items-center justify-center">
|
||||
<AlertCircle className="w-5 h-5 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
🏗️ {t('dashboard:config.title', 'Complete Your Bakery Setup')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-0.5">
|
||||
{t('dashboard:config.subtitle', 'Configure essential features to get started')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<span className="font-medium text-[var(--text-primary)]">
|
||||
{completedSections}/{totalSections} {t('dashboard:config.sections_complete', 'sections complete')}
|
||||
</span>
|
||||
<span className="text-[var(--color-primary)] font-bold">{progressPercentage}%</span>
|
||||
</div>
|
||||
<div className="w-full h-2.5 bg-[var(--bg-tertiary)] rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-success)] transition-all duration-500 ease-out"
|
||||
style={{ width: `${progressPercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sections List */}
|
||||
<div className="p-6 pt-4 space-y-3">
|
||||
{sections.map((section) => {
|
||||
const Icon = section.icon;
|
||||
const meetsRecommended = section.count >= section.recommended;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={section.id}
|
||||
onClick={() => navigate(section.path)}
|
||||
className={`w-full p-4 rounded-lg border-2 transition-all duration-200 text-left group ${
|
||||
section.isComplete
|
||||
? 'border-[var(--color-success)]/30 bg-[var(--color-success)]/5 hover:bg-[var(--color-success)]/10'
|
||||
: 'border-[var(--border-secondary)] hover:border-[var(--color-primary)] hover:bg-[var(--bg-secondary)]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Status Icon */}
|
||||
<div className={`flex-shrink-0 ${
|
||||
section.isComplete
|
||||
? 'text-[var(--color-success)]'
|
||||
: 'text-[var(--text-tertiary)]'
|
||||
}`}>
|
||||
{section.isComplete ? (
|
||||
<CheckCircle2 className="w-5 h-5" />
|
||||
) : (
|
||||
<Circle className="w-5 h-5" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Section Icon */}
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
||||
section.isComplete
|
||||
? 'bg-[var(--color-success)]/10 text-[var(--color-success)]'
|
||||
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'
|
||||
}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-semibold text-[var(--text-primary)]">{section.title}</h4>
|
||||
{section.isOptional && (
|
||||
<span className="text-xs px-2 py-0.5 bg-[var(--bg-tertiary)] text-[var(--text-tertiary)] rounded-full">
|
||||
{t('common:optional', 'Optional')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className={`font-medium ${
|
||||
section.isComplete
|
||||
? 'text-[var(--color-success)]'
|
||||
: 'text-[var(--text-secondary)]'
|
||||
}`}>
|
||||
{section.count} {t('dashboard:config.added', 'added')}
|
||||
</span>
|
||||
|
||||
{!section.isComplete && section.nextAction && (
|
||||
<span className="text-[var(--text-tertiary)]">
|
||||
• {section.nextAction}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{section.isComplete && !meetsRecommended && (
|
||||
<span className="text-[var(--text-tertiary)]">
|
||||
• {section.recommended} {t('dashboard:config.recommended', 'recommended')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chevron */}
|
||||
<ChevronRight className="w-5 h-5 text-[var(--text-tertiary)] group-hover:text-[var(--color-primary)] transition-colors flex-shrink-0" />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Next Action / Unlocked Features */}
|
||||
<div className="px-6 pb-6">
|
||||
{nextIncompleteSection ? (
|
||||
<div className="p-4 bg-[var(--color-warning)]/10 border border-[var(--color-warning)]/30 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-[var(--color-warning)] flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||
👉 {t('dashboard:config.next_step', 'Next Step')}
|
||||
</p>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-3">
|
||||
{nextIncompleteSection.nextAction}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate(nextIncompleteSection.path)}
|
||||
className="px-4 py-2 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary-dark)] transition-colors text-sm font-medium inline-flex items-center gap-2"
|
||||
>
|
||||
{t('dashboard:config.configure', 'Configure')} {nextIncompleteSection.title}
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : unlockedFeatures.length > 0 && (
|
||||
<div className="p-4 bg-[var(--color-success)]/10 border border-[var(--color-success)]/30 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="w-5 h-5 text-[var(--color-success)] flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)] mb-2">
|
||||
🎉 {t('dashboard:config.features_unlocked', 'Features Unlocked!')}
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{unlockedFeatures.map((feature, idx) => (
|
||||
<li key={idx} className="text-sm text-[var(--text-secondary)] flex items-center gap-2">
|
||||
<CheckCircle2 className="w-3 h-3 text-[var(--color-success)]" />
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
83
frontend/src/components/domain/dashboard/PriorityBadge.tsx
Normal file
83
frontend/src/components/domain/dashboard/PriorityBadge.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../../../lib/utils';
|
||||
|
||||
interface PriorityBadgeProps {
|
||||
score: number; // 0-100
|
||||
level: 'critical' | 'important' | 'standard' | 'info';
|
||||
className?: string;
|
||||
showScore?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* PriorityBadge - Visual indicator for alert priority
|
||||
*
|
||||
* Priority Levels:
|
||||
* - Critical (90-100): Red with pulse animation
|
||||
* - Important (70-89): Orange/Yellow
|
||||
* - Standard (50-69): Blue
|
||||
* - Info (0-49): Gray
|
||||
*/
|
||||
export const PriorityBadge: React.FC<PriorityBadgeProps> = ({
|
||||
score,
|
||||
level,
|
||||
className,
|
||||
showScore = true,
|
||||
}) => {
|
||||
const getStyles = () => {
|
||||
switch (level) {
|
||||
case 'critical':
|
||||
return {
|
||||
bg: 'bg-red-100 dark:bg-red-900/30',
|
||||
text: 'text-red-800 dark:text-red-200',
|
||||
border: 'border-red-300 dark:border-red-700',
|
||||
pulse: 'animate-pulse',
|
||||
};
|
||||
case 'important':
|
||||
return {
|
||||
bg: 'bg-orange-100 dark:bg-orange-900/30',
|
||||
text: 'text-orange-800 dark:text-orange-200',
|
||||
border: 'border-orange-300 dark:border-orange-700',
|
||||
pulse: '',
|
||||
};
|
||||
case 'standard':
|
||||
return {
|
||||
bg: 'bg-blue-100 dark:bg-blue-900/30',
|
||||
text: 'text-blue-800 dark:text-blue-200',
|
||||
border: 'border-blue-300 dark:border-blue-700',
|
||||
pulse: '',
|
||||
};
|
||||
case 'info':
|
||||
default:
|
||||
return {
|
||||
bg: 'bg-gray-100 dark:bg-gray-800',
|
||||
text: 'text-gray-700 dark:text-gray-300',
|
||||
border: 'border-gray-300 dark:border-gray-600',
|
||||
pulse: '',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const styles = getStyles();
|
||||
const displayText = level.charAt(0).toUpperCase() + level.slice(1);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border',
|
||||
styles.bg,
|
||||
styles.text,
|
||||
styles.border,
|
||||
styles.pulse,
|
||||
className
|
||||
)}
|
||||
title={`Puntuación de prioridad: ${score}/100`}
|
||||
>
|
||||
<span>{displayText}</span>
|
||||
{showScore && (
|
||||
<span className="font-bold">{score}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PriorityBadge;
|
||||
@@ -0,0 +1,358 @@
|
||||
// ================================================================
|
||||
// frontend/src/components/domain/dashboard/PriorityScoreExplainerModal.tsx
|
||||
// ================================================================
|
||||
/**
|
||||
* Priority Score Explainer Modal
|
||||
*
|
||||
* Educational modal explaining the 0-100 priority scoring algorithm.
|
||||
* Builds trust by showing transparency into AI decision-making.
|
||||
*
|
||||
* Shows:
|
||||
* - Overall priority score breakdown
|
||||
* - 4 weighted components (Business Impact 40%, Urgency 30%, Agency 20%, Confidence 10%)
|
||||
* - Example calculations
|
||||
* - Visual progress bars for each component
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { X, TrendingUp, DollarSign, Clock, Target, Brain, Info } from 'lucide-react';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { Badge } from '../../ui/Badge';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface PriorityScoreExplainerModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
exampleScore?: number;
|
||||
exampleBreakdown?: {
|
||||
businessImpact: number;
|
||||
urgency: number;
|
||||
agency: number;
|
||||
confidence: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface ScoreComponent {
|
||||
name: string;
|
||||
icon: typeof DollarSign;
|
||||
weight: number;
|
||||
description: string;
|
||||
examples: string[];
|
||||
color: string;
|
||||
}
|
||||
|
||||
export function PriorityScoreExplainerModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
exampleScore,
|
||||
exampleBreakdown,
|
||||
}: PriorityScoreExplainerModalProps) {
|
||||
const { t } = useTranslation('alerts');
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const components: ScoreComponent[] = [
|
||||
{
|
||||
name: t('priority_explainer.business_impact.name', 'Business Impact'),
|
||||
icon: DollarSign,
|
||||
weight: 40,
|
||||
description: t(
|
||||
'priority_explainer.business_impact.description',
|
||||
'Financial consequences, affected orders, customer satisfaction'
|
||||
),
|
||||
examples: [
|
||||
t('priority_explainer.business_impact.example1', '€500 in potential revenue at risk'),
|
||||
t('priority_explainer.business_impact.example2', '10 customer orders affected'),
|
||||
t('priority_explainer.business_impact.example3', 'High customer satisfaction impact'),
|
||||
],
|
||||
color: 'var(--color-error-500)',
|
||||
},
|
||||
{
|
||||
name: t('priority_explainer.urgency.name', 'Urgency'),
|
||||
icon: Clock,
|
||||
weight: 30,
|
||||
description: t(
|
||||
'priority_explainer.urgency.description',
|
||||
'Time sensitivity, deadlines, escalation potential'
|
||||
),
|
||||
examples: [
|
||||
t('priority_explainer.urgency.example1', 'Deadline in 2 hours'),
|
||||
t('priority_explainer.urgency.example2', 'Stockout imminent (4 hours)'),
|
||||
t('priority_explainer.urgency.example3', 'Production window closing soon'),
|
||||
],
|
||||
color: 'var(--color-warning-500)',
|
||||
},
|
||||
{
|
||||
name: t('priority_explainer.agency.name', 'User Agency'),
|
||||
icon: Target,
|
||||
weight: 20,
|
||||
description: t(
|
||||
'priority_explainer.agency.description',
|
||||
'Can you take action? Do you have control over the outcome?'
|
||||
),
|
||||
examples: [
|
||||
t('priority_explainer.agency.example1', 'Requires approval from you'),
|
||||
t('priority_explainer.agency.example2', 'One-click action available'),
|
||||
t('priority_explainer.agency.example3', 'Decision needed within your authority'),
|
||||
],
|
||||
color: 'var(--color-primary-500)',
|
||||
},
|
||||
{
|
||||
name: t('priority_explainer.confidence.name', 'AI Confidence'),
|
||||
icon: Brain,
|
||||
weight: 10,
|
||||
description: t(
|
||||
'priority_explainer.confidence.description',
|
||||
"How certain is the AI about this alert's validity?"
|
||||
),
|
||||
examples: [
|
||||
t('priority_explainer.confidence.example1', 'Based on historical patterns (95% match)'),
|
||||
t('priority_explainer.confidence.example2', 'Data quality: High'),
|
||||
t('priority_explainer.confidence.example3', 'Prediction accuracy validated'),
|
||||
],
|
||||
color: 'var(--color-info-500)',
|
||||
},
|
||||
];
|
||||
|
||||
const calculateExampleScore = (breakdown?: typeof exampleBreakdown): number => {
|
||||
if (!breakdown) return 75; // Default example
|
||||
|
||||
return (
|
||||
(breakdown.businessImpact * 0.4) +
|
||||
(breakdown.urgency * 0.3) +
|
||||
(breakdown.agency * 0.2) +
|
||||
(breakdown.confidence * 0.1)
|
||||
);
|
||||
};
|
||||
|
||||
const displayScore = exampleScore ?? calculateExampleScore(exampleBreakdown);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-in fade-in duration-200"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-[var(--bg-primary)] rounded-xl shadow-2xl max-w-3xl w-full max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="sticky top-0 z-10 flex items-center justify-between p-6 border-b"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
borderColor: 'var(--border-primary)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="p-2 rounded-lg"
|
||||
style={{ backgroundColor: 'var(--color-primary-100)' }}
|
||||
>
|
||||
<TrendingUp className="w-6 h-6" style={{ color: 'var(--color-primary-600)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('priority_explainer.title', 'Understanding Priority Scores')}
|
||||
</h2>
|
||||
<p className="text-sm mt-1" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('priority_explainer.subtitle', 'How AI calculates what needs your attention first')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:bg-[var(--bg-secondary)] transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Overview */}
|
||||
<div
|
||||
className="rounded-lg p-5 border"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-secondary)',
|
||||
borderColor: 'var(--border-primary)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="w-5 h-5 mt-0.5 flex-shrink-0" style={{ color: 'var(--color-info)' }} />
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('priority_explainer.overview.title', 'The Priority Score (0-100)')}
|
||||
</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t(
|
||||
'priority_explainer.overview.description',
|
||||
'Every alert receives a priority score from 0-100 based on four weighted components. This helps you focus on what truly matters for your bakery.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Example Score */}
|
||||
{exampleScore !== undefined && (
|
||||
<div
|
||||
className="rounded-lg p-5 border-2"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-primary-50)',
|
||||
borderColor: 'var(--color-primary-300)',
|
||||
}}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('priority_explainer.example_alert', 'Example Alert Priority')}
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<div className="text-5xl font-bold" style={{ color: 'var(--color-primary-600)' }}>
|
||||
{displayScore.toFixed(0)}
|
||||
</div>
|
||||
<Badge variant="primary" size="lg">
|
||||
{displayScore >= 90
|
||||
? t('priority_explainer.level.critical', 'CRITICAL')
|
||||
: displayScore >= 70
|
||||
? t('priority_explainer.level.important', 'IMPORTANT')
|
||||
: displayScore >= 50
|
||||
? t('priority_explainer.level.standard', 'STANDARD')
|
||||
: t('priority_explainer.level.info', 'INFO')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Components Breakdown */}
|
||||
<div>
|
||||
<h3 className="text-lg font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('priority_explainer.components_title', 'Score Components')}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{components.map((component) => {
|
||||
const Icon = component.icon;
|
||||
const componentValue = exampleBreakdown
|
||||
? exampleBreakdown[
|
||||
component.name.toLowerCase().replace(/\s+/g, '') as keyof typeof exampleBreakdown
|
||||
] || 75
|
||||
: 75;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={component.name}
|
||||
className="rounded-lg p-4 border"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-secondary)',
|
||||
borderColor: 'var(--border-primary)',
|
||||
}}
|
||||
>
|
||||
{/* Component Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="p-2 rounded-lg"
|
||||
style={{ backgroundColor: component.color + '20' }}
|
||||
>
|
||||
<Icon className="w-5 h-5" style={{ color: component.color }} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
{component.name}
|
||||
</h4>
|
||||
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{component.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary" size="sm">
|
||||
{component.weight}% {t('priority_explainer.weight', 'weight')}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-3">
|
||||
<div
|
||||
className="h-2 rounded-full overflow-hidden"
|
||||
style={{ backgroundColor: 'var(--bg-tertiary)' }}
|
||||
>
|
||||
<div
|
||||
className="h-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${componentValue}%`,
|
||||
backgroundColor: component.color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs mt-1">
|
||||
<span style={{ color: 'var(--text-tertiary)' }}>0</span>
|
||||
<span className="font-semibold" style={{ color: component.color }}>
|
||||
{componentValue}/100
|
||||
</span>
|
||||
<span style={{ color: 'var(--text-tertiary)' }}>100</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Examples */}
|
||||
<div className="space-y-1">
|
||||
{component.examples.map((example, idx) => (
|
||||
<div key={idx} className="flex items-start gap-2 text-xs">
|
||||
<span style={{ color: component.color }}>•</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{example}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Formula */}
|
||||
<div
|
||||
className="rounded-lg p-5 border"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-tertiary)',
|
||||
borderColor: 'var(--border-secondary)',
|
||||
}}
|
||||
>
|
||||
<h4 className="font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('priority_explainer.formula_title', 'The Formula')}
|
||||
</h4>
|
||||
<div
|
||||
className="font-mono text-sm p-3 rounded"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
Priority Score = (Business Impact × 0.40) + (Urgency × 0.30) + (User Agency × 0.20) + (AI Confidence ×
|
||||
0.10)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
className="sticky bottom-0 flex items-center justify-between p-6 border-t"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
borderColor: 'var(--border-primary)',
|
||||
}}
|
||||
>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t(
|
||||
'priority_explainer.footer',
|
||||
'This scoring helps AI prioritize alerts, ensuring you see the most important issues first.'
|
||||
)}
|
||||
</p>
|
||||
<Button onClick={onClose} variant="primary">
|
||||
{t('priority_explainer.got_it', 'Got it!')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,539 +0,0 @@
|
||||
import React, { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardHeader, CardBody } from '../../ui/Card';
|
||||
import { SeverityBadge } from '../../ui/Badge';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { useNotifications } from '../../../hooks/useNotifications';
|
||||
import { useAlertFilters } from '../../../hooks/useAlertFilters';
|
||||
import { useAlertGrouping, type GroupingMode } from '../../../hooks/useAlertGrouping';
|
||||
import { useAlertAnalytics, useAlertAnalyticsTracking } from '../../../hooks/useAlertAnalytics';
|
||||
import { useKeyboardNavigation } from '../../../hooks/useKeyboardNavigation';
|
||||
import { filterAlerts, getAlertStatistics, getTimeGroup } from '../../../utils/alertHelpers';
|
||||
import {
|
||||
Bell,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
CheckCircle,
|
||||
BarChart3,
|
||||
AlertTriangle,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import AlertFilters from './AlertFilters';
|
||||
import AlertGroupHeader from './AlertGroupHeader';
|
||||
import AlertCard from './AlertCard';
|
||||
import AlertTrends from './AlertTrends';
|
||||
import AlertBulkActions from './AlertBulkActions';
|
||||
|
||||
export interface RealTimeAlertsProps {
|
||||
className?: string;
|
||||
maxAlerts?: number;
|
||||
showAnalytics?: boolean;
|
||||
showGrouping?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* RealTimeAlerts - Dashboard component for displaying today's active alerts
|
||||
*
|
||||
* IMPORTANT: This component shows ONLY TODAY'S alerts (from 00:00 UTC today onwards)
|
||||
* to prevent flooding the dashboard with historical data.
|
||||
*
|
||||
* For historical alert data, use the Analytics panel or API endpoints:
|
||||
* - showAnalytics=true: Shows AlertTrends component with historical data (7 days, 30 days, etc.)
|
||||
* - API: /api/v1/tenants/{tenant_id}/alerts/analytics for historical analytics
|
||||
*
|
||||
* Alert scopes across the application:
|
||||
* - Dashboard (this component): TODAY'S alerts only
|
||||
* - Notification Bell: Last 24 hours
|
||||
* - Analytics Panel: Historical data (configurable: 7 days, 30 days, etc.)
|
||||
* - localStorage: Auto-cleanup of alerts >24h old on load
|
||||
* - Redis cache (initial_items): TODAY'S alerts only
|
||||
*/
|
||||
const RealTimeAlerts: React.FC<RealTimeAlertsProps> = ({
|
||||
className,
|
||||
maxAlerts = 50,
|
||||
showAnalytics = true,
|
||||
showGrouping = true,
|
||||
}) => {
|
||||
const { t } = useTranslation(['dashboard']);
|
||||
const [expandedAlerts, setExpandedAlerts] = useState<Set<string>>(new Set());
|
||||
const [selectedAlerts, setSelectedAlerts] = useState<Set<string>>(new Set());
|
||||
const [showBulkActions, setShowBulkActions] = useState(false);
|
||||
const [showAnalyticsPanel, setShowAnalyticsPanel] = useState(false);
|
||||
|
||||
// Pagination state
|
||||
const ALERTS_PER_PAGE = 3;
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const {
|
||||
notifications,
|
||||
isConnected,
|
||||
markAsRead,
|
||||
removeNotification,
|
||||
snoozeAlert,
|
||||
unsnoozeAlert,
|
||||
isAlertSnoozed,
|
||||
snoozedAlerts,
|
||||
markMultipleAsRead,
|
||||
removeMultiple,
|
||||
snoozeMultiple,
|
||||
} = useNotifications();
|
||||
|
||||
const {
|
||||
filters,
|
||||
toggleSeverity,
|
||||
toggleCategory,
|
||||
setTimeRange,
|
||||
setSearch,
|
||||
toggleShowSnoozed,
|
||||
clearFilters,
|
||||
hasActiveFilters,
|
||||
activeFilterCount,
|
||||
} = useAlertFilters();
|
||||
|
||||
// Dashboard shows only TODAY's alerts
|
||||
// Analytics panel shows historical data (configured separately)
|
||||
const filteredNotifications = useMemo(() => {
|
||||
// Filter to today's alerts only for dashboard display
|
||||
// This prevents showing yesterday's or older alerts on the main dashboard
|
||||
const todayAlerts = notifications.filter(alert => {
|
||||
const timeGroup = getTimeGroup(alert.timestamp);
|
||||
return timeGroup === 'today';
|
||||
});
|
||||
|
||||
return filterAlerts(todayAlerts, filters, snoozedAlerts).slice(0, maxAlerts);
|
||||
}, [notifications, filters, snoozedAlerts, maxAlerts]);
|
||||
|
||||
const {
|
||||
groupedAlerts,
|
||||
groupingMode,
|
||||
setGroupingMode,
|
||||
toggleGroupCollapse,
|
||||
isGroupCollapsed,
|
||||
} = useAlertGrouping(filteredNotifications, 'time');
|
||||
|
||||
const analytics = useAlertAnalytics(notifications);
|
||||
const { trackAcknowledgment, trackResolution } = useAlertAnalyticsTracking();
|
||||
|
||||
const stats = useMemo(() => {
|
||||
return getAlertStatistics(filteredNotifications, snoozedAlerts);
|
||||
}, [filteredNotifications, snoozedAlerts]);
|
||||
|
||||
const flatAlerts = useMemo(() => {
|
||||
return groupedAlerts.flatMap(group =>
|
||||
isGroupCollapsed(group.id) ? [] : group.alerts
|
||||
);
|
||||
}, [groupedAlerts, isGroupCollapsed]);
|
||||
|
||||
// Reset pagination when filters change
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [filters, groupingMode]);
|
||||
|
||||
// Pagination calculations
|
||||
const totalAlerts = flatAlerts.length;
|
||||
const totalPages = Math.ceil(totalAlerts / ALERTS_PER_PAGE);
|
||||
const startIndex = (currentPage - 1) * ALERTS_PER_PAGE;
|
||||
const endIndex = startIndex + ALERTS_PER_PAGE;
|
||||
|
||||
// Paginated alerts - slice the flat alerts for current page
|
||||
const paginatedAlerts = useMemo(() => {
|
||||
const alertsToShow = flatAlerts.slice(startIndex, endIndex);
|
||||
const alertIds = new Set(alertsToShow.map(a => a.id));
|
||||
|
||||
// Filter groups to only show alerts on current page
|
||||
return groupedAlerts
|
||||
.map(group => ({
|
||||
...group,
|
||||
alerts: group.alerts.filter(alert => alertIds.has(alert.id)),
|
||||
count: group.alerts.filter(alert => alertIds.has(alert.id)).length,
|
||||
}))
|
||||
.filter(group => group.alerts.length > 0);
|
||||
}, [groupedAlerts, flatAlerts, startIndex, endIndex]);
|
||||
|
||||
const { focusedIndex } = useKeyboardNavigation(
|
||||
flatAlerts.length,
|
||||
{
|
||||
onMoveUp: () => {},
|
||||
onMoveDown: () => {},
|
||||
onSelect: () => {
|
||||
if (flatAlerts[focusedIndex]) {
|
||||
toggleAlertSelection(flatAlerts[focusedIndex].id);
|
||||
}
|
||||
},
|
||||
onExpand: () => {
|
||||
if (flatAlerts[focusedIndex]) {
|
||||
toggleAlertExpansion(flatAlerts[focusedIndex].id);
|
||||
}
|
||||
},
|
||||
onMarkAsRead: () => {
|
||||
if (flatAlerts[focusedIndex]) {
|
||||
handleMarkAsRead(flatAlerts[focusedIndex].id);
|
||||
}
|
||||
},
|
||||
onDismiss: () => {
|
||||
if (flatAlerts[focusedIndex]) {
|
||||
handleRemoveAlert(flatAlerts[focusedIndex].id);
|
||||
}
|
||||
},
|
||||
onSnooze: () => {
|
||||
if (flatAlerts[focusedIndex]) {
|
||||
handleSnoozeAlert(flatAlerts[focusedIndex].id, '1hr');
|
||||
}
|
||||
},
|
||||
onEscape: () => {
|
||||
setExpandedAlerts(new Set());
|
||||
setSelectedAlerts(new Set());
|
||||
},
|
||||
onSelectAll: () => {
|
||||
handleSelectAll();
|
||||
},
|
||||
onSearch: () => {},
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
const toggleAlertExpansion = useCallback((alertId: string) => {
|
||||
setExpandedAlerts(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(alertId)) {
|
||||
next.delete(alertId);
|
||||
} else {
|
||||
next.add(alertId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleAlertSelection = useCallback((alertId: string) => {
|
||||
setSelectedAlerts(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(alertId)) {
|
||||
next.delete(alertId);
|
||||
} else {
|
||||
next.add(alertId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleMarkAsRead = useCallback((alertId: string) => {
|
||||
markAsRead(alertId);
|
||||
trackAcknowledgment(alertId).catch(err =>
|
||||
console.error('Failed to track acknowledgment:', err)
|
||||
);
|
||||
}, [markAsRead, trackAcknowledgment]);
|
||||
|
||||
const handleRemoveAlert = useCallback((alertId: string) => {
|
||||
removeNotification(alertId);
|
||||
trackResolution(alertId).catch(err =>
|
||||
console.error('Failed to track resolution:', err)
|
||||
);
|
||||
setExpandedAlerts(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(alertId);
|
||||
return next;
|
||||
});
|
||||
setSelectedAlerts(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(alertId);
|
||||
return next;
|
||||
});
|
||||
}, [removeNotification, trackResolution]);
|
||||
|
||||
const handleSnoozeAlert = useCallback((alertId: string, duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => {
|
||||
snoozeAlert(alertId, duration);
|
||||
}, [snoozeAlert]);
|
||||
|
||||
const handleUnsnoozeAlert = useCallback((alertId: string) => {
|
||||
unsnoozeAlert(alertId);
|
||||
}, [unsnoozeAlert]);
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
setSelectedAlerts(new Set(flatAlerts.map(a => a.id)));
|
||||
setShowBulkActions(true);
|
||||
}, [flatAlerts]);
|
||||
|
||||
const handleDeselectAll = useCallback(() => {
|
||||
setSelectedAlerts(new Set());
|
||||
setShowBulkActions(false);
|
||||
}, []);
|
||||
|
||||
const handleBulkMarkAsRead = useCallback(() => {
|
||||
const ids = Array.from(selectedAlerts);
|
||||
markMultipleAsRead(ids);
|
||||
ids.forEach(id =>
|
||||
trackAcknowledgment(id).catch(err =>
|
||||
console.error('Failed to track acknowledgment:', err)
|
||||
)
|
||||
);
|
||||
handleDeselectAll();
|
||||
}, [selectedAlerts, markMultipleAsRead, trackAcknowledgment, handleDeselectAll]);
|
||||
|
||||
const handleBulkRemove = useCallback(() => {
|
||||
const ids = Array.from(selectedAlerts);
|
||||
removeMultiple(ids);
|
||||
ids.forEach(id =>
|
||||
trackResolution(id).catch(err =>
|
||||
console.error('Failed to track resolution:', err)
|
||||
)
|
||||
);
|
||||
handleDeselectAll();
|
||||
}, [selectedAlerts, removeMultiple, trackResolution, handleDeselectAll]);
|
||||
|
||||
const handleBulkSnooze = useCallback((duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => {
|
||||
const ids = Array.from(selectedAlerts);
|
||||
snoozeMultiple(ids, duration);
|
||||
handleDeselectAll();
|
||||
}, [selectedAlerts, snoozeMultiple, handleDeselectAll]);
|
||||
|
||||
const activeAlerts = filteredNotifications.filter(a => a.status !== 'acknowledged' && !isAlertSnoozed(a.id));
|
||||
const urgentCount = activeAlerts.filter(a => a.severity === 'urgent').length;
|
||||
const highCount = activeAlerts.filter(a => a.severity === 'high').length;
|
||||
|
||||
return (
|
||||
<Card className={className} variant="elevated" padding="none">
|
||||
<CardHeader padding="lg" divider>
|
||||
<div className="flex items-start sm:items-center justify-between w-full gap-4 flex-col sm:flex-row">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="p-2.5 rounded-xl shadow-sm flex-shrink-0"
|
||||
style={{ backgroundColor: 'var(--color-primary)15' }}
|
||||
>
|
||||
<Bell className="w-5 h-5" style={{ color: 'var(--color-primary)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold mb-0.5" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('dashboard:alerts.title', 'Alertas')}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{isConnected ? (
|
||||
<Wifi className="w-3.5 h-3.5" style={{ color: 'var(--color-success)' }} />
|
||||
) : (
|
||||
<WifiOff className="w-3.5 h-3.5" style={{ color: 'var(--color-error)' }} />
|
||||
)}
|
||||
<span className="text-xs font-medium whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>
|
||||
{isConnected
|
||||
? t('dashboard:alerts.live', 'En vivo')
|
||||
: t('dashboard:alerts.offline', 'Desconectado')
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap w-full sm:w-auto">
|
||||
{/* Alert count badges */}
|
||||
<div className="flex items-center gap-2">
|
||||
{urgentCount > 0 && (
|
||||
<SeverityBadge
|
||||
severity="high"
|
||||
count={urgentCount}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
{highCount > 0 && (
|
||||
<SeverityBadge
|
||||
severity="medium"
|
||||
count={highCount}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
{showAnalytics && (
|
||||
<Button
|
||||
variant={showAnalyticsPanel ? 'primary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setShowAnalyticsPanel(!showAnalyticsPanel)}
|
||||
className="h-9"
|
||||
title="Toggle analytics"
|
||||
aria-label="Toggle analytics panel"
|
||||
>
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showGrouping && (
|
||||
<select
|
||||
value={groupingMode}
|
||||
onChange={(e) => setGroupingMode(e.target.value as GroupingMode)}
|
||||
className="px-3 py-2 text-sm font-medium border-2 border-[var(--border-primary)] rounded-lg bg-[var(--bg-primary)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all cursor-pointer hover:border-[var(--color-primary)]"
|
||||
aria-label="Group alerts by"
|
||||
>
|
||||
<option value="time">⏰ Por tiempo</option>
|
||||
<option value="category">📁 Por categoría</option>
|
||||
<option value="similarity">🔗 Similares</option>
|
||||
<option value="none">📋 Sin agrupar</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody padding="none">
|
||||
{showAnalyticsPanel && (
|
||||
<div className="p-4 border-b border-[var(--border-primary)]">
|
||||
<AlertTrends analytics={analytics} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-4 py-4 border-b border-[var(--border-primary)] bg-[var(--bg-secondary)]/30">
|
||||
<AlertFilters
|
||||
selectedSeverities={filters.severities}
|
||||
selectedCategories={filters.categories}
|
||||
selectedTimeRange={filters.timeRange}
|
||||
searchQuery={filters.search}
|
||||
showSnoozed={filters.showSnoozed}
|
||||
onToggleSeverity={toggleSeverity}
|
||||
onToggleCategory={toggleCategory}
|
||||
onSetTimeRange={setTimeRange}
|
||||
onSearchChange={setSearch}
|
||||
onToggleShowSnoozed={toggleShowSnoozed}
|
||||
onClearFilters={clearFilters}
|
||||
hasActiveFilters={hasActiveFilters}
|
||||
activeFilterCount={activeFilterCount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedAlerts.size > 0 && (
|
||||
<div className="p-4 border-b border-[var(--border-primary)]">
|
||||
<AlertBulkActions
|
||||
selectedCount={selectedAlerts.size}
|
||||
totalCount={flatAlerts.length}
|
||||
onMarkAsRead={handleBulkMarkAsRead}
|
||||
onRemove={handleBulkRemove}
|
||||
onSnooze={handleBulkSnooze}
|
||||
onDeselectAll={handleDeselectAll}
|
||||
onSelectAll={handleSelectAll}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredNotifications.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-[var(--color-success)]/10 mb-4">
|
||||
<CheckCircle className="w-8 h-8" style={{ color: 'var(--color-success)' }} />
|
||||
</div>
|
||||
<h4 className="text-base font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
{hasActiveFilters ? 'Sin resultados' : 'Todo despejado'}
|
||||
</h4>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{hasActiveFilters
|
||||
? 'No hay alertas que coincidan con los filtros seleccionados'
|
||||
: t('dashboard:alerts.no_alerts', 'No hay alertas activas en este momento')
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 p-4">
|
||||
{paginatedAlerts.map((group) => (
|
||||
<div key={group.id}>
|
||||
{(group.count > 1 || groupingMode !== 'none') && (
|
||||
<div className="mb-3">
|
||||
<AlertGroupHeader
|
||||
group={group}
|
||||
isCollapsed={isGroupCollapsed(group.id)}
|
||||
onToggleCollapse={() => toggleGroupCollapse(group.id)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isGroupCollapsed(group.id) && (
|
||||
<div className="space-y-3 ml-0">
|
||||
{group.alerts.map((alert) => (
|
||||
<AlertCard
|
||||
key={alert.id}
|
||||
alert={alert}
|
||||
isExpanded={expandedAlerts.has(alert.id)}
|
||||
isSelected={selectedAlerts.has(alert.id)}
|
||||
isSnoozed={isAlertSnoozed(alert.id)}
|
||||
snoozedUntil={snoozedAlerts.get(alert.id)?.until}
|
||||
onToggleExpand={() => toggleAlertExpansion(alert.id)}
|
||||
onToggleSelect={() => toggleAlertSelection(alert.id)}
|
||||
onMarkAsRead={() => handleMarkAsRead(alert.id)}
|
||||
onRemove={() => handleRemoveAlert(alert.id)}
|
||||
onSnooze={(duration) => handleSnoozeAlert(alert.id, duration)}
|
||||
onUnsnooze={() => handleUnsnoozeAlert(alert.id)}
|
||||
showCheckbox={showBulkActions || selectedAlerts.size > 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredNotifications.length > 0 && (
|
||||
<div
|
||||
className="px-4 py-3 border-t"
|
||||
style={{
|
||||
borderColor: 'var(--border-primary)',
|
||||
backgroundColor: 'var(--bg-secondary)/50',
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Stats row */}
|
||||
<div className="flex items-center justify-between text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
<span className="font-medium">
|
||||
Mostrando <span className="font-bold text-[var(--text-primary)]">{startIndex + 1}-{Math.min(endIndex, totalAlerts)}</span> de <span className="font-bold text-[var(--text-primary)]">{totalAlerts}</span> alertas
|
||||
</span>
|
||||
<div className="flex items-center gap-4">
|
||||
{stats.unread > 0 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-[var(--color-info)] animate-pulse" />
|
||||
<span className="font-semibold text-[var(--text-primary)]">{stats.unread}</span> sin leer
|
||||
</span>
|
||||
)}
|
||||
{stats.snoozed > 0 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
<span className="font-semibold text-[var(--text-primary)]">{stats.snoozed}</span> pospuestas
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination controls */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="h-8 px-3"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<span className="text-sm font-medium px-3" style={{ color: 'var(--text-primary)' }}>
|
||||
Página <span className="font-bold">{currentPage}</span> de <span className="font-bold">{totalPages}</span>
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="h-8 px-3"
|
||||
aria-label="Next page"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default RealTimeAlerts;
|
||||
@@ -0,0 +1,284 @@
|
||||
// ================================================================
|
||||
// frontend/src/components/domain/dashboard/SmartActionConsequencePreview.tsx
|
||||
// ================================================================
|
||||
/**
|
||||
* Smart Action Consequence Preview
|
||||
*
|
||||
* Shows "What happens if I click this?" before user takes action.
|
||||
* Displays expected outcomes, affected systems, and financial impact.
|
||||
*
|
||||
* Design: Tooltip/popover style preview with clear consequences
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Info, CheckCircle, AlertTriangle, DollarSign, Clock, ArrowRight } from 'lucide-react';
|
||||
import { Badge } from '../../ui/Badge';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface ActionConsequence {
|
||||
outcome: string;
|
||||
affectedSystems?: string[];
|
||||
financialImpact?: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
type: 'cost' | 'savings' | 'revenue';
|
||||
};
|
||||
timeImpact?: string;
|
||||
reversible: boolean;
|
||||
confidence?: number;
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
export interface SmartActionConsequencePreviewProps {
|
||||
action: {
|
||||
label: string;
|
||||
actionType: string;
|
||||
};
|
||||
consequences: ActionConsequence;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
isOpen: boolean;
|
||||
triggerElement?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SmartActionConsequencePreview({
|
||||
action,
|
||||
consequences,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
isOpen,
|
||||
triggerElement,
|
||||
}: SmartActionConsequencePreviewProps) {
|
||||
const { t } = useTranslation('alerts');
|
||||
const [showPreview, setShowPreview] = useState(isOpen);
|
||||
|
||||
const handleConfirm = () => {
|
||||
setShowPreview(false);
|
||||
onConfirm();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setShowPreview(false);
|
||||
onCancel();
|
||||
};
|
||||
|
||||
if (!showPreview) {
|
||||
return (
|
||||
<div onClick={() => setShowPreview(true)}>
|
||||
{triggerElement}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/20 backdrop-blur-sm animate-in fade-in duration-200"
|
||||
onClick={handleCancel}
|
||||
/>
|
||||
|
||||
{/* Preview Card */}
|
||||
<div
|
||||
className="fixed z-50 top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-full max-w-md animate-in zoom-in-95 duration-200"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="rounded-xl shadow-2xl border-2"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
borderColor: 'var(--color-primary-300)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="p-4 border-b"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-primary-50)',
|
||||
borderColor: 'var(--border-primary)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="p-2 rounded-lg"
|
||||
style={{ backgroundColor: 'var(--color-primary-100)' }}
|
||||
>
|
||||
<Info className="w-5 h-5" style={{ color: 'var(--color-primary-600)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('action_preview.title', 'Action Preview')}
|
||||
</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{action.label}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Expected Outcome */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<ArrowRight className="w-4 h-4" style={{ color: 'var(--color-primary)' }} />
|
||||
<h4 className="font-semibold text-sm" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('action_preview.outcome', 'What will happen')}
|
||||
</h4>
|
||||
</div>
|
||||
<p className="text-sm pl-6" style={{ color: 'var(--text-secondary)' }}>
|
||||
{consequences.outcome}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Financial Impact */}
|
||||
{consequences.financialImpact && (
|
||||
<div
|
||||
className="rounded-lg p-3 border"
|
||||
style={{
|
||||
backgroundColor:
|
||||
consequences.financialImpact.type === 'cost'
|
||||
? 'var(--color-error-50)'
|
||||
: 'var(--color-success-50)',
|
||||
borderColor:
|
||||
consequences.financialImpact.type === 'cost'
|
||||
? 'var(--color-error-200)'
|
||||
: 'var(--color-success-200)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign
|
||||
className="w-4 h-4"
|
||||
style={{
|
||||
color:
|
||||
consequences.financialImpact.type === 'cost'
|
||||
? 'var(--color-error-600)'
|
||||
: 'var(--color-success-600)',
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('action_preview.financial_impact', 'Financial Impact')}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="text-lg font-bold"
|
||||
style={{
|
||||
color:
|
||||
consequences.financialImpact.type === 'cost'
|
||||
? 'var(--color-error-600)'
|
||||
: 'var(--color-success-600)',
|
||||
}}
|
||||
>
|
||||
{consequences.financialImpact.type === 'cost' ? '-' : '+'}
|
||||
{consequences.financialImpact.currency}
|
||||
{consequences.financialImpact.amount.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Affected Systems */}
|
||||
{consequences.affectedSystems && consequences.affectedSystems.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-semibold text-sm mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('action_preview.affected_systems', 'Affected Systems')}
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{consequences.affectedSystems.map((system, idx) => (
|
||||
<Badge key={idx} variant="secondary" size="sm">
|
||||
{system}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Time Impact */}
|
||||
{consequences.timeImpact && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Clock className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{consequences.timeImpact}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reversibility */}
|
||||
<div
|
||||
className="flex items-center gap-2 text-sm p-2 rounded"
|
||||
style={{ backgroundColor: 'var(--bg-secondary)' }}
|
||||
>
|
||||
<CheckCircle
|
||||
className="w-4 h-4"
|
||||
style={{ color: consequences.reversible ? 'var(--color-success-500)' : 'var(--color-warning-500)' }}
|
||||
/>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>
|
||||
{consequences.reversible
|
||||
? t('action_preview.reversible', 'This action can be undone')
|
||||
: t('action_preview.not_reversible', 'This action cannot be undone')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Warnings */}
|
||||
{consequences.warnings && consequences.warnings.length > 0 && (
|
||||
<div
|
||||
className="rounded-lg p-3 border"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-warning-50)',
|
||||
borderColor: 'var(--color-warning-300)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" style={{ color: 'var(--color-warning-600)' }} />
|
||||
<div className="space-y-1">
|
||||
{consequences.warnings.map((warning, idx) => (
|
||||
<p key={idx} className="text-sm" style={{ color: 'var(--color-warning-700)' }}>
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confidence Score */}
|
||||
{consequences.confidence !== undefined && (
|
||||
<div className="text-xs text-center" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{t('action_preview.confidence', 'AI Confidence: {{confidence}}%', {
|
||||
confidence: consequences.confidence,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div
|
||||
className="flex items-center gap-3 p-4 border-t"
|
||||
style={{ borderColor: 'var(--border-primary)' }}
|
||||
>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="flex-1 px-4 py-2 rounded-lg font-medium transition-colors"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-secondary)',
|
||||
color: 'var(--text-primary)',
|
||||
border: '1px solid var(--border-primary)',
|
||||
}}
|
||||
>
|
||||
{t('action_preview.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
className="flex-1 px-4 py-2 rounded-lg font-semibold transition-colors"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-primary)',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
{t('action_preview.confirm', 'Confirm Action')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// ================================================================
|
||||
// frontend/src/components/domain/dashboard/TrendVisualizationComponent.tsx
|
||||
// ================================================================
|
||||
/**
|
||||
* Trend Visualization Component
|
||||
*
|
||||
* Displays visual trend indicators for TREND_WARNING type alerts.
|
||||
* Shows historical comparison with simple sparkline/arrow indicators.
|
||||
*
|
||||
* Design: Lightweight, inline trend indicators with color coding
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { TrendingUp, TrendingDown, Minus, AlertTriangle } from 'lucide-react';
|
||||
import { Badge } from '../../ui/Badge';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface TrendData {
|
||||
direction: 'increasing' | 'decreasing' | 'stable';
|
||||
percentageChange?: number;
|
||||
historicalComparison?: string;
|
||||
dataPoints?: number[];
|
||||
threshold?: number;
|
||||
current?: number;
|
||||
}
|
||||
|
||||
export interface TrendVisualizationProps {
|
||||
trend: TrendData;
|
||||
label?: string;
|
||||
showSparkline?: boolean;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TrendVisualizationComponent({
|
||||
trend,
|
||||
label,
|
||||
showSparkline = false,
|
||||
size = 'md',
|
||||
className = '',
|
||||
}: TrendVisualizationProps) {
|
||||
const { t } = useTranslation('alerts');
|
||||
|
||||
const getTrendIcon = () => {
|
||||
switch (trend.direction) {
|
||||
case 'increasing':
|
||||
return TrendingUp;
|
||||
case 'decreasing':
|
||||
return TrendingDown;
|
||||
case 'stable':
|
||||
return Minus;
|
||||
}
|
||||
};
|
||||
|
||||
const getTrendColor = () => {
|
||||
switch (trend.direction) {
|
||||
case 'increasing':
|
||||
return 'var(--color-error-500)';
|
||||
case 'decreasing':
|
||||
return 'var(--color-success-500)';
|
||||
case 'stable':
|
||||
return 'var(--text-tertiary)';
|
||||
}
|
||||
};
|
||||
|
||||
const getTrendBadgeVariant = (): 'error' | 'success' | 'secondary' => {
|
||||
switch (trend.direction) {
|
||||
case 'increasing':
|
||||
return 'error';
|
||||
case 'decreasing':
|
||||
return 'success';
|
||||
case 'stable':
|
||||
return 'secondary';
|
||||
}
|
||||
};
|
||||
|
||||
const getTrendLabel = () => {
|
||||
if (trend.percentageChange !== undefined) {
|
||||
const sign = trend.percentageChange > 0 ? '+' : '';
|
||||
return `${sign}${trend.percentageChange.toFixed(1)}%`;
|
||||
}
|
||||
return trend.direction.charAt(0).toUpperCase() + trend.direction.slice(1);
|
||||
};
|
||||
|
||||
const isNearThreshold = trend.threshold !== undefined && trend.current !== undefined
|
||||
? Math.abs(trend.current - trend.threshold) / trend.threshold < 0.1
|
||||
: false;
|
||||
|
||||
const TrendIcon = getTrendIcon();
|
||||
const trendColor = getTrendColor();
|
||||
|
||||
const iconSizes = {
|
||||
sm: 'w-3 h-3',
|
||||
md: 'w-4 h-4',
|
||||
lg: 'w-5 h-5',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`inline-flex items-center gap-2 ${className}`}>
|
||||
{/* Trend Indicator */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<TrendIcon
|
||||
className={iconSizes[size]}
|
||||
style={{ color: trendColor }}
|
||||
/>
|
||||
<Badge variant={getTrendBadgeVariant()} size={size === 'sm' ? 'sm' : 'md'}>
|
||||
{getTrendLabel()}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Optional Label */}
|
||||
{label && (
|
||||
<span
|
||||
className={`font-medium ${size === 'sm' ? 'text-xs' : size === 'lg' ? 'text-base' : 'text-sm'}`}
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Threshold Warning */}
|
||||
{isNearThreshold && (
|
||||
<div className="flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" style={{ color: 'var(--color-warning-500)' }} />
|
||||
<span className="text-xs font-medium" style={{ color: 'var(--color-warning-600)' }}>
|
||||
{t('trend.near_threshold', 'Near threshold')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sparkline (Simple Mini Chart) */}
|
||||
{showSparkline && trend.dataPoints && trend.dataPoints.length > 0 && (
|
||||
<div className="flex items-end gap-0.5 h-6">
|
||||
{trend.dataPoints.slice(-8).map((value, idx) => {
|
||||
const maxValue = Math.max(...trend.dataPoints!);
|
||||
const heightPercent = (value / maxValue) * 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="w-1 rounded-t transition-all"
|
||||
style={{
|
||||
height: `${heightPercent}%`,
|
||||
backgroundColor: trendColor,
|
||||
opacity: 0.3 + (idx / trend.dataPoints!.length) * 0.7,
|
||||
}}
|
||||
title={`${value}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Historical Comparison Text */}
|
||||
{trend.historicalComparison && (
|
||||
<span
|
||||
className={`${size === 'sm' ? 'text-xs' : 'text-sm'}`}
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
{trend.historicalComparison}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact version for inline use in alert cards
|
||||
*/
|
||||
export function TrendVisualizationInline({ trend }: { trend: TrendData }) {
|
||||
const TrendIcon = trend.direction === 'increasing' ? TrendingUp
|
||||
: trend.direction === 'decreasing' ? TrendingDown
|
||||
: Minus;
|
||||
|
||||
const color = trend.direction === 'increasing' ? 'var(--color-error-500)'
|
||||
: trend.direction === 'decreasing' ? 'var(--color-success-500)'
|
||||
: 'var(--text-tertiary)';
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<TrendIcon className="w-3 h-3" style={{ color }} />
|
||||
{trend.percentageChange !== undefined && (
|
||||
<span className="text-xs font-semibold" style={{ color }}>
|
||||
{trend.percentageChange > 0 ? '+' : ''}{trend.percentageChange.toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { extendDemoSession, destroyDemoSession } from '../../../api/services/demo';
|
||||
import { apiClient } from '../../../api/client';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -7,7 +7,7 @@ import { BookOpen, Clock, Sparkles, X } from 'lucide-react';
|
||||
|
||||
export const DemoBanner: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { startTour, resumeTour, tourState } = useDemoTour();
|
||||
const { startTour, resumeTour } = useDemoTour();
|
||||
const [isDemo, setIsDemo] = useState(() => localStorage.getItem('demo_mode') === 'true');
|
||||
const [expiresAt, setExpiresAt] = useState<string | null>(() => localStorage.getItem('demo_expires_at'));
|
||||
const [timeRemaining, setTimeRemaining] = useState<string>('');
|
||||
@@ -15,6 +15,16 @@ export const DemoBanner: React.FC = () => {
|
||||
const [extending, setExtending] = useState(false);
|
||||
const [showExitModal, setShowExitModal] = useState(false);
|
||||
|
||||
// Memoize tour state to prevent re-renders
|
||||
const tourState = useMemo(() => {
|
||||
try {
|
||||
return getTourState();
|
||||
} catch (error) {
|
||||
console.error('Error getting tour state:', error);
|
||||
return null;
|
||||
}
|
||||
}, []); // Only get tour state on initial render
|
||||
|
||||
useEffect(() => {
|
||||
const demoMode = localStorage.getItem('demo_mode') === 'true';
|
||||
const expires = localStorage.getItem('demo_expires_at');
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import React, { useState, useCallback, forwardRef } from 'react';
|
||||
import React, { forwardRef } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthUser, useIsAuthenticated } from '../../../stores';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { useNotifications } from '../../../hooks/useNotifications';
|
||||
import { useHasAccess } from '../../../hooks/useAccessControl';
|
||||
import { Button } from '../../ui';
|
||||
import { CountBadge } from '../../ui';
|
||||
import { TenantSwitcher } from '../../ui/TenantSwitcher';
|
||||
import { ThemeToggle } from '../../ui/ThemeToggle';
|
||||
import { NotificationPanel } from '../../ui/NotificationPanel/NotificationPanel';
|
||||
import { CompactLanguageSelector } from '../../ui/LanguageSelector';
|
||||
import {
|
||||
Menu,
|
||||
Bell,
|
||||
X,
|
||||
TrendingUp
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -33,10 +27,6 @@ export interface HeaderProps {
|
||||
* Show/hide search functionality
|
||||
*/
|
||||
showSearch?: boolean;
|
||||
/**
|
||||
* Show/hide notifications
|
||||
*/
|
||||
showNotifications?: boolean;
|
||||
/**
|
||||
* Show/hide theme toggle
|
||||
*/
|
||||
@@ -49,14 +39,6 @@ export interface HeaderProps {
|
||||
* Custom search placeholder
|
||||
*/
|
||||
searchPlaceholder?: string;
|
||||
/**
|
||||
* Notification count
|
||||
*/
|
||||
notificationCount?: number;
|
||||
/**
|
||||
* Custom notification handler
|
||||
*/
|
||||
onNotificationClick?: () => void;
|
||||
}
|
||||
|
||||
export interface HeaderRef {
|
||||
@@ -64,12 +46,10 @@ export interface HeaderRef {
|
||||
}
|
||||
|
||||
/**
|
||||
* Header - Top navigation header with logo, notifications, theme toggle
|
||||
* Header - Top navigation header with logo and theme toggle
|
||||
*
|
||||
* Features:
|
||||
* - Logo/brand area with responsive sizing
|
||||
* - Global search functionality with keyboard shortcuts
|
||||
* - Notifications bell with badge count
|
||||
* - Theme toggle button (light/dark/system)
|
||||
* - Mobile hamburger menu integration
|
||||
* - Keyboard navigation support
|
||||
@@ -79,39 +59,14 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
onMenuClick,
|
||||
sidebarCollapsed = false,
|
||||
showSearch = true,
|
||||
showNotifications = true,
|
||||
showThemeToggle = true,
|
||||
logo,
|
||||
searchPlaceholder,
|
||||
notificationCount = 0,
|
||||
onNotificationClick,
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthUser();
|
||||
const hasAccess = useHasAccess(); // Check both authentication and demo mode
|
||||
const { theme, resolvedTheme, setTheme } = useTheme();
|
||||
const {
|
||||
notifications,
|
||||
unreadCount,
|
||||
isConnected,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
removeNotification,
|
||||
clearAll
|
||||
} = useNotifications();
|
||||
|
||||
const [isNotificationPanelOpen, setIsNotificationPanelOpen] = useState(false);
|
||||
|
||||
// Filter notifications to last 24 hours for the notification bell
|
||||
// This prevents showing old/stale alerts in the notification panel
|
||||
const recentNotifications = React.useMemo(() => {
|
||||
const oneDayAgo = Date.now() - (24 * 60 * 60 * 1000);
|
||||
return notifications.filter(n => {
|
||||
const alertTime = new Date(n.timestamp).getTime();
|
||||
return alertTime > oneDayAgo;
|
||||
});
|
||||
}, [notifications]);
|
||||
|
||||
const defaultSearchPlaceholder = searchPlaceholder || t('common:forms.search_placeholder', 'Search...');
|
||||
|
||||
@@ -121,31 +76,7 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
}), []);
|
||||
|
||||
|
||||
// Keyboard shortcuts
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Escape to close menus
|
||||
if (e.key === 'Escape') {
|
||||
setIsNotificationPanelOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
// Close menus when clicking outside
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Element;
|
||||
if (!target.closest('[data-notification-panel]')) {
|
||||
setIsNotificationPanelOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}, []);
|
||||
// Removed notification panel effects as part of new alert system integration
|
||||
|
||||
|
||||
return (
|
||||
@@ -227,8 +158,6 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
{/* Right section */}
|
||||
{hasAccess && (
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Placeholder for potential future items */ }
|
||||
|
||||
{/* Language selector */}
|
||||
<CompactLanguageSelector className="w-auto min-w-[50px]" />
|
||||
|
||||
@@ -236,51 +165,6 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
{showThemeToggle && (
|
||||
<ThemeToggle variant="button" size="md" />
|
||||
)}
|
||||
|
||||
{/* Notifications */}
|
||||
{showNotifications && (
|
||||
<div className="relative" data-notification-panel>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsNotificationPanelOpen(!isNotificationPanelOpen)}
|
||||
className={clsx(
|
||||
"w-10 h-10 p-0 flex items-center justify-center relative",
|
||||
!isConnected && "opacity-50",
|
||||
isNotificationPanelOpen && "bg-[var(--bg-secondary)]"
|
||||
)}
|
||||
aria-label={`${t('common:navigation.notifications', 'Notifications')}${unreadCount > 0 ? ` (${unreadCount})` : ''}${!isConnected ? ` - ${t('common:status.disconnected', 'Disconnected')}` : ''}`}
|
||||
title={!isConnected ? t('common:status.no_realtime_connection', 'No real-time connection') : undefined}
|
||||
aria-expanded={isNotificationPanelOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<Bell className={clsx(
|
||||
"h-5 w-5 transition-colors",
|
||||
unreadCount > 0 && "text-[var(--color-warning)]"
|
||||
)} />
|
||||
{unreadCount > 0 && (
|
||||
<CountBadge
|
||||
count={unreadCount}
|
||||
max={99}
|
||||
variant="error"
|
||||
size="sm"
|
||||
overlay
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<NotificationPanel
|
||||
notifications={recentNotifications}
|
||||
isOpen={isNotificationPanelOpen}
|
||||
onClose={() => setIsNotificationPanelOpen(false)}
|
||||
onMarkAsRead={markAsRead}
|
||||
onMarkAllAsRead={markAllAsRead}
|
||||
onRemoveNotification={removeNotification}
|
||||
onClearAll={clearAll}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
Store,
|
||||
GraduationCap,
|
||||
Bell,
|
||||
MessageSquare,
|
||||
Settings,
|
||||
User,
|
||||
CreditCard,
|
||||
@@ -119,6 +120,7 @@ const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
training: GraduationCap,
|
||||
notifications: Bell,
|
||||
bell: Bell,
|
||||
communications: MessageSquare, // Added for communications section
|
||||
settings: Settings,
|
||||
user: User,
|
||||
'credit-card': CreditCard,
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { KeyValueEditor } from './KeyValueEditor';
|
||||
export default from './KeyValueEditor';
|
||||
export default KeyValueEditor;
|
||||
|
||||
@@ -7,14 +7,23 @@ import {
|
||||
Check,
|
||||
Trash2,
|
||||
AlertTriangle,
|
||||
AlertCircle,
|
||||
Info,
|
||||
CheckCircle,
|
||||
X
|
||||
X,
|
||||
Bot,
|
||||
TrendingUp,
|
||||
Clock,
|
||||
DollarSign,
|
||||
Phone,
|
||||
ExternalLink
|
||||
} from 'lucide-react';
|
||||
import { EnrichedAlert, AlertTypeClass } from '../../../types/alerts';
|
||||
import { useSmartActionHandler } from '../../../utils/smartActionHandlers';
|
||||
import { getPriorityColor, getTypeClassBadgeVariant, formatTimeUntilConsequence } from '../../../types/alerts';
|
||||
import { useAuthUser } from '../../../stores/auth.store';
|
||||
|
||||
export interface NotificationPanelProps {
|
||||
notifications: NotificationData[];
|
||||
enrichedAlerts?: EnrichedAlert[];
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onMarkAsRead: (id: string) => void;
|
||||
@@ -24,50 +33,7 @@ export interface NotificationPanelProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const getSeverityIcon = (severity: string) => {
|
||||
switch (severity) {
|
||||
case 'urgent':
|
||||
return AlertTriangle;
|
||||
case 'high':
|
||||
return AlertCircle;
|
||||
case 'medium':
|
||||
return Info;
|
||||
case 'low':
|
||||
return CheckCircle;
|
||||
default:
|
||||
return Info;
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityColor = (severity: string) => {
|
||||
switch (severity) {
|
||||
case 'urgent':
|
||||
return 'var(--color-error)';
|
||||
case 'high':
|
||||
return 'var(--color-warning)';
|
||||
case 'medium':
|
||||
return 'var(--color-info)';
|
||||
case 'low':
|
||||
return 'var(--color-success)';
|
||||
default:
|
||||
return 'var(--color-info)';
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityBadge = (severity: string) => {
|
||||
switch (severity) {
|
||||
case 'urgent':
|
||||
return 'error';
|
||||
case 'high':
|
||||
return 'warning';
|
||||
case 'medium':
|
||||
return 'info';
|
||||
case 'low':
|
||||
return 'success';
|
||||
default:
|
||||
return 'info';
|
||||
}
|
||||
};
|
||||
// Legacy severity functions removed - now using enriched priority_level and type_class
|
||||
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
const date = new Date(timestamp);
|
||||
@@ -82,8 +48,176 @@ const formatTimestamp = (timestamp: string) => {
|
||||
return date.toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' });
|
||||
};
|
||||
|
||||
// Enriched Alert Item Component
|
||||
const EnrichedAlertItem: React.FC<{
|
||||
alert: EnrichedAlert;
|
||||
isMobile: boolean;
|
||||
onMarkAsRead: (id: string) => void;
|
||||
onRemove: (id: string) => void;
|
||||
actionHandler: any;
|
||||
}> = ({ alert, isMobile, onMarkAsRead, onRemove, actionHandler }) => {
|
||||
const isUnread = alert.status === 'active';
|
||||
const priorityColor = getPriorityColor(alert.priority_level);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"transition-colors hover:bg-[var(--bg-secondary)] active:bg-[var(--bg-tertiary)]",
|
||||
isMobile ? 'px-4 py-4 mx-2 my-1 rounded-lg' : 'p-3',
|
||||
isUnread && "bg-[var(--color-info)]/5 border-l-4"
|
||||
)}
|
||||
style={isUnread ? { borderLeftColor: priorityColor } : {}}
|
||||
>
|
||||
<div className={`flex gap-${isMobile ? '4' : '3'}`}>
|
||||
{/* Priority Icon */}
|
||||
<div
|
||||
className={`flex-shrink-0 rounded-full mt-0.5 ${isMobile ? 'p-2' : 'p-1'}`}
|
||||
style={{ backgroundColor: priorityColor + '15' }}
|
||||
>
|
||||
{alert.type_class === AlertTypeClass.PREVENTED_ISSUE ? (
|
||||
<CheckCircle className={`${isMobile ? 'w-5 h-5' : 'w-3 h-3'}`} style={{ color: 'var(--color-success)' }} />
|
||||
) : alert.type_class === AlertTypeClass.ESCALATION ? (
|
||||
<Clock className={`${isMobile ? 'w-5 h-5' : 'w-3 h-3'}`} style={{ color: priorityColor }} />
|
||||
) : (
|
||||
<AlertTriangle className={`${isMobile ? 'w-5 h-5' : 'w-3 h-3'}`} style={{ color: priorityColor }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Header */}
|
||||
<div className={`flex items-start justify-between gap-2 ${isMobile ? 'mb-2' : 'mb-1'}`}>
|
||||
<div className={`flex items-center gap-2 ${isMobile ? 'flex-wrap' : ''}`}>
|
||||
<Badge variant={getTypeClassBadgeVariant(alert.type_class)} size={isMobile ? "md" : "sm"}>
|
||||
{alert.priority_level.toUpperCase()}
|
||||
</Badge>
|
||||
<Badge variant="secondary" size={isMobile ? "md" : "sm"}>
|
||||
{alert.priority_score}
|
||||
</Badge>
|
||||
{alert.is_group_summary && (
|
||||
<Badge variant="info" size={isMobile ? "md" : "sm"}>
|
||||
{alert.grouped_alert_count} agrupadas
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className={`font-medium text-[var(--text-secondary)] ${isMobile ? 'text-sm' : 'text-xs'}`}>
|
||||
{formatTimestamp(alert.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<p className={`font-medium leading-tight text-[var(--text-primary)] ${
|
||||
isMobile ? 'text-base mb-2' : 'text-sm mb-1'
|
||||
}`}>
|
||||
{alert.title}
|
||||
</p>
|
||||
|
||||
{/* Message */}
|
||||
<p className={`leading-relaxed text-[var(--text-secondary)] ${
|
||||
isMobile ? 'text-sm mb-3' : 'text-xs mb-2'
|
||||
}`}>
|
||||
{alert.message}
|
||||
</p>
|
||||
|
||||
{/* Context Badges */}
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{alert.orchestrator_context?.already_addressed && (
|
||||
<div className="flex items-center gap-1 px-2 py-1 rounded-md bg-success/10 text-success text-xs">
|
||||
<Bot className="w-3 h-3" />
|
||||
<span>AI ya gestionó esto</span>
|
||||
</div>
|
||||
)}
|
||||
{alert.business_impact?.financial_impact_eur && (
|
||||
<div className="flex items-center gap-1 px-2 py-1 rounded-md bg-warning/10 text-warning text-xs">
|
||||
<DollarSign className="w-3 h-3" />
|
||||
<span>€{alert.business_impact.financial_impact_eur.toFixed(0)} en riesgo</span>
|
||||
</div>
|
||||
)}
|
||||
{alert.urgency_context?.time_until_consequence_hours && (
|
||||
<div className="flex items-center gap-1 px-2 py-1 rounded-md bg-error/10 text-error text-xs">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{formatTimeUntilConsequence(alert.urgency_context.time_until_consequence_hours)}</span>
|
||||
</div>
|
||||
)}
|
||||
{alert.trend_context && (
|
||||
<div className="flex items-center gap-1 px-2 py-1 rounded-md bg-info/10 text-info text-xs">
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
<span>{alert.trend_context.change_percentage > 0 ? '+' : ''}{alert.trend_context.change_percentage.toFixed(1)}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI Reasoning Summary */}
|
||||
{alert.ai_reasoning_summary && (
|
||||
<div className="mb-3 p-2 rounded-md bg-primary/5 border border-primary/20">
|
||||
<div className="flex items-start gap-2">
|
||||
<Bot className="w-4 h-4 text-primary mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-[var(--text-secondary)] italic">
|
||||
{alert.ai_reasoning_summary}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Smart Actions */}
|
||||
{alert.actions && alert.actions.length > 0 && (
|
||||
<div className={`flex flex-wrap gap-2 ${isMobile ? 'mb-3' : 'mb-2'}`}>
|
||||
{alert.actions.slice(0, 3).map((action, idx) => (
|
||||
<Button
|
||||
key={idx}
|
||||
size={isMobile ? "sm" : "xs"}
|
||||
variant={action.variant === 'primary' ? 'default' : 'ghost'}
|
||||
onClick={() => actionHandler.handleAction(action)}
|
||||
disabled={action.disabled}
|
||||
className={`${isMobile ? 'text-xs' : 'text-[10px]'} ${
|
||||
action.variant === 'danger' ? 'text-error hover:text-error-dark' : ''
|
||||
}`}
|
||||
>
|
||||
{action.type === 'call_supplier' && <Phone className="w-3 h-3 mr-1" />}
|
||||
{action.type === 'navigate' && <ExternalLink className="w-3 h-3 mr-1" />}
|
||||
{action.label}
|
||||
{action.estimated_time_minutes && (
|
||||
<span className="ml-1 opacity-60">({action.estimated_time_minutes}m)</span>
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Standard Actions */}
|
||||
<div className={`flex items-center gap-2 ${isMobile ? 'flex-col sm:flex-row' : ''}`}>
|
||||
{isUnread && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={isMobile ? "md" : "sm"}
|
||||
onClick={() => onMarkAsRead(alert.id)}
|
||||
className={`${isMobile ? 'w-full sm:w-auto px-4 py-2 text-sm' : 'h-6 px-2 text-xs'}`}
|
||||
>
|
||||
<Check className={`${isMobile ? 'w-4 h-4 mr-2' : 'w-3 h-3 mr-1'}`} />
|
||||
Marcar como leído
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={isMobile ? "md" : "sm"}
|
||||
onClick={() => onRemove(alert.id)}
|
||||
className={`text-[var(--color-error)] hover:text-[var(--color-error-dark)] ${
|
||||
isMobile ? 'w-full sm:w-auto px-4 py-2 text-sm' : 'h-6 px-2 text-xs'
|
||||
}`}
|
||||
>
|
||||
<Trash2 className={`${isMobile ? 'w-4 h-4 mr-2' : 'w-3 h-3 mr-1'}`} />
|
||||
Eliminar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const NotificationPanel: React.FC<NotificationPanelProps> = ({
|
||||
notifications,
|
||||
enrichedAlerts = [],
|
||||
isOpen,
|
||||
onClose,
|
||||
onMarkAsRead,
|
||||
@@ -94,9 +228,20 @@ export const NotificationPanel: React.FC<NotificationPanelProps> = ({
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const unreadNotifications = notifications.filter(n => !n.read);
|
||||
const actionHandler = useSmartActionHandler();
|
||||
const user = useAuthUser();
|
||||
|
||||
// Memoize unread notifications to prevent recalculation on every render
|
||||
const unreadNotifications = React.useMemo(() =>
|
||||
notifications.filter(n => !n.read),
|
||||
[notifications]
|
||||
);
|
||||
|
||||
const isMobile = window.innerWidth < 768;
|
||||
|
||||
// Use enriched alerts if available, otherwise fallback to legacy notifications
|
||||
const useEnrichedAlerts = enrichedAlerts.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop - Only on mobile */}
|
||||
@@ -167,7 +312,7 @@ export const NotificationPanel: React.FC<NotificationPanelProps> = ({
|
||||
|
||||
{/* Notifications List */}
|
||||
<div className={`flex-1 overflow-y-auto ${isMobile ? 'px-2 py-2' : ''}`}>
|
||||
{notifications.length === 0 ? (
|
||||
{(useEnrichedAlerts ? enrichedAlerts.length === 0 : notifications.length === 0) ? (
|
||||
<div className={`text-center ${isMobile ? 'py-12 px-6' : 'p-8'}`}>
|
||||
<CheckCircle className={`mx-auto mb-3 ${isMobile ? 'w-12 h-12' : 'w-8 h-8'}`} style={{ color: 'var(--color-success)' }} />
|
||||
<p className={`text-[var(--text-secondary)] ${isMobile ? 'text-base' : 'text-sm'}`}>
|
||||
@@ -176,91 +321,47 @@ export const NotificationPanel: React.FC<NotificationPanelProps> = ({
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--border-primary)]">
|
||||
{notifications.map((notification) => {
|
||||
const SeverityIcon = getSeverityIcon(notification.severity);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={clsx(
|
||||
"transition-colors hover:bg-[var(--bg-secondary)] active:bg-[var(--bg-tertiary)]",
|
||||
isMobile ? 'px-4 py-4 mx-2 my-1 rounded-lg' : 'p-3',
|
||||
!notification.read && "bg-[var(--color-info)]/5"
|
||||
)}
|
||||
>
|
||||
<div className={`flex gap-${isMobile ? '4' : '3'}`}>
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={`flex-shrink-0 rounded-full mt-0.5 ${isMobile ? 'p-2' : 'p-1'}`}
|
||||
style={{ backgroundColor: getSeverityColor(notification.severity) + '15' }}
|
||||
>
|
||||
<SeverityIcon
|
||||
className={`${isMobile ? 'w-5 h-5' : 'w-3 h-3'}`}
|
||||
style={{ color: getSeverityColor(notification.severity) }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Header */}
|
||||
<div className={`flex items-start justify-between gap-2 ${isMobile ? 'mb-2' : 'mb-1'}`}>
|
||||
<div className={`flex items-center gap-2 ${isMobile ? 'flex-wrap' : ''}`}>
|
||||
<Badge variant={getSeverityBadge(notification.severity)} size={isMobile ? "md" : "sm"}>
|
||||
{notification.severity.toUpperCase()}
|
||||
</Badge>
|
||||
<Badge variant="secondary" size={isMobile ? "md" : "sm"}>
|
||||
{notification.item_type === 'alert' ? 'Alerta' : 'Recomendación'}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className={`font-medium text-[var(--text-secondary)] ${isMobile ? 'text-sm' : 'text-xs'}`}>
|
||||
{formatTimestamp(notification.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<p className={`font-medium leading-tight text-[var(--text-primary)] ${
|
||||
isMobile ? 'text-base mb-2' : 'text-sm mb-1'
|
||||
}`}>
|
||||
{notification.title}
|
||||
</p>
|
||||
|
||||
{/* Message */}
|
||||
<p className={`leading-relaxed text-[var(--text-secondary)] ${
|
||||
isMobile ? 'text-sm mb-4' : 'text-xs mb-2'
|
||||
}`}>
|
||||
{notification.message}
|
||||
</p>
|
||||
|
||||
{/* Actions */}
|
||||
<div className={`flex items-center gap-2 ${isMobile ? 'flex-col sm:flex-row' : ''}`}>
|
||||
{!notification.read && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={isMobile ? "md" : "sm"}
|
||||
onClick={() => onMarkAsRead(notification.id)}
|
||||
className={`${isMobile ? 'w-full sm:w-auto px-4 py-2 text-sm' : 'h-6 px-2 text-xs'}`}
|
||||
>
|
||||
<Check className={`${isMobile ? 'w-4 h-4 mr-2' : 'w-3 h-3 mr-1'}`} />
|
||||
Marcar como leído
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={isMobile ? "md" : "sm"}
|
||||
onClick={() => onRemoveNotification(notification.id)}
|
||||
className={`text-[var(--color-error)] hover:text-[var(--color-error-dark)] ${
|
||||
isMobile ? 'w-full sm:w-auto px-4 py-2 text-sm' : 'h-6 px-2 text-xs'
|
||||
}`}
|
||||
>
|
||||
<Trash2 className={`${isMobile ? 'w-4 h-4 mr-2' : 'w-3 h-3 mr-1'}`} />
|
||||
Eliminar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{useEnrichedAlerts ? (
|
||||
// Render enriched alerts
|
||||
enrichedAlerts
|
||||
.sort((a, b) => b.priority_score - a.priority_score)
|
||||
.map((alert) => (
|
||||
<EnrichedAlertItem
|
||||
key={alert.id}
|
||||
alert={alert}
|
||||
isMobile={isMobile}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onRemove={onRemoveNotification}
|
||||
actionHandler={actionHandler}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
// Render notifications as enriched alerts (NotificationData now has enriched fields)
|
||||
notifications
|
||||
.sort((a, b) => b.priority_score - a.priority_score)
|
||||
.map((notification) => (
|
||||
<EnrichedAlertItem
|
||||
key={notification.id}
|
||||
alert={{
|
||||
...notification,
|
||||
tenant_id: user?.tenant_id || '',
|
||||
status: notification.read ? 'acknowledged' : 'active',
|
||||
created_at: notification.timestamp,
|
||||
enriched_at: notification.timestamp,
|
||||
alert_metadata: notification.metadata || {},
|
||||
service: 'notification-service',
|
||||
alert_type: notification.item_type,
|
||||
actions: notification.actions || [],
|
||||
is_group_summary: false,
|
||||
placement: notification.placement || ['notification_panel']
|
||||
} as EnrichedAlert}
|
||||
isMobile={isMobile}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onRemove={onRemoveNotification}
|
||||
actionHandler={actionHandler}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user