refactor(FE-Storyless): replace TextInput with NumberInput for price and tax fields, enhance form handling

This commit is contained in:
rstubryan
2025-11-02 20:59:37 +07:00
parent 16db7af070
commit fc3b090da5
@@ -9,7 +9,11 @@ import useSWR from 'swr';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Button from '@/components/Button'; import Button from '@/components/Button';
import TextInput from '@/components/input/TextInput'; import TextInput from '@/components/input/TextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput'; import NumberInput from '@/components/input/NumberInput';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
@@ -24,7 +28,12 @@ import {
CreateProductPayload, CreateProductPayload,
UpdateProductPayload, UpdateProductPayload,
} from '@/types/api/master-data/product'; } from '@/types/api/master-data/product';
import { UomApi, ProductCategoryApi, SupplierApi, ProductApi } from '@/services/api/master-data'; import {
UomApi,
ProductCategoryApi,
SupplierApi,
ProductApi,
} from '@/services/api/master-data';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { PRODUCT_FLAG_OPTIONS } from '@/config/constant'; import { PRODUCT_FLAG_OPTIONS } from '@/config/constant';
@@ -67,30 +76,37 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
[router] [router]
); );
const formikInitialValues = useMemo<ProductFormValues>(() => ({ const formikInitialValues = useMemo<ProductFormValues>(
name: initialValues?.name ?? '', () => ({
brand: initialValues?.brand ?? '', name: initialValues?.name ?? '',
sku: initialValues?.sku ?? '', brand: initialValues?.brand ?? '',
uom: initialValues?.uom sku: initialValues?.sku ?? '',
? { value: initialValues.uom.id, label: initialValues.uom.name } uom: initialValues?.uom
: null, ? { value: initialValues.uom.id, label: initialValues.uom.name }
uom_id: initialValues?.uom?.id ?? 0, : null,
product_category: initialValues?.product_category uom_id: initialValues?.uom?.id ?? 0,
? { value: initialValues.product_category.id, label: initialValues.product_category.name } product_category: initialValues?.product_category
: null, ? {
product_category_id: initialValues?.product_category?.id ?? 0, value: initialValues.product_category.id,
product_price: initialValues?.product_price ?? 0, label: initialValues.product_category.name,
selling_price: initialValues?.selling_price ?? 0, }
tax: initialValues?.tax ?? 0, : null,
expiry_period: initialValues?.expiry_period ?? 0, product_category_id: initialValues?.product_category?.id ?? 0,
supplier: null, // not used for payload, just for UI product_price: initialValues?.product_price ?? 0,
supplier_ids: initialValues?.suppliers?.map(s => s.id) ?? [], selling_price: initialValues?.selling_price ?? 0,
flags: initialValues?.flags ?? [], tax: initialValues?.tax ?? 0,
}), [initialValues]); expiry_period: initialValues?.expiry_period ?? 0,
supplier: null, // not used for payload, just for UI
supplier_ids: initialValues?.suppliers?.map((s) => s.id) ?? [],
flags: initialValues?.flags ?? [],
}),
[initialValues]
);
const formik = useFormik<ProductFormValues>({ const formik = useFormik<ProductFormValues>({
initialValues: formikInitialValues, initialValues: formikInitialValues,
validationSchema: type === 'edit' ? UpdateProductFormSchema : ProductFormSchema, validationSchema:
type === 'edit' ? UpdateProductFormSchema : ProductFormSchema,
onSubmit: async (values) => { onSubmit: async (values) => {
setProductFormErrorMessage(''); setProductFormErrorMessage('');
const payload: CreateProductPayload = { const payload: CreateProductPayload = {
@@ -99,12 +115,16 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
sku: values.sku, sku: values.sku,
uom_id: values.uom_id, uom_id: values.uom_id,
product_category_id: values.product_category_id, product_category_id: values.product_category_id,
product_price: values.product_price, product_price: parseInt(values.product_price.toString()) || 0,
selling_price: values.selling_price, selling_price: parseInt(values.selling_price.toString()) || 0,
tax: values.tax, tax: parseInt(values.tax.toString()) || 0,
expiry_period: values.expiry_period, expiry_period: parseInt(values.expiry_period.toString()) || 0,
supplier_ids: (values.supplier_ids ?? []).filter((id): id is number => typeof id === 'number'), supplier_ids: (values.supplier_ids ?? []).filter(
flags: (values.flags ?? []).filter((f): f is string => typeof f === 'string'), (id): id is number => typeof id === 'number'
),
flags: (values.flags ?? []).filter(
(f): f is string => typeof f === 'string'
),
}; };
switch (type) { switch (type) {
case 'add': case 'add':
@@ -120,12 +140,11 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
const { setValues: formikSetValues } = formik; const { setValues: formikSetValues } = formik;
// UOM // UOM
const [uomSelectInputValue, setUomSelectInputValue] = useState(''); const {
const uomsUrl = `${UomApi.basePath}?${new URLSearchParams({ search: uomSelectInputValue ?? '' }).toString()}`; setInputValue: setUomSelectInputValue,
const { data: uoms, isLoading: isLoadingUoms } = useSWR(uomsUrl, UomApi.getAllFetcher); options: uomOptions,
const uomOptions = isResponseSuccess(uoms) isLoadingOptions: isLoadingUoms,
? uoms?.data.map((uom) => ({ value: uom.id, label: uom.name })) } = useSelect(UomApi.basePath, 'id', 'name');
: [];
const uomChangeHandler = (val: OptionType | OptionType[] | null) => { const uomChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('uom', true); formik.setFieldTouched('uom', true);
formik.setFieldValue('uom', val); formik.setFieldValue('uom', val);
@@ -134,12 +153,11 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
}; };
// Product Category // Product Category
const [categorySelectInputValue, setCategorySelectInputValue] = useState(''); const {
const categoriesUrl = `${ProductCategoryApi.basePath}?${new URLSearchParams({ search: categorySelectInputValue ?? '' }).toString()}`; setInputValue: setCategorySelectInputValue,
const { data: categories, isLoading: isLoadingCategories } = useSWR(categoriesUrl, ProductCategoryApi.getAllFetcher); options: categoryOptions,
const categoryOptions = isResponseSuccess(categories) isLoadingOptions: isLoadingCategories,
? categories?.data.map((cat) => ({ value: cat.id, label: cat.name })) } = useSelect(ProductCategoryApi.basePath, 'id', 'name');
: [];
const categoryChangeHandler = (val: OptionType | OptionType[] | null) => { const categoryChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('product_category', true); formik.setFieldTouched('product_category', true);
formik.setFieldValue('product_category', val); formik.setFieldValue('product_category', val);
@@ -147,19 +165,25 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
formik.setFieldValue('product_category_id', (val as OptionType)?.value); formik.setFieldValue('product_category_id', (val as OptionType)?.value);
}; };
// Supplier (multi select) // Supplier (multi select) - using SWR to filter by category
const [supplierSelectInputValue, setSupplierSelectInputValue] = useState(''); const [supplierSelectInputValue, setSupplierSelectInputValue] = useState('');
const suppliersUrl = `${SupplierApi.basePath}?${new URLSearchParams({ search: supplierSelectInputValue ?? '' }).toString()}`; const suppliersUrl = `${SupplierApi.basePath}?${new URLSearchParams({ search: supplierSelectInputValue ?? '' }).toString()}`;
const { data: suppliers, isLoading: isLoadingSuppliers } = useSWR(suppliersUrl, SupplierApi.getAllFetcher); const { data: suppliers, isLoading: isLoadingSuppliers } = useSWR(
suppliersUrl,
SupplierApi.getAllFetcher
);
const supplierOptions = isResponseSuccess(suppliers) const supplierOptions = isResponseSuccess(suppliers)
? suppliers?.data ? suppliers?.data
.filter((sup) => sup.category === 'SAPRONAK') .filter((sup) => sup.category === 'SAPRONAK')
.map((sup) => ({ value: sup.id, label: sup.name })) .map((sup) => ({ value: sup.id, label: sup.name }))
: []; : [];
const supplierChangeHandler = (val: OptionType | OptionType[] | null) => { const supplierChangeHandler = (val: OptionType | OptionType[] | null) => {
const arr = Array.isArray(val) ? val : val ? [val] : []; const arr = Array.isArray(val) ? val : val ? [val] : [];
formik.setFieldTouched('supplier_ids', true); formik.setFieldTouched('supplier_ids', true);
formik.setFieldValue('supplier_ids', arr.map((v) => (v as OptionType).value)); formik.setFieldValue(
'supplier_ids',
arr.map((v) => (v as OptionType).value)
);
}; };
const deleteProductClickHandler = () => { const deleteProductClickHandler = () => {
@@ -181,7 +205,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
return ( return (
<> <>
<section className='w-full max-w-xl'> <section className='w-full max-w-2xl'>
<header className='flex flex-col gap-4'> <header className='flex flex-col gap-4'>
<Button <Button
href='/master-data/product' href='/master-data/product'
@@ -260,60 +284,88 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
options={categoryOptions} options={categoryOptions}
onInputChange={setCategorySelectInputValue} onInputChange={setCategorySelectInputValue}
isLoading={isLoadingCategories} isLoading={isLoadingCategories}
isError={formik.touched.product_category_id && Boolean(formik.errors.product_category_id)} isError={
formik.touched.product_category_id &&
Boolean(formik.errors.product_category_id)
}
errorMessage={formik.errors.product_category_id as string} errorMessage={formik.errors.product_category_id as string}
isDisabled={type === 'detail'} isDisabled={type === 'detail'}
isClearable isClearable
/> />
<TextInput <NumberInput
required required
label='Harga Produk' label='Harga Produk'
name='product_price' name='product_price'
type='number'
placeholder='Masukkan harga produk' placeholder='Masukkan harga produk'
value={formik.values.product_price} value={formik.values.product_price}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
isError={formik.touched.product_price && Boolean(formik.errors.product_price)} decimalScale={2}
allowNegative={false}
thousandSeparator=','
decimalSeparator='.'
inputPrefix='Rp '
isError={
formik.touched.product_price &&
Boolean(formik.errors.product_price)
}
errorMessage={formik.errors.product_price as string} errorMessage={formik.errors.product_price as string}
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
<TextInput <NumberInput
required required
label='Harga Jual' label='Harga Jual'
name='selling_price' name='selling_price'
type='number'
placeholder='Masukkan harga jual' placeholder='Masukkan harga jual'
value={formik.values.selling_price} value={formik.values.selling_price}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
isError={formik.touched.selling_price && Boolean(formik.errors.selling_price)} decimalScale={2}
allowNegative={false}
thousandSeparator=','
decimalSeparator='.'
inputPrefix='Rp '
isError={
formik.touched.selling_price &&
Boolean(formik.errors.selling_price)
}
errorMessage={formik.errors.selling_price as string} errorMessage={formik.errors.selling_price as string}
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
<TextInput <NumberInput
required required
label='Pajak (%)' label='Pajak (%)'
name='tax' name='tax'
type='number'
placeholder='Masukkan pajak' placeholder='Masukkan pajak'
value={formik.values.tax} value={formik.values.tax}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
decimalScale={2}
allowNegative={false}
thousandSeparator=','
decimalSeparator='.'
inputSuffix='%'
isError={formik.touched.tax && Boolean(formik.errors.tax)} isError={formik.touched.tax && Boolean(formik.errors.tax)}
errorMessage={formik.errors.tax as string} errorMessage={formik.errors.tax as string}
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
<TextInput <NumberInput
required required
label='Periode Kadaluarsa (hari)' label='Periode Kadaluarsa (hari)'
name='expiry_period' name='expiry_period'
type='number'
placeholder='Masukkan periode kadaluarsa' placeholder='Masukkan periode kadaluarsa'
value={formik.values.expiry_period} value={formik.values.expiry_period}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
isError={formik.touched.expiry_period && Boolean(formik.errors.expiry_period)} decimalScale={0}
allowNegative={false}
thousandSeparator=','
decimalSeparator='.'
inputSuffix='hari'
isError={
formik.touched.expiry_period &&
Boolean(formik.errors.expiry_period)
}
errorMessage={formik.errors.expiry_period as string} errorMessage={formik.errors.expiry_period as string}
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
@@ -321,12 +373,17 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
required required
label='Supplier' label='Supplier'
isMulti isMulti
value={supplierOptions.filter(opt => formik.values.supplier_ids.includes(opt.value))} value={supplierOptions.filter((opt) =>
formik.values.supplier_ids.includes(opt.value)
)}
onChange={supplierChangeHandler} onChange={supplierChangeHandler}
options={supplierOptions} options={supplierOptions}
onInputChange={setSupplierSelectInputValue} onInputChange={setSupplierSelectInputValue}
isLoading={isLoadingSuppliers} isLoading={isLoadingSuppliers}
isError={formik.touched.supplier_ids && Boolean(formik.errors.supplier_ids)} isError={
formik.touched.supplier_ids &&
Boolean(formik.errors.supplier_ids)
}
errorMessage={formik.errors.supplier_ids as string} errorMessage={formik.errors.supplier_ids as string}
isDisabled={type === 'detail'} isDisabled={type === 'detail'}
isClearable isClearable
@@ -335,10 +392,15 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
required required
label='Flags' label='Flags'
isMulti isMulti
value={PRODUCT_FLAG_OPTIONS.filter(opt => formik.values.flags.includes(opt.value))} value={PRODUCT_FLAG_OPTIONS.filter((opt) =>
onChange={val => { formik.values.flags.includes(opt.value)
)}
onChange={(val) => {
const arr = Array.isArray(val) ? val : val ? [val] : []; const arr = Array.isArray(val) ? val : val ? [val] : [];
formik.setFieldValue('flags', arr.map((v) => (v as OptionType).value)); formik.setFieldValue(
'flags',
arr.map((v) => (v as OptionType).value)
);
}} }}
options={PRODUCT_FLAG_OPTIONS} options={PRODUCT_FLAG_OPTIONS}
isError={formik.touched.flags && Boolean(formik.errors.flags)} isError={formik.touched.flags && Boolean(formik.errors.flags)}
@@ -435,4 +497,4 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
); );
}; };
export default ProductForm; export default ProductForm;