From 71046973aea8f442740a730d1536f8b784a1567f Mon Sep 17 00:00:00 2001 From: shancheas Date: Mon, 31 Aug 2026 15:36:53 +0700 Subject: [PATCH] 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. --- .../src/packages/ui/CORE-APP-SHELL.md | 6 +- .../form-demo/components/all-fields-demo.tsx | 5 ++ .../forms/components/form-demo/i18n/en.json | 1 + .../forms/components/form-demo/i18n/id.json | 1 + .../product.remote.transformer.test.ts | 9 +++ .../product.remote.transformer.ts | 5 +- .../validators/product.validator.test.ts | 7 +- .../detail-component/detail-general.tsx | 8 +- .../form-component/form-general.tsx | 6 +- .../presentation/pages/product.page.index.tsx | 8 +- .../pages/sales-invoice.page.index.tsx | 8 +- .../sales-payment.remote.transformer.ts | 3 +- .../validators/sales-payment.validator.ts | 4 +- .../detail-component/detail-allocations.tsx | 3 +- .../form-component/form-allocations.tsx | 4 +- .../modules/sales/shared/detail-general.tsx | 20 ++++- .../modules/sales/shared/detail-products.tsx | 10 +-- .../modules/sales/shared/form-products.tsx | 15 ++-- .../modules/sales/shared/payable-invoice.ts | 5 +- .../sales/shared/sales-document.mapper.ts | 3 +- .../sales/shared/sales-document.validator.ts | 4 +- .../sales/shared/sales-line-options.test.ts | 6 +- .../core/domain/decimal-string.schema.test.ts | 48 ++++++++++++ .../src/core/domain/decimal-string.schema.ts | 49 ++++++++++-- .../__tests__/currency-input.field.test.tsx | 74 +++++++++++++++++++ .../Form/fields/currency-input.field.tsx | 58 +++++++++++++++ packages/ui/src/components/Form/index.ts | 2 + .../ui/src/components/field-value/index.ts | 1 + .../field-value/render-currency.test.tsx | 15 ++++ .../field-value/render-currency.tsx | 11 +++ .../components/data-table/index.tsx | 5 +- .../data-table/server-side-index.utils.ts | 8 +- .../utils/src/currency/currency.utils.test.ts | 48 +++++++++++- packages/utils/src/currency/currency.utils.ts | 59 ++++++++++++--- 34 files changed, 439 insertions(+), 80 deletions(-) create mode 100644 apps/web/src/core/domain/decimal-string.schema.test.ts create mode 100644 packages/ui/src/components/Form/__tests__/currency-input.field.test.tsx create mode 100644 packages/ui/src/components/Form/fields/currency-input.field.tsx create mode 100644 packages/ui/src/components/field-value/render-currency.test.tsx create mode 100644 packages/ui/src/components/field-value/render-currency.tsx diff --git a/apps/docs-dev/src/packages/ui/CORE-APP-SHELL.md b/apps/docs-dev/src/packages/ui/CORE-APP-SHELL.md index 43e5c94..77a3330 100644 --- a/apps/docs-dev/src/packages/ui/CORE-APP-SHELL.md +++ b/apps/docs-dev/src/packages/ui/CORE-APP-SHELL.md @@ -150,8 +150,7 @@ interface CoreAppShellFeatures { | `zIndex` | `number` | `200` | Base z-index passed to Mantine's `AppShell`. | | `disabled` | `boolean` | `false` | Disables the AppShell layout entirely (renders children without structural chrome). | -> [!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. +> [!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. --- @@ -234,8 +233,7 @@ import { useCoreAppShell } from '@repo/ui/components'; | `toggleNavbarPanel()` | `() => void` | Toggle the double-sidebar panel open/closed | | `setSidebarVariant()` | `(variant: SidebarVariant) => void` | Programmatically set the sidebar to `'expanded'`, `'mini'`, or `'hidden'` | -> [!WARNING] -> `useCoreAppShell()` **must** be called from within a `` 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. +> [!WARNING] > `useCoreAppShell()` **must** be called from within a `` 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. --- diff --git a/apps/showcase/src/pages/forms/components/form-demo/components/all-fields-demo.tsx b/apps/showcase/src/pages/forms/components/form-demo/components/all-fields-demo.tsx index 13a7ef7..09bd83b 100644 --- a/apps/showcase/src/pages/forms/components/form-demo/components/all-fields-demo.tsx +++ b/apps/showcase/src/pages/forms/components/form-demo/components/all-fields-demo.tsx @@ -5,6 +5,7 @@ import { FieldPasswordInput, FieldTextarea, FieldNumberInput, + FieldCurrencyInput, FieldJsonInput, FieldPinInput, FieldAutocomplete, @@ -82,6 +83,7 @@ export default function AllFieldsDemo() { password: '', description: '', age: undefined, + price: 12500.12345, jsonConfig: '', pin: '', country: '', @@ -142,6 +144,9 @@ export default function AllFieldsDemo() { + + + diff --git a/apps/showcase/src/pages/forms/components/form-demo/i18n/en.json b/apps/showcase/src/pages/forms/components/form-demo/i18n/en.json index a32a1b4..191ea36 100644 --- a/apps/showcase/src/pages/forms/components/form-demo/i18n/en.json +++ b/apps/showcase/src/pages/forms/components/form-demo/i18n/en.json @@ -33,6 +33,7 @@ "password": "Password", "description": "Description", "age": "Age", + "price": "Price", "jsonConfig": "JSON Config", "tags": "Tags", "terms": "I agree to the terms and conditions", diff --git a/apps/showcase/src/pages/forms/components/form-demo/i18n/id.json b/apps/showcase/src/pages/forms/components/form-demo/i18n/id.json index f0261f0..2d61b34 100644 --- a/apps/showcase/src/pages/forms/components/form-demo/i18n/id.json +++ b/apps/showcase/src/pages/forms/components/form-demo/i18n/id.json @@ -33,6 +33,7 @@ "password": "Kata Sandi", "description": "Deskripsi", "age": "Usia", + "price": "Harga", "jsonConfig": "Konfigurasi JSON", "tags": "Label (Tags)", "terms": "Saya setuju dengan syarat dan ketentuan", diff --git a/apps/web/src/apps/main/modules/configuration/products/domain/transformers/product.remote.transformer.test.ts b/apps/web/src/apps/main/modules/configuration/products/domain/transformers/product.remote.transformer.test.ts index 4e716ee..7f0c5af 100644 --- a/apps/web/src/apps/main/modules/configuration/products/domain/transformers/product.remote.transformer.test.ts +++ b/apps/web/src/apps/main/modules/configuration/products/domain/transformers/product.remote.transformer.test.ts @@ -64,4 +64,13 @@ describe('ProductsRemoteDataTransformer', () => { expect(payload).not.toHaveProperty('status'); 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'); + }); }); diff --git a/apps/web/src/apps/main/modules/configuration/products/domain/transformers/product.remote.transformer.ts b/apps/web/src/apps/main/modules/configuration/products/domain/transformers/product.remote.transformer.ts index a29453e..957e2d7 100644 --- a/apps/web/src/apps/main/modules/configuration/products/domain/transformers/product.remote.transformer.ts +++ b/apps/web/src/apps/main/modules/configuration/products/domain/transformers/product.remote.transformer.ts @@ -1,5 +1,6 @@ import { BaseDataTransformer } from '@repo/core-api/data-services'; import { emptyToNull, omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators'; +import { toDecimalStringValue } from '../../../../../../../core/domain/decimal-string.schema'; import type { ProductDto, ProductEntity } from '../entities'; export class ProductsRemoteDataTransformer extends BaseDataTransformer { @@ -28,7 +29,7 @@ export class ProductsRemoteDataTransformer extends BaseDataTransformer { }); 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); }); }); diff --git a/apps/web/src/apps/main/modules/configuration/products/presentation/components/detail-component/detail-general.tsx b/apps/web/src/apps/main/modules/configuration/products/presentation/components/detail-component/detail-general.tsx index 1ea12de..e7b3136 100644 --- a/apps/web/src/apps/main/modules/configuration/products/presentation/components/detail-component/detail-general.tsx +++ b/apps/web/src/apps/main/modules/configuration/products/presentation/components/detail-component/detail-general.tsx @@ -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 type { ProductEntity } from '../../../domain/entities'; @@ -17,7 +17,11 @@ export function DetailGeneral() { - + } + /> - formatRupiah(value) || '-', + }, { field: 'brand', headerName: t('common:fields.brand'), minWidth: 140 }, ]; }, [t]); diff --git a/apps/web/src/apps/main/modules/sales/invoices/presentation/pages/sales-invoice.page.index.tsx b/apps/web/src/apps/main/modules/sales/invoices/presentation/pages/sales-invoice.page.index.tsx index 9f04fab..c21e69a 100644 --- a/apps/web/src/apps/main/modules/sales/invoices/presentation/pages/sales-invoice.page.index.tsx +++ b/apps/web/src/apps/main/modules/sales/invoices/presentation/pages/sales-invoice.page.index.tsx @@ -7,6 +7,7 @@ import { import { ColDef, Text } from '@repo/ui/components'; import { Trans } from '@repo/core-i18n'; import { Receipt } from 'lucide-react'; +import { formatRupiah } from '@repo/utils'; import { SalesFilterFormContent } from '../../../shared/filter-content'; import { useSalesDocumentActions } from '../../../shared/use-sales-document-actions'; import { relationLabel } from '../../../../field/shared/relation-label'; @@ -38,7 +39,12 @@ export default function SalesInvoicePageIndex() { minWidth: 160, 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], ); diff --git a/apps/web/src/apps/main/modules/sales/payments/domain/transformers/sales-payment.remote.transformer.ts b/apps/web/src/apps/main/modules/sales/payments/domain/transformers/sales-payment.remote.transformer.ts index 465987d..8445c53 100644 --- a/apps/web/src/apps/main/modules/sales/payments/domain/transformers/sales-payment.remote.transformer.ts +++ b/apps/web/src/apps/main/modules/sales/payments/domain/transformers/sales-payment.remote.transformer.ts @@ -1,6 +1,7 @@ import { BaseDataTransformer } from '@repo/core-api/data-services'; import { formatDateValue, parseDateValue } from '@repo/ui/form'; 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 type { SalesPaymentDto, SalesPaymentEntity } from '../entities'; @@ -51,7 +52,7 @@ export class SalesPaymentsRemoteDataTransformer extends BaseDataTransformer { const invoiceId = relationId(row.invoice) ?? row.invoiceId; if (!invoiceId) return null; - return omitEmptyFields({ invoiceId, amount: row.amount }); + return omitEmptyFields({ invoiceId, amount: toDecimalStringValue(row.amount) }); }) .filter(Boolean); diff --git a/apps/web/src/apps/main/modules/sales/payments/domain/validators/sales-payment.validator.ts b/apps/web/src/apps/main/modules/sales/payments/domain/validators/sales-payment.validator.ts index 6f7a493..2a4ddc0 100644 --- a/apps/web/src/apps/main/modules/sales/payments/domain/validators/sales-payment.validator.ts +++ b/apps/web/src/apps/main/modules/sales/payments/domain/validators/sales-payment.validator.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; 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'; const NOTES_MAX = 1024; @@ -40,7 +40,7 @@ export function createSalesPaymentSchema(t: (key: string) => string) { z.object({ id: z.string().optional(), invoice: relationSchema, - amount: decimalStringSchema(t, 'common:fields.amount'), + amount: decimalStringSchema(t, 'common:fields.amount', PRICE_DECIMAL_SCALE), }), ) .min(1), diff --git a/apps/web/src/apps/main/modules/sales/payments/presentation/components/detail-component/detail-allocations.tsx b/apps/web/src/apps/main/modules/sales/payments/presentation/components/detail-component/detail-allocations.tsx index 95fb3c8..98e7138 100644 --- a/apps/web/src/apps/main/modules/sales/payments/presentation/components/detail-component/detail-allocations.tsx +++ b/apps/web/src/apps/main/modules/sales/payments/presentation/components/detail-component/detail-allocations.tsx @@ -1,5 +1,6 @@ import { Box, Paper, Table, Text } from '@repo/ui/components'; import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations'; +import { formatRupiah } from '@repo/utils'; import type { SalesPaymentEntity } from '../../../domain/entities'; import { relationLabel } from '../../../../../field/shared/relation-label'; @@ -34,7 +35,7 @@ export function DetailAllocations() { rows.map((row, index) => ( {relationLabel(row.invoice) || row.invoiceId} - {row.amount} + {formatRupiah(row.amount)} )) )} diff --git a/apps/web/src/apps/main/modules/sales/payments/presentation/components/form-component/form-allocations.tsx b/apps/web/src/apps/main/modules/sales/payments/presentation/components/form-component/form-allocations.tsx index 79c0b22..be1665e 100644 --- a/apps/web/src/apps/main/modules/sales/payments/presentation/components/form-component/form-allocations.tsx +++ b/apps/web/src/apps/main/modules/sales/payments/presentation/components/form-component/form-allocations.tsx @@ -3,7 +3,7 @@ import { Box, Button, FieldAsyncSelect, - FieldTextInput, + FieldCurrencyInput, Group, Paper, Table, @@ -59,7 +59,7 @@ export function FormAllocations() { /> - + )} - {showBalance ? : null} + {showBalance ? ( + } + /> + ) : null} {salesRequestHref && ( (); const { t } = useEnterpriseModuleTranslationContext(); @@ -50,8 +48,8 @@ export function DetailProducts() { {relationLabel(line.product) || line.productId} {line.quantity} - {line.price ? currency.format(line.price) : '-'} - {currency.format(total)} + {line.price ? formatRupiah(line.price) : '-'} + {formatRupiah(total)} ); }) @@ -60,7 +58,7 @@ export function DetailProducts() { - {t('common:fields.total')}: {currency.format(grandTotal)} + {t('common:fields.total')}: {formatRupiah(grandTotal)} ); diff --git a/apps/web/src/apps/main/modules/sales/shared/form-products.tsx b/apps/web/src/apps/main/modules/sales/shared/form-products.tsx index 43b4da3..47f9252 100644 --- a/apps/web/src/apps/main/modules/sales/shared/form-products.tsx +++ b/apps/web/src/apps/main/modules/sales/shared/form-products.tsx @@ -3,6 +3,7 @@ import { Box, Button, FieldAsyncSelect, + FieldCurrencyInput, FieldTextInput, Group, Paper, @@ -12,15 +13,13 @@ import { import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations'; import { useFieldArray, useWatch } from '@repo/ui/form'; import { Plus, Trash2 } from 'lucide-react'; -import { CurrencyUtils } from '@repo/utils'; +import { formatRupiah } from '@repo/utils'; import { loadProductOptions } from './load-product-options'; import { excludeProductIds, selectedProductIdsExceptLine } from './sales-line-options'; import { relationLabel } from '../../field/shared/relation-label'; import type { ProductEntity } from '../../configuration/products/domain/entities'; -const currency = new CurrencyUtils({ decimalScale: 4 }); - -function lineTotal(quantity?: string, price?: string) { +function lineTotal(quantity?: string, price?: string | number) { const qty = Number(quantity); const unitPrice = Number(price); 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 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, ); @@ -82,9 +81,9 @@ export function FormProducts() { - + - {currency.format(lineTotal(line?.quantity, line?.price))} + {formatRupiah(lineTotal(line?.quantity, line?.price))} - {t('common:fields.total')}: {currency.format(grandTotal)} + {t('common:fields.total')}: {formatRupiah(grandTotal)} diff --git a/apps/web/src/apps/main/modules/sales/shared/payable-invoice.ts b/apps/web/src/apps/main/modules/sales/shared/payable-invoice.ts index 10a7469..5d4c4b3 100644 --- a/apps/web/src/apps/main/modules/sales/shared/payable-invoice.ts +++ b/apps/web/src/apps/main/modules/sales/shared/payable-invoice.ts @@ -1,9 +1,6 @@ export const PAYABLE_INVOICE_STATUSES = ['processed', 'partial'] as const; -export function isPayableInvoice(invoice: { - status?: string | null; - balance?: string | number | null; -}): boolean { +export function isPayableInvoice(invoice: { status?: string | null; balance?: string | number | null }): boolean { const payable: readonly string[] = PAYABLE_INVOICE_STATUSES; if (!invoice.status || !payable.includes(invoice.status)) { return false; diff --git a/apps/web/src/apps/main/modules/sales/shared/sales-document.mapper.ts b/apps/web/src/apps/main/modules/sales/shared/sales-document.mapper.ts index 26b1ed5..aee74c8 100644 --- a/apps/web/src/apps/main/modules/sales/shared/sales-document.mapper.ts +++ b/apps/web/src/apps/main/modules/sales/shared/sales-document.mapper.ts @@ -1,5 +1,6 @@ import { formatDateValue, parseDateValue } from '@repo/ui/form'; import { emptyToNull, omitEmptyFields } from '../../../../../core/domain/configuration-field-validators'; +import { toDecimalStringValue } from '../../../../../core/domain/decimal-string.schema'; import type { LookupStub, SalesDocumentDto, @@ -127,7 +128,7 @@ export function toSalesWritePayload( return omitEmptyFields({ productId, quantity: line.quantity, - price: line.price, + price: toDecimalStringValue(line.price), }); }) .filter(Boolean); diff --git a/apps/web/src/apps/main/modules/sales/shared/sales-document.validator.ts b/apps/web/src/apps/main/modules/sales/shared/sales-document.validator.ts index d63c118..6b2d60a 100644 --- a/apps/web/src/apps/main/modules/sales/shared/sales-document.validator.ts +++ b/apps/web/src/apps/main/modules/sales/shared/sales-document.validator.ts @@ -5,7 +5,7 @@ import { optionalLatitudeSchema, optionalLongitudeSchema, } 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 IMAGE_URL_MAX = 2048; @@ -31,7 +31,7 @@ export function salesLineSchema(t: (key: string) => string) { id: z.string().optional(), product: relationSchema, quantity: decimalStringSchema(t, 'common:fields.quantity'), - price: z.preprocess(emptyToUndefined, decimalStringSchema(t, 'common:fields.price').optional()), + price: optionalDecimalStringSchema(t, 'common:fields.price'), }); } diff --git a/apps/web/src/apps/main/modules/sales/shared/sales-line-options.test.ts b/apps/web/src/apps/main/modules/sales/shared/sales-line-options.test.ts index 30be0b0..cc62a0e 100644 --- a/apps/web/src/apps/main/modules/sales/shared/sales-line-options.test.ts +++ b/apps/web/src/apps/main/modules/sales/shared/sales-line-options.test.ts @@ -2,11 +2,7 @@ import { describe, expect, it } from 'vitest'; import { excludeProductIds, selectedProductIdsExceptLine } from './sales-line-options'; describe('sales line product options', () => { - const lines = [ - { product: { id: 'prd-1' } }, - { product: { id: 'prd-2' } }, - { product: null }, - ]; + const lines = [{ product: { id: 'prd-1' } }, { product: { id: 'prd-2' } }, { product: null }]; it('collects product ids from other lines only', () => { expect(selectedProductIdsExceptLine(lines, 0)).toEqual(['prd-2']); diff --git a/apps/web/src/core/domain/decimal-string.schema.test.ts b/apps/web/src/core/domain/decimal-string.schema.test.ts new file mode 100644 index 0000000..227fefa --- /dev/null +++ b/apps/web/src/core/domain/decimal-string.schema.test.ts @@ -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(); + }); +}); diff --git a/apps/web/src/core/domain/decimal-string.schema.ts b/apps/web/src/core/domain/decimal-string.schema.ts index 835f01d..03c888b 100644 --- a/apps/web/src/core/domain/decimal-string.schema.ts +++ b/apps/web/src/core/domain/decimal-string.schema.ts @@ -1,20 +1,53 @@ 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) { return undefined; } + if (typeof value === 'number') { + return Number.isFinite(value) ? String(value) : value; + } return value; } -export function decimalStringSchema(t: (key: string) => string, fieldKey = 'common:fields.price') { - return z.string().regex(DECIMAL_PATTERN, { - message: JSON.stringify({ key: 'validation:invalid_format', values: { field: t(fieldKey) } }), - }); +export function toDecimalStringValue(value: unknown): string | undefined { + const next = coerceDecimalInput(value); + return typeof next === 'string' ? next : undefined; } -export function optionalDecimalStringSchema(t: (key: string) => string, fieldKey = 'common:fields.price') { - return z.preprocess(emptyToUndefined, decimalStringSchema(t, fieldKey).optional()); +function decimalPattern(maxDecimals: number) { + 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(), + ); } diff --git a/packages/ui/src/components/Form/__tests__/currency-input.field.test.tsx b/packages/ui/src/components/Form/__tests__/currency-input.field.test.tsx new file mode 100644 index 0000000..9a6f267 --- /dev/null +++ b/packages/ui/src/components/Form/__tests__/currency-input.field.test.tsx @@ -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 ( + +
+ +
{String(getValues('price'))}
+ + +
+ ); + } + + render(); + + 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 ( + +
+ + + +
+ ); + } + + render(); + 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); + }); +}); diff --git a/packages/ui/src/components/Form/fields/currency-input.field.tsx b/packages/ui/src/components/Form/fields/currency-input.field.tsx new file mode 100644 index 0000000..2f97934 --- /dev/null +++ b/packages/ui/src/components/Form/fields/currency-input.field.tsx @@ -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 = FieldPath, +> = UseControllerProps & Omit; + +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 = FieldPath, +>(props: FieldCurrencyInputProps) { + const { name, control, rules, shouldUnregister, defaultValue, disabled, ...mantineProps } = props; + const { + field, + fieldState: { error }, + } = useController({ + name, + control, + rules, + shouldUnregister, + defaultValue, + disabled, + }); + const translatedError = useTranslatedError(error?.message); + + return ( + 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'; diff --git a/packages/ui/src/components/Form/index.ts b/packages/ui/src/components/Form/index.ts index 9af17fc..edc625d 100644 --- a/packages/ui/src/components/Form/index.ts +++ b/packages/ui/src/components/Form/index.ts @@ -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'; diff --git a/packages/ui/src/components/field-value/index.ts b/packages/ui/src/components/field-value/index.ts index 1e3ed92..554a705 100644 --- a/packages/ui/src/components/field-value/index.ts +++ b/packages/ui/src/components/field-value/index.ts @@ -1,2 +1,3 @@ export * from './field-value'; export * from './render-date'; +export * from './render-currency'; diff --git a/packages/ui/src/components/field-value/render-currency.test.tsx b/packages/ui/src/components/field-value/render-currency.test.tsx new file mode 100644 index 0000000..d3fadc0 --- /dev/null +++ b/packages/ui/src/components/field-value/render-currency.test.tsx @@ -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(); + expect(screen.getByText('Rp 12.500,12')).toBeInTheDocument(); + }); + + it('renders the fallback when empty', () => { + render(); + expect(screen.getByText('-')).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/field-value/render-currency.tsx b/packages/ui/src/components/field-value/render-currency.tsx new file mode 100644 index 0000000..5b00a56 --- /dev/null +++ b/packages/ui/src/components/field-value/render-currency.tsx @@ -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}; +} diff --git a/packages/ui/src/foundations/enterprise-module/components/data-table/index.tsx b/packages/ui/src/foundations/enterprise-module/components/data-table/index.tsx index 6cdca4a..c56de66 100644 --- a/packages/ui/src/foundations/enterprise-module/components/data-table/index.tsx +++ b/packages/ui/src/foundations/enterprise-module/components/data-table/index.tsx @@ -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'; diff --git a/packages/ui/src/foundations/enterprise-module/components/data-table/server-side-index.utils.ts b/packages/ui/src/foundations/enterprise-module/components/data-table/server-side-index.utils.ts index 6a94884..fa8f93f 100644 --- a/packages/ui/src/foundations/enterprise-module/components/data-table/server-side-index.utils.ts +++ b/packages/ui/src/foundations/enterprise-module/components/data-table/server-side-index.utils.ts @@ -16,16 +16,12 @@ export function resolveServerSideInitialRowCount( return meta?.total; } -export function shouldRestorePaginationPage( - meta: Pick | null | undefined, -): boolean { +export function shouldRestorePaginationPage(meta: Pick | 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, diff --git a/packages/utils/src/currency/currency.utils.test.ts b/packages/utils/src/currency/currency.utils.test.ts index de1ea35..9383fa1 100644 --- a/packages/utils/src/currency/currency.utils.test.ts +++ b/packages/utils/src/currency/currency.utils.test.ts @@ -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(''); }); }); }); diff --git a/packages/utils/src/currency/currency.utils.ts b/packages/utils/src/currency/currency.utils.ts index a48e3c5..682d6ef 100644 --- a/packages/utils/src/currency/currency.utils.ts +++ b/packages/utils/src/currency/currency.utils.ts @@ -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 : ''; }