New alert service
This commit is contained in:
@@ -1,638 +0,0 @@
|
||||
/**
|
||||
* Alert Helper Utilities
|
||||
* Provides grouping, filtering, sorting, and categorization logic for alerts
|
||||
*/
|
||||
|
||||
import { PriorityLevel } from '../types/alerts';
|
||||
import type { Alert } from '../types/events';
|
||||
import { TFunction } from 'i18next';
|
||||
import { translateAlertTitle, translateAlertMessage } from './alertI18n';
|
||||
|
||||
export type AlertSeverity = 'urgent' | 'high' | 'medium' | 'low';
|
||||
export type AlertCategory = 'inventory' | 'production' | 'orders' | 'equipment' | 'quality' | 'suppliers' | 'other';
|
||||
export type TimeGroup = 'today' | 'yesterday' | 'this_week' | 'older';
|
||||
|
||||
/**
|
||||
* Map Alert priority_score to AlertSeverity
|
||||
*/
|
||||
export function getSeverity(alert: Alert): AlertSeverity {
|
||||
// Map based on priority_score for more granularity
|
||||
if (alert.priority_score >= 80) return 'urgent';
|
||||
if (alert.priority_score >= 60) return 'high';
|
||||
if (alert.priority_score >= 40) return 'medium';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
export interface AlertGroup {
|
||||
id: string;
|
||||
type: 'time' | 'category' | 'similarity';
|
||||
key: string;
|
||||
title: string;
|
||||
count: number;
|
||||
priority_level: PriorityLevel;
|
||||
alerts: Alert[];
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
export interface AlertFilters {
|
||||
priorities: PriorityLevel[];
|
||||
categories: AlertCategory[];
|
||||
timeRange: TimeGroup | 'all';
|
||||
search: string;
|
||||
showSnoozed: boolean;
|
||||
}
|
||||
|
||||
export interface SnoozedAlert {
|
||||
alertId: string;
|
||||
until: number; // timestamp
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorize alert based on title and message content
|
||||
*/
|
||||
export function categorizeAlert(alert: AlertOrNotification, t?: TFunction): AlertCategory {
|
||||
let title = alert.title;
|
||||
let message = alert.message;
|
||||
|
||||
// Use translated text if translation function is provided
|
||||
if (t) {
|
||||
title = translateAlertTitle(alert, t);
|
||||
message = translateAlertMessage(alert, t);
|
||||
}
|
||||
|
||||
const text = `${title} ${message}`.toLowerCase();
|
||||
|
||||
if (text.includes('stock') || text.includes('inventario') || text.includes('caducad') || text.includes('expi')) {
|
||||
return 'inventory';
|
||||
}
|
||||
if (text.includes('producci') || text.includes('production') || text.includes('lote') || text.includes('batch')) {
|
||||
return 'production';
|
||||
}
|
||||
if (text.includes('pedido') || text.includes('order') || text.includes('entrega') || text.includes('delivery')) {
|
||||
return 'orders';
|
||||
}
|
||||
if (text.includes('equip') || text.includes('maquina') || text.includes('mantenimiento') || text.includes('maintenance')) {
|
||||
return 'equipment';
|
||||
}
|
||||
if (text.includes('calidad') || text.includes('quality') || text.includes('temperatura') || text.includes('temperature')) {
|
||||
return 'quality';
|
||||
}
|
||||
if (text.includes('proveedor') || text.includes('supplier') || text.includes('compra') || text.includes('purchase')) {
|
||||
return 'suppliers';
|
||||
}
|
||||
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get category display name
|
||||
*/
|
||||
export function getCategoryName(category: AlertCategory, locale: string = 'es'): string {
|
||||
const names: Record<AlertCategory, Record<string, string>> = {
|
||||
inventory: { es: 'Inventario', en: 'Inventory' },
|
||||
production: { es: 'Producción', en: 'Production' },
|
||||
orders: { es: 'Pedidos', en: 'Orders' },
|
||||
equipment: { es: 'Maquinaria', en: 'Equipment' },
|
||||
quality: { es: 'Calidad', en: 'Quality' },
|
||||
suppliers: { es: 'Proveedores', en: 'Suppliers' },
|
||||
other: { es: 'Otros', en: 'Other' },
|
||||
};
|
||||
|
||||
return names[category][locale] || names[category]['es'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get category icon emoji
|
||||
*/
|
||||
export function getCategoryIcon(category: AlertCategory): string {
|
||||
const icons: Record<AlertCategory, string> = {
|
||||
inventory: '📦',
|
||||
production: '🏭',
|
||||
orders: '🚚',
|
||||
equipment: '⚙️',
|
||||
quality: '✅',
|
||||
suppliers: '🏢',
|
||||
other: '📋',
|
||||
};
|
||||
|
||||
return icons[category];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine time group for an alert
|
||||
*/
|
||||
export function getTimeGroup(timestamp: string): TimeGroup {
|
||||
const alertDate = new Date(timestamp);
|
||||
const now = new Date();
|
||||
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const weekAgo = new Date(today);
|
||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||
|
||||
if (alertDate >= today) {
|
||||
return 'today';
|
||||
}
|
||||
if (alertDate >= yesterday) {
|
||||
return 'yesterday';
|
||||
}
|
||||
if (alertDate >= weekAgo) {
|
||||
return 'this_week';
|
||||
}
|
||||
return 'older';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get time group display name
|
||||
*/
|
||||
export function getTimeGroupName(group: TimeGroup, locale: string = 'es'): string {
|
||||
const names: Record<TimeGroup, Record<string, string>> = {
|
||||
today: { es: 'Hoy', en: 'Today' },
|
||||
yesterday: { es: 'Ayer', en: 'Yesterday' },
|
||||
this_week: { es: 'Esta semana', en: 'This week' },
|
||||
older: { es: 'Anteriores', en: 'Older' },
|
||||
};
|
||||
|
||||
return names[group][locale] || names[group]['es'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two alerts are similar enough to group together
|
||||
*/
|
||||
export function areAlertsSimilar(alert1: Alert, alert2: Alert): boolean {
|
||||
// Must be same category and severity
|
||||
if (categorizeAlert(alert1) !== categorizeAlert(alert2)) {
|
||||
return false;
|
||||
}
|
||||
if (getSeverity(alert1) !== getSeverity(alert2)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract key terms from titles
|
||||
const getKeyTerms = (title: string): Set<string> => {
|
||||
const stopWords = new Set(['de', 'en', 'el', 'la', 'los', 'las', 'un', 'una', 'y', 'o', 'a', 'the', 'in', 'on', 'at', 'of', 'and', 'or']);
|
||||
return new Set(
|
||||
title
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter(word => word.length > 3 && !stopWords.has(word))
|
||||
);
|
||||
};
|
||||
|
||||
const terms1 = getKeyTerms(alert1.title);
|
||||
const terms2 = getKeyTerms(alert2.title);
|
||||
|
||||
// Calculate similarity: intersection / union
|
||||
const intersection = new Set([...terms1].filter(x => terms2.has(x)));
|
||||
const union = new Set([...terms1, ...terms2]);
|
||||
|
||||
const similarity = intersection.size / union.size;
|
||||
|
||||
return similarity > 0.5; // 50% similarity threshold
|
||||
}
|
||||
|
||||
/**
|
||||
* Group alerts by time periods
|
||||
*/
|
||||
export function groupAlertsByTime(alerts: Alert[]): AlertGroup[] {
|
||||
const groups: Map<TimeGroup, Alert[]> = new Map();
|
||||
|
||||
alerts.forEach(alert => {
|
||||
const timeGroup = getTimeGroup(alert.created_at);
|
||||
if (!groups.has(timeGroup)) {
|
||||
groups.set(timeGroup, []);
|
||||
}
|
||||
groups.get(timeGroup)!.push(alert);
|
||||
});
|
||||
|
||||
const timeOrder: TimeGroup[] = ['today', 'yesterday', 'this_week', 'older'];
|
||||
|
||||
return timeOrder
|
||||
.filter(key => groups.has(key))
|
||||
.map(key => {
|
||||
const groupAlerts = groups.get(key)!;
|
||||
const highestSeverity = getHighestSeverity(groupAlerts);
|
||||
|
||||
return {
|
||||
id: `time-${key}`,
|
||||
type: 'time' as const,
|
||||
key,
|
||||
title: getTimeGroupName(key),
|
||||
count: groupAlerts.length,
|
||||
severity: highestSeverity,
|
||||
alerts: groupAlerts,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Group alerts by category
|
||||
*/
|
||||
export function groupAlertsByCategory(alerts: Alert[]): AlertGroup[] {
|
||||
const groups: Map<AlertCategory, Alert[]> = new Map();
|
||||
|
||||
alerts.forEach(alert => {
|
||||
const category = categorizeAlert(alert);
|
||||
if (!groups.has(category)) {
|
||||
groups.set(category, []);
|
||||
}
|
||||
groups.get(category)!.push(alert);
|
||||
});
|
||||
|
||||
// Sort by count (descending)
|
||||
const sortedCategories = Array.from(groups.entries())
|
||||
.sort((a, b) => b[1].length - a[1].length);
|
||||
|
||||
return sortedCategories.map(([category, groupAlerts]) => {
|
||||
const highestSeverity = getHighestSeverity(groupAlerts);
|
||||
|
||||
return {
|
||||
id: `category-${category}`,
|
||||
type: 'category' as const,
|
||||
key: category,
|
||||
title: `${getCategoryIcon(category)} ${getCategoryName(category)}`,
|
||||
count: groupAlerts.length,
|
||||
severity: highestSeverity,
|
||||
alerts: groupAlerts,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Group similar alerts together
|
||||
*/
|
||||
export function groupSimilarAlerts(alerts: Alert[]): AlertGroup[] {
|
||||
const groups: AlertGroup[] = [];
|
||||
const processed = new Set<string>();
|
||||
|
||||
alerts.forEach(alert => {
|
||||
if (processed.has(alert.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find similar alerts
|
||||
const similarAlerts = alerts.filter(other =>
|
||||
!processed.has(other.id) && areAlertsSimilar(alert, other)
|
||||
);
|
||||
|
||||
if (similarAlerts.length > 1) {
|
||||
// Create a group
|
||||
similarAlerts.forEach(a => processed.add(a.id));
|
||||
|
||||
const category = categorizeAlert(alert);
|
||||
const highestSeverity = getHighestSeverity(similarAlerts);
|
||||
|
||||
groups.push({
|
||||
id: `similar-${alert.id}`,
|
||||
type: 'similarity',
|
||||
key: `${category}-${getSeverity(alert)}`,
|
||||
title: `${similarAlerts.length} alertas de ${getCategoryName(category).toLowerCase()}`,
|
||||
count: similarAlerts.length,
|
||||
severity: highestSeverity,
|
||||
alerts: similarAlerts,
|
||||
});
|
||||
} else {
|
||||
// Single alert, add as individual group
|
||||
processed.add(alert.id);
|
||||
groups.push({
|
||||
id: `single-${alert.id}`,
|
||||
type: 'similarity',
|
||||
key: alert.id,
|
||||
title: alert.title,
|
||||
count: 1,
|
||||
severity: getSeverity(alert) as AlertSeverity,
|
||||
alerts: [alert],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get highest severity from a list of alerts
|
||||
*/
|
||||
export function getHighestSeverity(alerts: Alert[]): AlertSeverity {
|
||||
const severityOrder: AlertSeverity[] = ['urgent', 'high', 'medium', 'low'];
|
||||
|
||||
for (const severity of severityOrder) {
|
||||
if (alerts.some(alert => getSeverity(alert) === severity)) {
|
||||
return severity;
|
||||
}
|
||||
}
|
||||
|
||||
return 'low';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort alerts by severity and timestamp
|
||||
*/
|
||||
export function sortAlerts(alerts: Alert[]): Alert[] {
|
||||
const severityOrder: Record<AlertSeverity, number> = {
|
||||
urgent: 4,
|
||||
high: 3,
|
||||
medium: 2,
|
||||
low: 1,
|
||||
};
|
||||
|
||||
return [...alerts].sort((a, b) => {
|
||||
// First by severity
|
||||
const severityDiff = severityOrder[getSeverity(b) as AlertSeverity] - severityOrder[getSeverity(a) as AlertSeverity];
|
||||
if (severityDiff !== 0) {
|
||||
return severityDiff;
|
||||
}
|
||||
|
||||
// Then by timestamp (newest first)
|
||||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter alerts based on criteria
|
||||
*/
|
||||
export function filterAlerts(
|
||||
alerts: Alert[],
|
||||
filters: AlertFilters,
|
||||
snoozedAlerts: Map<string, SnoozedAlert>,
|
||||
t?: TFunction
|
||||
): Alert[] {
|
||||
return alerts.filter(alert => {
|
||||
// Filter by priority
|
||||
if (filters.priorities.length > 0 && !filters.priorities.includes(alert.priority_level as PriorityLevel)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter by category
|
||||
if (filters.categories.length > 0) {
|
||||
const category = categorizeAlert(alert);
|
||||
if (!filters.categories.includes(category)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by time range
|
||||
if (filters.timeRange !== 'all') {
|
||||
const timeGroup = getTimeGroup(alert.created_at);
|
||||
if (timeGroup !== filters.timeRange) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by search text
|
||||
if (filters.search.trim()) {
|
||||
const searchLower = filters.search.toLowerCase();
|
||||
|
||||
// If translation function is provided, search in translated text
|
||||
if (t) {
|
||||
const translatedTitle = translateAlertTitle(alert, t);
|
||||
const translatedMessage = translateAlertMessage(alert, t);
|
||||
const searchableText = `${translatedTitle} ${translatedMessage}`.toLowerCase();
|
||||
if (!searchableText.includes(searchLower)) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// Fallback to original title and message
|
||||
const searchableText = `${alert.title} ${alert.message}`.toLowerCase();
|
||||
if (!searchableText.includes(searchLower)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter snoozed alerts
|
||||
if (!filters.showSnoozed) {
|
||||
const snoozed = snoozedAlerts.get(alert.id);
|
||||
if (snoozed && snoozed.until > Date.now()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if alert is snoozed
|
||||
*/
|
||||
export function isAlertSnoozed(alertId: string, snoozedAlerts: Map<string, SnoozedAlert>): boolean {
|
||||
const snoozed = snoozedAlerts.get(alertId);
|
||||
if (!snoozed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (snoozed.until <= Date.now()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get time remaining for snoozed alert
|
||||
*/
|
||||
export function getSnoozedTimeRemaining(alertId: string, snoozedAlerts: Map<string, SnoozedAlert>): string | null {
|
||||
const snoozed = snoozedAlerts.get(alertId);
|
||||
if (!snoozed || snoozed.until <= Date.now()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const remaining = snoozed.until - Date.now();
|
||||
const hours = Math.floor(remaining / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
if (hours > 24) {
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate snooze timestamp based on duration
|
||||
*/
|
||||
export function calculateSnoozeUntil(duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number): number {
|
||||
const now = Date.now();
|
||||
|
||||
if (typeof duration === 'number') {
|
||||
return now + duration;
|
||||
}
|
||||
|
||||
switch (duration) {
|
||||
case '15min':
|
||||
return now + 15 * 60 * 1000;
|
||||
case '1hr':
|
||||
return now + 60 * 60 * 1000;
|
||||
case '4hr':
|
||||
return now + 4 * 60 * 60 * 1000;
|
||||
case 'tomorrow': {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(9, 0, 0, 0); // 9 AM tomorrow
|
||||
return tomorrow.getTime();
|
||||
}
|
||||
default:
|
||||
return now + 60 * 60 * 1000; // default 1 hour
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get contextual action for alert type
|
||||
*/
|
||||
export interface ContextualAction {
|
||||
label: string;
|
||||
icon: string;
|
||||
variant: 'primary' | 'secondary' | 'outline';
|
||||
action: string; // action identifier
|
||||
route?: string; // navigation route
|
||||
metadata?: Record<string, any>; // Additional action metadata
|
||||
}
|
||||
|
||||
export function getContextualActions(alert: Alert): ContextualAction[] {
|
||||
const category = categorizeAlert(alert);
|
||||
const text = `${alert.title} ${alert.message}`.toLowerCase();
|
||||
|
||||
const actions: ContextualAction[] = [];
|
||||
|
||||
// Category-specific actions
|
||||
if (category === 'inventory') {
|
||||
if (text.includes('bajo') || text.includes('low')) {
|
||||
actions.push({
|
||||
label: 'Ordenar Stock',
|
||||
icon: '🛒',
|
||||
variant: 'primary',
|
||||
action: 'order_stock',
|
||||
route: '/app/procurement',
|
||||
});
|
||||
}
|
||||
if (text.includes('caduca') || text.includes('expir')) {
|
||||
actions.push({
|
||||
label: 'Planificar Uso',
|
||||
icon: '📅',
|
||||
variant: 'primary',
|
||||
action: 'plan_usage',
|
||||
route: '/app/production',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (category === 'equipment') {
|
||||
actions.push({
|
||||
label: 'Programar Mantenimiento',
|
||||
icon: '🔧',
|
||||
variant: 'primary',
|
||||
action: 'schedule_maintenance',
|
||||
route: '/app/operations/maquinaria',
|
||||
});
|
||||
}
|
||||
|
||||
if (category === 'orders') {
|
||||
if (text.includes('retraso') || text.includes('delayed')) {
|
||||
actions.push({
|
||||
label: 'Contactar Cliente',
|
||||
icon: '📞',
|
||||
variant: 'primary',
|
||||
action: 'contact_customer',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (category === 'production') {
|
||||
actions.push({
|
||||
label: 'Ver Producción',
|
||||
icon: '🏭',
|
||||
variant: 'secondary',
|
||||
action: 'view_production',
|
||||
route: '/app/production',
|
||||
});
|
||||
}
|
||||
|
||||
// Always add generic view details action
|
||||
actions.push({
|
||||
label: 'Ver Detalles',
|
||||
icon: '👁️',
|
||||
variant: 'outline',
|
||||
action: 'view_details',
|
||||
});
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search alerts with highlighting
|
||||
*/
|
||||
export interface SearchMatch {
|
||||
alert: Alert;
|
||||
highlights: {
|
||||
title: boolean;
|
||||
message: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function searchAlerts(alerts: Alert[], query: string): SearchMatch[] {
|
||||
if (!query.trim()) {
|
||||
return alerts.map(alert => ({
|
||||
alert,
|
||||
highlights: { title: false, message: false },
|
||||
}));
|
||||
}
|
||||
|
||||
const searchLower = query.toLowerCase();
|
||||
|
||||
return alerts
|
||||
.filter(alert => {
|
||||
const titleMatch = alert.title.toLowerCase().includes(searchLower);
|
||||
const messageMatch = alert.message.toLowerCase().includes(searchLower);
|
||||
return titleMatch || messageMatch;
|
||||
})
|
||||
.map(alert => ({
|
||||
alert,
|
||||
highlights: {
|
||||
title: alert.title.toLowerCase().includes(searchLower),
|
||||
message: alert.message.toLowerCase().includes(searchLower),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get alert statistics
|
||||
*/
|
||||
export interface AlertStats {
|
||||
total: number;
|
||||
bySeverity: Record<AlertSeverity, number>;
|
||||
byCategory: Record<AlertCategory, number>;
|
||||
unread: number;
|
||||
snoozed: number;
|
||||
}
|
||||
|
||||
export function getAlertStatistics(
|
||||
alerts: Alert[],
|
||||
snoozedAlerts: Map<string, SnoozedAlert>
|
||||
): AlertStats {
|
||||
const stats: AlertStats = {
|
||||
total: alerts.length,
|
||||
bySeverity: { urgent: 0, high: 0, medium: 0, low: 0 },
|
||||
byCategory: { inventory: 0, production: 0, orders: 0, equipment: 0, quality: 0, suppliers: 0, other: 0 },
|
||||
unread: 0,
|
||||
snoozed: 0,
|
||||
};
|
||||
|
||||
alerts.forEach(alert => {
|
||||
stats.bySeverity[getSeverity(alert) as AlertSeverity]++;
|
||||
stats.byCategory[categorizeAlert(alert)]++;
|
||||
|
||||
if (alert.status === 'active') {
|
||||
stats.unread++;
|
||||
}
|
||||
|
||||
if (isAlertSnoozed(alert.id, snoozedAlerts)) {
|
||||
stats.snoozed++;
|
||||
}
|
||||
});
|
||||
|
||||
return stats;
|
||||
}
|
||||
@@ -14,73 +14,7 @@ export interface AlertI18nData {
|
||||
message_params?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface AlertTranslationResult {
|
||||
title: string;
|
||||
message: string;
|
||||
isTranslated: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates alert title and message using i18n data from metadata
|
||||
*
|
||||
* @param alert - Alert object with title, message, and metadata
|
||||
* @param t - i18next translation function
|
||||
* @returns Translated or fallback title and message
|
||||
*/
|
||||
export function translateAlert(
|
||||
alert: {
|
||||
title: string;
|
||||
message: string;
|
||||
metadata?: Record<string, any>;
|
||||
},
|
||||
t: TFunction
|
||||
): AlertTranslationResult {
|
||||
// Extract i18n data from metadata
|
||||
const i18nData = alert.metadata?.i18n as AlertI18nData | undefined;
|
||||
|
||||
// If no i18n data, return original title and message
|
||||
if (!i18nData || (!i18nData.title_key && !i18nData.message_key)) {
|
||||
return {
|
||||
title: alert.title,
|
||||
message: alert.message,
|
||||
isTranslated: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Translate title
|
||||
let translatedTitle = alert.title;
|
||||
if (i18nData.title_key) {
|
||||
try {
|
||||
const translated = t(i18nData.title_key, i18nData.title_params || {});
|
||||
// Only use translation if it's not the key itself (i18next returns key if translation missing)
|
||||
if (translated !== i18nData.title_key) {
|
||||
translatedTitle = translated;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to translate alert title with key: ${i18nData.title_key}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Translate message
|
||||
let translatedMessage = alert.message;
|
||||
if (i18nData.message_key) {
|
||||
try {
|
||||
const translated = t(i18nData.message_key, i18nData.message_params || {});
|
||||
// Only use translation if it's not the key itself
|
||||
if (translated !== i18nData.message_key) {
|
||||
translatedMessage = translated;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to translate alert message with key: ${i18nData.message_key}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: translatedTitle,
|
||||
message: translatedMessage,
|
||||
isTranslated: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates alert title only
|
||||
@@ -91,23 +25,20 @@ export function translateAlert(
|
||||
*/
|
||||
export function translateAlertTitle(
|
||||
alert: {
|
||||
title: string;
|
||||
metadata?: Record<string, any>;
|
||||
i18n?: AlertI18nData;
|
||||
},
|
||||
t: TFunction
|
||||
): string {
|
||||
const i18nData = alert.metadata?.i18n as AlertI18nData | undefined;
|
||||
|
||||
if (!i18nData?.title_key) {
|
||||
return alert.title;
|
||||
if (!alert.i18n?.title_key) {
|
||||
return 'Alert';
|
||||
}
|
||||
|
||||
try {
|
||||
const translated = t(i18nData.title_key, i18nData.title_params || {});
|
||||
return translated !== i18nData.title_key ? translated : alert.title;
|
||||
const translated = t(alert.i18n.title_key, alert.i18n.title_params || {});
|
||||
return translated !== alert.i18n.title_key ? translated : alert.i18n.title_key;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to translate alert title with key: ${i18nData.title_key}`, error);
|
||||
return alert.title;
|
||||
console.warn(`Failed to translate alert title with key: ${alert.i18n.title_key}`, error);
|
||||
return alert.i18n.title_key;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,23 +51,20 @@ export function translateAlertTitle(
|
||||
*/
|
||||
export function translateAlertMessage(
|
||||
alert: {
|
||||
message: string;
|
||||
metadata?: Record<string, any>;
|
||||
i18n?: AlertI18nData;
|
||||
},
|
||||
t: TFunction
|
||||
): string {
|
||||
const i18nData = alert.metadata?.i18n as AlertI18nData | undefined;
|
||||
|
||||
if (!i18nData?.message_key) {
|
||||
return alert.message;
|
||||
if (!alert.i18n?.message_key) {
|
||||
return 'No message';
|
||||
}
|
||||
|
||||
try {
|
||||
const translated = t(i18nData.message_key, i18nData.message_params || {});
|
||||
return translated !== i18nData.message_key ? translated : alert.message;
|
||||
const translated = t(alert.i18n.message_key, alert.i18n.message_params || {});
|
||||
return translated !== alert.i18n.message_key ? translated : alert.i18n.message_key;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to translate alert message with key: ${i18nData.message_key}`, error);
|
||||
return alert.message;
|
||||
console.warn(`Failed to translate alert message with key: ${alert.i18n.message_key}`, error);
|
||||
return alert.i18n.message_key;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +74,8 @@ export function translateAlertMessage(
|
||||
* @param alert - Alert object
|
||||
* @returns True if i18n data is present
|
||||
*/
|
||||
export function hasI18nData(alert: { metadata?: Record<string, any> }): boolean {
|
||||
const i18nData = alert.metadata?.i18n as AlertI18nData | undefined;
|
||||
return !!(i18nData && (i18nData.title_key || i18nData.message_key));
|
||||
export function hasI18nData(alert: {
|
||||
i18n?: AlertI18nData;
|
||||
}): boolean {
|
||||
return !!(alert.i18n && (alert.i18n.title_key || alert.i18n.message_key));
|
||||
}
|
||||
|
||||
317
frontend/src/utils/alertManagement.ts
Normal file
317
frontend/src/utils/alertManagement.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* Unified Alert Management System
|
||||
*
|
||||
* Comprehensive system for handling all alert operations in the frontend
|
||||
* including API calls, SSE processing, and UI state management
|
||||
*/
|
||||
|
||||
import { Alert, Event, AlertTypeClass, PriorityLevel, EventDomain } from '../api/types/events';
|
||||
import { translateAlertTitle, translateAlertMessage } from '../utils/alertI18n';
|
||||
|
||||
// ============================================================
|
||||
// Type Definitions
|
||||
// ============================================================
|
||||
|
||||
export interface AlertFilterOptions {
|
||||
type_class?: AlertTypeClass[];
|
||||
priority_level?: PriorityLevel[];
|
||||
domain?: EventDomain[];
|
||||
status?: ('active' | 'acknowledged' | 'resolved' | 'dismissed' | 'in_progress')[];
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface AlertProcessingResult {
|
||||
success: boolean;
|
||||
alert?: Alert | AlertResponse;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Alert Processing Utilities
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Normalize alert to the unified structure (only for new Event structure)
|
||||
*/
|
||||
export function normalizeAlert(alert: any): Alert {
|
||||
// Only accept the new Event structure - no legacy support
|
||||
if (alert.event_class === 'alert') {
|
||||
return alert as Alert;
|
||||
}
|
||||
|
||||
// If it's an SSE EventSource message with nested data
|
||||
if (alert.data && alert.data.event_class === 'alert') {
|
||||
return alert.data as Alert;
|
||||
}
|
||||
|
||||
throw new Error('Only new Event structure is supported by normalizeAlert');
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply filters to an array of alerts
|
||||
*/
|
||||
export function applyAlertFilters(
|
||||
alerts: Alert[],
|
||||
filters: AlertFilterOptions = {},
|
||||
search: string = ''
|
||||
): Alert[] {
|
||||
return alerts.filter(alert => {
|
||||
// Filter by type class
|
||||
if (filters.type_class && filters.type_class.length > 0) {
|
||||
if (!alert.type_class || !filters.type_class.includes(alert.type_class as AlertTypeClass)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by priority level
|
||||
if (filters.priority_level && filters.priority_level.length > 0) {
|
||||
if (!alert.priority_level || !filters.priority_level.includes(alert.priority_level as PriorityLevel)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by domain
|
||||
if (filters.domain && filters.domain.length > 0) {
|
||||
if (!alert.event_domain || !filters.domain.includes(alert.event_domain as EventDomain)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by status
|
||||
if (filters.status && filters.status.length > 0) {
|
||||
if (!alert.status || !filters.status.includes(alert.status as any)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (search) {
|
||||
const searchLower = search.toLowerCase();
|
||||
const title = translateAlertTitle(alert, (key: string, params?: any) => key) || '';
|
||||
const message = translateAlertMessage(alert, (key: string, params?: any) => key) || '';
|
||||
|
||||
if (!title.toLowerCase().includes(searchLower) &&
|
||||
!message.toLowerCase().includes(searchLower) &&
|
||||
!alert.id.toLowerCase().includes(searchLower)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Alert Filtering and Sorting
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Filter alerts based on provided criteria
|
||||
*/
|
||||
export function filterAlerts(alerts: Alert[], filters: AlertFilterOptions = {}): Alert[] {
|
||||
return alerts.filter(alert => {
|
||||
// Type class filter
|
||||
if (filters.type_class && !filters.type_class.includes(alert.type_class)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Priority level filter
|
||||
if (filters.priority_level && !filters.priority_level.includes(alert.priority_level)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Domain filter
|
||||
if (filters.domain && !filters.domain.includes(alert.event_domain)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if (filters.status && !filters.status.includes(alert.status as any)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (filters.search) {
|
||||
const searchTerm = filters.search.toLowerCase();
|
||||
const title = translateAlertTitle(alert, (key: string, params?: any) => key).toLowerCase();
|
||||
const message = translateAlertMessage(alert, (key: string, params?: any) => key).toLowerCase();
|
||||
|
||||
if (!title.includes(searchTerm) && !message.includes(searchTerm)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort alerts by priority, urgency, and creation time
|
||||
*/
|
||||
export function sortAlerts(alerts: Alert[]): Alert[] {
|
||||
return [...alerts].sort((a, b) => {
|
||||
// Sort by priority level first
|
||||
const priorityOrder: Record<PriorityLevel, number> = {
|
||||
critical: 4,
|
||||
important: 3,
|
||||
standard: 2,
|
||||
info: 1
|
||||
};
|
||||
|
||||
const priorityDiff = priorityOrder[b.priority_level] - priorityOrder[a.priority_level];
|
||||
if (priorityDiff !== 0) return priorityDiff;
|
||||
|
||||
// If same priority, sort by type class
|
||||
const typeClassOrder: Record<AlertTypeClass, number> = {
|
||||
escalation: 5,
|
||||
action_needed: 4,
|
||||
prevented_issue: 3,
|
||||
trend_warning: 2,
|
||||
information: 1
|
||||
};
|
||||
|
||||
const typeDiff = typeClassOrder[b.type_class] - typeClassOrder[a.type_class];
|
||||
if (typeDiff !== 0) return typeDiff;
|
||||
|
||||
// If same type and priority, sort by creation time (newest first)
|
||||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Alert Utility Functions
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get alert icon based on type and priority
|
||||
*/
|
||||
export function getAlertIcon(alert: Alert): string {
|
||||
switch (alert.type_class) {
|
||||
case 'action_needed':
|
||||
return alert.priority_level === 'critical' ? 'alert-triangle' : 'alert-circle';
|
||||
case 'escalation':
|
||||
return 'alert-triangle';
|
||||
case 'trend_warning':
|
||||
return 'trending-up';
|
||||
case 'prevented_issue':
|
||||
return 'check-circle';
|
||||
case 'information':
|
||||
default:
|
||||
return 'info';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get alert color based on priority level
|
||||
*/
|
||||
export function getAlertColor(alert: Alert): string {
|
||||
switch (alert.priority_level) {
|
||||
case 'critical':
|
||||
return 'var(--color-error)';
|
||||
case 'important':
|
||||
return 'var(--color-warning)';
|
||||
case 'standard':
|
||||
return 'var(--color-info)';
|
||||
case 'info':
|
||||
default:
|
||||
return 'var(--color-success)';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if alert requires immediate attention
|
||||
*/
|
||||
export function requiresImmediateAttention(alert: Alert): boolean {
|
||||
return alert.type_class === 'action_needed' &&
|
||||
(alert.priority_level === 'critical' || alert.priority_level === 'important') &&
|
||||
alert.status === 'active';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if alert is actionable (not already addressed)
|
||||
*/
|
||||
export function isActionable(alert: Alert): boolean {
|
||||
return alert.status === 'active' &&
|
||||
!alert.orchestrator_context?.already_addressed;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SSE Processing
|
||||
// ============================================================
|
||||
|
||||
|
||||
// ============================================================
|
||||
// Alert State Management Utilities
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Merge new alerts with existing alerts, avoiding duplicates
|
||||
*/
|
||||
export function mergeAlerts(existingAlerts: Alert[], newAlerts: Alert[]): Alert[] {
|
||||
const existingIds = new Set(existingAlerts.map(alert => alert.id));
|
||||
const uniqueNewAlerts = newAlerts.filter(alert => !existingIds.has(alert.id));
|
||||
|
||||
return [...existingAlerts, ...uniqueNewAlerts];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update specific alert in array (for status changes, etc.)
|
||||
*/
|
||||
export function updateAlertInArray(alerts: Alert[], updatedAlert: Alert): Alert[] {
|
||||
return alerts.map(alert =>
|
||||
alert.id === updatedAlert.id ? updatedAlert : alert
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove specific alert from array
|
||||
*/
|
||||
export function removeAlertFromArray(alerts: Alert[], alertId: string): Alert[] {
|
||||
return alerts.filter(alert => alert.id !== alertId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get alert statistics
|
||||
*/
|
||||
export function getAlertStats(alerts: Alert[]) {
|
||||
const stats = {
|
||||
total: alerts.length,
|
||||
active: 0,
|
||||
acknowledged: 0,
|
||||
resolved: 0,
|
||||
critical: 0,
|
||||
important: 0,
|
||||
standard: 0,
|
||||
info: 0,
|
||||
actionNeeded: 0,
|
||||
preventedIssue: 0,
|
||||
trendWarning: 0,
|
||||
escalation: 0,
|
||||
information: 0
|
||||
};
|
||||
|
||||
alerts.forEach(alert => {
|
||||
switch (alert.status) {
|
||||
case 'active': stats.active++; break;
|
||||
case 'acknowledged': stats.acknowledged++; break;
|
||||
case 'resolved': stats.resolved++; break;
|
||||
}
|
||||
|
||||
switch (alert.priority_level) {
|
||||
case 'critical': stats.critical++; break;
|
||||
case 'important': stats.important++; break;
|
||||
case 'standard': stats.standard++; break;
|
||||
case 'info': stats.info++; break;
|
||||
}
|
||||
|
||||
switch (alert.type_class) {
|
||||
case 'action_needed': stats.actionNeeded++; break;
|
||||
case 'prevented_issue': stats.preventedIssue++; break;
|
||||
case 'trend_warning': stats.trendWarning++; break;
|
||||
case 'escalation': stats.escalation++; break;
|
||||
case 'information': stats.information++; break;
|
||||
}
|
||||
});
|
||||
|
||||
return stats;
|
||||
}
|
||||
178
frontend/src/utils/eventI18n.ts
Normal file
178
frontend/src/utils/eventI18n.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Clean i18n Parameter System for Event Content in Frontend
|
||||
*
|
||||
* Handles rendering of parameterized content for:
|
||||
* - Alert titles and messages
|
||||
* - Notification titles and messages
|
||||
* - Recommendation titles and messages
|
||||
* - AI reasoning summaries
|
||||
* - Action labels and consequences
|
||||
*/
|
||||
|
||||
import { I18nContent, Event, Alert, Notification, Recommendation, SmartAction } from '../api/types/events';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface I18nRenderer {
|
||||
renderTitle: (titleKey: string, titleParams?: Record<string, any>) => string;
|
||||
renderMessage: (messageKey: string, messageParams?: Record<string, any>) => string;
|
||||
renderReasoningSummary: (summaryKey: string, summaryParams?: Record<string, any>) => string;
|
||||
renderActionLabel: (labelKey: string, labelParams?: Record<string, any>) => string;
|
||||
renderUrgencyReason: (reasonKey: string, reasonParams?: Record<string, any>) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a parameterized template with given parameters
|
||||
*/
|
||||
export const renderTemplate = (template: string, params: Record<string, any> = {}): string => {
|
||||
if (!template) return '';
|
||||
|
||||
let result = template;
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
// Replace {{key}} with the value, handling nested properties
|
||||
const regex = new RegExp(`{{\\s*${key}\\s*}}`, 'g');
|
||||
result = result.replace(regex, String(value ?? ''));
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for accessing the i18n renderer within React components
|
||||
*/
|
||||
export const useEventI18n = (): I18nRenderer => {
|
||||
const { t } = useTranslation(['events', 'common']);
|
||||
|
||||
const renderTitle = (titleKey: string, titleParams: Record<string, any> = {}): string => {
|
||||
return t(titleKey, { defaultValue: titleKey, ...titleParams });
|
||||
};
|
||||
|
||||
const renderMessage = (messageKey: string, messageParams: Record<string, any> = {}): string => {
|
||||
return t(messageKey, { defaultValue: messageKey, ...messageParams });
|
||||
};
|
||||
|
||||
const renderReasoningSummary = (summaryKey: string, summaryParams: Record<string, any> = {}): string => {
|
||||
return t(summaryKey, { defaultValue: summaryKey, ...summaryParams });
|
||||
};
|
||||
|
||||
const renderActionLabel = (labelKey: string, labelParams: Record<string, any> = {}): string => {
|
||||
return t(labelKey, { defaultValue: labelKey, ...labelParams });
|
||||
};
|
||||
|
||||
const renderUrgencyReason = (reasonKey: string, reasonParams: Record<string, any> = {}): string => {
|
||||
return t(reasonKey, { defaultValue: reasonKey, ...reasonParams });
|
||||
};
|
||||
|
||||
return {
|
||||
renderTitle,
|
||||
renderMessage,
|
||||
renderReasoningSummary,
|
||||
renderActionLabel,
|
||||
renderUrgencyReason
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Render i18n content for an event
|
||||
*/
|
||||
export const renderEventContent = (i18n: I18nContent, language?: string): { title: string; message: string } => {
|
||||
const title = renderTemplate(i18n.title_key, i18n.title_params);
|
||||
const message = renderTemplate(i18n.message_key, i18n.message_params);
|
||||
|
||||
return { title, message };
|
||||
};
|
||||
|
||||
/**
|
||||
* Render all content for an alert
|
||||
*/
|
||||
export const renderAlertContent = (alert: Alert, language?: string) => {
|
||||
const { title, message } = renderEventContent(alert.i18n, language);
|
||||
|
||||
let reasoningSummary = '';
|
||||
if (alert.ai_reasoning?.summary_key) {
|
||||
reasoningSummary = renderTemplate(
|
||||
alert.ai_reasoning.summary_key,
|
||||
alert.ai_reasoning.summary_params
|
||||
);
|
||||
}
|
||||
|
||||
// Render smart actions with parameterized labels
|
||||
const renderedActions = alert.smart_actions.map(action => ({
|
||||
...action,
|
||||
label: renderTemplate(action.label_key, action.label_params),
|
||||
consequence: action.consequence_key
|
||||
? renderTemplate(action.consequence_key, action.consequence_params)
|
||||
: undefined,
|
||||
disabled_reason: action.disabled_reason_key
|
||||
? renderTemplate(action.disabled_reason_key, action.disabled_reason_params)
|
||||
: action.disabled_reason
|
||||
}));
|
||||
|
||||
return {
|
||||
title,
|
||||
message,
|
||||
reasoningSummary,
|
||||
renderedActions
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Render all content for a notification
|
||||
*/
|
||||
export const renderNotificationContent = (notification: Notification, language?: string) => {
|
||||
const { title, message } = renderEventContent(notification.i18n, language);
|
||||
|
||||
return {
|
||||
title,
|
||||
message
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Render all content for a recommendation
|
||||
*/
|
||||
export const renderRecommendationContent = (recommendation: Recommendation, language?: string) => {
|
||||
const { title, message } = renderEventContent(recommendation.i18n, language);
|
||||
|
||||
let reasoningSummary = '';
|
||||
if (recommendation.ai_reasoning?.summary_key) {
|
||||
reasoningSummary = renderTemplate(
|
||||
recommendation.ai_reasoning.summary_key,
|
||||
recommendation.ai_reasoning.summary_params
|
||||
);
|
||||
}
|
||||
|
||||
// Render suggested actions with parameterized labels
|
||||
const renderedSuggestedActions = recommendation.suggested_actions.map(action => ({
|
||||
...action,
|
||||
label: renderTemplate(action.label_key, action.label_params),
|
||||
consequence: action.consequence_key
|
||||
? renderTemplate(action.consequence_key, action.consequence_params)
|
||||
: undefined,
|
||||
disabled_reason: action.disabled_reason_key
|
||||
? renderTemplate(action.disabled_reason_key, action.disabled_reason_params)
|
||||
: action.disabled_reason
|
||||
}));
|
||||
|
||||
return {
|
||||
title,
|
||||
message,
|
||||
reasoningSummary,
|
||||
renderedSuggestedActions
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Render content for any event type
|
||||
*/
|
||||
export const renderEvent = (event: Event, language?: string) => {
|
||||
switch (event.event_class) {
|
||||
case 'alert':
|
||||
return renderAlertContent(event as Alert, language);
|
||||
case 'notification':
|
||||
return renderNotificationContent(event as Notification, language);
|
||||
case 'recommendation':
|
||||
return renderRecommendationContent(event as Recommendation, language);
|
||||
default:
|
||||
throw new Error(`Unknown event class: ${(event as any).event_class}`);
|
||||
}
|
||||
};
|
||||
366
frontend/src/utils/i18n/alertRendering.ts
Normal file
366
frontend/src/utils/i18n/alertRendering.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* Alert Rendering Utilities - i18n Parameter Substitution
|
||||
*
|
||||
* Centralized rendering functions for alert system with proper i18n support.
|
||||
* Uses new type system from /api/types/events.ts
|
||||
*/
|
||||
|
||||
import { TFunction } from 'i18next';
|
||||
import type {
|
||||
EventResponse,
|
||||
Alert,
|
||||
Notification,
|
||||
Recommendation,
|
||||
SmartAction,
|
||||
UrgencyContext,
|
||||
I18nDisplayContext,
|
||||
AIReasoningContext,
|
||||
isAlert,
|
||||
isNotification,
|
||||
isRecommendation,
|
||||
} from '../../api/types/events';
|
||||
|
||||
// ============================================================
|
||||
// EVENT CONTENT RENDERING
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Render event title with parameter substitution
|
||||
*/
|
||||
export function renderEventTitle(
|
||||
event: EventResponse,
|
||||
t: TFunction
|
||||
): string {
|
||||
try {
|
||||
const { title_key, title_params } = event.i18n;
|
||||
return t(title_key, title_params || {});
|
||||
} catch (error) {
|
||||
console.error('Error rendering event title:', error);
|
||||
return event.i18n.title_key || 'Untitled Event';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render event message with parameter substitution
|
||||
*/
|
||||
export function renderEventMessage(
|
||||
event: EventResponse,
|
||||
t: TFunction
|
||||
): string {
|
||||
try {
|
||||
const { message_key, message_params } = event.i18n;
|
||||
return t(message_key, message_params || {});
|
||||
} catch (error) {
|
||||
console.error('Error rendering event message:', error);
|
||||
return event.i18n.message_key || 'No message available';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SMART ACTION RENDERING
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Render action label with parameter substitution
|
||||
*/
|
||||
export function renderActionLabel(
|
||||
action: SmartAction,
|
||||
t: TFunction
|
||||
): string {
|
||||
try {
|
||||
return t(action.label_key, action.label_params || {});
|
||||
} catch (error) {
|
||||
console.error('Error rendering action label:', error);
|
||||
return action.label_key || 'Action';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render action consequence with parameter substitution
|
||||
*/
|
||||
export function renderActionConsequence(
|
||||
action: SmartAction,
|
||||
t: TFunction
|
||||
): string | null {
|
||||
if (!action.consequence_key) return null;
|
||||
|
||||
try {
|
||||
return t(action.consequence_key, action.consequence_params || {});
|
||||
} catch (error) {
|
||||
console.error('Error rendering action consequence:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render disabled reason with parameter substitution
|
||||
*/
|
||||
export function renderDisabledReason(
|
||||
action: SmartAction,
|
||||
t: TFunction
|
||||
): string | null {
|
||||
// Try i18n key first
|
||||
if (action.disabled_reason_key) {
|
||||
try {
|
||||
return t(action.disabled_reason_key, action.disabled_reason_params || {});
|
||||
} catch (error) {
|
||||
console.error('Error rendering disabled reason:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to plain text
|
||||
return action.disabled_reason || null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// AI REASONING RENDERING
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Render AI reasoning summary with parameter substitution
|
||||
*/
|
||||
export function renderAIReasoning(
|
||||
event: Alert,
|
||||
t: TFunction
|
||||
): string | null {
|
||||
if (!event.ai_reasoning?.summary_key) return null;
|
||||
|
||||
try {
|
||||
return t(
|
||||
event.ai_reasoning.summary_key,
|
||||
event.ai_reasoning.summary_params || {}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error rendering AI reasoning:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// URGENCY CONTEXT RENDERING
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Render urgency reason with parameter substitution
|
||||
*/
|
||||
export function renderUrgencyReason(
|
||||
urgency: UrgencyContext,
|
||||
t: TFunction
|
||||
): string | null {
|
||||
if (!urgency.urgency_reason_key) return null;
|
||||
|
||||
try {
|
||||
return t(urgency.urgency_reason_key, urgency.urgency_reason_params || {});
|
||||
} catch (error) {
|
||||
console.error('Error rendering urgency reason:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SAFE RENDERING WITH FALLBACKS
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Safely render any i18n context with fallback
|
||||
*/
|
||||
export function safeRenderI18n(
|
||||
key: string | undefined,
|
||||
params: Record<string, any> | undefined,
|
||||
t: TFunction,
|
||||
fallback: string = ''
|
||||
): string {
|
||||
if (!key) return fallback;
|
||||
|
||||
try {
|
||||
return t(key, params || {});
|
||||
} catch (error) {
|
||||
console.error(`Error rendering i18n key ${key}:`, error);
|
||||
return fallback || key;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// EVENT TYPE HELPERS
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get event type display name
|
||||
*/
|
||||
export function getEventTypeLabel(event: EventResponse, t: TFunction): string {
|
||||
if (isAlert(event)) {
|
||||
return t('common.event_types.alert', 'Alert');
|
||||
} else if (isNotification(event)) {
|
||||
return t('common.event_types.notification', 'Notification');
|
||||
} else if (isRecommendation(event)) {
|
||||
return t('common.event_types.recommendation', 'Recommendation');
|
||||
}
|
||||
return t('common.event_types.unknown', 'Event');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get priority level display name
|
||||
*/
|
||||
export function getPriorityLevelLabel(
|
||||
level: string,
|
||||
t: TFunction
|
||||
): string {
|
||||
const key = `common.priority_levels.${level}`;
|
||||
return t(key, level.charAt(0).toUpperCase() + level.slice(1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status display name
|
||||
*/
|
||||
export function getStatusLabel(status: string, t: TFunction): string {
|
||||
const key = `common.statuses.${status}`;
|
||||
return t(key, status.charAt(0).toUpperCase() + level.slice(1));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// FORMATTING HELPERS
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Format countdown time (for escalation alerts)
|
||||
*/
|
||||
export function formatCountdown(seconds: number | undefined): string {
|
||||
if (!seconds) return '';
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m ${secs}s`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${secs}s`;
|
||||
} else {
|
||||
return `${secs}s`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time until consequence (hours)
|
||||
*/
|
||||
export function formatTimeUntilConsequence(hours: number | undefined, t: TFunction): string {
|
||||
if (!hours) return '';
|
||||
|
||||
if (hours < 1) {
|
||||
const minutes = Math.round(hours * 60);
|
||||
return t('common.time.minutes', { count: minutes }, `${minutes} minutes`);
|
||||
} else if (hours < 24) {
|
||||
const roundedHours = Math.round(hours);
|
||||
return t('common.time.hours', { count: roundedHours }, `${roundedHours} hours`);
|
||||
} else {
|
||||
const days = Math.round(hours / 24);
|
||||
return t('common.time.days', { count: days }, `${days} days`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format deadline as relative time
|
||||
*/
|
||||
export function formatDeadline(deadline: string | undefined, t: TFunction): string {
|
||||
if (!deadline) return '';
|
||||
|
||||
try {
|
||||
const deadlineDate = new Date(deadline);
|
||||
const now = new Date();
|
||||
const diffMs = deadlineDate.getTime() - now.getTime();
|
||||
const diffHours = diffMs / (1000 * 60 * 60);
|
||||
|
||||
if (diffHours < 0) {
|
||||
return t('common.time.overdue', 'Overdue');
|
||||
}
|
||||
|
||||
return formatTimeUntilConsequence(diffHours, t);
|
||||
} catch (error) {
|
||||
console.error('Error formatting deadline:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CURRENCY FORMATTING
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Format currency value from params
|
||||
*/
|
||||
export function formatCurrency(value: number | undefined, currency: string = 'EUR'): string {
|
||||
if (value === undefined || value === null) return '';
|
||||
|
||||
try {
|
||||
return new Intl.NumberFormat('en-EU', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
}).format(value);
|
||||
} catch (error) {
|
||||
return `${value} ${currency}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// COMPLETE EVENT RENDERING
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Render complete event with all i18n content
|
||||
*/
|
||||
export interface RenderedEvent {
|
||||
title: string;
|
||||
message: string;
|
||||
actions: Array<{
|
||||
label: string;
|
||||
consequence: string | null;
|
||||
disabledReason: string | null;
|
||||
original: SmartAction;
|
||||
}>;
|
||||
aiReasoning: string | null;
|
||||
urgencyReason: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render all event content at once
|
||||
*/
|
||||
export function renderCompleteEvent(
|
||||
event: EventResponse,
|
||||
t: TFunction
|
||||
): RenderedEvent {
|
||||
const rendered: RenderedEvent = {
|
||||
title: renderEventTitle(event, t),
|
||||
message: renderEventMessage(event, t),
|
||||
actions: [],
|
||||
aiReasoning: null,
|
||||
urgencyReason: null,
|
||||
};
|
||||
|
||||
// Render actions (only for alerts)
|
||||
if (isAlert(event)) {
|
||||
rendered.actions = event.smart_actions.map((action) => ({
|
||||
label: renderActionLabel(action, t),
|
||||
consequence: renderActionConsequence(action, t),
|
||||
disabledReason: renderDisabledReason(action, t),
|
||||
original: action,
|
||||
}));
|
||||
|
||||
rendered.aiReasoning = renderAIReasoning(event, t);
|
||||
|
||||
if (event.urgency) {
|
||||
rendered.urgencyReason = renderUrgencyReason(event.urgency, t);
|
||||
}
|
||||
}
|
||||
|
||||
// Render suggested actions (only for recommendations)
|
||||
if (isRecommendation(event) && event.suggested_actions) {
|
||||
rendered.actions = event.suggested_actions.map((action) => ({
|
||||
label: renderActionLabel(action, t),
|
||||
consequence: renderActionConsequence(action, t),
|
||||
disabledReason: renderDisabledReason(action, t),
|
||||
original: action,
|
||||
}));
|
||||
}
|
||||
|
||||
return rendered;
|
||||
}
|
||||
@@ -1,37 +1,23 @@
|
||||
/**
|
||||
* Smart Action Handlers - Complete Implementation
|
||||
* Handles execution of all 14 smart action types from enriched alerts
|
||||
* Handles execution of all smart action types from enriched alerts
|
||||
*
|
||||
* NO PLACEHOLDERS - All action types fully implemented
|
||||
*/
|
||||
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { SmartAction as ImportedSmartAction, SmartActionType } from '../api/types/events';
|
||||
|
||||
// ============================================================
|
||||
// Types (matching backend SmartActionType enum)
|
||||
// Types (using imported types from events.ts)
|
||||
// ============================================================
|
||||
|
||||
export enum SmartActionType {
|
||||
APPROVE_PO = 'approve_po',
|
||||
REJECT_PO = 'reject_po',
|
||||
MODIFY_PO = 'modify_po',
|
||||
CALL_SUPPLIER = 'call_supplier',
|
||||
NAVIGATE = 'navigate',
|
||||
ADJUST_PRODUCTION = 'adjust_production',
|
||||
START_PRODUCTION_BATCH = 'start_production_batch',
|
||||
NOTIFY_CUSTOMER = 'notify_customer',
|
||||
CANCEL_AUTO_ACTION = 'cancel_auto_action',
|
||||
MARK_DELIVERY_RECEIVED = 'mark_delivery_received',
|
||||
COMPLETE_STOCK_RECEIPT = 'complete_stock_receipt',
|
||||
OPEN_REASONING = 'open_reasoning',
|
||||
SNOOZE = 'snooze',
|
||||
DISMISS = 'dismiss',
|
||||
MARK_READ = 'mark_read',
|
||||
}
|
||||
|
||||
// Legacy interface for backwards compatibility with existing handler code
|
||||
export interface SmartAction {
|
||||
label: string;
|
||||
type: SmartActionType;
|
||||
label?: string;
|
||||
label_key?: string;
|
||||
action_type: string;
|
||||
type?: string; // For backward compatibility
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||
metadata?: Record<string, any>;
|
||||
disabled?: boolean;
|
||||
@@ -40,6 +26,9 @@ export interface SmartAction {
|
||||
consequence?: string;
|
||||
}
|
||||
|
||||
// Re-export types from events.ts
|
||||
export { SmartActionType };
|
||||
|
||||
// ============================================================
|
||||
// Smart Action Handler Class
|
||||
// ============================================================
|
||||
@@ -65,7 +54,10 @@ export class SmartActionHandler {
|
||||
try {
|
||||
let result = false;
|
||||
|
||||
switch (action.type) {
|
||||
// Support both legacy (type) and new (action_type) field names
|
||||
const actionType = action.action_type || action.type;
|
||||
|
||||
switch (actionType) {
|
||||
case SmartActionType.APPROVE_PO:
|
||||
result = await this.handleApprovePO(action);
|
||||
break;
|
||||
@@ -78,6 +70,10 @@ export class SmartActionHandler {
|
||||
result = this.handleModifyPO(action);
|
||||
break;
|
||||
|
||||
case SmartActionType.VIEW_PO_DETAILS:
|
||||
result = this.handleViewPODetails(action);
|
||||
break;
|
||||
|
||||
case SmartActionType.CALL_SUPPLIER:
|
||||
result = this.handleCallSupplier(action);
|
||||
break;
|
||||
@@ -127,8 +123,8 @@ export class SmartActionHandler {
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn('Unknown action type:', action.type);
|
||||
this.onError?.(`Unknown action type: ${action.type}`);
|
||||
console.warn('Unknown action type:', actionType);
|
||||
this.onError?.(`Unknown action type: ${actionType}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -269,6 +265,28 @@ export class SmartActionHandler {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 3.5. VIEW_PO_DETAILS - Open PO in view mode
|
||||
*/
|
||||
private handleViewPODetails(action: SmartAction): boolean {
|
||||
const { po_id, tenant_id } = action.metadata || {};
|
||||
|
||||
if (!po_id) {
|
||||
console.error('Missing PO ID');
|
||||
this.onError?.('Missing PO ID for viewing details');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Emit event to open PO modal in view mode
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('po:open-details', {
|
||||
detail: { po_id, tenant_id, mode: 'view' },
|
||||
})
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 4. CALL_SUPPLIER - Initiate phone call
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user