refactor(FE-137): enhance RecordingForm validation to prevent duplicate project flock entries

This commit is contained in:
rstubryan
2025-10-27 06:18:27 +07:00
parent c8f596ad2a
commit 4b9d0d2064
2 changed files with 166 additions and 56 deletions
@@ -6,6 +6,91 @@ import {
} from '@/types/api/production/recording'; } from '@/types/api/production/recording';
export const RecordingFormSchema = Yup.object({ export const RecordingFormSchema = Yup.object({
project_flock_kandang: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
project_flock_kandang_id: Yup.number()
.default(0)
.typeError('Project Flock Kandang wajib diisi!')
.test(
'is-valid-project-flock-kandang',
'Project Flock Kandang wajib diisi!',
(value) => value !== undefined && value !== null && value > 0
)
.required('Project Flock Kandang wajib diisi!')
.test(
'not-already-recorded',
'Project Flock ini sudah direcord hari ini!',
function(value) {
const recordedProjectFlockIds = this.options.context?.recordedProjectFlockIds as Set<number>;
const formType = this.options.context?.type as 'add' | 'edit' | 'detail';
if (formType !== 'add') return true;
if (value && recordedProjectFlockIds?.has(value)) {
return false;
}
return true;
}
),
body_weights: Yup.array()
.of(
Yup.object({
weight: Yup.number()
.required('Berat ayam wajib diisi!')
.min(1, 'Berat ayam minimal 1 gram!')
.typeError('Berat ayam harus berupa angka!'),
qty: Yup.number()
.required('Jumlah ayam wajib diisi!')
.min(1, 'Jumlah ayam minimal 1 ekor!')
.typeError('Jumlah ayam harus berupa angka!')
.default(1),
average_weight: Yup.number()
.optional()
.min(0, 'Rata-rata berat tidak boleh negatif!')
.typeError('Rata-rata berat harus berupa angka!')
.default(0),
})
)
.min(1, 'Minimal harus ada 1 data bobot badan!')
.required('Data bobot badan wajib diisi!'),
stocks: Yup.array()
.of(
Yup.object({
product_warehouse_id: Yup.number()
.required('Produk wajib diisi!')
.min(1, 'Produk wajib diisi!')
.typeError('Produk harus berupa angka!'),
usage_amount: Yup.number()
.required('Jumlah penggunaan wajib diisi!')
.min(0, 'Jumlah penggunaan tidak boleh negatif!')
.typeError('Jumlah penggunaan harus berupa angka!'),
notes: Yup.string().optional(),
})
)
.min(1, 'Minimal harus ada 1 data stok!')
.required('Data stok wajib diisi!'),
depletions: Yup.array()
.of(
Yup.object({
total: Yup.number()
.required('Jumlah depletions wajib diisi!')
.min(1, 'Jumlah depletions minimal 1!')
.typeError('Jumlah depletions harus berupa angka!'),
notes: Yup.string()
.required('Kondisi depletions wajib diisi!')
.oneOf(
RECORDING_FLAG_OPTIONS.map((option) => option.value),
'Kondisi depletions tidak valid!'
)
.typeError('Kondisi depletions harus berupa teks!')
.min(1, 'Kondisi depletions wajib diisi!'),
})
)
.min(1, 'Minimal harus ada 1 data depletions!')
.required('Data depletions wajib diisi!'),
});
export const UpdateRecordingFormSchema = Yup.object({
project_flock_kandang: Yup.object({ project_flock_kandang: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
@@ -77,8 +162,6 @@ export const RecordingFormSchema = Yup.object({
.required('Data depletions wajib diisi!'), .required('Data depletions wajib diisi!'),
}); });
export const UpdateRecordingFormSchema = RecordingFormSchema;
export type RecordingFormValues = Yup.InferType<typeof RecordingFormSchema>; export type RecordingFormValues = Yup.InferType<typeof RecordingFormSchema>;
type RecordingFormData = Partial<Recording> & { type RecordingFormData = Partial<Recording> & {
@@ -86,6 +86,21 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
return `${ProductWarehouseApi.basePath}?${params.toString()}`; return `${ProductWarehouseApi.basePath}?${params.toString()}`;
}, [selectedLocation]); }, [selectedLocation]);
const today = new Date().toISOString().split('T')[0];
const existingRecordingsUrl = useMemo(() => {
return `${RecordingApi.basePath}?record_date=${today}`;
}, []);
const { data: existingRecordings } = useSWR(
existingRecordingsUrl,
RecordingApi.getAllFetcher
);
const recordedProjectFlockIds = useMemo(() => {
if (!isResponseSuccess(existingRecordings)) return new Set<number>();
return new Set(existingRecordings?.data.map(rec => rec.project_flock_kandang_id) || []);
}, [existingRecordings]);
const { data: stockProducts, isLoading: isLoadingStockProducts } = useSWR( const { data: stockProducts, isLoading: isLoadingStockProducts } = useSWR(
stockProductsUrl, stockProductsUrl,
ProductWarehouseApi.getAllFetcher ProductWarehouseApi.getAllFetcher
@@ -106,14 +121,19 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const options: OptionType[] = []; const options: OptionType[] = [];
projectFlocks?.data.forEach((projectFlock) => { projectFlocks?.data.forEach((projectFlock) => {
projectFlock.kandangs.forEach((kandang) => { projectFlock.kandangs.forEach((kandang) => {
const isAlreadyRecorded = recordedProjectFlockIds.has(projectFlock.id);
const label = isAlreadyRecorded
? `${projectFlock.flock.name} - ${projectFlock.area.name} - ${kandang.name} (Sudah Direcord)`
: `${projectFlock.flock.name} - ${projectFlock.area.name} - ${kandang.name}`;
options.push({ options.push({
value: projectFlock.id, value: projectFlock.id,
label: `${projectFlock.flock.name} - ${projectFlock.area.name} - ${kandang.name}`, label: label,
}); });
}); });
}); });
return options; return options;
}, [projectFlocks]); }, [projectFlocks, recordedProjectFlockIds, type]);
const unifiedStockProducts = useMemo(() => { const unifiedStockProducts = useMemo(() => {
const options: OptionType[] = []; const options: OptionType[] = [];
@@ -359,6 +379,11 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
}; };
const projectFlockKandangChangeHandler = (val: OptionType | OptionType[] | null) => { const projectFlockKandangChangeHandler = (val: OptionType | OptionType[] | null) => {
if (type === 'add' && val && recordedProjectFlockIds.has((val as OptionType).value as number)) {
toast.error('Project Flock ini sudah direcord hari ini!');
return;
}
formik.setFieldTouched('project_flock_kandang', true); formik.setFieldTouched('project_flock_kandang', true);
formik.setFieldValue('project_flock_kandang', val); formik.setFieldValue('project_flock_kandang', val);
formik.setFieldTouched('project_flock_kandang_id', true); formik.setFieldTouched('project_flock_kandang_id', true);
@@ -665,28 +690,30 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
isSearchable isSearchable
/> />
<SelectInput <div>
required <SelectInput
label='Project Flock' required
value={formik.values.project_flock_kandang ?? undefined} label='Project Flock'
onChange={projectFlockKandangChangeHandler} value={formik.values.project_flock_kandang ?? undefined}
options={projectFlockKandangOptions} onChange={projectFlockKandangChangeHandler}
onInputChange={setProjectFlockSearchValue} options={projectFlockKandangOptions}
isLoading={isLoadingProjectFlocks} onInputChange={setProjectFlockSearchValue}
isError={ isLoading={isLoadingProjectFlocks}
formik.touched.project_flock_kandang_id && isError={
Boolean(formik.errors.project_flock_kandang_id) formik.touched.project_flock_kandang_id &&
} Boolean(formik.errors.project_flock_kandang_id)
errorMessage={formik.errors.project_flock_kandang_id as string} }
isDisabled={type === 'detail' || !selectedLocation} errorMessage={formik.errors.project_flock_kandang_id as string}
placeholder={ isDisabled={type === 'detail' || !selectedLocation}
selectedLocation placeholder={
? 'Pilih Project Flock - Kandang' selectedLocation
: 'Pilih Lokasi terlebih dahulu' ? 'Pilih Project Flock - Kandang'
} : 'Pilih Lokasi terlebih dahulu'
isClearable }
isSearchable isClearable
/> isSearchable
/>
</div>
</div> </div>
</Card> </Card>
@@ -1044,38 +1071,38 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
isDisabled={type === 'detail'} isDisabled={type === 'detail'}
/> />
</td> </td>
<td> <td>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<NumberInput <NumberInput
required required
name={`stocks.${idx}.usage_amount`} name={`stocks.${idx}.usage_amount`}
value={stock.usage_amount} value={stock.usage_amount}
onChange={handleStockUsageAmountChangeWrapper(idx)} onChange={handleStockUsageAmountChangeWrapper(idx)}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
decimalScale={0} decimalScale={0}
allowNegative={false} allowNegative={false}
thousandSeparator=',' thousandSeparator=','
decimalSeparator='.' decimalSeparator='.'
isError={ isError={
isRepeaterInputError('stocks', 'usage_amount', idx) isRepeaterInputError('stocks', 'usage_amount', idx)
.isError || Boolean(getStockUsageError(idx)) .isError || Boolean(getStockUsageError(idx))
} }
errorMessage={ errorMessage={
isRepeaterInputError('stocks', 'usage_amount', idx) isRepeaterInputError('stocks', 'usage_amount', idx)
.errorMessage || .errorMessage ||
getStockUsageError(idx) || getStockUsageError(idx) ||
undefined undefined
} }
readOnly={type === 'detail'} readOnly={type === 'detail'}
className={{ className={{
wrapper: 'w-full min-w-24', wrapper: 'w-full min-w-24',
}} }}
placeholder="Jumlah Pakai" placeholder="Jumlah Pakai"
/> />
{type !== 'detail' && getStockUsageAdornment(idx)} {type !== 'detail' && getStockUsageAdornment(idx)}
</div> </div>
</td> </td>
{type !== 'detail' && ( {type !== 'detail' && (
<td> <td>
<div className='flex justify-center'> <div className='flex justify-center'>
<Button <Button