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
@@ -150,8 +150,7 @@ interface CoreAppShellFeatures {
| `zIndex` | `number` | `200` | Base z-index passed to Mantine's `AppShell`. | | `zIndex` | `number` | `200` | Base z-index passed to Mantine's `AppShell`. |
| `disabled` | `boolean` | `false` | Disables the AppShell layout entirely (renders children without structural chrome). | | `disabled` | `boolean` | `false` | Disables the AppShell layout entirely (renders children without structural chrome). |
> [!TIP] > [!TIP] > **Smart defaults**: You rarely need to set `withUtilityBar`, `withAside`, or `withFooter` explicitly. The engine auto-detects presence by checking if the corresponding slot is provided and truthy. Only set them to `false` when you want to **suppress** a slot that is being passed.
> **Smart defaults**: You rarely need to set `withUtilityBar`, `withAside`, or `withFooter` explicitly. The engine auto-detects presence by checking if the corresponding slot is provided and truthy. Only set them to `false` when you want to **suppress** a slot that is being passed.
--- ---
@@ -234,8 +233,7 @@ import { useCoreAppShell } from '@repo/ui/components';
| `toggleNavbarPanel()` | `() => void` | Toggle the double-sidebar panel open/closed | | `toggleNavbarPanel()` | `() => void` | Toggle the double-sidebar panel open/closed |
| `setSidebarVariant()` | `(variant: SidebarVariant) => void` | Programmatically set the sidebar to `'expanded'`, `'mini'`, or `'hidden'` | | `setSidebarVariant()` | `(variant: SidebarVariant) => void` | Programmatically set the sidebar to `'expanded'`, `'mini'`, or `'hidden'` |
> [!WARNING] > [!WARNING] > `useCoreAppShell()` **must** be called from within a `<CoreAppShell>` subtree. Calling it outside the provider will throw: `"useCoreAppShell must be used within CoreAppShellProvider"`. If you need context access in the header slot, pass a component (not inline JSX) so it mounts inside the provider tree.
> `useCoreAppShell()` **must** be called from within a `<CoreAppShell>` subtree. Calling it outside the provider will throw: `"useCoreAppShell must be used within CoreAppShellProvider"`. If you need context access in the header slot, pass a component (not inline JSX) so it mounts inside the provider tree.
--- ---
@@ -5,6 +5,7 @@ import {
FieldPasswordInput, FieldPasswordInput,
FieldTextarea, FieldTextarea,
FieldNumberInput, FieldNumberInput,
FieldCurrencyInput,
FieldJsonInput, FieldJsonInput,
FieldPinInput, FieldPinInput,
FieldAutocomplete, FieldAutocomplete,
@@ -82,6 +83,7 @@ export default function AllFieldsDemo() {
password: '', password: '',
description: '', description: '',
age: undefined, age: undefined,
price: 12500.12345,
jsonConfig: '', jsonConfig: '',
pin: '', pin: '',
country: '', country: '',
@@ -142,6 +144,9 @@ export default function AllFieldsDemo() {
<FieldPasswordInput name="password" control={control} label={t.fields.password} /> <FieldPasswordInput name="password" control={control} label={t.fields.password} />
<FieldNumberInput name="age" control={control} label={t.fields.age} /> <FieldNumberInput name="age" control={control} label={t.fields.age} />
</Group> </Group>
<Group grow align="flex-start" mb="md">
<FieldCurrencyInput name="price" control={control} label={t.fields.price} />
</Group>
<Group grow align="flex-start" mb="md"> <Group grow align="flex-start" mb="md">
<FieldTextarea name="description" control={control} label={t.fields.description} minRows={3} /> <FieldTextarea name="description" control={control} label={t.fields.description} minRows={3} />
<FieldJsonInput name="jsonConfig" control={control} label={t.fields.jsonConfig} formatOnBlur /> <FieldJsonInput name="jsonConfig" control={control} label={t.fields.jsonConfig} formatOnBlur />
@@ -33,6 +33,7 @@
"password": "Password", "password": "Password",
"description": "Description", "description": "Description",
"age": "Age", "age": "Age",
"price": "Price",
"jsonConfig": "JSON Config", "jsonConfig": "JSON Config",
"tags": "Tags", "tags": "Tags",
"terms": "I agree to the terms and conditions", "terms": "I agree to the terms and conditions",
@@ -33,6 +33,7 @@
"password": "Kata Sandi", "password": "Kata Sandi",
"description": "Deskripsi", "description": "Deskripsi",
"age": "Usia", "age": "Usia",
"price": "Harga",
"jsonConfig": "Konfigurasi JSON", "jsonConfig": "Konfigurasi JSON",
"tags": "Label (Tags)", "tags": "Label (Tags)",
"terms": "Saya setuju dengan syarat dan ketentuan", "terms": "Saya setuju dengan syarat dan ketentuan",
@@ -64,4 +64,13 @@ describe('ProductsRemoteDataTransformer', () => {
expect(payload).not.toHaveProperty('status'); expect(payload).not.toHaveProperty('status');
expect(payload).not.toHaveProperty('id'); expect(payload).not.toHaveProperty('id');
}); });
it('stringifies a numeric price on write without rounding', () => {
const payload = transformer.transformCreatePayload({
code: 'SKU_003',
name: 'Priced Widget',
price: 12500.12345 as unknown as string,
});
expect(payload.price).toBe('12500.12345');
});
}); });
@@ -1,5 +1,6 @@
import { BaseDataTransformer } from '@repo/core-api/data-services'; import { BaseDataTransformer } from '@repo/core-api/data-services';
import { emptyToNull, omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators'; import { emptyToNull, omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators';
import { toDecimalStringValue } from '../../../../../../../core/domain/decimal-string.schema';
import type { ProductDto, ProductEntity } from '../entities'; import type { ProductDto, ProductEntity } from '../entities';
export class ProductsRemoteDataTransformer extends BaseDataTransformer<ProductEntity> { export class ProductsRemoteDataTransformer extends BaseDataTransformer<ProductEntity> {
@@ -28,7 +29,7 @@ export class ProductsRemoteDataTransformer extends BaseDataTransformer<ProductEn
code: entity.code, code: entity.code,
name: entity.name, name: entity.name,
unit: entity.unit, unit: entity.unit,
price: entity.price, price: toDecimalStringValue(entity.price),
brand: entity.brand, brand: entity.brand,
}); });
} }
@@ -38,7 +39,7 @@ export class ProductsRemoteDataTransformer extends BaseDataTransformer<ProductEn
code: entity.code, code: entity.code,
name: entity.name, name: entity.name,
unit: emptyToNull(entity.unit) as string | null, unit: emptyToNull(entity.unit) as string | null,
price: emptyToNull(entity.price) as string | null, price: emptyToNull(toDecimalStringValue(entity.price)) as string | null,
brand: emptyToNull(entity.brand) as string | null, brand: emptyToNull(entity.brand) as string | null,
}; };
} }
@@ -29,6 +29,11 @@ describe('createProductSchema', () => {
}); });
it('rejects a non-decimal price', () => { it('rejects a non-decimal price', () => {
expect(schema.safeParse({ ...valid, price: '12.34567' }).success).toBe(false); expect(schema.safeParse({ ...valid, price: 'abc' }).success).toBe(false);
});
it('accepts a numeric price with five decimal places', () => {
expect(schema.safeParse({ ...valid, price: 12.34567 }).success).toBe(true);
expect(schema.safeParse({ ...valid, price: '12.34567' }).success).toBe(true);
}); });
}); });
@@ -1,4 +1,4 @@
import { Box, Paper, SimpleGrid, FieldValue, RenderDate, Text, StatusBadge } from '@repo/ui/components'; import { Box, Paper, SimpleGrid, FieldValue, RenderCurrency, RenderDate, Text, StatusBadge } from '@repo/ui/components';
import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations'; import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import type { ProductEntity } from '../../../domain/entities'; import type { ProductEntity } from '../../../domain/entities';
@@ -17,7 +17,11 @@ export function DetailGeneral() {
<FieldValue label={t('common:fields.code')} value={data?.code} /> <FieldValue label={t('common:fields.code')} value={data?.code} />
<FieldValue label={t('common:fields.name')} value={data?.name} /> <FieldValue label={t('common:fields.name')} value={data?.name} />
<FieldValue label={t('common:fields.unit')} value={data?.unit} /> <FieldValue label={t('common:fields.unit')} value={data?.unit} />
<FieldValue label={t('common:fields.price')} value={data?.price} /> <FieldValue
label={t('common:fields.price')}
value={data?.price}
render={(val) => <RenderCurrency value={val as string | number | null} />}
/>
<FieldValue label={t('common:fields.brand')} value={data?.brand} /> <FieldValue label={t('common:fields.brand')} value={data?.brand} />
<FieldValue <FieldValue
label={t('common:fields.status')} label={t('common:fields.status')}
@@ -1,4 +1,4 @@
import { Box, FieldTextInput, Paper, SimpleGrid, Text } from '@repo/ui/components'; import { Box, FieldCurrencyInput, FieldTextInput, Paper, SimpleGrid, Text } from '@repo/ui/components';
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations'; import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
export function FormGeneral() { export function FormGeneral() {
@@ -35,11 +35,11 @@ export function FormGeneral() {
placeholder="e.g. PCS" placeholder="e.g. PCS"
radius="md" radius="md"
/> />
<FieldTextInput <FieldCurrencyInput
control={formControl.control} control={formControl.control}
name="price" name="price"
label={t('common:fields.price')} label={t('common:fields.price')}
placeholder="12500.0000" placeholder="Rp 12.500,00"
radius="md" radius="md"
/> />
<FieldTextInput <FieldTextInput
@@ -7,6 +7,7 @@ import {
import { ColDef, Text } from '@repo/ui/components'; import { ColDef, Text } from '@repo/ui/components';
import { Trans } from '@repo/core-i18n'; import { Trans } from '@repo/core-i18n';
import { Package } from 'lucide-react'; import { Package } from 'lucide-react';
import { formatRupiah } from '@repo/utils';
import { FilterFormContent } from '../components/index-component/filter-content'; import { FilterFormContent } from '../components/index-component/filter-content';
import type { ProductEntity } from '../../domain/entities'; import type { ProductEntity } from '../../domain/entities';
@@ -18,7 +19,12 @@ export default function ProductPageIndex() {
{ field: 'code', headerName: t('common:fields.code'), minWidth: 140 }, { field: 'code', headerName: t('common:fields.code'), minWidth: 140 },
{ field: 'name', headerName: t('common:fields.name'), minWidth: 180 }, { field: 'name', headerName: t('common:fields.name'), minWidth: 180 },
{ field: 'unit', headerName: t('common:fields.unit'), minWidth: 100 }, { field: 'unit', headerName: t('common:fields.unit'), minWidth: 100 },
{ field: 'price', headerName: t('common:fields.price'), minWidth: 140 }, {
field: 'price',
headerName: t('common:fields.price'),
minWidth: 140,
valueFormatter: ({ value }) => formatRupiah(value) || '-',
},
{ field: 'brand', headerName: t('common:fields.brand'), minWidth: 140 }, { field: 'brand', headerName: t('common:fields.brand'), minWidth: 140 },
]; ];
}, [t]); }, [t]);
@@ -7,6 +7,7 @@ import {
import { ColDef, Text } from '@repo/ui/components'; import { ColDef, Text } from '@repo/ui/components';
import { Trans } from '@repo/core-i18n'; import { Trans } from '@repo/core-i18n';
import { Receipt } from 'lucide-react'; import { Receipt } from 'lucide-react';
import { formatRupiah } from '@repo/utils';
import { SalesFilterFormContent } from '../../../shared/filter-content'; import { SalesFilterFormContent } from '../../../shared/filter-content';
import { useSalesDocumentActions } from '../../../shared/use-sales-document-actions'; import { useSalesDocumentActions } from '../../../shared/use-sales-document-actions';
import { relationLabel } from '../../../../field/shared/relation-label'; import { relationLabel } from '../../../../field/shared/relation-label';
@@ -38,7 +39,12 @@ export default function SalesInvoicePageIndex() {
minWidth: 160, minWidth: 160,
valueGetter: ({ data }) => relationLabel(data?.branch) || data?.branchId, valueGetter: ({ data }) => relationLabel(data?.branch) || data?.branchId,
}, },
{ field: 'balance', headerName: t('common:fields.balance'), minWidth: 140 }, {
field: 'balance',
headerName: t('common:fields.balance'),
minWidth: 140,
valueFormatter: ({ value }) => formatRupiah(value) || '-',
},
], ],
[t], [t],
); );
@@ -1,6 +1,7 @@
import { BaseDataTransformer } from '@repo/core-api/data-services'; import { BaseDataTransformer } from '@repo/core-api/data-services';
import { formatDateValue, parseDateValue } from '@repo/ui/form'; import { formatDateValue, parseDateValue } from '@repo/ui/form';
import { emptyToNull, omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators'; import { emptyToNull, omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators';
import { toDecimalStringValue } from '../../../../../../../core/domain/decimal-string.schema';
import { mapSalesImagesFromDto, relationId, toLookup } from '../../../shared/sales-document.mapper'; import { mapSalesImagesFromDto, relationId, toLookup } from '../../../shared/sales-document.mapper';
import type { SalesPaymentDto, SalesPaymentEntity } from '../entities'; import type { SalesPaymentDto, SalesPaymentEntity } from '../entities';
@@ -51,7 +52,7 @@ export class SalesPaymentsRemoteDataTransformer extends BaseDataTransformer<Sale
.map((row) => { .map((row) => {
const invoiceId = relationId(row.invoice) ?? row.invoiceId; const invoiceId = relationId(row.invoice) ?? row.invoiceId;
if (!invoiceId) return null; if (!invoiceId) return null;
return omitEmptyFields({ invoiceId, amount: row.amount }); return omitEmptyFields({ invoiceId, amount: toDecimalStringValue(row.amount) });
}) })
.filter(Boolean); .filter(Boolean);
@@ -1,6 +1,6 @@
import { z } from 'zod'; import { z } from 'zod';
import { compose, maxLength, required } from '@repo/ui/validators'; import { compose, maxLength, required } from '@repo/ui/validators';
import { decimalStringSchema } from '../../../../../../../core/domain/decimal-string.schema'; import { decimalStringSchema, PRICE_DECIMAL_SCALE } from '../../../../../../../core/domain/decimal-string.schema';
import { salesImageSchema } from '../../../shared/sales-document.validator'; import { salesImageSchema } from '../../../shared/sales-document.validator';
const NOTES_MAX = 1024; const NOTES_MAX = 1024;
@@ -40,7 +40,7 @@ export function createSalesPaymentSchema(t: (key: string) => string) {
z.object({ z.object({
id: z.string().optional(), id: z.string().optional(),
invoice: relationSchema, invoice: relationSchema,
amount: decimalStringSchema(t, 'common:fields.amount'), amount: decimalStringSchema(t, 'common:fields.amount', PRICE_DECIMAL_SCALE),
}), }),
) )
.min(1), .min(1),
@@ -1,5 +1,6 @@
import { Box, Paper, Table, Text } from '@repo/ui/components'; import { Box, Paper, Table, Text } from '@repo/ui/components';
import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations'; import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import { formatRupiah } from '@repo/utils';
import type { SalesPaymentEntity } from '../../../domain/entities'; import type { SalesPaymentEntity } from '../../../domain/entities';
import { relationLabel } from '../../../../../field/shared/relation-label'; import { relationLabel } from '../../../../../field/shared/relation-label';
@@ -34,7 +35,7 @@ export function DetailAllocations() {
rows.map((row, index) => ( rows.map((row, index) => (
<Table.Tr key={row.id ?? `${row.invoiceId}-${index}`}> <Table.Tr key={row.id ?? `${row.invoiceId}-${index}`}>
<Table.Td>{relationLabel(row.invoice) || row.invoiceId}</Table.Td> <Table.Td>{relationLabel(row.invoice) || row.invoiceId}</Table.Td>
<Table.Td ta="right">{row.amount}</Table.Td> <Table.Td ta="right">{formatRupiah(row.amount)}</Table.Td>
</Table.Tr> </Table.Tr>
)) ))
)} )}
@@ -3,7 +3,7 @@ import {
Box, Box,
Button, Button,
FieldAsyncSelect, FieldAsyncSelect,
FieldTextInput, FieldCurrencyInput,
Group, Group,
Paper, Paper,
Table, Table,
@@ -59,7 +59,7 @@ export function FormAllocations() {
/> />
</Table.Td> </Table.Td>
<Table.Td miw={140}> <Table.Td miw={140}>
<FieldTextInput control={formControl.control} name={`invoices.${index}.amount`} radius="md" /> <FieldCurrencyInput control={formControl.control} name={`invoices.${index}.amount`} radius="md" />
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<ActionIcon <ActionIcon
@@ -1,4 +1,14 @@
import { Anchor, Box, Paper, SimpleGrid, FieldValue, RenderDate, Text, StatusBadge } from '@repo/ui/components'; import {
Anchor,
Box,
Paper,
SimpleGrid,
FieldValue,
RenderCurrency,
RenderDate,
Text,
StatusBadge,
} from '@repo/ui/components';
import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations'; import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import type { SalesDocumentEntity } from './sales-document.entity'; import type { SalesDocumentEntity } from './sales-document.entity';
import { relationLabel } from '../../field/shared/relation-label'; import { relationLabel } from '../../field/shared/relation-label';
@@ -83,7 +93,13 @@ export function DetailGeneral({
} }
/> />
)} )}
{showBalance ? <FieldValue label={t('common:fields.balance')} value={data?.balance} /> : null} {showBalance ? (
<FieldValue
label={t('common:fields.balance')}
value={data?.balance}
render={(val) => <RenderCurrency value={val as string | number | null} />}
/>
) : null}
{salesRequestHref && ( {salesRequestHref && (
<FieldValue <FieldValue
label={t('common:fields.salesRequest')} label={t('common:fields.salesRequest')}
@@ -1,11 +1,9 @@
import { Box, Paper, Table, Text } from '@repo/ui/components'; import { Box, Paper, Table, Text } from '@repo/ui/components';
import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations'; import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import { CurrencyUtils } from '@repo/utils'; import { formatRupiah } from '@repo/utils';
import type { SalesDocumentEntity } from './sales-document.entity'; import type { SalesDocumentEntity } from './sales-document.entity';
import { relationLabel } from '../../field/shared/relation-label'; import { relationLabel } from '../../field/shared/relation-label';
const currency = new CurrencyUtils({ decimalScale: 4 });
export function DetailProducts() { export function DetailProducts() {
const { detailData } = useDetailPageContext<SalesDocumentEntity>(); const { detailData } = useDetailPageContext<SalesDocumentEntity>();
const { t } = useEnterpriseModuleTranslationContext(); const { t } = useEnterpriseModuleTranslationContext();
@@ -50,8 +48,8 @@ export function DetailProducts() {
<Table.Tr key={line.id ?? `${line.productId}-${index}`}> <Table.Tr key={line.id ?? `${line.productId}-${index}`}>
<Table.Td>{relationLabel(line.product) || line.productId}</Table.Td> <Table.Td>{relationLabel(line.product) || line.productId}</Table.Td>
<Table.Td ta="right">{line.quantity}</Table.Td> <Table.Td ta="right">{line.quantity}</Table.Td>
<Table.Td ta="right">{line.price ? currency.format(line.price) : '-'}</Table.Td> <Table.Td ta="right">{line.price ? formatRupiah(line.price) : '-'}</Table.Td>
<Table.Td ta="right">{currency.format(total)}</Table.Td> <Table.Td ta="right">{formatRupiah(total)}</Table.Td>
</Table.Tr> </Table.Tr>
); );
}) })
@@ -60,7 +58,7 @@ export function DetailProducts() {
</Table> </Table>
</Box> </Box>
<Text fw={600} ta="right" mt="md"> <Text fw={600} ta="right" mt="md">
{t('common:fields.total')}: {currency.format(grandTotal)} {t('common:fields.total')}: {formatRupiah(grandTotal)}
</Text> </Text>
</Paper> </Paper>
); );
@@ -3,6 +3,7 @@ import {
Box, Box,
Button, Button,
FieldAsyncSelect, FieldAsyncSelect,
FieldCurrencyInput,
FieldTextInput, FieldTextInput,
Group, Group,
Paper, Paper,
@@ -12,15 +13,13 @@ import {
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations'; import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
import { useFieldArray, useWatch } from '@repo/ui/form'; import { useFieldArray, useWatch } from '@repo/ui/form';
import { Plus, Trash2 } from 'lucide-react'; import { Plus, Trash2 } from 'lucide-react';
import { CurrencyUtils } from '@repo/utils'; import { formatRupiah } from '@repo/utils';
import { loadProductOptions } from './load-product-options'; import { loadProductOptions } from './load-product-options';
import { excludeProductIds, selectedProductIdsExceptLine } from './sales-line-options'; import { excludeProductIds, selectedProductIdsExceptLine } from './sales-line-options';
import { relationLabel } from '../../field/shared/relation-label'; import { relationLabel } from '../../field/shared/relation-label';
import type { ProductEntity } from '../../configuration/products/domain/entities'; import type { ProductEntity } from '../../configuration/products/domain/entities';
const currency = new CurrencyUtils({ decimalScale: 4 }); function lineTotal(quantity?: string, price?: string | number) {
function lineTotal(quantity?: string, price?: string) {
const qty = Number(quantity); const qty = Number(quantity);
const unitPrice = Number(price); const unitPrice = Number(price);
if (!Number.isFinite(qty) || !Number.isFinite(unitPrice)) return 0; if (!Number.isFinite(qty) || !Number.isFinite(unitPrice)) return 0;
@@ -36,7 +35,7 @@ export function FormProducts() {
}); });
const products = useWatch({ control: formControl.control, name: 'products' }) ?? []; const products = useWatch({ control: formControl.control, name: 'products' }) ?? [];
const grandTotal = products.reduce( const grandTotal = products.reduce(
(sum: number, line: { quantity?: string; price?: string }) => sum + lineTotal(line?.quantity, line.price), (sum: number, line: { quantity?: string; price?: string | number }) => sum + lineTotal(line?.quantity, line.price),
0, 0,
); );
@@ -82,9 +81,9 @@ export function FormProducts() {
<FieldTextInput control={formControl.control} name={`products.${index}.quantity`} radius="md" /> <FieldTextInput control={formControl.control} name={`products.${index}.quantity`} radius="md" />
</Table.Td> </Table.Td>
<Table.Td miw={140}> <Table.Td miw={140}>
<FieldTextInput control={formControl.control} name={`products.${index}.price`} radius="md" /> <FieldCurrencyInput control={formControl.control} name={`products.${index}.price`} radius="md" />
</Table.Td> </Table.Td>
<Table.Td ta="right">{currency.format(lineTotal(line?.quantity, line?.price))}</Table.Td> <Table.Td ta="right">{formatRupiah(lineTotal(line?.quantity, line?.price))}</Table.Td>
<Table.Td> <Table.Td>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
@@ -111,7 +110,7 @@ export function FormProducts() {
{t('add_line')} {t('add_line')}
</Button> </Button>
<Text fw={600}> <Text fw={600}>
{t('common:fields.total')}: {currency.format(grandTotal)} {t('common:fields.total')}: {formatRupiah(grandTotal)}
</Text> </Text>
</Group> </Group>
</Paper> </Paper>
@@ -1,9 +1,6 @@
export const PAYABLE_INVOICE_STATUSES = ['processed', 'partial'] as const; export const PAYABLE_INVOICE_STATUSES = ['processed', 'partial'] as const;
export function isPayableInvoice(invoice: { export function isPayableInvoice(invoice: { status?: string | null; balance?: string | number | null }): boolean {
status?: string | null;
balance?: string | number | null;
}): boolean {
const payable: readonly string[] = PAYABLE_INVOICE_STATUSES; const payable: readonly string[] = PAYABLE_INVOICE_STATUSES;
if (!invoice.status || !payable.includes(invoice.status)) { if (!invoice.status || !payable.includes(invoice.status)) {
return false; return false;
@@ -1,5 +1,6 @@
import { formatDateValue, parseDateValue } from '@repo/ui/form'; import { formatDateValue, parseDateValue } from '@repo/ui/form';
import { emptyToNull, omitEmptyFields } from '../../../../../core/domain/configuration-field-validators'; import { emptyToNull, omitEmptyFields } from '../../../../../core/domain/configuration-field-validators';
import { toDecimalStringValue } from '../../../../../core/domain/decimal-string.schema';
import type { import type {
LookupStub, LookupStub,
SalesDocumentDto, SalesDocumentDto,
@@ -127,7 +128,7 @@ export function toSalesWritePayload(
return omitEmptyFields({ return omitEmptyFields({
productId, productId,
quantity: line.quantity, quantity: line.quantity,
price: line.price, price: toDecimalStringValue(line.price),
}); });
}) })
.filter(Boolean); .filter(Boolean);
@@ -5,7 +5,7 @@ import {
optionalLatitudeSchema, optionalLatitudeSchema,
optionalLongitudeSchema, optionalLongitudeSchema,
} from '../../../../../core/domain/configuration-field-validators'; } from '../../../../../core/domain/configuration-field-validators';
import { decimalStringSchema } from '../../../../../core/domain/decimal-string.schema'; import { decimalStringSchema, optionalDecimalStringSchema } from '../../../../../core/domain/decimal-string.schema';
const NOTES_MAX = 1024; const NOTES_MAX = 1024;
const IMAGE_URL_MAX = 2048; const IMAGE_URL_MAX = 2048;
@@ -31,7 +31,7 @@ export function salesLineSchema(t: (key: string) => string) {
id: z.string().optional(), id: z.string().optional(),
product: relationSchema, product: relationSchema,
quantity: decimalStringSchema(t, 'common:fields.quantity'), quantity: decimalStringSchema(t, 'common:fields.quantity'),
price: z.preprocess(emptyToUndefined, decimalStringSchema(t, 'common:fields.price').optional()), price: optionalDecimalStringSchema(t, 'common:fields.price'),
}); });
} }
@@ -2,11 +2,7 @@ import { describe, expect, it } from 'vitest';
import { excludeProductIds, selectedProductIdsExceptLine } from './sales-line-options'; import { excludeProductIds, selectedProductIdsExceptLine } from './sales-line-options';
describe('sales line product options', () => { describe('sales line product options', () => {
const lines = [ const lines = [{ product: { id: 'prd-1' } }, { product: { id: 'prd-2' } }, { product: null }];
{ product: { id: 'prd-1' } },
{ product: { id: 'prd-2' } },
{ product: null },
];
it('collects product ids from other lines only', () => { it('collects product ids from other lines only', () => {
expect(selectedProductIdsExceptLine(lines, 0)).toEqual(['prd-2']); expect(selectedProductIdsExceptLine(lines, 0)).toEqual(['prd-2']);
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { decimalStringSchema, optionalDecimalStringSchema, toDecimalStringValue } from './decimal-string.schema';
const t = (key: string) => key;
describe('decimalStringSchema', () => {
const schema = decimalStringSchema(t, 'common:fields.quantity');
it('accepts a decimal string within four places', () => {
expect(schema.safeParse('2.0000').success).toBe(true);
});
it('coerces a number into a decimal string', () => {
const result = schema.safeParse(12.5);
expect(result.success).toBe(true);
if (result.success) expect(result.data).toBe('12.5');
});
it('rejects more than four decimal places', () => {
expect(schema.safeParse('12.34567').success).toBe(false);
});
});
describe('optionalDecimalStringSchema', () => {
const schema = optionalDecimalStringSchema(t, 'common:fields.price');
it('accepts five decimal places and numbers', () => {
expect(schema.safeParse('12.34567').success).toBe(true);
expect(schema.safeParse(12500.12345).success).toBe(true);
});
it('accepts empty values', () => {
expect(schema.safeParse('').success).toBe(true);
expect(schema.safeParse(undefined).success).toBe(true);
});
it('rejects six decimal places', () => {
expect(schema.safeParse('12.345678').success).toBe(false);
});
});
describe('toDecimalStringValue', () => {
it('keeps five decimal places when stringifying a number', () => {
expect(toDecimalStringValue(12500.12345)).toBe('12500.12345');
expect(toDecimalStringValue('12500.12345')).toBe('12500.12345');
expect(toDecimalStringValue('')).toBeUndefined();
});
});
@@ -1,20 +1,53 @@
import { z } from 'zod'; import { z } from 'zod';
import { CURRENCY_DATA_SCALE } from '@repo/utils';
const DECIMAL_PATTERN = /^\d+(\.\d{1,4})?$/; export const PRICE_DECIMAL_SCALE = CURRENCY_DATA_SCALE;
export const QUANTITY_DECIMAL_SCALE = 4;
function emptyToUndefined(value: unknown) { function coerceDecimalInput(value: unknown) {
if (value === '' || value === null || value === undefined) { if (value === '' || value === null || value === undefined) {
return undefined; return undefined;
} }
if (typeof value === 'number') {
return Number.isFinite(value) ? String(value) : value;
}
return value; return value;
} }
export function decimalStringSchema(t: (key: string) => string, fieldKey = 'common:fields.price') { export function toDecimalStringValue(value: unknown): string | undefined {
return z.string().regex(DECIMAL_PATTERN, { const next = coerceDecimalInput(value);
message: JSON.stringify({ key: 'validation:invalid_format', values: { field: t(fieldKey) } }), return typeof next === 'string' ? next : undefined;
});
} }
export function optionalDecimalStringSchema(t: (key: string) => string, fieldKey = 'common:fields.price') { function decimalPattern(maxDecimals: number) {
return z.preprocess(emptyToUndefined, decimalStringSchema(t, fieldKey).optional()); return new RegExp(`^\\d+(\\.\\d{1,${maxDecimals}})?$`);
}
export function decimalStringSchema(
t: (key: string) => string,
fieldKey = 'common:fields.price',
maxDecimals = QUANTITY_DECIMAL_SCALE,
) {
return z.preprocess(
coerceDecimalInput,
z.string().regex(decimalPattern(maxDecimals), {
message: JSON.stringify({ key: 'validation:invalid_format', values: { field: t(fieldKey) } }),
}),
);
}
export function optionalDecimalStringSchema(
t: (key: string) => string,
fieldKey = 'common:fields.price',
maxDecimals = PRICE_DECIMAL_SCALE,
) {
return z.preprocess(
coerceDecimalInput,
z
.string()
.regex(decimalPattern(maxDecimals), {
message: JSON.stringify({ key: 'validation:invalid_format', values: { field: t(fieldKey) } }),
})
.optional(),
);
} }
@@ -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 { FieldPasswordInput } from './fields/password-input.field';
export { FieldTextarea } from './fields/textarea.field'; export { FieldTextarea } from './fields/textarea.field';
export { FieldNumberInput } from './fields/number-input.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 { FieldJsonInput } from './fields/json-input.field';
export { FieldPinInput } from './fields/pin-input.field'; export { FieldPinInput } from './fields/pin-input.field';
export { FieldAutocomplete } from './fields/autocomplete.field'; export { FieldAutocomplete } from './fields/autocomplete.field';
@@ -1,2 +1,3 @@
export * from './field-value'; export * from './field-value';
export * from './render-date'; 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 { EntityId } from '../../../../../../core-api/src/data-services/types';
import { computeActionColumnWidth } from './action-column.utils'; import { computeActionColumnWidth } from './action-column.utils';
import { formatAuditActor, formatAuditTimestamp, resolveAuditValue } from './audit-column.utils'; import { formatAuditActor, formatAuditTimestamp, resolveAuditValue } from './audit-column.utils';
import { import { resolveServerSideInitialRowCount, shouldRestorePaginationPage } from './server-side-index.utils';
resolveServerSideInitialRowCount,
shouldRestorePaginationPage,
} from './server-side-index.utils';
export * from 'ag-grid-community'; export * from 'ag-grid-community';
export * from 'ag-grid-react'; export * from 'ag-grid-react';
@@ -16,16 +16,12 @@ export function resolveServerSideInitialRowCount(
return meta?.total; return meta?.total;
} }
export function shouldRestorePaginationPage( export function shouldRestorePaginationPage(meta: Pick<StandardPaginationMeta, 'page'> | null | undefined): boolean {
meta: Pick<StandardPaginationMeta, 'page'> | null | undefined,
): boolean {
return Boolean(meta?.page && meta.page > 1); return Boolean(meta?.page && meta.page > 1);
} }
/** Drop stale total/page after create so the next index fetch can grow by one row. */ /** Drop stale total/page after create so the next index fetch can grow by one row. */
export function paginationMetaAfterCreate( export function paginationMetaAfterCreate(meta: StandardPaginationMeta | null | undefined): StandardPaginationMeta {
meta: StandardPaginationMeta | null | undefined,
): StandardPaginationMeta {
return { return {
limit: meta?.limit ?? DEFAULT_PAGE_SIZE, limit: meta?.limit ?? DEFAULT_PAGE_SIZE,
page: 1, page: 1,
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { CurrencyUtils } from './currency.utils'; import { CurrencyUtils, formatRupiah, toCurrencyNumber } from './currency.utils';
describe('CurrencyUtils', () => { describe('CurrencyUtils', () => {
let currency: CurrencyUtils; let currency: CurrencyUtils;
@@ -70,7 +70,14 @@ describe('CurrencyUtils', () => {
it('should round decimal when decimalScale is set', () => { it('should round decimal when decimalScale is set', () => {
const c = new CurrencyUtils({ decimalScale: 2 }); 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', () => { 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', () => { it('should format numbers with multiple decimals correctly when decimalScale is set', () => {
const c = new CurrencyUtils({ decimalScale: 3 }); 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('');
}); });
}); });
}); });
+45 -8
View File
@@ -19,8 +19,16 @@
* ------------------------------------------------------------ * ------------------------------------------------------------
*/ */
import { NumberUtils } from '../number/number.utils';
export type CurrencyInput = number | string | null | undefined; 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 { export interface CurrencyOptions {
/** Currency symbol or prefix for display, e.g., "Rp ", "$" */ /** Currency symbol or prefix for display, e.g., "Rp ", "$" */
prefix?: string; prefix?: string;
@@ -120,20 +128,20 @@ export class CurrencyUtils {
format(value: CurrencyInput): string { format(value: CurrencyInput): string {
if (value === undefined || value === null || value === '') return ''; if (value === undefined || value === null || value === '') return '';
if (this.decimalScale === undefined) {
const stringValue = `${value}`; const stringValue = `${value}`;
const [integer, decimal] = stringValue.split('.'); const [integer, decimal] = stringValue.split('.');
// Add thousand separator to integer part
const formattedInteger = integer.replace(/\B(?=(\d{3})+(?!\d))/g, this.thousandSeparator); 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) {
return `${this.prefix}${formattedInteger}${decimal ? this.decimalSeparator + decimal : ''}`; return `${this.prefix}${formattedInteger}${decimal ? this.decimalSeparator + decimal : ''}`;
} }
// Display rounded decimal according to decimalScale const numeric = typeof value === 'number' ? value : Number(value);
const roundedDecimal = decimal.slice(0, this.decimalScale); if (!Number.isFinite(numeric)) return '';
return `${this.prefix}${formattedInteger}${this.decimalSeparator}${roundedDecimal}`;
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; 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 : '';
} }