refactor(FE-62,65): refactor MovementForm schema and component for improved product and ekspedisi handling

This commit is contained in:
rstubryan
2025-10-10 10:19:56 +07:00
parent 27f58051ad
commit 095190d757
2 changed files with 577 additions and 483 deletions
@@ -1,6 +1,72 @@
import * as Yup from 'yup'; import * as Yup from 'yup';
import { Movement } from '@/types/api/inventory/movement'; import { Movement } from '@/types/api/inventory/movement';
export type ProductSchema = {
product: {
value: number;
label: string;
} | null;
product_id: number;
qty_product: number;
};
export type EkspedisiSchema = {
product: {
value: number;
label: string;
} | null;
product_id: number;
qty: number;
supplier: {
value: number;
label: string;
} | null;
supplier_id: number;
plat_nomor: string;
no_surat_jalan: string;
dokumen: string | File;
biaya_ekspedisi: number;
nama_sopir: string;
};
// Define schemas for nested objects
const ProductObjectSchema: Yup.ObjectSchema<ProductSchema> = Yup.object({
product: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
product_id: Yup.number().required('Produk wajib diisi!'),
qty_product: Yup.number()
.required('Qty wajib diisi!')
.min(1, 'Qty minimal 1!')
.typeError('Qty harus berupa angka!'),
});
const EkspedisiObjectSchema: Yup.ObjectSchema<EkspedisiSchema> = Yup.object({
product: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
product_id: Yup.number().required('Produk wajib diisi!'),
qty: Yup.number()
.required('Qty wajib diisi!')
.min(1, 'Qty minimal 1!')
.typeError('Qty harus berupa angka!'),
supplier: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
supplier_id: Yup.number().required('Supplier wajib diisi!'),
plat_nomor: Yup.string().required('Plat nomor wajib diisi!'),
no_surat_jalan: Yup.string().required('No surat jalan wajib diisi!'),
dokumen: Yup.mixed<string | File>().required('Dokumen wajib diisi!'),
biaya_ekspedisi: Yup.number()
.required('Biaya ekspedisi wajib diisi!')
.min(0, 'Biaya minimal 0!')
.typeError('Biaya harus berupa angka!'),
nama_sopir: Yup.string().required('Nama sopir wajib diisi!'),
});
export const MovementFormSchema = Yup.object({ export const MovementFormSchema = Yup.object({
alasan_transfer: Yup.string().required('Alasan transfer wajib diisi!'), alasan_transfer: Yup.string().required('Alasan transfer wajib diisi!'),
tanggal_transfer: Yup.string().required('Tanggal transfer wajib diisi!'), tanggal_transfer: Yup.string().required('Tanggal transfer wajib diisi!'),
@@ -19,49 +85,9 @@ export const MovementFormSchema = Yup.object({
.required('Gudang tujuan wajib diisi!') .required('Gudang tujuan wajib diisi!')
.typeError('Gudang tujuan wajib diisi!'), .typeError('Gudang tujuan wajib diisi!'),
product: Yup.array() product: Yup.array()
.of( .of(ProductObjectSchema)
Yup.object({
product: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
product_id: Yup.number().required('Produk wajib diisi!'),
qty_product: Yup.number()
.required('Qty wajib diisi!')
.min(1, 'Qty minimal 1!')
.typeError('Qty harus berupa angka!'),
})
)
.min(1, 'Minimal harus ada 1 produk!'), .min(1, 'Minimal harus ada 1 produk!'),
ekspedisi: Yup.array() ekspedisi: Yup.array().of(EkspedisiObjectSchema).optional().default([]),
.of(
Yup.object({
product: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
product_id: Yup.number().required('Produk wajib diisi!'),
qty: Yup.number()
.required('Qty wajib diisi!')
.min(1, 'Qty minimal 1!')
.typeError('Qty harus berupa angka!'),
supplier: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
supplier_id: Yup.number().required('Supplier wajib diisi!'),
plat_nomor: Yup.string().required('Plat nomor wajib diisi!'),
no_surat_jalan: Yup.string().required('No surat jalan wajib diisi!'),
dokumen: Yup.mixed().required('Dokumen wajib diisi!'),
biaya_ekspedisi: Yup.number()
.required('Biaya ekspedisi wajib diisi!')
.min(0, 'Biaya minimal 0!')
.typeError('Biaya harus berupa angka!'),
nama_sopir: Yup.string().required('Nama sopir wajib diisi!'),
})
)
.optional()
.default([]),
}); });
export const UpdateMovementFormSchema = MovementFormSchema; export const UpdateMovementFormSchema = MovementFormSchema;
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { FieldArray, FormikProvider, useFormik } from 'formik'; import { useFormik } from 'formik';
import useSWR from 'swr'; import useSWR from 'swr';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
@@ -21,6 +21,8 @@ import {
MovementFormValues, MovementFormValues,
UpdateMovementFormSchema, UpdateMovementFormSchema,
getMovementFormInitialValues, getMovementFormInitialValues,
ProductSchema,
EkspedisiSchema,
} from '@/components/pages/inventory/movement/form/MovementForm.schema'; } from '@/components/pages/inventory/movement/form/MovementForm.schema';
import { useMovementFormHandlers } from './useMovementFormHandlers'; import { useMovementFormHandlers } from './useMovementFormHandlers';
import { import {
@@ -91,6 +93,72 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
}, },
}); });
const addProduct = () => {
const newProducts = [
...(formik.values.product || []),
{
product: null,
product_id: 0,
qty_product: 0,
},
];
formik.setFieldValue('product', newProducts);
};
const removeProduct = (index: number) => {
const newProducts = formik.values.product?.filter(
(_, idx) => idx !== index
);
formik.setFieldValue('product', newProducts);
};
const addEkspedisi = () => {
const newEkspedisi = [
...(formik.values.ekspedisi || []),
{
product: null,
product_id: 0,
qty: 0,
supplier: null,
supplier_id: 0,
plat_nomor: '',
no_surat_jalan: '',
dokumen: '',
biaya_ekspedisi: 0,
nama_sopir: '',
},
];
formik.setFieldValue('ekspedisi', newEkspedisi);
};
const removeEkspedisi = (index: number) => {
const newEkspedisi = formik.values.ekspedisi?.filter(
(_, idx) => idx !== index
);
formik.setFieldValue('ekspedisi', newEkspedisi);
};
const isRepeaterInputError = <T extends 'product' | 'ekspedisi'>(
arrayName: T,
column: T extends 'product' ? keyof ProductSchema : keyof EkspedisiSchema,
idx: number
) => {
if (
!formik.touched[arrayName] ||
!Array.isArray(formik.touched[arrayName])
) {
return false;
}
const touchedField = formik.touched[arrayName]?.[idx]?.[column as string];
const errorField = formik.errors[arrayName]?.[idx] as Record<
string,
unknown
>;
return touchedField && Boolean(errorField?.[column as string]);
};
// Warehouse selection // Warehouse selection
const [warehouseSelectInputValue, setWarehouseSelectInputValue] = const [warehouseSelectInputValue, setWarehouseSelectInputValue] =
useState(''); useState('');
@@ -139,7 +207,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
title='Movement' title='Movement'
backUrl='/inventory/movement' backUrl='/inventory/movement'
/> />
<FormikProvider value={formik}>
<form <form
onSubmit={formik.handleSubmit} onSubmit={formik.handleSubmit}
onReset={formik.handleReset} onReset={formik.handleReset}
@@ -243,34 +310,29 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<div className='card bg-base-100 shadow mb-4'> <div className='card bg-base-100 shadow mb-4'>
<div className='card-body'> <div className='card-body'>
<h2 className='card-title mb-4'>Produk</h2> <h2 className='card-title mb-4'>Produk</h2>
<FieldArray name='product'> <div className='overflow-x-auto'>
{({ push, remove }) => (
<>
<table className='table'> <table className='table'>
<thead> <thead>
<tr> <tr>
<th>Produk</th> <th>Produk</th>
<th>Qty</th> <th>Qty</th>
<th>Aksi</th> {type !== 'detail' && <th>Aksi</th>}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{formik.values.product?.map((_, index) => ( {formik.values.product?.map((product, idx) => (
<tr key={index}> <tr key={idx}>
<td> <td>
<SelectInput <SelectInput
required required
value={ value={product.product ?? undefined}
formik.values.product?.[index]?.product ??
undefined
}
onChange={(val) => { onChange={(val) => {
formik.setFieldValue( formik.setFieldValue(
`product.${index}.product`, `product.${idx}.product`,
val val
); );
formik.setFieldValue( formik.setFieldValue(
`product.${index}.product_id`, `product.${idx}.product_id`,
(val as OptionType)?.value (val as OptionType)?.value
); );
}} }}
@@ -279,61 +341,63 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
isLoading={isLoadingProducts} isLoading={isLoadingProducts}
isDisabled={type === 'detail'} isDisabled={type === 'detail'}
isClearable isClearable
isError={isRepeaterInputError(
'product',
'product',
idx
)}
/> />
</td> </td>
<td> <td>
<TextInput <TextInput
required required
name={`product.${index}.qty_product`}
type='number' type='number'
value={ name={`product.${idx}.qty_product`}
formik.values.product?.[index] value={product.qty_product ?? ''}
?.qty_product ?? '' onChange={formik.handleChange}
} onBlur={formik.handleBlur}
onChange={(e) => isError={isRepeaterInputError(
formik.setFieldValue( 'product',
`product.${index}.qty_product`, 'qty_product',
e.target.value idx
) )}
}
readOnly={type === 'detail'} readOnly={type === 'detail'}
className={{
wrapper: 'w-full min-w-24',
}}
/> />
</td> </td>
<td>
{type !== 'detail' && ( {type !== 'detail' && (
<td>
<Button <Button
type='button' type='button'
color='error' color='error'
onClick={() => remove(index)} onClick={() => removeProduct(idx)}
> >
<Icon icon='material-symbols:delete-outline' /> <Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
/>
</Button> </Button>
)}
</td> </td>
)}
</tr> </tr>
)) ?? []} ))}
</tbody> </tbody>
</table> </table>
</div>
{type !== 'detail' && ( {type !== 'detail' && (
<Button <Button
type='button' type='button'
color='primary' color='success'
onClick={() => onClick={addProduct}
push({ className='w-fit mx-auto mt-4'
product: null,
product_id: 0,
qty_product: 0,
})
}
className='mt-4'
> >
<Icon icon='ic:round-plus' /> <Icon icon='ic:round-plus' width={24} height={24} />
Tambah Produk Tambah Produk
</Button> </Button>
)} )}
</>
)}
</FieldArray>
</div> </div>
</div> </div>
@@ -341,9 +405,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<div className='card bg-base-100 shadow mb-4'> <div className='card bg-base-100 shadow mb-4'>
<div className='card-body'> <div className='card-body'>
<h2 className='card-title mb-4'>Ekspedisi</h2> <h2 className='card-title mb-4'>Ekspedisi</h2>
<FieldArray name='ekspedisi'> <div className='overflow-x-auto'>
{({ push, remove }) => (
<>
<table className='table'> <table className='table'>
<thead> <thead>
<tr> <tr>
@@ -355,26 +417,23 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<th>Dokumen</th> <th>Dokumen</th>
<th>Biaya Ekspedisi</th> <th>Biaya Ekspedisi</th>
<th>Nama Sopir</th> <th>Nama Sopir</th>
<th>Aksi</th> {type !== 'detail' && <th>Aksi</th>}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{formik.values.ekspedisi?.map((ekspedisi, index) => ( {formik.values.ekspedisi?.map((ekspedisi, idx) => (
<tr key={index}> <tr key={idx}>
<td> <td>
<SelectInput <SelectInput
required required
value={ value={ekspedisi.product ?? undefined}
formik.values.ekspedisi?.[index]?.product ??
undefined
}
onChange={(val) => { onChange={(val) => {
formik.setFieldValue( formik.setFieldValue(
`ekspedisi.${index}.product`, `ekspedisi.${idx}.product`,
val val
); );
formik.setFieldValue( formik.setFieldValue(
`ekspedisi.${index}.product_id`, `ekspedisi.${idx}.product_id`,
(val as OptionType)?.value (val as OptionType)?.value
); );
}} }}
@@ -383,39 +442,43 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
isLoading={isLoadingProducts} isLoading={isLoadingProducts}
isDisabled={type === 'detail'} isDisabled={type === 'detail'}
isClearable isClearable
isError={isRepeaterInputError(
'ekspedisi',
'product',
idx
)}
/> />
</td> </td>
<td> <td>
<TextInput <TextInput
name={`ekspedisi.${index}.qty`}
required required
type='number' type='number'
value={ name={`ekspedisi.${idx}.qty`}
formik.values.ekspedisi?.[index]?.qty ?? '' value={ekspedisi.qty ?? ''}
} onChange={formik.handleChange}
onChange={(e) => onBlur={formik.handleBlur}
formik.setFieldValue( isError={isRepeaterInputError(
`ekspedisi.${index}.qty`, 'ekspedisi',
e.target.value 'qty',
) idx
} )}
readOnly={type === 'detail'} readOnly={type === 'detail'}
className={{
wrapper: 'w-full min-w-24',
}}
/> />
</td> </td>
<td> <td>
<SelectInput <SelectInput
required required
value={ value={ekspedisi.supplier ?? undefined}
formik.values.ekspedisi?.[index]
?.supplier ?? undefined
}
onChange={(val) => { onChange={(val) => {
formik.setFieldValue( formik.setFieldValue(
`ekspedisi.${index}.supplier`, `ekspedisi.${idx}.supplier`,
val val
); );
formik.setFieldValue( formik.setFieldValue(
`ekspedisi.${index}.supplier_id`, `ekspedisi.${idx}.supplier_id`,
(val as OptionType)?.value (val as OptionType)?.value
); );
}} }}
@@ -424,136 +487,142 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
isLoading={isLoadingSuppliers} isLoading={isLoadingSuppliers}
isDisabled={type === 'detail'} isDisabled={type === 'detail'}
isClearable isClearable
isError={isRepeaterInputError(
'ekspedisi',
'supplier',
idx
)}
/> />
</td> </td>
<td> <td>
<TextInput <TextInput
name={`ekspedisi.${index}.plat_nomor`}
required required
value={ name={`ekspedisi.${idx}.plat_nomor`}
formik.values.ekspedisi?.[index] value={ekspedisi.plat_nomor ?? ''}
?.plat_nomor ?? '' onChange={formik.handleChange}
} onBlur={formik.handleBlur}
onChange={(e) => isError={isRepeaterInputError(
formik.setFieldValue( 'ekspedisi',
`ekspedisi.${index}.plat_nomor`, 'plat_nomor',
e.target.value idx
) )}
}
readOnly={type === 'detail'} readOnly={type === 'detail'}
className={{
wrapper: 'w-full min-w-24',
}}
/> />
</td> </td>
<td> <td>
<TextInput <TextInput
name={`ekspedisi.${index}.no_surat_jalan`}
required required
value={ name={`ekspedisi.${idx}.no_surat_jalan`}
formik.values.ekspedisi?.[index] value={ekspedisi.no_surat_jalan ?? ''}
?.no_surat_jalan ?? '' onChange={formik.handleChange}
} onBlur={formik.handleBlur}
onChange={(e) => isError={isRepeaterInputError(
formik.setFieldValue( 'ekspedisi',
`ekspedisi.${index}.no_surat_jalan`, 'no_surat_jalan',
e.target.value idx
) )}
}
readOnly={type === 'detail'} readOnly={type === 'detail'}
className={{
wrapper: 'w-full min-w-24',
}}
/> />
</td> </td>
<td> <td>
<TextInput <TextInput
name={`ekspedisi.${index}.dokumen`}
required required
type='file' type='file'
name={`ekspedisi.${idx}.dokumen`}
onChange={(e) => { onChange={(e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
formik.setFieldValue( formik.setFieldValue(
`ekspedisi.${index}.dokumen`, `ekspedisi.${idx}.dokumen`,
file file
); );
} }
}} }}
isError={isRepeaterInputError(
'ekspedisi',
'dokumen',
idx
)}
readOnly={type === 'detail'} readOnly={type === 'detail'}
className={{
wrapper: 'w-full min-w-24',
}}
/> />
</td> </td>
<td> <td>
<TextInput <TextInput
name={`ekspedisi.${index}.biaya_ekspedisi`}
required required
type='number' type='number'
value={ name={`ekspedisi.${idx}.biaya_ekspedisi`}
formik.values.ekspedisi?.[index] value={ekspedisi.biaya_ekspedisi ?? ''}
?.biaya_ekspedisi ?? '' onChange={formik.handleChange}
} onBlur={formik.handleBlur}
onChange={(e) => isError={isRepeaterInputError(
formik.setFieldValue( 'ekspedisi',
`ekspedisi.${index}.biaya_ekspedisi`, 'biaya_ekspedisi',
e.target.value idx
) )}
}
readOnly={type === 'detail'} readOnly={type === 'detail'}
className={{
wrapper: 'w-full min-w-24',
}}
/> />
</td> </td>
<td> <td>
<TextInput <TextInput
name={`ekspedisi.${index}.nama_sopir`}
required required
value={ name={`ekspedisi.${idx}.nama_sopir`}
formik.values.ekspedisi?.[index] value={ekspedisi.nama_sopir ?? ''}
?.nama_sopir ?? '' onChange={formik.handleChange}
} onBlur={formik.handleBlur}
onChange={(e) => isError={isRepeaterInputError(
formik.setFieldValue( 'ekspedisi',
`ekspedisi.${index}.nama_sopir`, 'nama_sopir',
e.target.value idx
) )}
}
readOnly={type === 'detail'} readOnly={type === 'detail'}
className={{
wrapper: 'w-full min-w-24',
}}
/> />
</td> </td>
<td>
{type !== 'detail' && ( {type !== 'detail' && (
<td>
<Button <Button
type='button' type='button'
color='error' color='error'
onClick={() => remove(index)} onClick={() => removeEkspedisi(idx)}
> >
<Icon icon='material-symbols:delete-outline' /> <Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
/>
</Button> </Button>
)}
</td> </td>
)}
</tr> </tr>
)) ?? []} ))}
</tbody> </tbody>
</table> </table>
</div>
{type !== 'detail' && ( {type !== 'detail' && (
<Button <Button
type='button' type='button'
color='primary' color='success'
onClick={() => onClick={addEkspedisi}
push({ className='w-fit mx-auto mt-4'
product: null,
product_id: 0,
qty: 0,
supplier: null,
supplier_id: 0,
plat_nomor: '',
no_surat_jalan: '',
dokumen: '',
biaya_ekspedisi: 0,
nama_sopir: '',
})
}
className='mt-4'
> >
<Icon icon='ic:round-plus' /> <Icon icon='ic:round-plus' width={24} height={24} />
Tambah Ekspedisi Tambah Ekspedisi
</Button> </Button>
)} )}
</>
)}
</FieldArray>
</div> </div>
</div> </div>
@@ -580,7 +649,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
</div> </div>
)} )}
</form> </form>
</FormikProvider>
</section> </section>
{type !== 'add' && ( {type !== 'add' && (