feat: add currency input field and enhance currency formatting utilities

- Introduced `FieldCurrencyInput` component for handling currency input in forms, supporting formatted display while maintaining numeric values.
- Implemented `RenderCurrency` component for displaying currency values with proper formatting.
- Enhanced `CurrencyUtils` to include parsing and formatting functions for Rupiah, ensuring accurate representation in UI.
- Updated various components and forms to utilize the new currency input and rendering capabilities, improving user experience in financial data entry.
- Added unit tests for currency input and formatting functionalities to ensure reliability and correctness.

These changes enhance the application's handling of currency inputs and displays, providing a more user-friendly experience for financial transactions.
This commit is contained in:
shancheas
2026-08-31 15:36:53 +07:00
parent efe321ca2f
commit 71046973ae
34 changed files with 439 additions and 80 deletions
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { CurrencyUtils } from './currency.utils';
import { CurrencyUtils, formatRupiah, toCurrencyNumber } from './currency.utils';
describe('CurrencyUtils', () => {
let currency: CurrencyUtils;
@@ -70,7 +70,14 @@ describe('CurrencyUtils', () => {
it('should round decimal when decimalScale is set', () => {
const c = new CurrencyUtils({ decimalScale: 2 });
expect(c.format(1234.5678)).toBe('Rp 1.234,56');
expect(c.format(1234.5678)).toBe('Rp 1.234,57');
});
it('should pad to decimalScale for integers and single-decimal values', () => {
const c = new CurrencyUtils({ decimalScale: 2 });
expect(c.format(1234)).toBe('Rp 1.234,00');
expect(c.format(1234.5)).toBe('Rp 1.234,50');
expect(c.format('12500.12345')).toBe('Rp 12.500,12');
});
it('should handle zero and negative values', () => {
@@ -108,7 +115,42 @@ describe('CurrencyUtils', () => {
it('should format numbers with multiple decimals correctly when decimalScale is set', () => {
const c = new CurrencyUtils({ decimalScale: 3 });
expect(c.format(1234.56789)).toBe('Rp 1.234,567');
expect(c.format(1234.56789)).toBe('Rp 1.234,568');
});
});
describe('Parsing to number', () => {
it('should parse formatted string to a number without rounding', () => {
expect(currency.parseToNumber('Rp 1.234.567')).toBe(1234567);
expect(currency.parseToNumber('Rp 1.234,56789')).toBe(1234.56789);
});
it('should return null for empty input', () => {
expect(currency.parseToNumber(null)).toBeNull();
expect(currency.parseToNumber(undefined)).toBeNull();
expect(currency.parseToNumber('')).toBeNull();
});
});
describe('formatRupiah', () => {
it('should format with two display decimals without changing the source', () => {
const stored = '12500.12345';
expect(formatRupiah(stored)).toBe('Rp 12.500,12');
expect(stored).toBe('12500.12345');
});
});
describe('toCurrencyNumber', () => {
it('should keep numeric values as numbers', () => {
expect(toCurrencyNumber(12500.12345)).toBe(12500.12345);
expect(toCurrencyNumber('12500.12345')).toBe(12500.12345);
});
it('should return empty string for blank or invalid values', () => {
expect(toCurrencyNumber('')).toBe('');
expect(toCurrencyNumber(null)).toBe('');
expect(toCurrencyNumber(undefined)).toBe('');
expect(toCurrencyNumber('abc')).toBe('');
});
});
});
+48 -11
View File
@@ -19,8 +19,16 @@
* ------------------------------------------------------------
*/
import { NumberUtils } from '../number/number.utils';
export type CurrencyInput = number | string | null | undefined;
/** Display-only decimal places for Rupiah (tables, details, blurred inputs). */
export const CURRENCY_DISPLAY_SCALE = 2;
/** Stored / form decimal places. Display must not truncate this precision. */
export const CURRENCY_DATA_SCALE = 5;
export interface CurrencyOptions {
/** Currency symbol or prefix for display, e.g., "Rp ", "$" */
prefix?: string;
@@ -120,20 +128,20 @@ export class CurrencyUtils {
format(value: CurrencyInput): string {
if (value === undefined || value === null || value === '') return '';
const stringValue = `${value}`;
const [integer, decimal] = stringValue.split('.');
// Add thousand separator to integer part
const formattedInteger = integer.replace(/\B(?=(\d{3})+(?!\d))/g, this.thousandSeparator);
// If no decimalScale or no decimal part, display full decimal
if (this.decimalScale === undefined || !decimal) {
if (this.decimalScale === undefined) {
const stringValue = `${value}`;
const [integer, decimal] = stringValue.split('.');
const formattedInteger = integer.replace(/\B(?=(\d{3})+(?!\d))/g, this.thousandSeparator);
return `${this.prefix}${formattedInteger}${decimal ? this.decimalSeparator + decimal : ''}`;
}
// Display rounded decimal according to decimalScale
const roundedDecimal = decimal.slice(0, this.decimalScale);
return `${this.prefix}${formattedInteger}${this.decimalSeparator}${roundedDecimal}`;
const numeric = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(numeric)) return '';
const rounded = NumberUtils.roundToDecimal(numeric, this.decimalScale);
const [integer, decimal] = rounded.toFixed(this.decimalScale).split('.');
const formattedInteger = integer.replace(/\B(?=(\d{3})+(?!\d))/g, this.thousandSeparator);
return `${this.prefix}${formattedInteger}${this.decimalSeparator}${decimal}`;
}
/** ------------------------------------------------------------
@@ -164,4 +172,33 @@ export class CurrencyUtils {
return value;
}
/**
* Parse a formatted currency string into a number without rounding.
* Returns null when the input is empty or not numeric.
*/
parseToNumber(displayValue: string | undefined | null): number | null {
const raw = this.parseToRaw(displayValue);
if (!raw) return null;
const numeric = Number(raw);
return Number.isFinite(numeric) ? numeric : null;
}
}
const displayCurrency = new CurrencyUtils({ decimalScale: CURRENCY_DISPLAY_SCALE });
/** Format a value as Rupiah with at most two display decimals. Does not mutate the source. */
export function formatRupiah(value: CurrencyInput): string {
return displayCurrency.format(value);
}
/**
* Coerce a stored price (number or decimal string) into a NumberInput value.
* Empty / invalid inputs stay as '' so the form field can remain blank.
*/
export function toCurrencyNumber(value: CurrencyInput): number | '' {
if (value === undefined || value === null || value === '') return '';
if (typeof value === 'number') return Number.isFinite(value) ? value : '';
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : '';
}