@umituz/web-design-system 4.29.4 → 4.30.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/money.test.tsx +101 -0
- package/src/infrastructure/utils/index.ts +13 -0
- package/src/infrastructure/utils/money.utils.ts +108 -0
- package/src/presentation/atoms/Money.tsx +27 -0
- package/src/presentation/atoms/Sparkline.tsx +3 -1
- package/src/presentation/atoms/index.ts +3 -0
- package/src/presentation/organisms/KpiCard.tsx +13 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/web-design-system",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.30.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Web Design System - Tailwind v4 authored, precompiled CSS shipped. Atomic Design components (Atoms, Molecules, Organisms, Templates) for React applications.",
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Money utils + Money atom — minor-unit contract (ISO 4217 digits, parsing, totals).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it } from 'vitest';
|
|
6
|
+
import { render, screen } from '@testing-library/react';
|
|
7
|
+
import { cleanup } from '@testing-library/react';
|
|
8
|
+
import { afterEach } from 'vitest';
|
|
9
|
+
import {
|
|
10
|
+
currencyFractionDigits,
|
|
11
|
+
formatMoney,
|
|
12
|
+
formatMoneyCompact,
|
|
13
|
+
toMinor,
|
|
14
|
+
parseAmountToMinor,
|
|
15
|
+
pctOfMinor,
|
|
16
|
+
lineTotals,
|
|
17
|
+
sumTotals,
|
|
18
|
+
sumMinor,
|
|
19
|
+
} from '../infrastructure/utils';
|
|
20
|
+
import { Money } from '../presentation/atoms/Money';
|
|
21
|
+
|
|
22
|
+
afterEach(cleanup);
|
|
23
|
+
|
|
24
|
+
describe('currencyFractionDigits', () => {
|
|
25
|
+
it('defaults to 2 and matches ISO 4217 exceptions case-insensitively', () => {
|
|
26
|
+
expect(currencyFractionDigits('USD')).toBe(2);
|
|
27
|
+
expect(currencyFractionDigits('jpy')).toBe(0);
|
|
28
|
+
expect(currencyFractionDigits('KWD')).toBe(3);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('formatMoney', () => {
|
|
33
|
+
it('formats minor units with the currency fraction digits', () => {
|
|
34
|
+
expect(formatMoney(1250, 'USD')).toBe('$12.50');
|
|
35
|
+
expect(formatMoney(1250, 'JPY')).toBe('¥1,250');
|
|
36
|
+
expect(formatMoney(1250, 'KWD')).toContain('1.250');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('honors the locale argument', () => {
|
|
40
|
+
expect(formatMoney(1250, 'EUR', 'de-DE')).toContain('12,50 €');
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('formatMoneyCompact', () => {
|
|
45
|
+
it('compacts large amounts to one fraction digit', () => {
|
|
46
|
+
expect(formatMoneyCompact(1_250_000, 'USD')).toBe('$12.5K');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('toMinor', () => {
|
|
51
|
+
it('converts a major amount with rounding and currency digits', () => {
|
|
52
|
+
expect(toMinor(12.5, 'USD')).toBe(1250);
|
|
53
|
+
expect(toMinor(12.5, 'JPY')).toBe(13);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('parseAmountToMinor', () => {
|
|
58
|
+
it('parses valid input including comma decimals', () => {
|
|
59
|
+
expect(parseAmountToMinor('12.5', 'USD')).toBe(1250);
|
|
60
|
+
expect(parseAmountToMinor('12,50', 'EUR')).toBe(1250);
|
|
61
|
+
expect(parseAmountToMinor('12', 'JPY')).toBe(12);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('returns null for invalid or over-precise input', () => {
|
|
65
|
+
expect(parseAmountToMinor('12.500', 'USD')).toBeNull();
|
|
66
|
+
expect(parseAmountToMinor('12.5', 'JPY')).toBeNull();
|
|
67
|
+
expect(parseAmountToMinor('-5', 'USD')).toBeNull();
|
|
68
|
+
expect(parseAmountToMinor('abc', 'USD')).toBeNull();
|
|
69
|
+
expect(parseAmountToMinor('', 'USD')).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('totals math', () => {
|
|
74
|
+
it('computes line totals with half-up tax rounding', () => {
|
|
75
|
+
expect(lineTotals(3, 499, 10)).toEqual({ subtotalMinor: 1497, taxMinor: 150, totalMinor: 1647 });
|
|
76
|
+
expect(pctOfMinor(1495, 10)).toBe(150);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('sums lines into invoice totals', () => {
|
|
80
|
+
const totals = sumTotals([
|
|
81
|
+
{ qty: 2, unitPriceMinor: 1000, taxRatePct: 10 },
|
|
82
|
+
{ qty: 1, unitPriceMinor: 500, taxRatePct: 0 },
|
|
83
|
+
]);
|
|
84
|
+
expect(totals).toEqual({ subtotalMinor: 2500, taxMinor: 200, totalMinor: 2700 });
|
|
85
|
+
expect(sumMinor([2500, 200])).toBe(2700);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('Money atom', () => {
|
|
90
|
+
it('renders the formatted amount', () => {
|
|
91
|
+
render(<Money amountMinor={1250} currency="USD" />);
|
|
92
|
+
expect(screen.getByText('$12.50')).toBeTruthy();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('keeps the tabular-nums class alongside custom classes', () => {
|
|
96
|
+
render(<Money amountMinor={9} currency="JPY" className="text-lg" />);
|
|
97
|
+
const el = screen.getByText('¥9');
|
|
98
|
+
expect(el.className).toContain('tabular-nums');
|
|
99
|
+
expect(el.className).toContain('text-lg');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -32,3 +32,16 @@ export {
|
|
|
32
32
|
export type { PageBounds } from './calculation.utils';
|
|
33
33
|
|
|
34
34
|
export { hashSeed } from './hash.util';
|
|
35
|
+
|
|
36
|
+
export {
|
|
37
|
+
currencyFractionDigits,
|
|
38
|
+
formatMoney,
|
|
39
|
+
formatMoneyCompact,
|
|
40
|
+
toMinor,
|
|
41
|
+
parseAmountToMinor,
|
|
42
|
+
pctOfMinor,
|
|
43
|
+
lineTotals,
|
|
44
|
+
sumTotals,
|
|
45
|
+
sumMinor,
|
|
46
|
+
} from './money.utils';
|
|
47
|
+
export type { LineTotals, TotalsInputLine } from './money.utils';
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Money Utils
|
|
3
|
+
* @description Minor-unit money math and locale-aware formatting (ISO 4217)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Currencies that don't use 2 decimal places. Everything else defaults to 2. */
|
|
7
|
+
const FRACTION_DIGITS: Record<string, number> = {
|
|
8
|
+
BHD: 3,
|
|
9
|
+
IQD: 3,
|
|
10
|
+
JOD: 3,
|
|
11
|
+
KWD: 3,
|
|
12
|
+
LYD: 3,
|
|
13
|
+
OMR: 3,
|
|
14
|
+
TND: 3,
|
|
15
|
+
BIF: 0,
|
|
16
|
+
CLP: 0,
|
|
17
|
+
DJF: 0,
|
|
18
|
+
GNF: 0,
|
|
19
|
+
ISK: 0,
|
|
20
|
+
JPY: 0,
|
|
21
|
+
KMF: 0,
|
|
22
|
+
KRW: 0,
|
|
23
|
+
PYG: 0,
|
|
24
|
+
RWF: 0,
|
|
25
|
+
UGX: 0,
|
|
26
|
+
VND: 0,
|
|
27
|
+
VUV: 0,
|
|
28
|
+
XAF: 0,
|
|
29
|
+
XOF: 0,
|
|
30
|
+
XPF: 0,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function currencyFractionDigits(currency: string): number {
|
|
34
|
+
return FRACTION_DIGITS[currency.toUpperCase()] ?? 2;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function formatMoney(amountMinor: number, currency: string, locale = 'en-US'): string {
|
|
38
|
+
const digits = currencyFractionDigits(currency);
|
|
39
|
+
return new Intl.NumberFormat(locale, {
|
|
40
|
+
style: 'currency',
|
|
41
|
+
currency,
|
|
42
|
+
minimumFractionDigits: digits,
|
|
43
|
+
maximumFractionDigits: digits,
|
|
44
|
+
}).format(amountMinor / 10 ** digits);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function formatMoneyCompact(amountMinor: number, currency: string, locale = 'en-US'): string {
|
|
48
|
+
const digits = currencyFractionDigits(currency);
|
|
49
|
+
return new Intl.NumberFormat(locale, {
|
|
50
|
+
style: 'currency',
|
|
51
|
+
currency,
|
|
52
|
+
notation: 'compact',
|
|
53
|
+
maximumFractionDigits: 1,
|
|
54
|
+
}).format(amountMinor / 10 ** digits);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function toMinor(amount: number, currency: string): number {
|
|
58
|
+
return Math.round(amount * 10 ** currencyFractionDigits(currency));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Parses user input like '12.50' into minor units for the currency. Null when invalid. */
|
|
62
|
+
export function parseAmountToMinor(input: string, currency: string): number | null {
|
|
63
|
+
const digits = currencyFractionDigits(currency);
|
|
64
|
+
const trimmed = input.trim().replace(',', '.');
|
|
65
|
+
const decimals = digits === 0 ? '' : `(\\.\\d{1,${digits}})?`;
|
|
66
|
+
const pattern = new RegExp(`^\\d+${decimals}$`);
|
|
67
|
+
if (!pattern.test(trimmed)) return null;
|
|
68
|
+
const minor = Math.round(Number.parseFloat(trimmed) * 10 ** digits);
|
|
69
|
+
return Number.isSafeInteger(minor) ? minor : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Percentage of an amount, rounded half-up to whole minor units. */
|
|
73
|
+
export function pctOfMinor(amountMinor: number, pct: number): number {
|
|
74
|
+
return Math.round((amountMinor * pct) / 100);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface LineTotals {
|
|
78
|
+
subtotalMinor: number;
|
|
79
|
+
taxMinor: number;
|
|
80
|
+
totalMinor: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function lineTotals(qty: number, unitPriceMinor: number, taxRatePct: number): LineTotals {
|
|
84
|
+
const subtotalMinor = Math.round(qty * unitPriceMinor);
|
|
85
|
+
const taxMinor = pctOfMinor(subtotalMinor, taxRatePct);
|
|
86
|
+
return { subtotalMinor, taxMinor, totalMinor: subtotalMinor + taxMinor };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface TotalsInputLine {
|
|
90
|
+
qty: number;
|
|
91
|
+
unitPriceMinor: number;
|
|
92
|
+
taxRatePct: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function sumTotals(lines: readonly TotalsInputLine[]): LineTotals {
|
|
96
|
+
let subtotalMinor = 0;
|
|
97
|
+
let taxMinor = 0;
|
|
98
|
+
for (const line of lines) {
|
|
99
|
+
const totals = lineTotals(line.qty, line.unitPriceMinor, line.taxRatePct);
|
|
100
|
+
subtotalMinor += totals.subtotalMinor;
|
|
101
|
+
taxMinor += totals.taxMinor;
|
|
102
|
+
}
|
|
103
|
+
return { subtotalMinor, taxMinor, totalMinor: subtotalMinor + taxMinor };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function sumMinor(amounts: readonly number[]): number {
|
|
107
|
+
return amounts.reduce((acc, value) => acc + value, 0);
|
|
108
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Money Component (Atom)
|
|
3
|
+
* @description Locale-aware currency amount from minor units, tabular digits
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { forwardRef, type HTMLAttributes } from 'react';
|
|
7
|
+
import { formatMoney } from '../../infrastructure/utils';
|
|
8
|
+
import { cn } from '../../infrastructure/utils';
|
|
9
|
+
import type { BaseProps } from '../../domain/types';
|
|
10
|
+
|
|
11
|
+
export interface MoneyProps extends HTMLAttributes<HTMLSpanElement>, BaseProps {
|
|
12
|
+
/** Integer amount in minor units (e.g. cents) — never floating point. */
|
|
13
|
+
amountMinor: number;
|
|
14
|
+
/** ISO 4217 currency code; drives the fraction digits. */
|
|
15
|
+
currency: string;
|
|
16
|
+
locale?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const Money = forwardRef<HTMLSpanElement, MoneyProps>(
|
|
20
|
+
({ amountMinor, currency, locale = 'en-US', className, ...props }, ref) => (
|
|
21
|
+
<span ref={ref} className={cn('tabular-nums', className)} {...props}>
|
|
22
|
+
{formatMoney(amountMinor, currency, locale)}
|
|
23
|
+
</span>
|
|
24
|
+
)
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
Money.displayName = 'Money';
|
|
@@ -11,7 +11,7 @@ export interface SparklineProps extends Omit<SVGAttributes<SVGSVGElement>, 'data
|
|
|
11
11
|
data: readonly number[];
|
|
12
12
|
width?: number;
|
|
13
13
|
height?: number;
|
|
14
|
-
tone?: 'primary' | 'secondary' | 'success' | 'warning' | 'destructive' | 'info' | 'muted' | 'violet';
|
|
14
|
+
tone?: 'primary' | 'secondary' | 'success' | 'warning' | 'destructive' | 'info' | 'muted' | 'violet' | 'danger' | 'neutral';
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
const toneClasses: Record<NonNullable<SparklineProps['tone']>, string> = {
|
|
@@ -23,6 +23,8 @@ const toneClasses: Record<NonNullable<SparklineProps['tone']>, string> = {
|
|
|
23
23
|
info: 'text-info',
|
|
24
24
|
muted: 'text-muted-foreground',
|
|
25
25
|
violet: 'text-violet-600',
|
|
26
|
+
danger: 'text-destructive',
|
|
27
|
+
neutral: 'text-muted-foreground',
|
|
26
28
|
};
|
|
27
29
|
|
|
28
30
|
export const Sparkline = forwardRef<SVGSVGElement, SparklineProps>(
|
|
@@ -10,7 +10,16 @@ import type { BaseProps } from '../../domain/types';
|
|
|
10
10
|
import { Card, CardContent } from './Card';
|
|
11
11
|
import { Sparkline } from '../atoms/Sparkline';
|
|
12
12
|
|
|
13
|
-
export type KpiIconTone =
|
|
13
|
+
export type KpiIconTone =
|
|
14
|
+
| 'primary'
|
|
15
|
+
| 'success'
|
|
16
|
+
| 'warning'
|
|
17
|
+
| 'destructive'
|
|
18
|
+
| 'info'
|
|
19
|
+
| 'muted'
|
|
20
|
+
| 'violet'
|
|
21
|
+
| 'danger'
|
|
22
|
+
| 'neutral';
|
|
14
23
|
|
|
15
24
|
export interface KpiDelta {
|
|
16
25
|
value: ReactNode;
|
|
@@ -42,6 +51,8 @@ const toneTextClasses: Record<KpiIconTone, string> = {
|
|
|
42
51
|
info: 'text-info bg-info/10',
|
|
43
52
|
muted: 'text-muted-foreground bg-muted',
|
|
44
53
|
violet: 'text-violet-600 bg-violet-500/10',
|
|
54
|
+
danger: 'text-destructive bg-destructive/10',
|
|
55
|
+
neutral: 'text-muted-foreground bg-muted',
|
|
45
56
|
};
|
|
46
57
|
|
|
47
58
|
export const KpiCard = forwardRef<HTMLDivElement, KpiCardProps>(
|
|
@@ -69,7 +80,7 @@ export const KpiCard = forwardRef<HTMLDivElement, KpiCardProps>(
|
|
|
69
80
|
<div className="flex items-start justify-between gap-3">
|
|
70
81
|
<div className="min-w-0">
|
|
71
82
|
<p className="truncate text-sm font-medium text-muted-foreground">{label}</p>
|
|
72
|
-
<p className={cn('mt-1 text-2xl font-semibold leading-none tracking-tight', valueTone === 'success' && 'text-success', valueTone === 'warning' && 'text-warning', valueTone === 'destructive' && 'text-destructive', valueTone === 'info' && 'text-info', valueTone === 'violet' && 'text-violet-600', valueTone === 'muted' && 'text-muted-foreground')}>{value}</p>
|
|
83
|
+
<p className={cn('mt-1 text-2xl font-semibold leading-none tracking-tight', valueTone === 'success' && 'text-success', valueTone === 'warning' && 'text-warning', (valueTone === 'destructive' || valueTone === 'danger') && 'text-destructive', valueTone === 'info' && 'text-info', valueTone === 'violet' && 'text-violet-600', (valueTone === 'muted' || valueTone === 'neutral') && 'text-muted-foreground')}>{value}</p>
|
|
73
84
|
</div>
|
|
74
85
|
{icon !== undefined && (
|
|
75
86
|
<span className={cn('flex h-9 w-9 shrink-0 items-center justify-center rounded-lg [&_svg]:size-4', toneTextClasses[iconTone])}>
|