New alert system and panel de control page
This commit is contained in:
@@ -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++;
|
||||
}
|
||||
|
||||
|
||||
152
frontend/src/utils/alertI18n.ts
Normal file
152
frontend/src/utils/alertI18n.ts
Normal 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));
|
||||
}
|
||||
Reference in New Issue
Block a user