feat: add price per supplier input in product form

This commit is contained in:
ValdiANS
2026-01-15 11:45:31 +07:00
parent 94c6d82967
commit 57a148b6cf
2 changed files with 210 additions and 54 deletions
@@ -3,7 +3,7 @@ import * as Yup from 'yup';
type ProductFormSchemaType = {
name: string;
brand: string;
sku: string;
sku?: string;
uom?: {
value: number;
label: string;
@@ -15,10 +15,16 @@ type ProductFormSchemaType = {
} | null;
product_category_id: number;
product_price: number | string;
selling_price: number | string;
tax: number | string;
expiry_period: number | string;
supplier_ids: number[];
selling_price?: number | string;
tax?: number | string;
expiry_period?: number | string;
suppliers: {
supplier: {
value: number;
label: string;
} | null;
price: number;
}[];
flags: string[];
};
@@ -26,7 +32,7 @@ export const ProductFormSchema: Yup.ObjectSchema<ProductFormSchemaType> =
Yup.object({
name: Yup.string().required('Nama wajib diisi!'),
brand: Yup.string().required('Merek wajib diisi!'),
sku: Yup.string().required('SKU wajib diisi!'),
sku: Yup.string(),
uom: Yup.object({
value: Yup.number()
@@ -58,23 +64,34 @@ export const ProductFormSchema: Yup.ObjectSchema<ProductFormSchemaType> =
.min(1, 'Harga produk tidak boleh kurang dari 1!'),
selling_price: Yup.number()
.required('Harga jual wajib diisi!')
.typeError('Harga jual wajib diisi!')
.typeError('Harga hanya boleh angka!')
.min(1, 'Harga jual tidak boleh kurang dari 1!'),
tax: Yup.number()
.required('Pajak wajib diisi!')
.typeError('Pajak wajib diisi!')
.typeError('Pajak hanya boleh angka!')
.min(0, 'Pajak tidak boleh kurang dari 0!')
.max(100, 'Pajak tidak boleh lebih dari 100%!'),
expiry_period: Yup.number()
.required('Periode kadaluarsa wajib diisi!')
.typeError('Periode kadaluarsa wajib diisi!')
.typeError('Periode kadaluarsa hanya boleh angka!')
.min(1, 'Periode kadaluarsa tidak boleh kurang dari 1 hari!'),
supplier_ids: Yup.array()
.of(Yup.number().required().typeError('Supplier tidak valid!'))
suppliers: Yup.array()
.of(
Yup.object({
supplier: Yup.object({
value: Yup.number()
.min(1, 'Supplier wajib dipilih!')
.required('Supplier wajib dipilih!')
.typeError('Supplier wajib dipilih!'),
label: Yup.string().required('Supplier wajib dipilih!'),
}).required('Supplier wajib dipilih!'),
price: Yup.number()
.min(1, 'Harga tidak boleh kurang dari 1!')
.required('Harga wajib diisi!')
.typeError('Harga wajib diisi!'),
})
)
.min(1, 'Minimal harus ada 1 supplier!')
.required('Supplier wajib diisi!'),
@@ -41,6 +41,8 @@ import { cn } from '@/lib/helper';
import { PRODUCT_FLAG_OPTIONS } from '@/config/constant';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import { Supplier } from '@/types/api/master-data/supplier';
import Card from '@/components/Card';
import { removeArrayItemAndSync } from '@/lib/utils/formik';
interface ProductFormProps {
type?: 'add' | 'edit' | 'detail';
@@ -101,7 +103,15 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
selling_price: initialValues?.selling_price ?? '',
tax: initialValues?.tax ?? '',
expiry_period: initialValues?.expiry_period ?? '',
supplier_ids: initialValues?.suppliers?.map((s) => s.id) ?? [],
suppliers: initialValues?.suppliers
? initialValues.suppliers.map((supplier) => ({
supplier: {
value: supplier.id,
label: supplier.name,
},
price: supplier.price,
}))
: [],
flags: initialValues?.flags ?? [],
}),
[initialValues]
@@ -120,12 +130,17 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
uom_id: values.uom_id,
product_category_id: values.product_category_id,
product_price: parseInt(values.product_price.toString()) || 0,
selling_price: parseInt(values.selling_price.toString()) || 0,
tax: parseInt(values.tax.toString()) || 0,
expiry_period: parseInt(values.expiry_period.toString()) || 0,
supplier_ids: values.supplier_ids.filter(
(id): id is number => typeof id === 'number'
),
selling_price: values.selling_price
? parseInt(values.selling_price.toString()) || 0
: undefined,
tax: values.tax ? parseInt(values.tax.toString()) || 0 : undefined,
expiry_period: values.expiry_period
? parseInt(values.expiry_period.toString()) || 0
: undefined,
suppliers: values.suppliers.map((s) => ({
supplier_id: s.supplier?.value as number,
price: parseInt(s.price.toString()) || 0,
})),
flags: values.flags.filter((f): f is string => typeof f === 'string'),
};
switch (type) {
@@ -179,13 +194,29 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
category: 'SAPRONAK',
});
const supplierChangeHandler = (val: OptionType | OptionType[] | null) => {
const arr = Array.isArray(val) ? val : val ? [val] : [];
formik.setFieldTouched('supplier_ids', true);
formik.setFieldValue(
'supplier_ids',
arr.map((v) => (v as OptionType).value)
);
const filteredSupplierOptions = useMemo(() => {
return supplierOptions.filter((opt) => {
return !formik.values.suppliers.some(
(s) => s.supplier?.value === opt.value
);
});
}, [supplierOptions, formik.values.suppliers]);
const addSupplierHandler = () => {
formik.setFieldValue('suppliers', [
...formik.values.suppliers,
{
supplier_id: '',
price: formik.values.product_price,
},
]);
};
const deleteSupplierItemHandler = (idx: number) => {
const path = 'suppliers';
// trims values, errors, and touched at idx
removeArrayItemAndSync(formik, path, idx);
};
const deleteProductClickHandler = () => {
@@ -201,6 +232,19 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
router.push('/master-data/product');
};
const isSupplierRepeaterError = (
column: 'supplier' | 'price',
supplierIdx: number
) => {
return (
formik.touched.suppliers?.[supplierIdx]?.[column] &&
Boolean(
formik.errors.suppliers?.[supplierIdx] instanceof Object &&
formik.errors.suppliers?.[supplierIdx]?.[column]
)
);
};
useEffect(() => {
formikSetValues(formikInitialValues);
}, [formikSetValues, formikInitialValues]);
@@ -271,7 +315,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
readOnly={type === 'detail'}
/>
<TextInput
required
label='SKU'
name='sku'
placeholder='Masukkan SKU...'
@@ -344,7 +387,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
readOnly={type === 'detail'}
/>
<NumberInput
required
label='Harga Jual'
name='selling_price'
placeholder='Masukkan harga jual...'
@@ -366,7 +408,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
</div>
<div className='grid sm:grid-cols-2 gap-4'>
<NumberInput
required
label='Pajak (%)'
name='tax'
placeholder='Masukkan pajak...'
@@ -383,7 +424,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
readOnly={type === 'detail'}
/>
<NumberInput
required
label='Periode Kadaluarsa (hari)'
name='expiry_period'
placeholder='Masukkan periode kadaluarsa...'
@@ -403,28 +443,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
readOnly={type === 'detail'}
/>
</div>
<div className='grid sm:grid-cols-2 gap-4'>
<SelectInput
required
label='Supplier'
placeholder='Pilih supplier...'
isMulti
value={supplierOptions.filter((opt) =>
(formik.values.supplier_ids || []).includes(opt.value)
)}
onChange={supplierChangeHandler}
options={supplierOptions}
onInputChange={setSupplierSelectInputValue}
onMenuScrollToBottom={loadMoreSuppliers}
isLoading={isLoadingSuppliers}
isError={
formik.touched.supplier_ids &&
Boolean(formik.errors.supplier_ids)
}
errorMessage={formik.errors.supplier_ids as string}
isDisabled={type === 'detail'}
isClearable
/>
<div className='grid sm:grid-cols-1 gap-4'>
<SelectInput
required
label='Flags'
@@ -447,6 +466,126 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
isClearable
/>
</div>
<div className='grid sm:grid-cols-1 gap-4'>
{type !== 'detail' && formik.values.suppliers.length === 0 && (
<Button
type='button'
color='success'
onClick={addSupplierHandler}
className='w-fit mx-auto'
>
<Icon icon='ic:round-plus' width={24} height={24} /> Tambah
Supplier
</Button>
)}
{formik.values.suppliers.length > 0 && (
<Card
className={{
wrapper: 'w-full',
body: 'p-4 shadow',
}}
>
<div className='mb-4 text-center'>
<h4 className='font-bold text-xl'>Supplier</h4>
</div>
<div className='overflow-x-auto'>
<table className='table'>
<thead>
<tr>
<th className='after:content-["*"] after:text-red-500 after:ml-0.5'>
Supplier
</th>
<th className='after:content-["*"] after:text-red-500 after:ml-0.5'>
Harga
</th>
<th>Aksi</th>
</tr>
</thead>
<tbody>
{formik.values.suppliers.map((supplier, idx) => (
<tr key={idx}>
<td className='p-2 w-full max-w-1/2'>
<SelectInput
placeholder='Pilih Supplier'
options={filteredSupplierOptions}
onInputChange={setSupplierSelectInputValue}
onMenuScrollToBottom={loadMoreSuppliers}
isLoading={isLoadingSuppliers}
value={formik.values.suppliers[idx].supplier}
onChange={(val) => {
formik.setFieldValue(
`suppliers.${idx}.supplier`,
val
);
}}
isError={isSupplierRepeaterError(
'supplier',
idx
)}
isClearable
className={{
wrapper: 'min-w-48 w-full',
}}
/>
</td>
<td className='p-2 w-full max-w-1/2'>
<NumberInput
required
name={`suppliers.${idx}.price`}
placeholder='Masukkan harga...'
value={formik.values.suppliers[idx].price}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
decimalScale={2}
allowNegative={false}
thousandSeparator=','
decimalSeparator='.'
inputPrefix='Rp '
isError={isSupplierRepeaterError('price', idx)}
readOnly={type === 'detail'}
className={{
wrapper: 'min-w-48 w-full',
}}
/>
</td>
{type !== 'detail' && (
<td>
<Button
type='button'
color='error'
onClick={() => deleteSupplierItemHandler(idx)}
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
/>
</Button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
<div className='w-full flex flex-row justify-center'>
<Button
type='button'
color='success'
onClick={addSupplierHandler}
>
<Icon icon='ic:round-plus' width={24} height={24} />{' '}
Tambah Supplier
</Button>
</div>
</Card>
)}
</div>
</div>
<div className='flex flex-row justify-between gap-2 flex-wrap'>
{type !== 'add' && (