From b1a3796ecaa61dbefad07d83357cce7e1c3c456a Mon Sep 17 00:00:00 2001 From: rstubryan Date: Wed, 15 Oct 2025 09:28:57 +0700 Subject: [PATCH] feat(FE-114,136): implement RecordingForm component with data handling and validation --- .../flock/recording/form/RecordingForm.tsx | 873 ++++++++++++++++++ .../form/useRecordingFormHandlers.ts | 70 ++ 2 files changed, 943 insertions(+) diff --git a/src/components/pages/flock/recording/form/RecordingForm.tsx b/src/components/pages/flock/recording/form/RecordingForm.tsx index e69de29b..1b09b274 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.tsx +++ b/src/components/pages/flock/recording/form/RecordingForm.tsx @@ -0,0 +1,873 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { FormikProps, useFormik } from 'formik'; +import { Icon } from '@iconify/react'; +import { toast } from 'react-hot-toast'; +import Button from '@/components/Button'; +import TextInput from '@/components/input/TextInput'; +import SelectInput, { OptionType } from '@/components/input/SelectInput'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; +import { FormHeader } from '@/components/helper/form/FormHeader'; +import { FormActions } from '@/components/helper/form/FormActions'; +import { CreateRecordingPayload, Recording } from '@/types/api/flock/recording'; +import { + RecordingFormSchema, + RecordingFormValues, + getRecordingFormInitialValues, + UpdateRecordingFormSchema, +} from './RecordingForm.schema'; +import { useRecordingFormHandlers } from './useRecordingFormHandlers'; +import { FlockApi } from '@/services/api/flock'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { RECORDING_FLAG_OPTIONS } from '@/config/constant'; +import useSWR from 'swr'; + +interface RecordingFormProps { + type?: 'add' | 'edit' | 'detail'; + initialValues?: Recording; +} + +const DUMMY_FLOCKS = [ + { value: 1, label: 'Flock A' }, + { value: 2, label: 'Flock B' }, + { value: 3, label: 'Flock C' }, + { value: 4, label: 'Flock D' }, +]; + +const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { + const [flockSelectInputValue, setFlockSelectInputValue] = useState(''); + const [selectedPakan, setSelectedPakan] = useState([]); + const [selectedBobot, setSelectedBobot] = useState([]); + const [selectedVaksin, setSelectedVaksin] = useState([]); + const [selectedMortal, setSelectedMortal] = useState([]); + const [, setRecordingFormErrorMessage] = useState(''); + const { + deleteModal, + recordingFormErrorMessage, + isDeleteLoading, + createRecordingHandler, + updateRecordingHandler, + deleteRecordingClickHandler, + confirmationModalDeleteClickHandler, + } = useRecordingFormHandlers(initialValues?.id); + + const formikInitialValues = useMemo( + () => getRecordingFormInitialValues(initialValues), + [initialValues] + ); + + const formik = useFormik({ + initialValues: formikInitialValues, + validationSchema: + type === 'edit' ? UpdateRecordingFormSchema : RecordingFormSchema, + validateOnChange: true, + validateOnBlur: true, + onSubmit: async (values) => { + setRecordingFormErrorMessage(''); + const payload: CreateRecordingPayload = { + flock_id: values.flock_id, + data_pakan: (values.data_pakan ?? []).map((p) => ({ + nama_pakan: p.nama_pakan, + qty_pakan: p.qty_pakan, + stock_pakan: p.stock_pakan, + })), + bobot_badan: (values.bobot_badan ?? []).map((b) => ({ + berat_ayam: b.berat_ayam, + jumlah_ayam: b.jumlah_ayam, + rata_rata_berat_ayam: b.rata_rata_berat_ayam, + })), + vaksinasi: (values.vaksinasi ?? []).map((v) => ({ + nama_vaksin: v.nama_vaksin, + total_stock: v.total_stock, + jumlah_stock: v.jumlah_stock, + })), + mortalitas: (values.mortalitas ?? []).map((m) => ({ + kondisi: m.kondisi, + jumlah: m.jumlah, + })), + }; + + switch (type) { + case 'add': + await createRecordingHandler(payload); + break; + case 'edit': + await updateRecordingHandler(initialValues?.id as number, payload); + break; + } + }, + }); + + // // Flock selection + // const flocksUrl = `${FlockApi.basePath}?${new URLSearchParams({ search: flockSelectInputValue }).toString()}`; + // const { data: flocks, isLoading: isLoadingFlocks } = useSWR( + // flocksUrl, + // FlockApi.getAllFetcher + // ); + // const flockOptions = isResponseSuccess(flocks) + // ? flocks?.data.map((f) => ({ value: f.id, label: f.name })) + // : []; + + const flockOptions = DUMMY_FLOCKS; + + const addDataPakan = () => { + const newDataPakan = [ + ...(formik.values.data_pakan || []), + { + nama_pakan: '', + qty_pakan: 0, + stock_pakan: 0, + }, + ]; + formik.setFieldValue('data_pakan', newDataPakan); + }; + + const removeDataPakan = (idx: number) => { + const updatedDataPakan = formik.values.data_pakan?.filter( + (_, i) => i !== idx + ); + formik.setFieldValue('data_pakan', updatedDataPakan); + }; + + const removeSelectedDataPakan = () => { + const updatedDataPakan = formik.values.data_pakan?.filter( + (_, idx) => !selectedPakan.includes(idx) + ); + formik.setFieldValue('data_pakan', updatedDataPakan); + setSelectedPakan([]); + }; + + const addBobotBadan = () => { + const newBobotBadan = [ + ...(formik.values.bobot_badan || []), + { + berat_ayam: 0, + jumlah_ayam: 0, + rata_rata_berat_ayam: 0, + }, + ]; + formik.setFieldValue('bobot_badan', newBobotBadan); + }; + + const removeBobotBadan = (idx: number) => { + const updatedBobotBadan = formik.values.bobot_badan?.filter( + (_, i) => i !== idx + ); + formik.setFieldValue('bobot_badan', updatedBobotBadan); + }; + + const removeSelectedBobotBadan = () => { + const updatedBobotBadan = formik.values.bobot_badan?.filter( + (_, idx) => !selectedBobot.includes(idx) + ); + formik.setFieldValue('bobot_badan', updatedBobotBadan); + setSelectedBobot([]); + }; + + const addVaksinasi = () => { + const newVaksinasi = [ + ...(formik.values.vaksinasi || []), + { + nama_vaksin: '', + total_stock: 0, + jumlah_stock: 0, + }, + ]; + formik.setFieldValue('vaksinasi', newVaksinasi); + }; + + const removeVaksinasi = (idx: number) => { + const updatedVaksinasi = formik.values.vaksinasi?.filter( + (_, i) => i !== idx + ); + formik.setFieldValue('vaksinasi', updatedVaksinasi); + }; + + const removeSelectedVaksinasi = () => { + const updatedVaksinasi = formik.values.vaksinasi?.filter( + (_, idx) => !selectedVaksin.includes(idx) + ); + formik.setFieldValue('vaksinasi', updatedVaksinasi); + setSelectedVaksin([]); + }; + + const addMortalitas = () => { + const newMortalitas = [ + ...(formik.values.mortalitas || []), + { + kondisi: RECORDING_FLAG_OPTIONS[0].value, + jumlah: 0, + }, + ]; + formik.setFieldValue('mortalitas', newMortalitas); + }; + + const removeMortalitas = (idx: number) => { + const updatedMortalitas = formik.values.mortalitas?.filter( + (_, i) => i !== idx + ); + formik.setFieldValue('mortalitas', updatedMortalitas); + }; + + const removeSelectedMortalitas = () => { + const updatedMortalitas = formik.values.mortalitas?.filter( + (_, idx) => !selectedMortal.includes(idx) + ); + formik.setFieldValue('mortalitas', updatedMortalitas); + setSelectedMortal([]); + }; + + return ( + <> +
+ +
+ {/* Basic Info Card */} +
+
+
+ { + formik.setFieldValue('flock', val); + formik.setFieldValue( + 'flock_id', + (val as OptionType)?.value + ); + }} + options={flockOptions} + onInputChange={(val) => { + // Filter options locally instead of API call + return val; + }} + isLoading={false} // Remove isLoadingFlocks + isError={ + formik.touched.flock_id && Boolean(formik.errors.flock_id) + } + errorMessage={formik.errors.flock_id as string} + isDisabled={type === 'detail'} + isClearable + /> +
+
+
+ + {/* Data Pakan Table */} +
+
+

Data Pakan

+
+ + + + {type !== 'detail' && ( + + )} + + + + {type !== 'detail' && } + + + + {formik.values.data_pakan?.map((pakan, idx) => ( + + {type !== 'detail' && ( + + )} + + + + {type !== 'detail' && ( + + )} + + ))} + +
+ 0 + } + onChange={(e) => { + if (e.target.checked) { + setSelectedPakan( + formik.values.data_pakan?.map( + (_, idx) => idx + ) ?? [] + ); + } else { + setSelectedPakan([]); + } + }} + /> + Nama PakanQty PakanStock PakanAksi
+ { + if (e.target.checked) { + setSelectedPakan([...selectedPakan, idx]); + } else { + setSelectedPakan( + selectedPakan.filter((i) => i !== idx) + ); + } + }} + /> + + + + + + + + +
+
+ {type !== 'detail' && ( +
+ {selectedPakan.length > 0 && ( + + )} + +
+ )} +
+
+ + {/* Bobot Badan Table */} +
+
+

Bobot Badan

+
+ + + + {type !== 'detail' && ( + + )} + + + + {type !== 'detail' && } + + + + {formik.values.bobot_badan?.map((bobot, idx) => ( + + {type !== 'detail' && ( + + )} + + + + {type !== 'detail' && ( + + )} + + ))} + +
+ 0 + } + onChange={(e) => { + if (e.target.checked) { + setSelectedBobot( + formik.values.bobot_badan?.map( + (_, idx) => idx + ) ?? [] + ); + } else { + setSelectedBobot([]); + } + }} + /> + Berat AyamJumlah AyamRata-rata Berat AyamAksi
+ { + if (e.target.checked) { + setSelectedBobot([...selectedBobot, idx]); + } else { + setSelectedBobot( + selectedBobot.filter((i) => i !== idx) + ); + } + }} + /> + + + + + + + + +
+
+ {type !== 'detail' && ( +
+ {selectedBobot.length > 0 && ( + + )} + +
+ )} +
+
+ + {/* Vaksinasi Table */} +
+
+

Vaksinasi

+
+ + + + {type !== 'detail' && ( + + )} + + + + {type !== 'detail' && } + + + + {formik.values.vaksinasi?.map((vaksin, idx) => ( + + {type !== 'detail' && ( + + )} + + + + {type !== 'detail' && ( + + )} + + ))} + +
+ 0 + } + onChange={(e) => { + if (e.target.checked) { + setSelectedVaksin( + formik.values.vaksinasi?.map( + (_, idx) => idx + ) ?? [] + ); + } else { + setSelectedVaksin([]); + } + }} + /> + Nama VaksinTotal StockJumlah StockAksi
+ { + if (e.target.checked) { + setSelectedVaksin([...selectedVaksin, idx]); + } else { + setSelectedVaksin( + selectedVaksin.filter((i) => i !== idx) + ); + } + }} + /> + + + + + + + + +
+
+ {type !== 'detail' && ( +
+ {selectedVaksin.length > 0 && ( + + )} + +
+ )} +
+
+ + {/* Mortalitas Table */} +
+
+

Mortalitas

+
+ + + + {type !== 'detail' && ( + + )} + + + {type !== 'detail' && } + + + + {formik.values.mortalitas?.map((mortal, idx) => ( + + {type !== 'detail' && ( + + )} + + + {type !== 'detail' && ( + + )} + + ))} + +
+ 0 + } + onChange={(e) => { + if (e.target.checked) { + setSelectedMortal( + formik.values.mortalitas?.map( + (_, idx) => idx + ) ?? [] + ); + } else { + setSelectedMortal([]); + } + }} + /> + KondisiJumlahAksi
+ { + if (e.target.checked) { + setSelectedMortal([...selectedMortal, idx]); + } else { + setSelectedMortal( + selectedMortal.filter((i) => i !== idx) + ); + } + }} + /> + + opt.value === mortal.kondisi + )} + onChange={(val) => { + formik.setFieldValue( + `mortalitas.${idx}.kondisi`, + (val as OptionType)?.value + ); + }} + options={RECORDING_FLAG_OPTIONS} + isDisabled={type === 'detail'} + /> + + + + +
+
+ {type !== 'detail' && ( +
+ {selectedMortal.length > 0 && ( + + )} + +
+ )} +
+
+ + {/* Action buttons */} + + type={type} + formik={formik} + editUrl={ + initialValues + ? `/flock/recording/detail/edit/?recordingId=${initialValues.id}` + : undefined + } + onDelete={deleteRecordingClickHandler} + /> + + {recordingFormErrorMessage && ( +
+ + {recordingFormErrorMessage} +
+ )} + +
+ + {type !== 'add' && ( + + )} + + ); +}; + +export default RecordingForm; diff --git a/src/components/pages/flock/recording/form/useRecordingFormHandlers.ts b/src/components/pages/flock/recording/form/useRecordingFormHandlers.ts index e69de29b..a508806f 100644 --- a/src/components/pages/flock/recording/form/useRecordingFormHandlers.ts +++ b/src/components/pages/flock/recording/form/useRecordingFormHandlers.ts @@ -0,0 +1,70 @@ +import { useCallback, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { toast } from 'react-hot-toast'; +import { useModal } from '@/components/Modal'; +import { RecordingApi } from '@/services/api/flock'; +import { + CreateRecordingPayload, + UpdateRecordingPayload, +} from '@/types/api/flock/recording'; +import { isResponseError } from '@/lib/api-helper'; + +export const useRecordingFormHandlers = (initialValuesId?: number) => { + const router = useRouter(); + const deleteModal = useModal(); + const [recordingFormErrorMessage, setRecordingFormErrorMessage] = + useState(''); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + const createRecordingHandler = useCallback( + async (payload: CreateRecordingPayload) => { + const res = await RecordingApi.create(payload); + if (isResponseError(res)) { + setRecordingFormErrorMessage(res.message); + return; + } + toast.success(res?.message as string); + router.push('/flock/recording'); + }, + [router] + ); + + const updateRecordingHandler = useCallback( + async (recordingId: number, payload: UpdateRecordingPayload) => { + const res = await RecordingApi.update(recordingId, payload); + if (res?.status === 'error') { + setRecordingFormErrorMessage(res.message); + return; + } + toast.success(res?.message as string); + router.refresh(); + router.push('/flock/recording'); + }, + [router] + ); + + const deleteRecordingClickHandler = useCallback(() => { + deleteModal.openModal(); + }, [deleteModal]); + + const confirmationModalDeleteClickHandler = useCallback(async () => { + if (!initialValuesId) return; + + setIsDeleteLoading(true); + await RecordingApi.delete(initialValuesId); + deleteModal.closeModal(); + toast.success('Successfully delete Recording!'); + setIsDeleteLoading(false); + router.push('/flock/recording'); + }, [deleteModal, initialValuesId, router]); + + return { + deleteModal, + recordingFormErrorMessage, + isDeleteLoading, + createRecordingHandler, + updateRecordingHandler, + deleteRecordingClickHandler, + confirmationModalDeleteClickHandler, + }; +};