feat(FE-62,63,65): refactor MovementForm and related types for improved clarity and consistency

This commit is contained in:
rstubryan
2025-10-15 12:00:17 +07:00
parent cf78687315
commit df73ee1fdf
4 changed files with 335 additions and 373 deletions
+1 -6
View File
@@ -76,12 +76,7 @@ export const FormActions = <T,>({
color='primary' color='primary'
className='px-4' className='px-4'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={ disabled={disableSubmit || !formik.isValid || formik.isSubmitting}
disableSubmit ||
!formik.isValid ||
!formik.dirty ||
formik.isSubmitting
}
> >
Submit Submit
</Button> </Button>
@@ -7,27 +7,28 @@ export type ProductSchema = {
label: string; label: string;
} | null; } | null;
product_id: number; product_id: number;
qty_product: number; product_qty: number;
}; };
export type EkspedisiSchema = { export type DeliverySchema = {
product: { delivery_cost: number;
value: number; delivery_cost_per_item?: number | undefined;
label: string; document: string | File;
} | null; driver_name: string;
product_id: number; vehicle_plate: string;
qty: number;
supplier: { supplier: {
value: number; value: number;
label: string; label: string;
} | null; } | null;
supplier_id: number; supplier_id: number;
plat_nomor: string; products: {
no_surat_jalan: string; product: {
dokumen: string | File; value: number;
biaya_ekspedisi: number; label: string;
biaya_ekspedisi_per_item?: number | undefined; } | null;
nama_sopir: string; product_id: number;
product_qty: number;
}[];
}; };
const ProductObjectSchema: Yup.ObjectSchema<ProductSchema> = Yup.object({ const ProductObjectSchema: Yup.ObjectSchema<ProductSchema> = Yup.object({
@@ -36,40 +37,34 @@ const ProductObjectSchema: Yup.ObjectSchema<ProductSchema> = Yup.object({
label: Yup.string().required(), label: Yup.string().required(),
}).nullable(), }).nullable(),
product_id: Yup.number().required('Produk wajib diisi!'), product_id: Yup.number().required('Produk wajib diisi!'),
qty_product: Yup.number() product_qty: Yup.number()
.required('Qty wajib diisi!') .required('Qty wajib diisi!')
.min(1, 'Qty minimal 1!') .min(1, 'Qty minimal 1!')
.typeError('Qty harus berupa angka!'), .typeError('Qty harus berupa angka!'),
}); });
const EkspedisiObjectSchema: Yup.ObjectSchema<EkspedisiSchema> = Yup.object({ const DeliveryProductObjectSchema = Yup.object({
product: Yup.object({ product: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
}).nullable(), }).nullable(),
product_id: Yup.number().required('Produk wajib diisi!'), product_id: Yup.number().required('Produk wajib diisi!'),
qty: Yup.number() product_qty: Yup.number()
.required('Qty wajib diisi!') .required('Qty wajib diisi!')
.min(1, 'Qty minimal 1!') .min(1, 'Qty minimal 1!')
.typeError('Qty harus berupa angka!') .typeError('Qty harus berupa angka!'),
.test('max-product-qty', 'Qty melebihi stok produk!', function (value) { });
const { product_id } = this.parent;
const products = (this.options.context?.product ?? []) as { const DeliveryObjectSchema: Yup.ObjectSchema<DeliverySchema> = Yup.object({
product_id: number; delivery_cost: Yup.number()
qty_product: number; .required('Biaya pengiriman wajib diisi!')
}[]; .min(0, 'Biaya minimal 0!')
const product = products.find((p) => p.product_id === product_id); .typeError('Biaya harus berupa angka!'),
if (!product) return true; delivery_cost_per_item: Yup.number()
return (value ?? 0) <= Number(product.qty_product); .transform((value) => (isNaN(value) ? undefined : value))
}), .min(0, 'Biaya per item minimal 0!')
supplier: Yup.object({ .typeError('Biaya per item harus berupa angka!'),
value: Yup.number().min(1).required(), document: Yup.mixed<string | File>()
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!') .required('Dokumen wajib diisi!')
.test( .test(
'fileType', 'fileType',
@@ -86,44 +81,44 @@ const EkspedisiObjectSchema: Yup.ObjectSchema<EkspedisiSchema> = Yup.object({
typeof value === 'string' || typeof value === 'string' ||
(value instanceof File && value.size <= 2 * 1024 * 1024) (value instanceof File && value.size <= 2 * 1024 * 1024)
), ),
biaya_ekspedisi: Yup.number() driver_name: Yup.string().required('Nama sopir wajib diisi!'),
.required('Biaya ekspedisi wajib diisi!') vehicle_plate: Yup.string().required('Plat nomor wajib diisi!'),
.min(0, 'Biaya minimal 0!') supplier: Yup.object({
.typeError('Biaya harus berupa angka!'), value: Yup.number().min(1).required(),
biaya_ekspedisi_per_item: Yup.number() label: Yup.string().required(),
.transform((value) => (isNaN(value) ? undefined : value)) }).nullable(),
.min(0, 'Biaya per item minimal 0!') supplier_id: Yup.number().required('Supplier wajib diisi!'),
.typeError('Biaya per item harus berupa angka!') products: Yup.array()
.optional() .of(DeliveryProductObjectSchema)
.default(undefined), .min(1, 'Minimal harus ada 1 produk!')
nama_sopir: Yup.string().required('Nama sopir wajib diisi!'), .required('Produk wajib diisi!'),
}); });
export const MovementFormSchema = Yup.object({ export const MovementFormSchema = Yup.object({
alasan_transfer: Yup.string().required('Alasan transfer wajib diisi!'), transfer_reason: Yup.string().required('Alasan transfer wajib diisi!'),
tanggal_transfer: Yup.string().required('Tanggal transfer wajib diisi!'), transfer_date: Yup.string().required('Tanggal transfer wajib diisi!'),
warehouse_asal: Yup.object({ source_warehouse: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
}).nullable(), }).nullable(),
warehouse_asal_id: Yup.number() source_warehouse_id: Yup.number()
.required('Gudang asal wajib diisi!') .required('Gudang asal wajib diisi!')
.typeError('Gudang asal wajib diisi!'), .typeError('Gudang asal wajib diisi!'),
warehouse_tujuan: Yup.object({ destination_warehouse: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
}).nullable(), }).nullable(),
warehouse_tujuan_id: Yup.number() destination_warehouse_id: Yup.number()
.required('Gudang tujuan wajib diisi!') .required('Gudang tujuan wajib diisi!')
.typeError('Gudang tujuan wajib diisi!'), .typeError('Gudang tujuan wajib diisi!'),
product: Yup.array() products: Yup.array()
.of(ProductObjectSchema) .of(ProductObjectSchema)
.min(1, 'Minimal harus ada 1 produk!') .min(1, 'Minimal harus ada 1 produk!')
.required('Produk wajib diisi!'), .required('Produk wajib diisi!'),
ekspedisi: Yup.array() deliveries: Yup.array()
.of(EkspedisiObjectSchema) .of(DeliveryObjectSchema)
.min(1, 'Minimal harus ada 1 ekspedisi!') .min(1, 'Minimal harus ada 1 pengiriman!')
.required('Ekspedisi wajib diisi!'), .required('Pengiriman wajib diisi!'),
}); });
export const UpdateMovementFormSchema = MovementFormSchema; export const UpdateMovementFormSchema = MovementFormSchema;
@@ -133,40 +128,41 @@ export type MovementFormValues = Yup.InferType<typeof MovementFormSchema>;
export const getMovementFormInitialValues = ( export const getMovementFormInitialValues = (
initialValues?: Movement initialValues?: Movement
): MovementFormValues => ({ ): MovementFormValues => ({
alasan_transfer: initialValues?.alasan_transfer ?? '', transfer_reason: initialValues?.transfer_reason ?? '',
tanggal_transfer: initialValues?.tanggal_transfer ?? '', transfer_date: initialValues?.transfer_date ?? '',
warehouse_asal: initialValues?.warehouse_asal source_warehouse: initialValues?.source_warehouse
? { ? {
value: initialValues.warehouse_asal.id, value: initialValues.source_warehouse.id,
label: initialValues.warehouse_asal.name, label: initialValues.source_warehouse.name,
} }
: null, : null,
warehouse_asal_id: initialValues?.warehouse_asal?.id ?? 0, source_warehouse_id: initialValues?.source_warehouse?.id ?? 0,
warehouse_tujuan: initialValues?.warehouse_tujuan destination_warehouse: initialValues?.destination_warehouse
? { ? {
value: initialValues.warehouse_tujuan.id, value: initialValues.destination_warehouse.id,
label: initialValues.warehouse_tujuan.name, label: initialValues.destination_warehouse.name,
} }
: null, : null,
warehouse_tujuan_id: initialValues?.warehouse_tujuan?.id ?? 0, destination_warehouse_id: initialValues?.destination_warehouse?.id ?? 0,
product: products:
initialValues?.product?.map((p) => ({ initialValues?.products?.map((p) => ({
product: { value: p.product.id, label: p.product.name }, product: { value: p.product.id, label: p.product.name },
product_id: p.product.id, product_id: p.product.id,
qty_product: p.qty_product, product_qty: p.product_qty,
})) ?? [], })) ?? [],
ekspedisi: deliveries:
initialValues?.ekspedisi?.map((e) => ({ initialValues?.deliveries?.map((d) => ({
product: { value: e.product_id, label: '' }, delivery_cost: d.delivery_cost,
product_id: e.product_id, delivery_cost_per_item: d.delivery_cost_per_item,
qty: e.qty, document: d.document,
supplier: { value: e.supplier.id, label: e.supplier.name }, driver_name: d.driver_name,
supplier_id: e.supplier.id, vehicle_plate: d.vehicle_plate,
plat_nomor: e.plat_nomor, supplier: { value: d.supplier.id, label: d.supplier.name },
no_surat_jalan: e.no_surat_jalan, supplier_id: d.supplier.id,
dokumen: e.dokumen, products: d.products.map((p) => ({
biaya_ekspedisi: e.biaya_ekspedisi, product: { value: p.product.id, label: p.product.name },
biaya_ekspedisi_per_item: e.biaya_ekspedisi, product_id: p.product.id,
nama_sopir: e.nama_sopir, product_qty: p.product_qty,
})),
})) ?? [], })) ?? [],
}); });
@@ -22,7 +22,7 @@ import {
UpdateMovementFormSchema, UpdateMovementFormSchema,
getMovementFormInitialValues, getMovementFormInitialValues,
ProductSchema, ProductSchema,
EkspedisiSchema, DeliverySchema,
} 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 {
@@ -41,7 +41,7 @@ interface MovementFormProps {
const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
const [, setMovementFormErrorMessage] = useState(''); const [, setMovementFormErrorMessage] = useState('');
const [selectedProducts, setSelectedProducts] = useState<number[]>([]); const [selectedProducts, setSelectedProducts] = useState<number[]>([]);
const [selectedEkspedisi, setSelectedEkspedisi] = useState<number[]>([]); const [selectedDeliveries, setSelectedDeliveries] = useState<number[]>([]);
const { const {
deleteModal, deleteModal,
@@ -64,31 +64,30 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
type === 'edit' ? UpdateMovementFormSchema : MovementFormSchema, type === 'edit' ? UpdateMovementFormSchema : MovementFormSchema,
validateOnChange: true, validateOnChange: true,
validateOnBlur: true, validateOnBlur: true,
validateOnMount: true, validateOnMount: false,
enableReinitialize: true,
onSubmit: async (values) => { onSubmit: async (values) => {
setMovementFormErrorMessage(''); setMovementFormErrorMessage('');
const payload: CreateMovementPayload = { const payload: CreateMovementPayload = {
alasan_transfer: values.alasan_transfer, transfer_reason: values.transfer_reason,
tanggal_transfer: values.tanggal_transfer, transfer_date: values.transfer_date,
warehouse_asal_id: values.warehouse_asal_id, source_warehouse_id: values.source_warehouse_id,
warehouse_tujuan_id: values.warehouse_tujuan_id, destination_warehouse_id: values.destination_warehouse_id,
product: (values.product ?? []).map((p) => ({ products: values.products.map((p) => ({
product_id: p.product_id, product_id: p.product_id,
qty_product: p.qty_product, product_qty: p.product_qty,
})),
deliveries: values.deliveries.map((d) => ({
delivery_cost: d.delivery_cost,
delivery_cost_per_item: d.delivery_cost_per_item ?? 0,
document: d.document instanceof File ? d.document : d.document,
driver_name: d.driver_name,
vehicle_plate: d.vehicle_plate,
supplier_id: d.supplier_id,
products: d.products.map((p) => ({
product_id: p.product_id,
product_qty: p.product_qty,
})), })),
ekspedisi: (values.ekspedisi ?? []).map((e) => ({
product_id: e.product_id,
qty: e.qty,
supplier_id: e.supplier_id,
plat_nomor: e.plat_nomor,
no_surat_jalan: e.no_surat_jalan,
dokumen:
e.dokumen instanceof File ? e.dokumen : (e.dokumen as string),
biaya_ekspedisi: e.biaya_ekspedisi,
biaya_ekspedisi_per_item: e.qty
? e.biaya_ekspedisi / e.qty
: e.biaya_ekspedisi,
nama_sopir: e.nama_sopir,
})), })),
}; };
@@ -105,65 +104,67 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
const addProduct = () => { const addProduct = () => {
const newProducts = [ const newProducts = [
...(formik.values.product || []), ...(formik.values.products || []),
{ {
product: null, product: null,
product_id: 0, product_id: 0,
qty_product: 0, product_qty: 0,
}, },
]; ];
formik.setFieldValue('product', newProducts); formik.setFieldValue('products', newProducts);
}; };
const removeProduct = useCallback( const removeProduct = useCallback(
(i: number) => { (i: number) => {
const updatedProducts = const updatedProducts =
formik.values.product?.reduce((acc: ProductSchema[], item, index) => { formik.values.products?.reduce((acc: ProductSchema[], item, index) => {
if (index !== i) { if (index !== i) {
acc.push(item); acc.push(item);
} }
return acc; return acc;
}, []) ?? []; }, []) ?? [];
formik.setFieldValue('product', updatedProducts); formik.setFieldValue('products', updatedProducts);
}, },
[formik] [formik]
); );
const bulkRemoveProduct = useCallback(() => { const bulkRemoveProduct = useCallback(() => {
const updatedProducts = const updatedProducts =
formik.values.product?.filter( formik.values.products?.filter(
(_, idx) => !selectedProducts.includes(idx) (_, idx) => !selectedProducts.includes(idx)
) ?? []; ) ?? [];
formik.setFieldValue('product', updatedProducts); formik.setFieldValue('products', updatedProducts);
setSelectedProducts([]); setSelectedProducts([]);
}, [formik, selectedProducts]); }, [formik, selectedProducts]);
const addEkspedisi = () => { const addDelivery = () => {
const newEkspedisi = [ formik.setFieldValue('deliveries', [
...(formik.values.ekspedisi || []), ...(formik.values.deliveries || []),
{
delivery_cost: 0,
delivery_cost_per_item: 0,
document: '',
driver_name: '',
vehicle_plate: '',
supplier: null,
supplier_id: 0,
products: [
{ {
product: null, product: null,
product_id: 0, product_id: 0,
qty: 0, product_qty: 0,
supplier: null,
supplier_id: 0,
plat_nomor: '',
no_surat_jalan: '',
dokumen: '',
biaya_ekspedisi: 0,
biaya_ekspedisi_per_item: 0,
nama_sopir: '',
}, },
]; ],
formik.setFieldValue('ekspedisi', newEkspedisi); },
]);
}; };
const removeEkspedisi = useCallback( const removeDelivery = useCallback(
(i: number) => { (i: number) => {
const updatedEkspedisi = const updatedDeliveries =
formik.values.ekspedisi?.reduce( formik.values.deliveries?.reduce(
(acc: EkspedisiSchema[], item, index) => { (acc: DeliverySchema[], item, index) => {
if (index !== i) { if (index !== i) {
acc.push(item); acc.push(item);
} }
@@ -172,23 +173,23 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
[] []
) ?? []; ) ?? [];
formik.setFieldValue('ekspedisi', updatedEkspedisi); formik.setFieldValue('deliveries', updatedDeliveries);
}, },
[formik] [formik]
); );
const bulkRemoveEkspedisi = useCallback(() => { const bulkRemoveDelivery = useCallback(() => {
const updatedEkspedisi = const updatedDeliveries =
formik.values.ekspedisi?.filter( formik.values.deliveries?.filter(
(_, idx) => !selectedEkspedisi.includes(idx) (_, idx) => !selectedDeliveries.includes(idx)
) ?? []; ) ?? [];
formik.setFieldValue('ekspedisi', updatedEkspedisi); formik.setFieldValue('deliveries', updatedDeliveries);
setSelectedEkspedisi([]); setSelectedDeliveries([]);
}, [formik, selectedEkspedisi]); }, [formik, selectedDeliveries]);
const isRepeaterInputError = <T extends 'product' | 'ekspedisi'>( const isRepeaterInputError = <T extends 'products' | 'deliveries'>(
arrayName: T, arrayName: T,
column: T extends 'product' ? keyof ProductSchema : keyof EkspedisiSchema, column: T extends 'products' ? keyof ProductSchema : keyof DeliverySchema,
idx: number idx: number
) => { ) => {
if ( if (
@@ -260,57 +261,80 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
? suppliers?.data.map((s) => ({ value: s.id, label: s.name })) ? suppliers?.data.map((s) => ({ value: s.id, label: s.name }))
: []; : [];
const { setValues: formikSetValues } = formik;
useEffect(() => { useEffect(() => {
formikSetValues(formikInitialValues); formik.values.deliveries?.forEach((delivery, idx) => {
}, [formikSetValues, formikInitialValues]); const productQty = delivery.products.reduce(
(sum, p) => sum + p.product_qty,
useEffect(() => { 0
formik.values.ekspedisi?.forEach((eks, idx) => { );
if (eks.qty && eks.biaya_ekspedisi) { if (productQty && delivery.delivery_cost) {
const perItem = eks.biaya_ekspedisi / eks.qty; const perItem = delivery.delivery_cost / productQty;
formik.setFieldValue( formik.setFieldValue(
`ekspedisi.${idx}.biaya_ekspedisi_per_item`, `deliveries.${idx}.delivery_cost_per_item`,
perItem perItem
); );
} }
}); });
}, [formik.values.ekspedisi]); }, [formik.values.deliveries]);
const getFilteredProductOptions = useCallback(() => { const getFilteredProductOptions = useCallback(() => {
return ( return (
formik.values.product formik.values.products
?.filter((p) => p.product) ?.filter((p) => p.product)
.map((p) => ({ .map((p) => ({
value: p.product_id, value: p.product_id,
label: (p.product as OptionType)?.label, label: (p.product as OptionType)?.label,
})) ?? [] })) ?? []
); );
}, [formik.values.product]); }, [formik.values.products]);
const validateEkspedisiQty = (ekspedisiIdx: number, qty: number) => { const validateDeliveryQty = useCallback(
const productId = formik.values.ekspedisi?.[ekspedisiIdx]?.product_id; (deliveryIdx: number, deliveryProductIdx: number, qty: number) => {
const delivery = formik.values.deliveries?.[deliveryIdx];
if (!delivery) return true;
const deliveryProduct = delivery.products[deliveryProductIdx];
if (!deliveryProduct) return true;
const productId = deliveryProduct.product_id;
if (!productId) return true; if (!productId) return true;
const relatedProduct = formik.values.product?.find(
const relatedProduct = formik.values.products?.find(
(p) => p.product_id === productId (p) => p.product_id === productId
); );
if (!relatedProduct) return true; if (!relatedProduct) return true;
const totalQtyUsed =
formik.values.ekspedisi?.reduce((total, eks, i) => {
if (eks.product_id === productId && i !== ekspedisiIdx) {
return total + (Number(eks.qty) || 0);
}
return total;
}, 0) || 0;
return totalQtyUsed + qty <= Number(relatedProduct.qty_product);
};
const invalidQtyRows = const totalQtyUsed =
formik.values.ekspedisi?.map((eks, idx) => { formik.values.deliveries?.reduce((total, d) => {
const qty = Number(eks.qty) || 0; const productQty = d.products.reduce((sum, p, pIdx) => {
return !validateEkspedisiQty(idx, qty); if (
}) ?? []; p.product_id === productId &&
!(d === delivery && pIdx === deliveryProductIdx)
) {
return sum + (Number(p.product_qty) || 0);
}
return sum;
}, 0);
return total + productQty;
}, 0) || 0;
return totalQtyUsed + qty <= Number(relatedProduct.product_qty);
},
[formik.values.deliveries, formik.values.products]
);
const invalidQtyRows = useMemo(
() =>
formik.values.deliveries?.flatMap((delivery, deliveryIdx) =>
delivery.products.map((product, productIdx) => {
const qty = Number(product.product_qty) || 0;
return !validateDeliveryQty(deliveryIdx, productIdx, qty);
})
) ?? [],
[formik.values.deliveries, formik.values.products, validateDeliveryQty]
);
const hasInvalidQty = invalidQtyRows.some(Boolean);
return ( return (
<> <>
@@ -332,30 +356,30 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<TextInput <TextInput
required required
label='Alasan Transfer' label='Alasan Transfer'
name='alasan_transfer' name='transfer_reason'
value={formik.values.alasan_transfer} value={formik.values.transfer_reason}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
isError={ isError={
formik.touched.alasan_transfer && formik.touched.transfer_reason &&
Boolean(formik.errors.alasan_transfer) Boolean(formik.errors.transfer_reason)
} }
errorMessage={formik.errors.alasan_transfer} errorMessage={formik.errors.transfer_reason}
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
<TextInput <TextInput
required required
label='Tanggal Transfer' label='Tanggal Transfer'
type='date' type='date'
name='tanggal_transfer' name='transfer_date'
value={formik.values.tanggal_transfer} value={formik.values.transfer_date}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
isError={ isError={
formik.touched.tanggal_transfer && formik.touched.transfer_date &&
Boolean(formik.errors.tanggal_transfer) Boolean(formik.errors.transfer_date)
} }
errorMessage={formik.errors.tanggal_transfer} errorMessage={formik.errors.transfer_date}
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
</div> </div>
@@ -370,11 +394,11 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<SelectInput <SelectInput
required required
label='Gudang' label='Gudang'
value={formik.values.warehouse_asal ?? undefined} value={formik.values.source_warehouse}
onChange={(val) => { onChange={(val) => {
formik.setFieldValue('warehouse_asal', val); formik.setFieldValue('source_warehouse', val);
formik.setFieldValue( formik.setFieldValue(
'warehouse_asal_id', 'source_warehouse_id',
(val as WarehouseOptionType)?.value (val as WarehouseOptionType)?.value
); );
}} }}
@@ -382,10 +406,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
onInputChange={setWarehouseSelectInputValue} onInputChange={setWarehouseSelectInputValue}
isLoading={isLoadingWarehouses} isLoading={isLoadingWarehouses}
isError={ isError={
formik.touched.warehouse_asal_id && formik.touched.source_warehouse_id &&
Boolean(formik.errors.warehouse_asal_id) Boolean(formik.errors.source_warehouse_id)
} }
errorMessage={formik.errors.warehouse_asal_id as string} errorMessage={formik.errors.source_warehouse_id as string}
isDisabled={type === 'detail'} isDisabled={type === 'detail'}
isClearable isClearable
/> />
@@ -394,9 +418,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<div className='space-y-4'> <div className='space-y-4'>
<TextInput <TextInput
label='Area' label='Area'
name='warehouse_asal_area' name='source_warehouse_area'
value={ value={
(formik.values.warehouse_asal as WarehouseOptionType) (formik.values.source_warehouse as WarehouseOptionType)
?.area || '-' ?.area || '-'
} }
readOnly readOnly
@@ -407,9 +431,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
/> />
<TextInput <TextInput
label='Lokasi' label='Lokasi'
name='warehouse_asal_location' name='source_warehouse_location'
value={ value={
(formik.values.warehouse_asal as WarehouseOptionType) (formik.values.source_warehouse as WarehouseOptionType)
?.location || '-' ?.location || '-'
} }
readOnly readOnly
@@ -428,11 +452,11 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<SelectInput <SelectInput
required required
label='Gudang' label='Gudang'
value={formik.values.warehouse_tujuan ?? undefined} value={formik.values.destination_warehouse}
onChange={(val) => { onChange={(val) => {
formik.setFieldValue('warehouse_tujuan', val); formik.setFieldValue('destination_warehouse', val);
formik.setFieldValue( formik.setFieldValue(
'warehouse_tujuan_id', 'destination_warehouse_id',
(val as WarehouseOptionType)?.value (val as WarehouseOptionType)?.value
); );
}} }}
@@ -440,10 +464,12 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
onInputChange={setWarehouseSelectInputValue} onInputChange={setWarehouseSelectInputValue}
isLoading={isLoadingWarehouses} isLoading={isLoadingWarehouses}
isError={ isError={
formik.touched.warehouse_tujuan_id && formik.touched.destination_warehouse_id &&
Boolean(formik.errors.warehouse_tujuan_id) Boolean(formik.errors.destination_warehouse_id)
}
errorMessage={
formik.errors.destination_warehouse_id as string
} }
errorMessage={formik.errors.warehouse_tujuan_id as string}
isDisabled={type === 'detail'} isDisabled={type === 'detail'}
isClearable isClearable
/> />
@@ -452,10 +478,12 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<div className='space-y-4'> <div className='space-y-4'>
<TextInput <TextInput
label='Area' label='Area'
name='warehouse_tujuan_area' name='destination_warehouse_area'
value={ value={
(formik.values.warehouse_tujuan as WarehouseOptionType) (
?.area || '-' formik.values
.destination_warehouse as WarehouseOptionType
)?.area || '-'
} }
readOnly readOnly
disabled disabled
@@ -465,10 +493,12 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
/> />
<TextInput <TextInput
label='Lokasi' label='Lokasi'
name='warehouse_tujuan_location' name='destination_warehouse_location'
value={ value={
(formik.values.warehouse_tujuan as WarehouseOptionType) (
?.location || '-' formik.values
.destination_warehouse as WarehouseOptionType
)?.location || '-'
} }
readOnly readOnly
disabled disabled
@@ -495,15 +525,16 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
type='checkbox' type='checkbox'
className='checkbox' className='checkbox'
checked={ checked={
formik.values.product?.length === formik.values.products?.length ===
selectedProducts.length && selectedProducts.length &&
formik.values.product?.length > 0 formik.values.products?.length > 0
} }
onChange={(e) => { onChange={(e) => {
if (e.target.checked) { if (e.target.checked) {
setSelectedProducts( setSelectedProducts(
formik.values.product?.map((_, idx) => idx) ?? formik.values.products?.map(
[] (_, idx) => idx
) ?? []
); );
} else { } else {
setSelectedProducts([]); setSelectedProducts([]);
@@ -518,7 +549,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{formik.values.product?.map((product, idx) => ( {formik.values.products?.map((product, idx) => (
<tr key={`product-row-${idx}-${product.product_id}`}> <tr key={`product-row-${idx}-${product.product_id}`}>
{type !== 'detail' && ( {type !== 'detail' && (
<td> <td>
@@ -547,11 +578,11 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
value={product.product ?? undefined} value={product.product ?? undefined}
onChange={(val) => { onChange={(val) => {
formik.setFieldValue( formik.setFieldValue(
`product.${idx}.product`, `products.${idx}.product`,
val val
); );
formik.setFieldValue( formik.setFieldValue(
`product.${idx}.product_id`, `products.${idx}.product_id`,
(val as OptionType)?.value (val as OptionType)?.value
); );
}} }}
@@ -560,20 +591,24 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
isLoading={isLoadingProducts} isLoading={isLoadingProducts}
isDisabled={type === 'detail'} isDisabled={type === 'detail'}
isClearable isClearable
{...isRepeaterInputError('product', 'product', idx)} {...isRepeaterInputError(
'products',
'product',
idx
)}
/> />
</td> </td>
<td> <td>
<TextInput <TextInput
required required
type='number' type='number'
name={`product.${idx}.qty_product`} name={`products.${idx}.product_qty`}
value={product.qty_product ?? ''} value={product.product_qty ?? ''}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
{...isRepeaterInputError( {...isRepeaterInputError(
'product', 'products',
'qty_product', 'product_qty',
idx idx
)} )}
readOnly={type === 'detail'} readOnly={type === 'detail'}
@@ -633,10 +668,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
</div> </div>
</div> </div>
{/* Ekspedisi table */} {/* Deliveries table */}
<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'>Pengiriman</h2>
<div className='overflow-x-auto'> <div className='overflow-x-auto'>
<table className='table'> <table className='table'>
<thead> <thead>
@@ -647,19 +682,19 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
type='checkbox' type='checkbox'
className='checkbox' className='checkbox'
checked={ checked={
formik.values.ekspedisi?.length === formik.values.deliveries?.length ===
selectedEkspedisi.length && selectedDeliveries.length &&
formik.values.ekspedisi?.length > 0 formik.values.deliveries?.length > 0
} }
onChange={(e) => { onChange={(e) => {
if (e.target.checked) { if (e.target.checked) {
setSelectedEkspedisi( setSelectedDeliveries(
formik.values.ekspedisi?.map( formik.values.deliveries?.map(
(_, idx) => idx (_, idx) => idx
) ?? [] ) ?? []
); );
} else { } else {
setSelectedEkspedisi([]); setSelectedDeliveries([]);
} }
}} }}
/> />
@@ -669,34 +704,31 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<th>Qty</th> <th>Qty</th>
<th>Supplier</th> <th>Supplier</th>
<th>Plat Nomor</th> <th>Plat Nomor</th>
<th>No Surat Jalan</th>
<th>Dokumen</th> <th>Dokumen</th>
<th>Biaya Ekspedisi (Rp.)</th> <th>Biaya Pengiriman (Rp.)</th>
<th>Biaya Ekspedisi / Item (Rp.)</th> <th>Biaya Per Item (Rp.)</th>
<th>Nama Sopir</th> <th>Nama Sopir</th>
{type !== 'detail' && <th>Aksi</th>} {type !== 'detail' && <th>Aksi</th>}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{formik.values.ekspedisi?.map((ekspedisi, idx) => ( {formik.values.deliveries?.map((delivery, idx) => (
<tr <tr key={`delivery-row-${idx}`}>
key={`ekspedisi-row-${idx}-${ekspedisi.product_id}-${ekspedisi.supplier_id}`}
>
{type !== 'detail' && ( {type !== 'detail' && (
<td> <td>
<input <input
type='checkbox' type='checkbox'
className='checkbox' className='checkbox'
checked={selectedEkspedisi.includes(idx)} checked={selectedDeliveries.includes(idx)}
onChange={(e) => { onChange={(e) => {
if (e.target.checked) { if (e.target.checked) {
setSelectedEkspedisi([ setSelectedDeliveries([
...selectedEkspedisi, ...selectedDeliveries,
idx, idx,
]); ]);
} else { } else {
setSelectedEkspedisi( setSelectedDeliveries(
selectedEkspedisi.filter((i) => i !== idx) selectedDeliveries.filter((i) => i !== idx)
); );
} }
}} }}
@@ -706,24 +738,18 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<td> <td>
<SelectInput <SelectInput
required required
value={ekspedisi.product ?? undefined} value={delivery.products[0]?.product ?? undefined}
onChange={(val) => { onChange={(val) => {
formik.setFieldValue( formik.setFieldValue(
`ekspedisi.${idx}.product`, `deliveries.${idx}.products.0.product`,
val val
); );
formik.setFieldValue( formik.setFieldValue(
`ekspedisi.${idx}.product_id`, `deliveries.${idx}.products.0.product_id`,
(val as OptionType)?.value (val as OptionType)?.value
); );
formik.setFieldValue(`ekspedisi.${idx}.qty`, '');
}} }}
options={getFilteredProductOptions()} options={getFilteredProductOptions()}
{...isRepeaterInputError(
'ekspedisi',
'product',
idx
)}
isDisabled={type === 'detail'} isDisabled={type === 'detail'}
isClearable isClearable
/> />
@@ -732,11 +758,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<TextInput <TextInput
required required
type='number' type='number'
name={`ekspedisi.${idx}.qty`} name={`deliveries.${idx}.products.0.product_qty`}
value={ekspedisi.qty ?? ''} value={delivery.products[0]?.product_qty ?? ''}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
{...isRepeaterInputError('ekspedisi', 'qty', idx)}
readOnly={type === 'detail'} readOnly={type === 'detail'}
className={{ className={{
wrapper: 'w-full min-w-24', wrapper: 'w-full min-w-24',
@@ -746,14 +771,14 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<td> <td>
<SelectInput <SelectInput
required required
value={ekspedisi.supplier ?? undefined} value={delivery.supplier}
onChange={(val) => { onChange={(val) => {
formik.setFieldValue( formik.setFieldValue(
`ekspedisi.${idx}.supplier`, `deliveries.${idx}.supplier`,
val val
); );
formik.setFieldValue( formik.setFieldValue(
`ekspedisi.${idx}.supplier_id`, `deliveries.${idx}.supplier_id`,
(val as OptionType)?.value (val as OptionType)?.value
); );
}} }}
@@ -762,128 +787,75 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
isLoading={isLoadingSuppliers} isLoading={isLoadingSuppliers}
isDisabled={type === 'detail'} isDisabled={type === 'detail'}
isClearable isClearable
{...isRepeaterInputError(
'ekspedisi',
'supplier',
idx
)}
/> />
</td> </td>
<td> <td>
<TextInput <TextInput
required required
name={`ekspedisi.${idx}.plat_nomor`} name={`deliveries.${idx}.vehicle_plate`}
value={ekspedisi.plat_nomor ?? ''} value={delivery.vehicle_plate}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
{...isRepeaterInputError( {...isRepeaterInputError(
'ekspedisi', 'deliveries',
'plat_nomor', 'vehicle_plate',
idx idx
)} )}
readOnly={type === 'detail'} readOnly={type === 'detail'}
className={{
wrapper: 'w-full min-w-24',
}}
/>
</td>
<td>
<TextInput
required
name={`ekspedisi.${idx}.no_surat_jalan`}
value={ekspedisi.no_surat_jalan ?? ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
{...isRepeaterInputError(
'ekspedisi',
'no_surat_jalan',
idx
)}
readOnly={type === 'detail'}
className={{
wrapper: 'w-full min-w-24',
}}
/> />
</td> </td>
<td> <td>
<FileInput <FileInput
required required
name={`ekspedisi.${idx}.dokumen`} name={`deliveries.${idx}.document`}
onChange={(e) => { onChange={(e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
const allowedTypes = [
'application/pdf',
'image/jpeg',
'image/jpg',
];
if (!allowedTypes.includes(file.type)) {
toast.error(
'Mohon upload file berformat PDF atau JPEG/JPG.'
);
return;
}
if (file.size > 2 * 1024 * 1024) { if (file.size > 2 * 1024 * 1024) {
toast.error('Ukuran dokumen maksimal 2 MB!'); toast.error('Ukuran dokumen maksimal 2 MB!');
return; return;
} }
formik.setFieldValue( formik.setFieldValue(
`ekspedisi.${idx}.dokumen`, `deliveries.${idx}.document`,
file file
); );
} }
}} }}
{...isRepeaterInputError( {...isRepeaterInputError(
'ekspedisi', 'deliveries',
'dokumen', 'document',
idx idx
)} )}
readOnly={type === 'detail'} readOnly={type === 'detail'}
className={{
wrapper: 'w-full min-w-24',
}}
/> />
</td> </td>
<td> <td>
<TextInput <TextInput
required required
type='number' type='number'
name={`ekspedisi.${idx}.biaya_ekspedisi`} name={`deliveries.${idx}.delivery_cost`}
value={ekspedisi.biaya_ekspedisi ?? ''} value={delivery.delivery_cost}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
{...isRepeaterInputError( {...isRepeaterInputError(
'ekspedisi', 'deliveries',
'biaya_ekspedisi', 'delivery_cost',
idx idx
)} )}
readOnly={type === 'detail'} readOnly={type === 'detail'}
className={{
wrapper: 'w-full min-w-24',
}}
/> />
</td> </td>
<td> <td>
<TextInput <TextInput
disabled disabled
onChange={formik.handleChange} name={`deliveries.${idx}.delivery_cost_per_item`}
onBlur={formik.handleBlur}
{...isRepeaterInputError(
'ekspedisi',
'biaya_ekspedisi_per_item',
idx
)}
name={`ekspedisi.${idx}.biaya_ekspedisi_per_item`}
value={ value={
ekspedisi.qty && ekspedisi.biaya_ekspedisi delivery.delivery_cost_per_item?.toLocaleString(
? ( 'id-ID'
ekspedisi.biaya_ekspedisi / ekspedisi.qty ) ?? '0'
).toLocaleString('id-ID')
: '0'
} }
readOnly readOnly
className={{ className={{
wrapper: 'w-full min-w-24',
input: 'bg-base-200', input: 'bg-base-200',
}} }}
/> />
@@ -891,19 +863,16 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<td> <td>
<TextInput <TextInput
required required
name={`ekspedisi.${idx}.nama_sopir`} name={`deliveries.${idx}.driver_name`}
value={ekspedisi.nama_sopir ?? ''} value={delivery.driver_name}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
{...isRepeaterInputError( {...isRepeaterInputError(
'ekspedisi', 'deliveries',
'nama_sopir', 'driver_name',
idx idx
)} )}
readOnly={type === 'detail'} readOnly={type === 'detail'}
className={{
wrapper: 'w-full min-w-24',
}}
/> />
</td> </td>
{type !== 'detail' && ( {type !== 'detail' && (
@@ -911,7 +880,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<Button <Button
type='button' type='button'
color='error' color='error'
onClick={() => removeEkspedisi(idx)} onClick={() => removeDelivery(idx)}
> >
<Icon <Icon
icon='material-symbols:delete-outline-rounded' icon='material-symbols:delete-outline-rounded'
@@ -928,12 +897,11 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
</div> </div>
{type !== 'detail' && ( {type !== 'detail' && (
<div className='flex justify-center items-center mt-4 gap-4'> <div className='flex justify-center items-center mt-4 gap-4'>
{selectedEkspedisi.length > 0 && ( {selectedDeliveries.length > 0 && (
<Button <Button
type='button' type='button'
color='error' color='error'
onClick={bulkRemoveEkspedisi} onClick={bulkRemoveDelivery}
disabled={selectedEkspedisi.length === 0}
className='w-fit' className='w-fit'
> >
<Icon <Icon
@@ -941,17 +909,17 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
width={24} width={24}
height={24} height={24}
/> />
Hapus Ekspedisi Terpilih ({selectedEkspedisi.length}) Hapus Pengiriman Terpilih ({selectedDeliveries.length})
</Button> </Button>
)} )}
<Button <Button
type='button' type='button'
color='success' color='success'
onClick={addEkspedisi} onClick={addDelivery}
className='w-fit' className='w-fit'
> >
<Icon icon='ic:round-plus' width={24} height={24} /> <Icon icon='ic:round-plus' width={24} height={24} />
Tambah Ekspedisi Tambah Pengiriman
</Button> </Button>
</div> </div>
)} )}
@@ -968,7 +936,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
: undefined : undefined
} }
onDelete={deleteMovementClickHandler} onDelete={deleteMovementClickHandler}
disableSubmit={invalidQtyRows.some(Boolean)} disableSubmit={hasInvalidQty}
/> />
{movementFormErrorMessage && ( {movementFormErrorMessage && (
+32 -29
View File
@@ -5,47 +5,50 @@ import { Warehouse } from '@/types/api/master-data/warehouse';
export type BaseMovement = { export type BaseMovement = {
id: number; id: number;
alasan_transfer: string; transfer_reason: string;
tanggal_transfer: string; transfer_date: string;
warehouse_asal: Warehouse; source_warehouse: Warehouse;
warehouse_tujuan: Warehouse; destination_warehouse: Warehouse;
product: { products: {
product: Product; product: Product;
qty_product: number; product_qty: number;
}[]; }[];
ekspedisi: { deliveries: {
product_id: number; delivery_cost: number;
qty: number; delivery_cost_per_item: number;
document: string;
driver_name: string;
vehicle_plate: string;
supplier: Supplier; supplier: Supplier;
plat_nomor: string; products: {
no_surat_jalan: string; product: Product;
dokumen: string; product_qty: number;
biaya_ekspedisi: number; }[];
nama_sopir: string;
}[]; }[];
}; };
export type Movement = BaseMetadata & BaseMovement; export type Movement = BaseMetadata & BaseMovement;
export type CreateMovementPayload = { export type CreateMovementPayload = {
alasan_transfer: string; transfer_reason: string;
tanggal_transfer: string; transfer_date: string;
warehouse_asal_id: number; source_warehouse_id: number;
warehouse_tujuan_id: number; destination_warehouse_id: number;
product: { products: {
product_id: number; product_id: number;
qty_product: number; product_qty: number;
}[]; }[];
ekspedisi: { deliveries: {
product_id: number; delivery_cost: number;
qty: number; delivery_cost_per_item: number;
document: string | File;
driver_name: string;
vehicle_plate: string;
supplier_id: number; supplier_id: number;
plat_nomor: string; products: {
no_surat_jalan: string; product_id: number;
dokumen: string | File; product_qty: number;
biaya_ekspedisi: number; }[];
biaya_ekspedisi_per_item?: number;
nama_sopir: string;
}[]; }[];
}; };