Improve the frontend

This commit is contained in:
Urtzi Alfaro
2025-10-21 19:50:07 +02:00
parent 05da20357d
commit 8d30172483
105 changed files with 14699 additions and 4630 deletions

View File

@@ -0,0 +1,601 @@
/**
* Alert Helper Utilities
* Provides grouping, filtering, sorting, and categorization logic for alerts
*/
import { NotificationData } from '../hooks/useNotifications';
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';
export interface AlertGroup {
id: string;
type: 'time' | 'category' | 'similarity';
key: string;
title: string;
count: number;
severity: AlertSeverity;
alerts: NotificationData[];
collapsed?: boolean;
}
export interface AlertFilters {
severities: AlertSeverity[];
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: NotificationData): AlertCategory {
const text = `${alert.title} ${alert.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: NotificationData, alert2: NotificationData): boolean {
// Must be same category and severity
if (categorizeAlert(alert1) !== categorizeAlert(alert2)) {
return false;
}
if (alert1.severity !== alert2.severity) {
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: NotificationData[]): AlertGroup[] {
const groups: Map<TimeGroup, NotificationData[]> = new Map();
alerts.forEach(alert => {
const timeGroup = getTimeGroup(alert.timestamp);
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: NotificationData[]): AlertGroup[] {
const groups: Map<AlertCategory, NotificationData[]> = 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: NotificationData[]): 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}-${alert.severity}`,
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: alert.severity as AlertSeverity,
alerts: [alert],
});
}
});
return groups;
}
/**
* Get highest severity from a list of alerts
*/
export function getHighestSeverity(alerts: NotificationData[]): AlertSeverity {
const severityOrder: AlertSeverity[] = ['urgent', 'high', 'medium', 'low'];
for (const severity of severityOrder) {
if (alerts.some(alert => alert.severity === severity)) {
return severity;
}
}
return 'low';
}
/**
* Sort alerts by severity and timestamp
*/
export function sortAlerts(alerts: NotificationData[]): NotificationData[] {
const severityOrder: Record<AlertSeverity, number> = {
urgent: 4,
high: 3,
medium: 2,
low: 1,
};
return [...alerts].sort((a, b) => {
// First by severity
const severityDiff = severityOrder[b.severity as AlertSeverity] - severityOrder[a.severity as AlertSeverity];
if (severityDiff !== 0) {
return severityDiff;
}
// Then by timestamp (newest first)
return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
});
}
/**
* Filter alerts based on criteria
*/
export function filterAlerts(
alerts: NotificationData[],
filters: AlertFilters,
snoozedAlerts: Map<string, SnoozedAlert>
): NotificationData[] {
return alerts.filter(alert => {
// Filter by severity
if (filters.severities.length > 0 && !filters.severities.includes(alert.severity as AlertSeverity)) {
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.timestamp);
if (timeGroup !== filters.timeRange) {
return false;
}
}
// 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;
}
}
// 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
}
export function getContextualActions(alert: NotificationData): 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: NotificationData;
highlights: {
title: boolean;
message: boolean;
};
}
export function searchAlerts(alerts: NotificationData[], 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: NotificationData[],
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[alert.severity as AlertSeverity]++;
stats.byCategory[categorizeAlert(alert)]++;
if (!alert.read) {
stats.unread++;
}
if (isAlertSnoozed(alert.id, snoozedAlerts)) {
stats.snoozed++;
}
});
return stats;
}

View File

@@ -0,0 +1,306 @@
/**
* Number Formatting Utilities
* Provides locale-aware number, currency, and measurement formatting
* Fixes floating point precision issues
*/
export interface FormatNumberOptions {
minimumFractionDigits?: number;
maximumFractionDigits?: number;
useGrouping?: boolean;
}
export interface FormatCurrencyOptions extends FormatNumberOptions {
currency?: string;
currencyDisplay?: 'symbol' | 'code' | 'name';
}
export interface FormatWeightOptions extends FormatNumberOptions {
unit?: 'kg' | 'g' | 'lb' | 'oz';
}
/**
* Round number to specified decimal places to avoid floating point errors
*/
export function roundToPrecision(value: number, decimals: number = 2): number {
const multiplier = Math.pow(10, decimals);
return Math.round(value * multiplier) / multiplier;
}
/**
* Format number with locale-specific formatting
*/
export function formatNumber(
value: number | string,
locale: string = 'es-ES',
options: FormatNumberOptions = {}
): string {
const numValue = typeof value === 'string' ? parseFloat(value) : value;
if (isNaN(numValue)) {
return '0';
}
// Round to avoid floating point errors
const rounded = roundToPrecision(numValue, options.maximumFractionDigits ?? 2);
const defaultOptions: Intl.NumberFormatOptions = {
minimumFractionDigits: options.minimumFractionDigits ?? 0,
maximumFractionDigits: options.maximumFractionDigits ?? 2,
useGrouping: options.useGrouping ?? true,
};
return new Intl.NumberFormat(locale, defaultOptions).format(rounded);
}
/**
* Format currency with locale-specific formatting
*/
export function formatCurrency(
value: number | string,
locale: string = 'es-ES',
options: FormatCurrencyOptions = {}
): string {
const numValue = typeof value === 'string' ? parseFloat(value) : value;
if (isNaN(numValue)) {
return '€0,00';
}
// Round to avoid floating point errors
const rounded = roundToPrecision(numValue, options.maximumFractionDigits ?? 2);
const defaultOptions: Intl.NumberFormatOptions = {
style: 'currency',
currency: options.currency ?? 'EUR',
currencyDisplay: options.currencyDisplay ?? 'symbol',
minimumFractionDigits: options.minimumFractionDigits ?? 2,
maximumFractionDigits: options.maximumFractionDigits ?? 2,
};
return new Intl.NumberFormat(locale, defaultOptions).format(rounded);
}
/**
* Format weight/mass with unit
*/
export function formatWeight(
value: number | string,
locale: string = 'es-ES',
options: FormatWeightOptions = {}
): string {
const numValue = typeof value === 'string' ? parseFloat(value) : value;
if (isNaN(numValue)) {
return '0 kg';
}
const unit = options.unit ?? 'kg';
// Round to avoid floating point errors
const rounded = roundToPrecision(numValue, options.maximumFractionDigits ?? 2);
const formatted = formatNumber(rounded, locale, {
minimumFractionDigits: options.minimumFractionDigits ?? 0,
maximumFractionDigits: options.maximumFractionDigits ?? 2,
useGrouping: options.useGrouping ?? true,
});
return `${formatted} ${unit}`;
}
/**
* Format volume with unit
*/
export function formatVolume(
value: number | string,
locale: string = 'es-ES',
unit: 'L' | 'mL' | 'gal' = 'L',
decimals: number = 2
): string {
const numValue = typeof value === 'string' ? parseFloat(value) : value;
if (isNaN(numValue)) {
return `0 ${unit}`;
}
const rounded = roundToPrecision(numValue, decimals);
const formatted = formatNumber(rounded, locale, {
maximumFractionDigits: decimals,
});
return `${formatted} ${unit}`;
}
/**
* Format percentage
*/
export function formatPercentage(
value: number | string,
locale: string = 'es-ES',
decimals: number = 1
): string {
const numValue = typeof value === 'string' ? parseFloat(value) : value;
if (isNaN(numValue)) {
return '0%';
}
const rounded = roundToPrecision(numValue, decimals);
const formatted = formatNumber(rounded, locale, {
minimumFractionDigits: 0,
maximumFractionDigits: decimals,
});
return `${formatted}%`;
}
/**
* Format compact number (1K, 1M, etc.)
*/
export function formatCompactNumber(
value: number | string,
locale: string = 'es-ES'
): string {
const numValue = typeof value === 'string' ? parseFloat(value) : value;
if (isNaN(numValue)) {
return '0';
}
const rounded = roundToPrecision(numValue, 1);
return new Intl.NumberFormat(locale, {
notation: 'compact',
maximumFractionDigits: 1,
}).format(rounded);
}
/**
* Format integer (no decimals)
*/
export function formatInteger(
value: number | string,
locale: string = 'es-ES'
): string {
const numValue = typeof value === 'string' ? parseFloat(value) : value;
if (isNaN(numValue)) {
return '0';
}
return new Intl.NumberFormat(locale, {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(Math.round(numValue));
}
/**
* Parse localized number string to number
*/
export function parseLocalizedNumber(
value: string,
locale: string = 'es-ES'
): number {
// Get locale-specific decimal and group separators
const parts = new Intl.NumberFormat(locale).formatToParts(1234.5);
const decimalSeparator = parts.find(p => p.type === 'decimal')?.value ?? ',';
const groupSeparator = parts.find(p => p.type === 'group')?.value ?? '.';
// Remove group separators and replace decimal separator with dot
const normalized = value
.replace(new RegExp(`\\${groupSeparator}`, 'g'), '')
.replace(decimalSeparator, '.');
return parseFloat(normalized);
}
/**
* Format unit of measure with value
*/
export function formatMeasurement(
value: number | string,
unit: string,
locale: string = 'es-ES',
decimals: number = 2
): string {
const numValue = typeof value === 'string' ? parseFloat(value) : value;
if (isNaN(numValue)) {
return `0 ${unit}`;
}
const rounded = roundToPrecision(numValue, decimals);
const formatted = formatNumber(rounded, locale, {
minimumFractionDigits: 0,
maximumFractionDigits: decimals,
});
return `${formatted} ${unit}`;
}
/**
* Safe division to avoid floating point errors
*/
export function safeDivide(
numerator: number,
denominator: number,
decimals: number = 2
): number {
if (denominator === 0) {
return 0;
}
return roundToPrecision(numerator / denominator, decimals);
}
/**
* Safe multiplication to avoid floating point errors
*/
export function safeMultiply(
a: number,
b: number,
decimals: number = 2
): number {
return roundToPrecision(a * b, decimals);
}
/**
* Safe addition to avoid floating point errors
*/
export function safeAdd(
...values: number[]
): number {
const sum = values.reduce((acc, val) => acc + val, 0);
return roundToPrecision(sum, 2);
}
/**
* Format quantity with appropriate unit of measure
* Handles common bakery units
*/
export function formatQuantity(
value: number | string,
unit: string,
locale: string = 'es-ES'
): string {
const numValue = typeof value === 'string' ? parseFloat(value) : value;
if (isNaN(numValue)) {
return `0 ${unit}`;
}
// Determine decimals based on unit
let decimals = 2;
if (unit.toLowerCase() === 'unidades' || unit.toLowerCase() === 'units') {
decimals = 0;
} else if (['kg', 'l', 'lb'].includes(unit.toLowerCase())) {
decimals = 2;
}
const rounded = roundToPrecision(numValue, decimals);
const formatted = formatNumber(rounded, locale, {
minimumFractionDigits: 0,
maximumFractionDigits: decimals,
});
return `${formatted} ${unit}`;
}