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
@@ -0,0 +1,74 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { useForm } from 'react-hook-form';
import { MantineProvider } from '@mantine/core';
import { FieldCurrencyInput } from '../fields/currency-input.field';
vi.mock('@repo/core-i18n', () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { exists: () => false },
}),
}));
describe('FieldCurrencyInput', () => {
it('renders a formatted Rupiah value while keeping the form value as a number', async () => {
const onSubmit = vi.fn();
function TestForm() {
const { control, handleSubmit, getValues } = useForm({
defaultValues: { price: 12500.12345 },
});
return (
<MantineProvider>
<form onSubmit={handleSubmit(onSubmit)}>
<FieldCurrencyInput name="price" control={control} label="Price" />
<div data-testid="stored">{String(getValues('price'))}</div>
<button type="submit">Save</button>
</form>
</MantineProvider>
);
}
render(<TestForm />);
expect(screen.getByLabelText('Price')).toBeInTheDocument();
expect(screen.getByTestId('stored')).toHaveTextContent('12500.12345');
await userEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({ price: 12500.12345 }, expect.anything());
});
});
it('writes a number on change, not a formatted string', async () => {
const onSubmit = vi.fn();
const user = userEvent.setup();
function TestForm() {
const { control, handleSubmit } = useForm({
defaultValues: { price: '' as number | '' },
});
return (
<MantineProvider>
<form onSubmit={handleSubmit(onSubmit)}>
<FieldCurrencyInput name="price" control={control} label="Price" />
<button type="submit">Save</button>
</form>
</MantineProvider>
);
}
render(<TestForm />);
const input = screen.getByLabelText('Price');
await user.type(input, '15000,12345');
await user.click(screen.getByText('Save'));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledTimes(1);
});
expect(onSubmit.mock.calls[0][0].price).toBe(15000.12345);
});
});
@@ -0,0 +1,58 @@
import React from 'react';
import { NumberInput, type NumberInputProps } from '@mantine/core';
import { useController, type FieldPath, type FieldValues, type UseControllerProps } from 'react-hook-form';
import { CURRENCY_DATA_SCALE, CurrencyUtils, toCurrencyNumber } from '@repo/utils';
import { useTranslatedError } from '../useTranslatedError';
type ManagedProps = 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error';
export type FieldCurrencyInputProps<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = UseControllerProps<TFieldValues, TName> & Omit<NumberInputProps, ManagedProps>;
function toFieldNumber(next: string | number): number | '' {
if (next === '') return '';
const numeric = typeof next === 'number' ? next : Number(next);
return Number.isFinite(numeric) ? numeric : '';
}
function FieldCurrencyInputInner<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>(props: FieldCurrencyInputProps<TFieldValues, TName>) {
const { name, control, rules, shouldUnregister, defaultValue, disabled, ...mantineProps } = props;
const {
field,
fieldState: { error },
} = useController<TFieldValues, TName>({
name,
control,
rules,
shouldUnregister,
defaultValue,
disabled,
});
const translatedError = useTranslatedError(error?.message);
return (
<NumberInput
hideControls
allowNegative={false}
min={0}
thousandSeparator="."
decimalSeparator=","
prefix={CurrencyUtils.getGlobalPrefix()}
{...mantineProps}
decimalScale={CURRENCY_DATA_SCALE}
value={toCurrencyNumber(field.value)}
onChange={(next) => field.onChange(toFieldNumber(next))}
onBlur={field.onBlur}
error={translatedError}
disabled={field.disabled}
/>
);
}
export const FieldCurrencyInput = React.memo(FieldCurrencyInputInner) as typeof FieldCurrencyInputInner;
(FieldCurrencyInput as { displayName?: string }).displayName = 'FieldCurrencyInput';
+2
View File
@@ -25,6 +25,8 @@ export { FieldTextInput } from './fields/text-input.field';
export { FieldPasswordInput } from './fields/password-input.field';
export { FieldTextarea } from './fields/textarea.field';
export { FieldNumberInput } from './fields/number-input.field';
export { FieldCurrencyInput } from './fields/currency-input.field';
export type { FieldCurrencyInputProps } from './fields/currency-input.field';
export { FieldJsonInput } from './fields/json-input.field';
export { FieldPinInput } from './fields/pin-input.field';
export { FieldAutocomplete } from './fields/autocomplete.field';
@@ -1,2 +1,3 @@
export * from './field-value';
export * from './render-date';
export * from './render-currency';
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import { RenderCurrency } from './render-currency';
describe('RenderCurrency', () => {
it('renders Rupiah with two display decimals', () => {
render(<RenderCurrency value="12500.12345" />);
expect(screen.getByText('Rp 12.500,12')).toBeInTheDocument();
});
it('renders the fallback when empty', () => {
render(<RenderCurrency value={null} />);
expect(screen.getByText('-')).toBeInTheDocument();
});
});
@@ -0,0 +1,11 @@
import { formatRupiah } from '@repo/utils';
export interface RenderCurrencyProps {
value?: string | number | null;
fallback?: string;
}
export function RenderCurrency({ value, fallback = '-' }: RenderCurrencyProps) {
const formatted = formatRupiah(value);
return <>{formatted || fallback}</>;
}
@@ -53,10 +53,7 @@ import { TableFilterDrawer, TableFilterConfig } from './components/table-filter-
import { EntityId } from '../../../../../../core-api/src/data-services/types';
import { computeActionColumnWidth } from './action-column.utils';
import { formatAuditActor, formatAuditTimestamp, resolveAuditValue } from './audit-column.utils';
import {
resolveServerSideInitialRowCount,
shouldRestorePaginationPage,
} from './server-side-index.utils';
import { resolveServerSideInitialRowCount, shouldRestorePaginationPage } from './server-side-index.utils';
export * from 'ag-grid-community';
export * from 'ag-grid-react';
@@ -16,16 +16,12 @@ export function resolveServerSideInitialRowCount(
return meta?.total;
}
export function shouldRestorePaginationPage(
meta: Pick<StandardPaginationMeta, 'page'> | null | undefined,
): boolean {
export function shouldRestorePaginationPage(meta: Pick<StandardPaginationMeta, 'page'> | null | undefined): boolean {
return Boolean(meta?.page && meta.page > 1);
}
/** Drop stale total/page after create so the next index fetch can grow by one row. */
export function paginationMetaAfterCreate(
meta: StandardPaginationMeta | null | undefined,
): StandardPaginationMeta {
export function paginationMetaAfterCreate(meta: StandardPaginationMeta | null | undefined): StandardPaginationMeta {
return {
limit: meta?.limit ?? DEFAULT_PAGE_SIZE,
page: 1,