New alert system and panel de control page

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

View File

@@ -3,25 +3,39 @@
* Provides grouping, filtering, sorting, and categorization logic for alerts
*/
import { NotificationData } from '../hooks/useNotifications';
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;
severity: AlertSeverity;
alerts: NotificationData[];
priority_level: PriorityLevel;
alerts: Alert[];
collapsed?: boolean;
}
export interface AlertFilters {
severities: AlertSeverity[];
priorities: PriorityLevel[];
categories: AlertCategory[];
timeRange: TimeGroup | 'all';
search: string;
@@ -37,8 +51,17 @@ export interface SnoozedAlert {
/**
* Categorize alert based on title and message content
*/
export function categorizeAlert(alert: NotificationData): AlertCategory {
const text = `${alert.title} ${alert.message}`.toLowerCase();
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';
@@ -138,12 +161,12 @@ export function getTimeGroupName(group: TimeGroup, locale: string = 'es'): strin
/**
* Check if two alerts are similar enough to group together
*/
export function areAlertsSimilar(alert1: NotificationData, alert2: NotificationData): boolean {
export function areAlertsSimilar(alert1: Alert, alert2: Alert): boolean {
// Must be same category and severity
if (categorizeAlert(alert1) !== categorizeAlert(alert2)) {
return false;
}
if (alert1.severity !== alert2.severity) {
if (getSeverity(alert1) !== getSeverity(alert2)) {
return false;
}
@@ -173,11 +196,11 @@ export function areAlertsSimilar(alert1: NotificationData, alert2: NotificationD
/**
* Group alerts by time periods
*/
export function groupAlertsByTime(alerts: NotificationData[]): AlertGroup[] {
const groups: Map<TimeGroup, NotificationData[]> = new Map();
export function groupAlertsByTime(alerts: Alert[]): AlertGroup[] {
const groups: Map<TimeGroup, Alert[]> = new Map();
alerts.forEach(alert => {
const timeGroup = getTimeGroup(alert.timestamp);
const timeGroup = getTimeGroup(alert.created_at);
if (!groups.has(timeGroup)) {
groups.set(timeGroup, []);
}
@@ -207,8 +230,8 @@ export function groupAlertsByTime(alerts: NotificationData[]): AlertGroup[] {
/**
* Group alerts by category
*/
export function groupAlertsByCategory(alerts: NotificationData[]): AlertGroup[] {
const groups: Map<AlertCategory, NotificationData[]> = new Map();
export function groupAlertsByCategory(alerts: Alert[]): AlertGroup[] {
const groups: Map<AlertCategory, Alert[]> = new Map();
alerts.forEach(alert => {
const category = categorizeAlert(alert);
@@ -240,7 +263,7 @@ export function groupAlertsByCategory(alerts: NotificationData[]): AlertGroup[]
/**
* Group similar alerts together
*/
export function groupSimilarAlerts(alerts: NotificationData[]): AlertGroup[] {
export function groupSimilarAlerts(alerts: Alert[]): AlertGroup[] {
const groups: AlertGroup[] = [];
const processed = new Set<string>();
@@ -264,7 +287,7 @@ export function groupSimilarAlerts(alerts: NotificationData[]): AlertGroup[] {
groups.push({
id: `similar-${alert.id}`,
type: 'similarity',
key: `${category}-${alert.severity}`,
key: `${category}-${getSeverity(alert)}`,
title: `${similarAlerts.length} alertas de ${getCategoryName(category).toLowerCase()}`,
count: similarAlerts.length,
severity: highestSeverity,
@@ -279,7 +302,7 @@ export function groupSimilarAlerts(alerts: NotificationData[]): AlertGroup[] {
key: alert.id,
title: alert.title,
count: 1,
severity: alert.severity as AlertSeverity,
severity: getSeverity(alert) as AlertSeverity,
alerts: [alert],
});
}
@@ -291,11 +314,11 @@ export function groupSimilarAlerts(alerts: NotificationData[]): AlertGroup[] {
/**
* Get highest severity from a list of alerts
*/
export function getHighestSeverity(alerts: NotificationData[]): AlertSeverity {
export function getHighestSeverity(alerts: Alert[]): AlertSeverity {
const severityOrder: AlertSeverity[] = ['urgent', 'high', 'medium', 'low'];
for (const severity of severityOrder) {
if (alerts.some(alert => alert.severity === severity)) {
if (alerts.some(alert => getSeverity(alert) === severity)) {
return severity;
}
}
@@ -306,7 +329,7 @@ export function getHighestSeverity(alerts: NotificationData[]): AlertSeverity {
/**
* Sort alerts by severity and timestamp
*/
export function sortAlerts(alerts: NotificationData[]): NotificationData[] {
export function sortAlerts(alerts: Alert[]): Alert[] {
const severityOrder: Record<AlertSeverity, number> = {
urgent: 4,
high: 3,
@@ -316,13 +339,13 @@ export function sortAlerts(alerts: NotificationData[]): NotificationData[] {
return [...alerts].sort((a, b) => {
// First by severity
const severityDiff = severityOrder[b.severity as AlertSeverity] - severityOrder[a.severity as AlertSeverity];
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.timestamp).getTime() - new Date(a.timestamp).getTime();
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
});
}
@@ -330,13 +353,14 @@ export function sortAlerts(alerts: NotificationData[]): NotificationData[] {
* Filter alerts based on criteria
*/
export function filterAlerts(
alerts: NotificationData[],
alerts: Alert[],
filters: AlertFilters,
snoozedAlerts: Map<string, SnoozedAlert>
): NotificationData[] {
snoozedAlerts: Map<string, SnoozedAlert>,
t?: TFunction
): Alert[] {
return alerts.filter(alert => {
// Filter by severity
if (filters.severities.length > 0 && !filters.severities.includes(alert.severity as AlertSeverity)) {
// Filter by priority
if (filters.priorities.length > 0 && !filters.priorities.includes(alert.priority_level as PriorityLevel)) {
return false;
}
@@ -350,7 +374,7 @@ export function filterAlerts(
// Filter by time range
if (filters.timeRange !== 'all') {
const timeGroup = getTimeGroup(alert.timestamp);
const timeGroup = getTimeGroup(alert.created_at);
if (timeGroup !== filters.timeRange) {
return false;
}
@@ -359,9 +383,21 @@ export function filterAlerts(
// Filter by search text
if (filters.search.trim()) {
const searchLower = filters.search.toLowerCase();
const searchableText = `${alert.title} ${alert.message}`.toLowerCase();
if (!searchableText.includes(searchLower)) {
return false;
// 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;
}
}
}
@@ -453,9 +489,10 @@ export interface ContextualAction {
variant: 'primary' | 'secondary' | 'outline';
action: string; // action identifier
route?: string; // navigation route
metadata?: Record<string, any>; // Additional action metadata
}
export function getContextualActions(alert: NotificationData): ContextualAction[] {
export function getContextualActions(alert: Alert): ContextualAction[] {
const category = categorizeAlert(alert);
const text = `${alert.title} ${alert.message}`.toLowerCase();
@@ -529,14 +566,14 @@ export function getContextualActions(alert: NotificationData): ContextualAction[
* Search alerts with highlighting
*/
export interface SearchMatch {
alert: NotificationData;
alert: Alert;
highlights: {
title: boolean;
message: boolean;
};
}
export function searchAlerts(alerts: NotificationData[], query: string): SearchMatch[] {
export function searchAlerts(alerts: Alert[], query: string): SearchMatch[] {
if (!query.trim()) {
return alerts.map(alert => ({
alert,
@@ -573,7 +610,7 @@ export interface AlertStats {
}
export function getAlertStatistics(
alerts: NotificationData[],
alerts: Alert[],
snoozedAlerts: Map<string, SnoozedAlert>
): AlertStats {
const stats: AlertStats = {
@@ -585,10 +622,10 @@ export function getAlertStatistics(
};
alerts.forEach(alert => {
stats.bySeverity[alert.severity as AlertSeverity]++;
stats.bySeverity[getSeverity(alert) as AlertSeverity]++;
stats.byCategory[categorizeAlert(alert)]++;
if (!alert.read) {
if (alert.status === 'active') {
stats.unread++;
}

View File

@@ -0,0 +1,152 @@
/**
* Alert i18n Translation Utility
*
* Handles translation of alert titles and messages using i18n keys from backend enrichment.
* Falls back to raw title/message if i18n data is not available.
*/
import { TFunction } from 'i18next';
export interface AlertI18nData {
title_key?: string;
title_params?: Record<string, any>;
message_key?: string;
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
*
* @param alert - Alert object
* @param t - i18next translation function
* @returns Translated or fallback title
*/
export function translateAlertTitle(
alert: {
title: string;
metadata?: Record<string, any>;
},
t: TFunction
): string {
const i18nData = alert.metadata?.i18n as AlertI18nData | undefined;
if (!i18nData?.title_key) {
return alert.title;
}
try {
const translated = t(i18nData.title_key, i18nData.title_params || {});
return translated !== i18nData.title_key ? translated : alert.title;
} catch (error) {
console.warn(`Failed to translate alert title with key: ${i18nData.title_key}`, error);
return alert.title;
}
}
/**
* Translates alert message only
*
* @param alert - Alert object
* @param t - i18next translation function
* @returns Translated or fallback message
*/
export function translateAlertMessage(
alert: {
message: string;
metadata?: Record<string, any>;
},
t: TFunction
): string {
const i18nData = alert.metadata?.i18n as AlertI18nData | undefined;
if (!i18nData?.message_key) {
return alert.message;
}
try {
const translated = t(i18nData.message_key, i18nData.message_params || {});
return translated !== i18nData.message_key ? translated : alert.message;
} catch (error) {
console.warn(`Failed to translate alert message with key: ${i18nData.message_key}`, error);
return alert.message;
}
}
/**
* Checks if alert has i18n data available
*
* @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));
}