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