Improve the frontend
This commit is contained in:
601
frontend/src/utils/alertHelpers.ts
Normal file
601
frontend/src/utils/alertHelpers.ts
Normal 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;
|
||||
}
|
||||
306
frontend/src/utils/numberFormatting.ts
Normal file
306
frontend/src/utils/numberFormatting.ts
Normal 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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user