feat: enhance sales module with decimal formatting and input improvements
- Updated various components to utilize `FieldCurrencyInput` for quantity inputs, improving user experience in financial data entry. - Introduced `formatDecimal` utility for consistent decimal formatting across the application, ensuring accurate display of numeric values. - Refactored quantity handling in forms and tables to support decimal values, enhancing data integrity and usability. - Added unit tests for new decimal formatting functionality to ensure reliability and correctness. These changes improve the sales module's handling of numeric inputs and displays, providing a more user-friendly experience for managing financial data.
This commit is contained in:
+2
-2
@@ -11,7 +11,7 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
} from '@repo/ui/components';
|
} from '@repo/ui/components';
|
||||||
import { useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
import { useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||||
import { formatRupiah } from '@repo/utils';
|
import { formatDecimal, formatRupiah } from '@repo/utils';
|
||||||
import { salesInvoicesDataService } from '../../../../../sales/invoices/domain/factories';
|
import { salesInvoicesDataService } from '../../../../../sales/invoices/domain/factories';
|
||||||
import { packingSlipsModuleDataService } from '../../../../packing-slips/domain/factories';
|
import { packingSlipsModuleDataService } from '../../../../packing-slips/domain/factories';
|
||||||
import { relationLabel } from '../../../../shared/relation-label';
|
import { relationLabel } from '../../../../shared/relation-label';
|
||||||
@@ -171,7 +171,7 @@ function DocumentProductsTable({
|
|||||||
return (
|
return (
|
||||||
<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">{formatDecimal(line.quantity) || '-'}</Table.Td>
|
||||||
<Table.Td ta="right">{line.price ? formatRupiah(line.price) : '-'}</Table.Td>
|
<Table.Td ta="right">{line.price ? formatRupiah(line.price) : '-'}</Table.Td>
|
||||||
<Table.Td ta="right">{formatRupiah(lineTotal)}</Table.Td>
|
<Table.Td ta="right">{formatRupiah(lineTotal)}</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Button, FieldTextInput, Group, Modal, Stack, Text } from '@repo/ui/components';
|
import { Button, FieldCurrencyInput, Group, Modal, Stack, Text } from '@repo/ui/components';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useFieldArray, useForm } from 'react-hook-form';
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
import { useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
import { useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||||
import { relationLabel } from '../../field/shared/relation-label';
|
import { relationLabel } from '../../field/shared/relation-label';
|
||||||
import type { SalesLineEntity } from './sales-document.entity';
|
import type { SalesLineEntity } from './sales-document.entity';
|
||||||
|
import { toDecimalStringValue } from '../../../../../core/domain/decimal-string.schema';
|
||||||
|
|
||||||
export function CompletePackingModal({
|
export function CompletePackingModal({
|
||||||
opened,
|
opened,
|
||||||
@@ -17,7 +18,7 @@ export function CompletePackingModal({
|
|||||||
onSubmit: (products: Array<{ productId: string; quantity: string }>) => Promise<void> | void;
|
onSubmit: (products: Array<{ productId: string; quantity: string }>) => Promise<void> | void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useEnterpriseModuleTranslationContext();
|
const { t } = useEnterpriseModuleTranslationContext();
|
||||||
const form = useForm<{ products: Array<{ productId: string; quantity: string; label: string }> }>({
|
const form = useForm<{ products: Array<{ productId: string; quantity: string | number; label: string }> }>({
|
||||||
defaultValues: { products: [] },
|
defaultValues: { products: [] },
|
||||||
});
|
});
|
||||||
const { fields } = useFieldArray({ control: form.control, name: 'products' });
|
const { fields } = useFieldArray({ control: form.control, name: 'products' });
|
||||||
@@ -37,7 +38,7 @@ export function CompletePackingModal({
|
|||||||
await onSubmit(
|
await onSubmit(
|
||||||
values.products.map((line) => ({
|
values.products.map((line) => ({
|
||||||
productId: line.productId,
|
productId: line.productId,
|
||||||
quantity: line.quantity,
|
quantity: toDecimalStringValue(line.quantity) ?? '',
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
onClose();
|
onClose();
|
||||||
@@ -49,10 +50,11 @@ export function CompletePackingModal({
|
|||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Text size="sm">{t('complete_packing_help')}</Text>
|
<Text size="sm">{t('complete_packing_help')}</Text>
|
||||||
{fields.map((field, index) => (
|
{fields.map((field, index) => (
|
||||||
<FieldTextInput
|
<FieldCurrencyInput
|
||||||
key={field.id}
|
key={field.id}
|
||||||
control={form.control as any}
|
control={form.control as any}
|
||||||
name={`products.${index}.quantity`}
|
name={`products.${index}.quantity`}
|
||||||
|
prefix=""
|
||||||
label={`${t('delivered_quantity')} — ${form.getValues(`products.${index}.label`) || field.label}`}
|
label={`${t('delivered_quantity')} — ${form.getValues(`products.${index}.label`) || field.label}`}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +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 { formatDecimal, 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';
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ export function DetailProducts() {
|
|||||||
return (
|
return (
|
||||||
<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">{formatDecimal(line.quantity) || '-'}</Table.Td>
|
||||||
<Table.Td ta="right">{line.price ? formatRupiah(line.price) : '-'}</Table.Td>
|
<Table.Td ta="right">{line.price ? formatRupiah(line.price) : '-'}</Table.Td>
|
||||||
<Table.Td ta="right">{formatRupiah(total)}</Table.Td>
|
<Table.Td ta="right">{formatRupiah(total)}</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
FieldAsyncSelect,
|
FieldAsyncSelect,
|
||||||
FieldCurrencyInput,
|
FieldCurrencyInput,
|
||||||
FieldTextInput,
|
|
||||||
Group,
|
Group,
|
||||||
Paper,
|
Paper,
|
||||||
Table,
|
Table,
|
||||||
@@ -19,7 +18,7 @@ import { excludeProductIds, selectedProductIdsExceptLine } from './sales-line-op
|
|||||||
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';
|
||||||
|
|
||||||
function lineTotal(quantity?: string, price?: string | number) {
|
function lineTotal(quantity?: string | number, price?: string | number) {
|
||||||
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;
|
||||||
@@ -35,7 +34,8 @@ 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 | number }) => sum + lineTotal(line?.quantity, line.price),
|
(sum: number, line: { quantity?: string | number; price?: string | number }) =>
|
||||||
|
sum + lineTotal(line?.quantity, line.price),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -78,7 +78,12 @@ export function FormProducts() {
|
|||||||
/>
|
/>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td miw={120}>
|
<Table.Td miw={120}>
|
||||||
<FieldTextInput control={formControl.control} name={`products.${index}.quantity`} radius="md" />
|
<FieldCurrencyInput
|
||||||
|
control={formControl.control}
|
||||||
|
name={`products.${index}.quantity`}
|
||||||
|
prefix=""
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td miw={140}>
|
<Table.Td miw={140}>
|
||||||
<FieldCurrencyInput control={formControl.control} name={`products.${index}.price`} radius="md" />
|
<FieldCurrencyInput control={formControl.control} name={`products.${index}.price`} radius="md" />
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export function toSalesWritePayload(
|
|||||||
if (!productId) return null;
|
if (!productId) return null;
|
||||||
return omitEmptyFields({
|
return omitEmptyFields({
|
||||||
productId,
|
productId,
|
||||||
quantity: line.quantity,
|
quantity: toDecimalStringValue(line.quantity),
|
||||||
price: toDecimalStringValue(line.price),
|
price: toDecimalStringValue(line.price),
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import {
|
|||||||
optionalLatitudeSchema,
|
optionalLatitudeSchema,
|
||||||
optionalLongitudeSchema,
|
optionalLongitudeSchema,
|
||||||
} from '../../../../../core/domain/configuration-field-validators';
|
} from '../../../../../core/domain/configuration-field-validators';
|
||||||
import { decimalStringSchema, optionalDecimalStringSchema } from '../../../../../core/domain/decimal-string.schema';
|
import {
|
||||||
|
decimalStringSchema,
|
||||||
|
optionalDecimalStringSchema,
|
||||||
|
PRICE_DECIMAL_SCALE,
|
||||||
|
} from '../../../../../core/domain/decimal-string.schema';
|
||||||
|
|
||||||
const NOTES_MAX = 1024;
|
const NOTES_MAX = 1024;
|
||||||
const IMAGE_URL_MAX = 2048;
|
const IMAGE_URL_MAX = 2048;
|
||||||
@@ -30,7 +34,7 @@ export function salesLineSchema(t: (key: string) => string) {
|
|||||||
return z.object({
|
return z.object({
|
||||||
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_DECIMAL_SCALE),
|
||||||
price: optionalDecimalStringSchema(t, 'common:fields.price'),
|
price: optionalDecimalStringSchema(t, 'common:fields.price'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './field-value';
|
export * from './field-value';
|
||||||
export * from './render-date';
|
export * from './render-date';
|
||||||
export * from './render-currency';
|
export * from './render-currency';
|
||||||
|
export * from './render-decimal';
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { RenderDecimal } from './render-decimal';
|
||||||
|
|
||||||
|
describe('RenderDecimal', () => {
|
||||||
|
it('renders two display decimals without a currency prefix', () => {
|
||||||
|
render(<RenderDecimal value="2.0000" />);
|
||||||
|
expect(screen.getByText('2,00')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the fallback when empty', () => {
|
||||||
|
render(<RenderDecimal value={null} />);
|
||||||
|
expect(screen.getByText('-')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { formatDecimal } from '@repo/utils';
|
||||||
|
|
||||||
|
export interface RenderDecimalProps {
|
||||||
|
value?: string | number | null;
|
||||||
|
fallback?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RenderDecimal({ value, fallback = '-' }: RenderDecimalProps) {
|
||||||
|
const formatted = formatDecimal(value);
|
||||||
|
return <>{formatted || fallback}</>;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
import { CurrencyUtils, formatRupiah, toCurrencyNumber } from './currency.utils';
|
import { CurrencyUtils, formatDecimal, formatRupiah, toCurrencyNumber } from './currency.utils';
|
||||||
|
|
||||||
describe('CurrencyUtils', () => {
|
describe('CurrencyUtils', () => {
|
||||||
let currency: CurrencyUtils;
|
let currency: CurrencyUtils;
|
||||||
@@ -140,6 +140,15 @@ describe('CurrencyUtils', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('formatDecimal', () => {
|
||||||
|
it('should format with two display decimals and no currency prefix', () => {
|
||||||
|
const stored = '2.0000';
|
||||||
|
expect(formatDecimal(stored)).toBe('2,00');
|
||||||
|
expect(formatDecimal('1234.5678')).toBe('1.234,57');
|
||||||
|
expect(stored).toBe('2.0000');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('toCurrencyNumber', () => {
|
describe('toCurrencyNumber', () => {
|
||||||
it('should keep numeric values as numbers', () => {
|
it('should keep numeric values as numbers', () => {
|
||||||
expect(toCurrencyNumber(12500.12345)).toBe(12500.12345);
|
expect(toCurrencyNumber(12500.12345)).toBe(12500.12345);
|
||||||
|
|||||||
@@ -192,6 +192,13 @@ export function formatRupiah(value: CurrencyInput): string {
|
|||||||
return displayCurrency.format(value);
|
return displayCurrency.format(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const displayDecimal = new CurrencyUtils({ prefix: '', decimalScale: CURRENCY_DISPLAY_SCALE });
|
||||||
|
|
||||||
|
/** Format a numeric value with two display decimals and no currency prefix. */
|
||||||
|
export function formatDecimal(value: CurrencyInput): string {
|
||||||
|
return displayDecimal.format(value);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Coerce a stored price (number or decimal string) into a NumberInput 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.
|
* Empty / invalid inputs stay as '' so the form field can remain blank.
|
||||||
|
|||||||
Reference in New Issue
Block a user