Improve the frontend
This commit is contained in:
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