feat(FE-65): add validation for quantity and required fields in MovementForm

This commit is contained in:
rstubryan
2025-10-14 18:00:34 +07:00
parent ff9e35eb52
commit 4b4b74d07c
3 changed files with 70 additions and 52 deletions
+3 -1
View File
@@ -8,6 +8,7 @@ interface FormActionsProps<T> {
formik: FormikContextType<T>;
editUrl?: string;
onDelete?: () => void;
disableSubmit?: boolean;
}
export const FormActions = <T,>({
@@ -15,6 +16,7 @@ export const FormActions = <T,>({
formik,
editUrl,
onDelete,
disableSubmit = false,
}: FormActionsProps<T>) => {
return (
<div className='flex flex-row justify-between gap-2 flex-wrap'>
@@ -71,7 +73,7 @@ export const FormActions = <T,>({
color='primary'
className='px-4'
isLoading={formik.isSubmitting}
disabled={!formik.isValid || formik.isSubmitting}
disabled={disableSubmit || !formik.isValid || formik.isSubmitting}
>
Submit
</Button>
@@ -51,7 +51,17 @@ const EkspedisiObjectSchema: Yup.ObjectSchema<EkspedisiSchema> = Yup.object({
qty: Yup.number()
.required('Qty wajib diisi!')
.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 {
product_id: number;
qty_product: number;
}[];
const product = products.find((p) => p.product_id === product_id);
if (!product) return true;
return (value ?? 0) <= Number(product.qty_product);
}),
supplier: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
@@ -108,8 +118,12 @@ export const MovementFormSchema = Yup.object({
.typeError('Gudang tujuan wajib diisi!'),
product: Yup.array()
.of(ProductObjectSchema)
.min(1, 'Minimal harus ada 1 produk!'),
ekspedisi: Yup.array().of(EkspedisiObjectSchema).optional().default([]),
.min(1, 'Minimal harus ada 1 produk!')
.required('Produk wajib diisi!'),
ekspedisi: Yup.array()
.of(EkspedisiObjectSchema)
.min(1, 'Minimal harus ada 1 ekspedisi!')
.required('Ekspedisi wajib diisi!'),
});
export const UpdateMovementFormSchema = MovementFormSchema;
@@ -1,7 +1,7 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useFormik } from 'formik';
import { FormikProps, useFormik } from 'formik';
import useSWR from 'swr';
import { Icon } from '@iconify/react';
@@ -32,13 +32,30 @@ import {
} from '@/services/api/master-data';
import { toast } from 'react-hot-toast';
import FileInput from '@/components/input/FileInput';
import { containsFile } from '@/lib/form-data';
interface MovementFormProps {
type?: 'add' | 'edit' | 'detail';
initialValues?: Movement;
}
function getEkspedisiFieldError(
formik: FormikProps<MovementFormValues>,
idx: number,
field: keyof EkspedisiSchema
) {
const errorObj = formik.errors.ekspedisi?.[idx];
const touched = formik.touched.ekspedisi?.[idx]?.[field];
const isError =
touched &&
typeof errorObj === 'object' &&
!!(errorObj as Record<string, unknown>)?.[field];
const errorMessage =
typeof errorObj === 'object'
? (errorObj as Record<string, string>)?.[field]
: undefined;
return { isError, errorMessage };
}
const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
const [, setMovementFormErrorMessage] = useState('');
const [selectedProducts, setSelectedProducts] = useState<number[]>([]);
@@ -63,12 +80,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
initialValues: formikInitialValues,
validationSchema:
type === 'edit' ? UpdateMovementFormSchema : MovementFormSchema,
validateOnChange: true,
validateOnBlur: true,
onSubmit: async (values) => {
console.log(
'Dokumen:',
values.ekspedisi?.map((e) => e.dokumen)
);
setMovementFormErrorMessage('');
const payload: CreateMovementPayload = {
alasan_transfer: values.alasan_transfer,
@@ -95,9 +109,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
})),
};
console.log('containsFile:', containsFile(payload));
console.log('payload:', payload);
switch (type) {
case 'add':
await createMovementHandler(payload);
@@ -292,12 +303,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
const validateEkspedisiQty = (ekspedisiIdx: number, qty: number) => {
const productId = formik.values.ekspedisi?.[ekspedisiIdx]?.product_id;
if (!productId) return true;
const relatedProduct = formik.values.product?.find(
(p) => p.product_id === productId
);
if (!relatedProduct) return true;
const totalQtyUsed =
formik.values.ekspedisi?.reduce((total, eks, i) => {
if (eks.product_id === productId && i !== ekspedisiIdx) {
@@ -305,10 +314,15 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
}
return total;
}, 0) || 0;
return totalQtyUsed + qty <= Number(relatedProduct.qty_product);
};
const invalidQtyRows =
formik.values.ekspedisi?.map((eks, idx) => {
const qty = Number(eks.qty) || 0;
return !validateEkspedisiQty(idx, qty);
}) ?? [];
return (
<>
<section className='w-full max-w-5xl'>
@@ -735,22 +749,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
type='number'
name={`ekspedisi.${idx}.qty`}
value={ekspedisi.qty ?? ''}
onChange={(e) => {
const newQty = Number(e.target.value);
if (validateEkspedisiQty(idx, newQty)) {
formik.handleChange(e);
} else {
toast.error(
'Quantity exceeds available product quantity'
);
}
}}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={isRepeaterInputError(
'ekspedisi',
'qty',
idx
)}
{...getEkspedisiFieldError(formik, idx, 'qty')}
readOnly={type === 'detail'}
className={{
wrapper: 'w-full min-w-24',
@@ -790,10 +791,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
value={ekspedisi.plat_nomor ?? ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={isRepeaterInputError(
'ekspedisi',
'plat_nomor',
idx
{...getEkspedisiFieldError(
formik,
idx,
'plat_nomor'
)}
readOnly={type === 'detail'}
className={{
@@ -808,10 +809,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
value={ekspedisi.no_surat_jalan ?? ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={isRepeaterInputError(
'ekspedisi',
'no_surat_jalan',
idx
{...getEkspedisiFieldError(
formik,
idx,
'no_surat_jalan'
)}
readOnly={type === 'detail'}
className={{
@@ -866,10 +867,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
value={ekspedisi.biaya_ekspedisi ?? ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={isRepeaterInputError(
'ekspedisi',
'biaya_ekspedisi',
idx
{...getEkspedisiFieldError(
formik,
idx,
'biaya_ekspedisi'
)}
readOnly={type === 'detail'}
className={{
@@ -882,10 +883,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
disabled
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={isRepeaterInputError(
'ekspedisi',
'biaya_ekspedisi_per_item',
idx
{...getEkspedisiFieldError(
formik,
idx,
'biaya_ekspedisi_per_item'
)}
name={`ekspedisi.${idx}.biaya_ekspedisi_per_item`}
value={
@@ -909,10 +910,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
value={ekspedisi.nama_sopir ?? ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={isRepeaterInputError(
'ekspedisi',
'nama_sopir',
idx
{...getEkspedisiFieldError(
formik,
idx,
'nama_sopir'
)}
readOnly={type === 'detail'}
className={{
@@ -982,6 +983,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
: undefined
}
onDelete={deleteMovementClickHandler}
disableSubmit={invalidQtyRows.some(Boolean)}
/>
{movementFormErrorMessage && (