New alert service

This commit is contained in:
Urtzi Alfaro
2025-12-05 20:07:01 +01:00
parent 1fe3a73549
commit 667e6e0404
393 changed files with 26002 additions and 61033 deletions

View File

@@ -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;
}

View File

@@ -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));
}

View 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;
}

View 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}`);
}
};

View 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;
}

View File

@@ -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
*/