From 1869fa8dc55cf1e5e9105981c8614866532b86af Mon Sep 17 00:00:00 2001 From: rstubryan Date: Tue, 14 Oct 2025 22:03:09 +0700 Subject: [PATCH 01/68] feat(FE-136): add flock and recording management with validation in forms --- .../recording/form/RecordingForm.schema.ts | 121 ++++++++++++++++++ src/config/constant.ts | 30 +++++ src/services/api/flock.ts | 22 ++++ src/types/api/flock/flock.d.ts | 14 ++ src/types/api/flock/recording.d.ts | 54 ++++++++ 5 files changed, 241 insertions(+) create mode 100644 src/components/pages/flock/recording/form/RecordingForm.schema.ts create mode 100644 src/services/api/flock.ts create mode 100644 src/types/api/flock/flock.d.ts create mode 100644 src/types/api/flock/recording.d.ts diff --git a/src/components/pages/flock/recording/form/RecordingForm.schema.ts b/src/components/pages/flock/recording/form/RecordingForm.schema.ts new file mode 100644 index 00000000..f78e65e1 --- /dev/null +++ b/src/components/pages/flock/recording/form/RecordingForm.schema.ts @@ -0,0 +1,121 @@ +import * as Yup from 'yup'; +import { RECORDING_FLAG_OPTIONS } from '@/config/constant'; + +export const RecordingFormSchema = Yup.object({ + flock_id: Yup.number().required('Flock wajib diisi!'), + tanggal: Yup.string().required('Tanggal wajib diisi!'), + data_pakan: Yup.array() + .of( + Yup.object({ + nama_pakan: Yup.string().required('Nama pakan wajib diisi!'), + qty_pakan: Yup.number() + .required('Qty pakan wajib diisi!') + .min(1, 'Qty minimal 1!'), + stock_pakan: Yup.number() + .required('Stock pakan wajib diisi!') + .min(0, 'Stock minimal 0!'), + }) + ) + .min(1, 'Minimal harus ada 1 data pakan!') + .required('Data pakan wajib diisi!'), + bobot_badan: Yup.array() + .of( + Yup.object({ + berat_ayam: Yup.number() + .required('Berat ayam wajib diisi!') + .min(1, 'Berat minimal 1!'), + jumlah_ayam: Yup.number() + .required('Jumlah ayam wajib diisi!') + .min(1, 'Jumlah minimal 1!'), + rata_rata_berat_ayam: Yup.number() + .required('Rata-rata berat ayam wajib diisi!') + .min(1, 'Rata-rata minimal 1!'), + }) + ) + .min(1, 'Minimal harus ada 1 data bobot badan!') + .required('Data bobot badan wajib diisi!'), + vaksinasi: Yup.array() + .of( + Yup.object({ + nama_vaksin: Yup.string().required('Nama vaksin wajib diisi!'), + total_stock: Yup.number() + .required('Total stock wajib diisi!') + .min(0, 'Total stock minimal 0!'), + jumlah_stock: Yup.number() + .required('Jumlah stock wajib diisi!') + .min(0, 'Jumlah stock minimal 0!'), + }) + ) + .min(1, 'Minimal harus ada 1 data vaksinasi!') + .required('Data vaksinasi wajib diisi!'), + mortalitas: Yup.array() + .of( + Yup.object({ + kondisi: Yup.mixed() + .oneOf( + RECORDING_FLAG_OPTIONS.map((opt) => opt.value), + 'Kondisi tidak valid!' + ) + .required('Kondisi wajib diisi!'), + jumlah: Yup.number() + .required('Jumlah wajib diisi!') + .min(1, 'Jumlah minimal 1!'), + }) + ) + .min(1, 'Minimal harus ada 1 data mortalitas!') + .required('Data mortalitas wajib diisi!'), +}); + +export const UpdateRecordingFormSchema = RecordingFormSchema; + +export type RecordingFormValues = Yup.InferType; + +export const getRecordingFormInitialValues = ( + initialValues?: Partial +): RecordingFormValues => ({ + flock_id: initialValues?.flock_id ?? 0, + tanggal: initialValues?.tanggal ?? '', + data_pakan: + Array.isArray(initialValues?.data_pakan) && + initialValues.data_pakan.length > 0 + ? initialValues.data_pakan + : [ + { + nama_pakan: '', + qty_pakan: 0, + stock_pakan: 0, + }, + ], + bobot_badan: + Array.isArray(initialValues?.bobot_badan) && + initialValues.bobot_badan.length > 0 + ? initialValues.bobot_badan + : [ + { + berat_ayam: 0, + jumlah_ayam: 0, + rata_rata_berat_ayam: 0, + }, + ], + vaksinasi: + Array.isArray(initialValues?.vaksinasi) && + initialValues.vaksinasi.length > 0 + ? initialValues.vaksinasi + : [ + { + nama_vaksin: '', + total_stock: 0, + jumlah_stock: 0, + }, + ], + mortalitas: + Array.isArray(initialValues?.mortalitas) && + initialValues.mortalitas.length > 0 + ? initialValues.mortalitas + : [ + { + kondisi: '', + jumlah: 0, + }, + ], +}); diff --git a/src/config/constant.ts b/src/config/constant.ts index 8f712726..87dcd927 100644 --- a/src/config/constant.ts +++ b/src/config/constant.ts @@ -12,6 +12,29 @@ export const MAIN_DRAWER_LINKS: MAIN_DRAWER_MENU[] = [ icon: 'gg:chart', }, + { + title: 'Flock', + link: '/flock', + icon: 'mdi:chicken', + submenu: [ + { + title: 'List Flock', + link: '/flock/list', + icon: 'mdi:chicken', + }, + { + title: 'Chick In', + link: '/flock/chick-in', + icon: 'mdi:home-import-outline', + }, + { + title: 'Recording', + link: '/flock/recording', + icon: 'mdi:clipboard-text', + }, + ], + }, + { title: 'Persediaan', link: '/inventory', @@ -175,3 +198,10 @@ export const PRODUCT_FLAG_OPTIONS = [ export const SUPPLIER_FLAG_OPTIONS = [ { label: 'EKSPEDISI', value: 'EKSPEDISI' }, ]; + +export const RECORDING_FLAG_OPTIONS = [ + { label: 'Ayam Afkir', value: 'Ayam Afkir' }, + { label: 'Ayam Culling', value: 'Ayam Culling' }, + { label: 'Ayam Mati', value: 'Ayam Mati' }, + { label: 'DOC', value: 'DOC' }, +]; diff --git a/src/services/api/flock.ts b/src/services/api/flock.ts new file mode 100644 index 00000000..83fbb0d8 --- /dev/null +++ b/src/services/api/flock.ts @@ -0,0 +1,22 @@ +import { + CreateFlockPayload, + Flock, + UpdateFlockPayload, +} from '@/types/api/flock/flock'; +import { + CreateRecordingPayload, + Recording, + UpdateRecordingPayload, +} from '@/types/api/flock/recording'; +import { BaseApiService } from '@/services/api/base'; + +export const FlockApi = new BaseApiService< + Flock, + CreateFlockPayload, + UpdateFlockPayload +>('/flock/flocks'); +export const RecordingApi = new BaseApiService< + Recording, + CreateRecordingPayload, + UpdateRecordingPayload +>('/flock/recordings'); diff --git a/src/types/api/flock/flock.d.ts b/src/types/api/flock/flock.d.ts new file mode 100644 index 00000000..e0dcfda4 --- /dev/null +++ b/src/types/api/flock/flock.d.ts @@ -0,0 +1,14 @@ +import { BaseMetadata } from '@/types/api/api-general'; + +export type BaseFlock = { + id: number; + name: string; +}; + +export type Flock = BaseMetadata & BaseFlock; + +export type CreateFlockPayload = { + name: string; +}; + +export type UpdateFlockPayload = CreateFlockPayload; diff --git a/src/types/api/flock/recording.d.ts b/src/types/api/flock/recording.d.ts new file mode 100644 index 00000000..66818f0c --- /dev/null +++ b/src/types/api/flock/recording.d.ts @@ -0,0 +1,54 @@ +import { BaseMetadata } from '@/types/api/api-general'; +import { Flock } from '@/types/api/flock/flock'; + +export type BaseRecording = { + id: number; + flock: Flock; + data_pakan: { + nama_pakan: string; + qty_pakan: number; + stock_pakan; + }[]; + bobot_badan: { + berat_ayam: number; + jumlah_ayam: number; + rata_rata_berat_ayam: number; + }[]; + vaksinasi: { + nama_vaksin: string; + total_stock: number; + jumlah_stock: number; + }[]; + mortalitas: { + kondisi: string; + jumlah: number; + }[]; +}; + +export type Recording = BaseMetadata & BaseRecording; + +export type CreateRecordingPayload = { + flock_id: number; + tanggal: string; + data_pakan: { + nama_pakan: string; + qty_pakan: number; + stock_pakan: number; + }[]; + bobot_badan: { + berat_ayam: number; + jumlah_ayam: number; + rata_rata_berat_ayam: number; + }[]; + vaksinasi: { + nama_vaksin: string; + total_stock: number; + jumlah_stock: number; + }[]; + mortalitas: { + kondisi: string; + jumlah: number; + }[]; +}; + +export type UpdateRecordingPayload = CreateRecordingPayload; From 6dcb97bcacdb671740863371e9e0c70c862c0f11 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Tue, 14 Oct 2025 22:03:51 +0700 Subject: [PATCH 02/68] feat(FE-114,129): add RecordingForm and RecordingTable components with handlers --- src/components/pages/flock/recording/RecordingTable.tsx | 0 src/components/pages/flock/recording/form/RecordingForm.tsx | 0 .../pages/flock/recording/form/useRecordingFormHandlers.ts | 0 3 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/components/pages/flock/recording/RecordingTable.tsx create mode 100644 src/components/pages/flock/recording/form/RecordingForm.tsx create mode 100644 src/components/pages/flock/recording/form/useRecordingFormHandlers.ts diff --git a/src/components/pages/flock/recording/RecordingTable.tsx b/src/components/pages/flock/recording/RecordingTable.tsx new file mode 100644 index 00000000..e69de29b diff --git a/src/components/pages/flock/recording/form/RecordingForm.tsx b/src/components/pages/flock/recording/form/RecordingForm.tsx new file mode 100644 index 00000000..e69de29b diff --git a/src/components/pages/flock/recording/form/useRecordingFormHandlers.ts b/src/components/pages/flock/recording/form/useRecordingFormHandlers.ts new file mode 100644 index 00000000..e69de29b From 89318407eaa0c242fa3018d5f0ad534d2763dec4 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Tue, 14 Oct 2025 23:01:01 +0700 Subject: [PATCH 03/68] feat(FE-136): update RecordingForm schema to remove tanggal and add flock object --- .../recording/form/RecordingForm.schema.ts | 87 +++++++++---------- src/types/api/flock/recording.d.ts | 1 - 2 files changed, 40 insertions(+), 48 deletions(-) diff --git a/src/components/pages/flock/recording/form/RecordingForm.schema.ts b/src/components/pages/flock/recording/form/RecordingForm.schema.ts index f78e65e1..ffd9e2bd 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.schema.ts +++ b/src/components/pages/flock/recording/form/RecordingForm.schema.ts @@ -1,9 +1,13 @@ import * as Yup from 'yup'; import { RECORDING_FLAG_OPTIONS } from '@/config/constant'; +import { Recording } from '@/types/api/flock/recording'; export const RecordingFormSchema = Yup.object({ + flock: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), flock_id: Yup.number().required('Flock wajib diisi!'), - tanggal: Yup.string().required('Tanggal wajib diisi!'), data_pakan: Yup.array() .of( Yup.object({ @@ -71,51 +75,40 @@ export const UpdateRecordingFormSchema = RecordingFormSchema; export type RecordingFormValues = Yup.InferType; export const getRecordingFormInitialValues = ( - initialValues?: Partial + initialValues?: Recording ): RecordingFormValues => ({ - flock_id: initialValues?.flock_id ?? 0, - tanggal: initialValues?.tanggal ?? '', - data_pakan: - Array.isArray(initialValues?.data_pakan) && - initialValues.data_pakan.length > 0 - ? initialValues.data_pakan - : [ - { - nama_pakan: '', - qty_pakan: 0, - stock_pakan: 0, - }, - ], - bobot_badan: - Array.isArray(initialValues?.bobot_badan) && - initialValues.bobot_badan.length > 0 - ? initialValues.bobot_badan - : [ - { - berat_ayam: 0, - jumlah_ayam: 0, - rata_rata_berat_ayam: 0, - }, - ], - vaksinasi: - Array.isArray(initialValues?.vaksinasi) && - initialValues.vaksinasi.length > 0 - ? initialValues.vaksinasi - : [ - { - nama_vaksin: '', - total_stock: 0, - jumlah_stock: 0, - }, - ], - mortalitas: - Array.isArray(initialValues?.mortalitas) && - initialValues.mortalitas.length > 0 - ? initialValues.mortalitas - : [ - { - kondisi: '', - jumlah: 0, - }, - ], + flock: initialValues?.flock + ? { + value: initialValues.flock.id, + label: initialValues.flock.name, + } + : null, + flock_id: initialValues?.flock?.id ?? 0, + data_pakan: initialValues?.data_pakan ?? [ + { + nama_pakan: '', + qty_pakan: 0, + stock_pakan: 0, + }, + ], + bobot_badan: initialValues?.bobot_badan ?? [ + { + berat_ayam: 0, + jumlah_ayam: 0, + rata_rata_berat_ayam: 0, + }, + ], + vaksinasi: initialValues?.vaksinasi ?? [ + { + nama_vaksin: '', + total_stock: 0, + jumlah_stock: 0, + }, + ], + mortalitas: initialValues?.mortalitas ?? [ + { + kondisi: '', + jumlah: 0, + }, + ], }); diff --git a/src/types/api/flock/recording.d.ts b/src/types/api/flock/recording.d.ts index 66818f0c..7fc873ef 100644 --- a/src/types/api/flock/recording.d.ts +++ b/src/types/api/flock/recording.d.ts @@ -29,7 +29,6 @@ export type Recording = BaseMetadata & BaseRecording; export type CreateRecordingPayload = { flock_id: number; - tanggal: string; data_pakan: { nama_pakan: string; qty_pakan: number; From b1a3796ecaa61dbefad07d83357cce7e1c3c456a Mon Sep 17 00:00:00 2001 From: rstubryan Date: Wed, 15 Oct 2025 09:28:57 +0700 Subject: [PATCH 04/68] 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, + }; +}; From 53ee4cdc1bb3cf89276f3bae826bea2fdaf05fa1 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Wed, 15 Oct 2025 09:29:29 +0700 Subject: [PATCH 05/68] feat(FE-114): add Layout and AddRecording components with routing link --- src/app/flock/recording/add/page.tsx | 11 +++++++++++ src/app/flock/recording/detail/edit/page.tsx | 0 src/app/flock/recording/detail/layout.tsx | 11 +++++++++++ src/app/flock/recording/detail/page.tsx | 0 src/app/flock/recording/page.tsx | 9 +++++++++ 5 files changed, 31 insertions(+) create mode 100644 src/app/flock/recording/add/page.tsx create mode 100644 src/app/flock/recording/detail/edit/page.tsx create mode 100644 src/app/flock/recording/detail/layout.tsx create mode 100644 src/app/flock/recording/detail/page.tsx create mode 100644 src/app/flock/recording/page.tsx diff --git a/src/app/flock/recording/add/page.tsx b/src/app/flock/recording/add/page.tsx new file mode 100644 index 00000000..50bb1d92 --- /dev/null +++ b/src/app/flock/recording/add/page.tsx @@ -0,0 +1,11 @@ +import RecordingForm from '@/components/pages/flock/recording/form/RecordingForm'; + +const AddRecording = () => { + return ( +
+ +
+ ); +}; + +export default AddRecording; diff --git a/src/app/flock/recording/detail/edit/page.tsx b/src/app/flock/recording/detail/edit/page.tsx new file mode 100644 index 00000000..e69de29b diff --git a/src/app/flock/recording/detail/layout.tsx b/src/app/flock/recording/detail/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/flock/recording/detail/layout.tsx @@ -0,0 +1,11 @@ +import SuspenseHelper from '@/components/helper/SuspenseHelper'; + +const Layout = ({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) => { + return {children}; +}; + +export default Layout; diff --git a/src/app/flock/recording/detail/page.tsx b/src/app/flock/recording/detail/page.tsx new file mode 100644 index 00000000..e69de29b diff --git a/src/app/flock/recording/page.tsx b/src/app/flock/recording/page.tsx new file mode 100644 index 00000000..01154025 --- /dev/null +++ b/src/app/flock/recording/page.tsx @@ -0,0 +1,9 @@ +import Link from 'next/link'; + +export default function Page() { + return ( + <> + Recording + + ); +} From 6f0467918b790229ccd0f741e91b3f91ea4a35d3 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Wed, 15 Oct 2025 09:50:21 +0700 Subject: [PATCH 06/68] feat(FE-114): add tanggal_recording field to RecordingForm and update schema validation --- .../recording/form/RecordingForm.schema.ts | 6 +++ .../flock/recording/form/RecordingForm.tsx | 51 ++++++++++++++++++- src/types/api/flock/recording.d.ts | 4 +- 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/src/components/pages/flock/recording/form/RecordingForm.schema.ts b/src/components/pages/flock/recording/form/RecordingForm.schema.ts index ffd9e2bd..90d91c4f 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.schema.ts +++ b/src/components/pages/flock/recording/form/RecordingForm.schema.ts @@ -8,6 +8,9 @@ export const RecordingFormSchema = Yup.object({ label: Yup.string().required(), }).nullable(), flock_id: Yup.number().required('Flock wajib diisi!'), + tanggal_recording: Yup.date() + .required('Tanggal recording wajib diisi') + .typeError('Format tanggal tidak valid'), data_pakan: Yup.array() .of( Yup.object({ @@ -84,6 +87,9 @@ export const getRecordingFormInitialValues = ( } : null, flock_id: initialValues?.flock?.id ?? 0, + tanggal_recording: initialValues?.tanggal_recording + ? new Date(initialValues.tanggal_recording) + : new Date(), data_pakan: initialValues?.data_pakan ?? [ { nama_pakan: '', diff --git a/src/components/pages/flock/recording/form/RecordingForm.tsx b/src/components/pages/flock/recording/form/RecordingForm.tsx index 1b09b274..cb291720 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.tsx +++ b/src/components/pages/flock/recording/form/RecordingForm.tsx @@ -67,6 +67,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { setRecordingFormErrorMessage(''); const payload: CreateRecordingPayload = { flock_id: values.flock_id, + tanggal_recording: values.tanggal_recording.toISOString(), data_pakan: (values.data_pakan ?? []).map((p) => ({ nama_pakan: p.nama_pakan, qty_pakan: p.qty_pakan, @@ -230,6 +231,33 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
+ {/* {*/} + {/* formik.setFieldValue(*/} + {/* 'flock_id',*/} + {/* (val as OptionType)?.value*/} + {/* );*/} + {/* }}*/} + {/* options={flockOptions}*/} + {/* onInputChange={setFlockSelectInputValue}*/} + {/* isLoading={isLoadingFlocks}*/} + {/* isError={*/} + {/* formik.touched.flock_id && Boolean(formik.errors.flock_id)*/} + {/* }*/} + {/* errorMessage={formik.errors.flock_id as string}*/} + {/* isDisabled={type === 'detail'}*/} + {/* isClearable*/} + {/*/>*/} { }} options={flockOptions} onInputChange={(val) => { - // Filter options locally instead of API call return val; }} - isLoading={false} // Remove isLoadingFlocks + isLoading={false} isError={ formik.touched.flock_id && Boolean(formik.errors.flock_id) } @@ -254,6 +281,26 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { isDisabled={type === 'detail'} isClearable /> + { + const date = new Date(e.target.value); + formik.setFieldValue('tanggal_recording', date); + }} + onBlur={formik.handleBlur} + isError={ + formik.touched.tanggal_recording && + Boolean(formik.errors.tanggal_recording) + } + errorMessage={formik.errors.tanggal_recording as string} + readOnly={type === 'detail'} + />
diff --git a/src/types/api/flock/recording.d.ts b/src/types/api/flock/recording.d.ts index 7fc873ef..69c2b0b5 100644 --- a/src/types/api/flock/recording.d.ts +++ b/src/types/api/flock/recording.d.ts @@ -4,10 +4,11 @@ import { Flock } from '@/types/api/flock/flock'; export type BaseRecording = { id: number; flock: Flock; + tanggal_recording: string; data_pakan: { nama_pakan: string; qty_pakan: number; - stock_pakan; + stock_pakan: number; }[]; bobot_badan: { berat_ayam: number; @@ -29,6 +30,7 @@ export type Recording = BaseMetadata & BaseRecording; export type CreateRecordingPayload = { flock_id: number; + tanggal_recording: string; data_pakan: { nama_pakan: string; qty_pakan: number; From 24144f01d4bce7495776fdebdc10e698ae96a928 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Wed, 15 Oct 2025 10:37:04 +0700 Subject: [PATCH 07/68] feat(FE-114,136): add error handling for repeater inputs in RecordingForm --- .../flock/recording/form/RecordingForm.tsx | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/src/components/pages/flock/recording/form/RecordingForm.tsx b/src/components/pages/flock/recording/form/RecordingForm.tsx index cb291720..c0efe1fa 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.tsx +++ b/src/components/pages/flock/recording/form/RecordingForm.tsx @@ -112,6 +112,32 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const flockOptions = DUMMY_FLOCKS; + const isRepeaterInputError = ( + arrayName: T, + field: T extends 'data_pakan' + ? keyof CreateRecordingPayload['data_pakan'][0] + : T extends 'bobot_badan' + ? keyof CreateRecordingPayload['bobot_badan'][0] + : T extends 'vaksinasi' + ? keyof CreateRecordingPayload['vaksinasi'][0] + : T extends 'mortalitas' + ? keyof CreateRecordingPayload['mortalitas'][0] + : never, + idx: number + ) => { + const touched = formik.touched[arrayName] as + | Record[] + | undefined; + const errors = formik.errors[arrayName] as + | Record[] + | undefined; + + return ( + touched?.[idx]?.[field as string] && + Boolean(errors?.[idx]?.[field as string]) + ); + }; + const addDataPakan = () => { const newDataPakan = [ ...(formik.values.data_pakan || []), @@ -371,7 +397,15 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={pakan.nama_pakan} onChange={formik.handleChange} onBlur={formik.handleBlur} + isError={isRepeaterInputError( + 'data_pakan', + 'nama_pakan', + idx + )} readOnly={type === 'detail'} + className={{ + wrapper: 'w-full min-w-24', + }} /> @@ -382,7 +416,15 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={pakan.qty_pakan} onChange={formik.handleChange} onBlur={formik.handleBlur} + isError={isRepeaterInputError( + 'data_pakan', + 'qty_pakan', + idx + )} readOnly={type === 'detail'} + className={{ + wrapper: 'w-full min-w-24', + }} /> @@ -393,7 +435,15 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={pakan.stock_pakan} onChange={formik.handleChange} onBlur={formik.handleBlur} + isError={isRepeaterInputError( + 'data_pakan', + 'stock_pakan', + idx + )} readOnly={type === 'detail'} + className={{ + wrapper: 'w-full min-w-24', + }} /> {type !== 'detail' && ( @@ -515,6 +565,11 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={bobot.berat_ayam} onChange={formik.handleChange} onBlur={formik.handleBlur} + isError={isRepeaterInputError( + 'bobot_badan', + 'berat_ayam', + idx + )} readOnly={type === 'detail'} /> @@ -526,6 +581,11 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={bobot.jumlah_ayam} onChange={formik.handleChange} onBlur={formik.handleBlur} + isError={isRepeaterInputError( + 'bobot_badan', + 'jumlah_ayam', + idx + )} readOnly={type === 'detail'} /> @@ -537,6 +597,11 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={bobot.rata_rata_berat_ayam} onChange={formik.handleChange} onBlur={formik.handleBlur} + isError={isRepeaterInputError( + 'bobot_badan', + 'rata_rata_berat_ayam', + idx + )} readOnly={type === 'detail'} /> @@ -658,7 +723,15 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={vaksin.nama_vaksin} onChange={formik.handleChange} onBlur={formik.handleBlur} + isError={isRepeaterInputError( + 'vaksinasi', + 'nama_vaksin', + idx + )} readOnly={type === 'detail'} + className={{ + wrapper: 'w-full min-w-24', + }} /> @@ -669,7 +742,15 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={vaksin.total_stock} onChange={formik.handleChange} onBlur={formik.handleBlur} + isError={isRepeaterInputError( + 'vaksinasi', + 'total_stock', + idx + )} readOnly={type === 'detail'} + className={{ + wrapper: 'w-full min-w-24', + }} /> @@ -680,7 +761,15 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={vaksin.jumlah_stock} onChange={formik.handleChange} onBlur={formik.handleBlur} + isError={isRepeaterInputError( + 'vaksinasi', + 'jumlah_stock', + idx + )} readOnly={type === 'detail'} + className={{ + wrapper: 'w-full min-w-24', + }} /> {type !== 'detail' && ( @@ -805,6 +894,11 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { (val as OptionType)?.value ); }} + isError={isRepeaterInputError( + 'mortalitas', + 'kondisi', + idx + )} options={RECORDING_FLAG_OPTIONS} isDisabled={type === 'detail'} /> @@ -817,7 +911,15 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={mortal.jumlah} onChange={formik.handleChange} onBlur={formik.handleBlur} + isError={isRepeaterInputError( + 'mortalitas', + 'jumlah', + idx + )} readOnly={type === 'detail'} + className={{ + wrapper: 'w-full min-w-24', + }} /> {type !== 'detail' && ( From 2ee88a274249f47cf0edd34f1a99645ab3930dd0 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Wed, 15 Oct 2025 13:45:48 +0700 Subject: [PATCH 08/68] refactor(FE-114): enhance tanggal_recording handling and improve error messaging in RecordingForm --- .../flock/recording/form/RecordingForm.tsx | 247 +++++++++++++----- 1 file changed, 179 insertions(+), 68 deletions(-) diff --git a/src/components/pages/flock/recording/form/RecordingForm.tsx b/src/components/pages/flock/recording/form/RecordingForm.tsx index c0efe1fa..f53123f1 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.tsx +++ b/src/components/pages/flock/recording/form/RecordingForm.tsx @@ -67,7 +67,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { setRecordingFormErrorMessage(''); const payload: CreateRecordingPayload = { flock_id: values.flock_id, - tanggal_recording: values.tanggal_recording.toISOString(), + tanggal_recording: + values.tanggal_recording instanceof Date + ? values.tanggal_recording.toISOString() + : new Date().toISOString(), data_pakan: (values.data_pakan ?? []).map((p) => ({ nama_pakan: p.nama_pakan, qty_pakan: p.qty_pakan, @@ -126,16 +129,31 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { idx: number ) => { const touched = formik.touched[arrayName] as - | Record[] - | undefined; - const errors = formik.errors[arrayName] as - | Record[] + | { + [key: string]: boolean | undefined; + }[] | undefined; - return ( - touched?.[idx]?.[field as string] && - Boolean(errors?.[idx]?.[field as string]) - ); + const errors = formik.errors[arrayName] as + | { + [key: string]: string | undefined; + }[] + | undefined; + + if (!touched || !Array.isArray(touched)) { + return { + isError: false, + errorMessage: undefined, + }; + } + + const touchedField = touched[idx]?.[field as string]; + const errorField = errors?.[idx]?.[field as string]; + + return { + isError: touchedField && Boolean(errorField), + errorMessage: touchedField ? errorField : undefined, + }; }; const addDataPakan = () => { @@ -312,11 +330,17 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { label='Tanggal Recording' type='date' name='tanggal_recording' - value={formik.values.tanggal_recording - .toISOString() - .substring(0, 10)} + value={ + formik.values.tanggal_recording instanceof Date + ? formik.values.tanggal_recording + .toISOString() + .substring(0, 10) + : '' + } onChange={(e) => { - const date = new Date(e.target.value); + const date = e.target.value + ? new Date(e.target.value) + : new Date(); formik.setFieldValue('tanggal_recording', date); }} onBlur={formik.handleBlur} @@ -397,11 +421,20 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={pakan.nama_pakan} onChange={formik.handleChange} onBlur={formik.handleBlur} - isError={isRepeaterInputError( - 'data_pakan', - 'nama_pakan', - idx - )} + isError={ + isRepeaterInputError( + 'data_pakan', + 'nama_pakan', + idx + ).isError + } + errorMessage={ + isRepeaterInputError( + 'data_pakan', + 'nama_pakan', + idx + ).errorMessage + } readOnly={type === 'detail'} className={{ wrapper: 'w-full min-w-24', @@ -416,11 +449,20 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={pakan.qty_pakan} onChange={formik.handleChange} onBlur={formik.handleBlur} - isError={isRepeaterInputError( - 'data_pakan', - 'qty_pakan', - idx - )} + isError={ + isRepeaterInputError( + 'data_pakan', + 'qty_pakan', + idx + ).isError + } + errorMessage={ + isRepeaterInputError( + 'data_pakan', + 'qty_pakan', + idx + ).errorMessage + } readOnly={type === 'detail'} className={{ wrapper: 'w-full min-w-24', @@ -435,11 +477,20 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={pakan.stock_pakan} onChange={formik.handleChange} onBlur={formik.handleBlur} - isError={isRepeaterInputError( - 'data_pakan', - 'stock_pakan', - idx - )} + isError={ + isRepeaterInputError( + 'data_pakan', + 'stock_pakan', + idx + ).isError + } + errorMessage={ + isRepeaterInputError( + 'data_pakan', + 'stock_pakan', + idx + ).errorMessage + } readOnly={type === 'detail'} className={{ wrapper: 'w-full min-w-24', @@ -565,11 +616,20 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={bobot.berat_ayam} onChange={formik.handleChange} onBlur={formik.handleBlur} - isError={isRepeaterInputError( - 'bobot_badan', - 'berat_ayam', - idx - )} + isError={ + isRepeaterInputError( + 'bobot_badan', + 'berat_ayam', + idx + ).isError + } + errorMessage={ + isRepeaterInputError( + 'bobot_badan', + 'berat_ayam', + idx + ).errorMessage + } readOnly={type === 'detail'} /> @@ -581,11 +641,20 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={bobot.jumlah_ayam} onChange={formik.handleChange} onBlur={formik.handleBlur} - isError={isRepeaterInputError( - 'bobot_badan', - 'jumlah_ayam', - idx - )} + isError={ + isRepeaterInputError( + 'bobot_badan', + 'jumlah_ayam', + idx + ).isError + } + errorMessage={ + isRepeaterInputError( + 'bobot_badan', + 'jumlah_ayam', + idx + ).errorMessage + } readOnly={type === 'detail'} /> @@ -597,11 +666,20 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={bobot.rata_rata_berat_ayam} onChange={formik.handleChange} onBlur={formik.handleBlur} - isError={isRepeaterInputError( - 'bobot_badan', - 'rata_rata_berat_ayam', - idx - )} + isError={ + isRepeaterInputError( + 'bobot_badan', + 'rata_rata_berat_ayam', + idx + ).isError + } + errorMessage={ + isRepeaterInputError( + 'bobot_badan', + 'rata_rata_berat_ayam', + idx + ).errorMessage + } readOnly={type === 'detail'} /> @@ -723,11 +801,20 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={vaksin.nama_vaksin} onChange={formik.handleChange} onBlur={formik.handleBlur} - isError={isRepeaterInputError( - 'vaksinasi', - 'nama_vaksin', - idx - )} + isError={ + isRepeaterInputError( + 'vaksinasi', + 'nama_vaksin', + idx + ).isError + } + errorMessage={ + isRepeaterInputError( + 'vaksinasi', + 'nama_vaksin', + idx + ).errorMessage + } readOnly={type === 'detail'} className={{ wrapper: 'w-full min-w-24', @@ -742,11 +829,20 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={vaksin.total_stock} onChange={formik.handleChange} onBlur={formik.handleBlur} - isError={isRepeaterInputError( - 'vaksinasi', - 'total_stock', - idx - )} + isError={ + isRepeaterInputError( + 'vaksinasi', + 'total_stock', + idx + ).isError + } + errorMessage={ + isRepeaterInputError( + 'vaksinasi', + 'total_stock', + idx + ).errorMessage + } readOnly={type === 'detail'} className={{ wrapper: 'w-full min-w-24', @@ -761,11 +857,20 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={vaksin.jumlah_stock} onChange={formik.handleChange} onBlur={formik.handleBlur} - isError={isRepeaterInputError( - 'vaksinasi', - 'jumlah_stock', - idx - )} + isError={ + isRepeaterInputError( + 'vaksinasi', + 'jumlah_stock', + idx + ).isError + } + errorMessage={ + isRepeaterInputError( + 'vaksinasi', + 'jumlah_stock', + idx + ).errorMessage + } readOnly={type === 'detail'} className={{ wrapper: 'w-full min-w-24', @@ -894,11 +999,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { (val as OptionType)?.value ); }} - isError={isRepeaterInputError( - 'mortalitas', - 'kondisi', - idx - )} + isError={ + isRepeaterInputError('mortalitas', 'kondisi', idx) + .isError + } + errorMessage={ + isRepeaterInputError('mortalitas', 'kondisi', idx) + .errorMessage + } options={RECORDING_FLAG_OPTIONS} isDisabled={type === 'detail'} /> @@ -911,11 +1019,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { value={mortal.jumlah} onChange={formik.handleChange} onBlur={formik.handleBlur} - isError={isRepeaterInputError( - 'mortalitas', - 'jumlah', - idx - )} + isError={ + isRepeaterInputError('mortalitas', 'jumlah', idx) + .isError + } + errorMessage={ + isRepeaterInputError('mortalitas', 'jumlah', idx) + .errorMessage + } readOnly={type === 'detail'} className={{ wrapper: 'w-full min-w-24', From 64a32fd214f41179e133055386794f3595a3265c Mon Sep 17 00:00:00 2001 From: rstubryan Date: Wed, 15 Oct 2025 17:39:27 +0700 Subject: [PATCH 09/68] refactor(FE-114,136): update RecordingForm schema and types to include location and coop fields --- .../recording/form/RecordingForm.schema.ts | 86 ++- .../flock/recording/form/RecordingForm.tsx | 610 ++++++++++-------- src/types/api/flock/recording.d.ts | 66 +- 3 files changed, 443 insertions(+), 319 deletions(-) diff --git a/src/components/pages/flock/recording/form/RecordingForm.schema.ts b/src/components/pages/flock/recording/form/RecordingForm.schema.ts index 90d91c4f..394b08b0 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.schema.ts +++ b/src/components/pages/flock/recording/form/RecordingForm.schema.ts @@ -8,63 +8,73 @@ export const RecordingFormSchema = Yup.object({ label: Yup.string().required(), }).nullable(), flock_id: Yup.number().required('Flock wajib diisi!'), - tanggal_recording: Yup.date() + location: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), + location_id: Yup.number().required('Lokasi wajib diisi!'), + coop: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), + coop_id: Yup.number().required('Kandang wajib diisi!'), + recording_date: Yup.date() .required('Tanggal recording wajib diisi') .typeError('Format tanggal tidak valid'), - data_pakan: Yup.array() + feed_data: Yup.array() .of( Yup.object({ - nama_pakan: Yup.string().required('Nama pakan wajib diisi!'), - qty_pakan: Yup.number() + feed_name: Yup.string().required('Nama pakan wajib diisi!'), + feed_qty: Yup.number() .required('Qty pakan wajib diisi!') .min(1, 'Qty minimal 1!'), - stock_pakan: Yup.number() + feed_stock: Yup.number() .required('Stock pakan wajib diisi!') .min(0, 'Stock minimal 0!'), }) ) .min(1, 'Minimal harus ada 1 data pakan!') .required('Data pakan wajib diisi!'), - bobot_badan: Yup.array() + body_weight: Yup.array() .of( Yup.object({ - berat_ayam: Yup.number() + chicken_weight: Yup.number() .required('Berat ayam wajib diisi!') .min(1, 'Berat minimal 1!'), - jumlah_ayam: Yup.number() + chicken_count: Yup.number() .required('Jumlah ayam wajib diisi!') .min(1, 'Jumlah minimal 1!'), - rata_rata_berat_ayam: Yup.number() + average_chicken_weight: Yup.number() .required('Rata-rata berat ayam wajib diisi!') .min(1, 'Rata-rata minimal 1!'), }) ) .min(1, 'Minimal harus ada 1 data bobot badan!') .required('Data bobot badan wajib diisi!'), - vaksinasi: Yup.array() + vaccination: Yup.array() .of( Yup.object({ - nama_vaksin: Yup.string().required('Nama vaksin wajib diisi!'), + vaccine_name: Yup.string().required('Nama vaksin wajib diisi!'), total_stock: Yup.number() .required('Total stock wajib diisi!') .min(0, 'Total stock minimal 0!'), - jumlah_stock: Yup.number() + used_stock: Yup.number() .required('Jumlah stock wajib diisi!') .min(0, 'Jumlah stock minimal 0!'), }) ) .min(1, 'Minimal harus ada 1 data vaksinasi!') .required('Data vaksinasi wajib diisi!'), - mortalitas: Yup.array() + mortality: Yup.array() .of( Yup.object({ - kondisi: Yup.mixed() + condition: Yup.mixed() .oneOf( RECORDING_FLAG_OPTIONS.map((opt) => opt.value), 'Kondisi tidak valid!' ) .required('Kondisi wajib diisi!'), - jumlah: Yup.number() + count: Yup.number() .required('Jumlah wajib diisi!') .min(1, 'Jumlah minimal 1!'), }) @@ -87,34 +97,48 @@ export const getRecordingFormInitialValues = ( } : null, flock_id: initialValues?.flock?.id ?? 0, - tanggal_recording: initialValues?.tanggal_recording - ? new Date(initialValues.tanggal_recording) + location: initialValues?.location + ? { + value: initialValues.location.id, + label: initialValues.location.name, + } + : null, + location_id: initialValues?.location?.id ?? 0, + coop: initialValues?.coop + ? { + value: initialValues.coop.id, + label: initialValues.coop.name, + } + : null, + coop_id: initialValues?.coop?.id ?? 0, + recording_date: initialValues?.recording_date + ? new Date(initialValues.recording_date) : new Date(), - data_pakan: initialValues?.data_pakan ?? [ + feed_data: initialValues?.feed_data ?? [ { - nama_pakan: '', - qty_pakan: 0, - stock_pakan: 0, + feed_name: '', + feed_qty: 0, + feed_stock: 0, }, ], - bobot_badan: initialValues?.bobot_badan ?? [ + body_weight: initialValues?.body_weight ?? [ { - berat_ayam: 0, - jumlah_ayam: 0, - rata_rata_berat_ayam: 0, + chicken_weight: 0, + chicken_count: 0, + average_chicken_weight: 0, }, ], - vaksinasi: initialValues?.vaksinasi ?? [ + vaccination: initialValues?.vaccination ?? [ { - nama_vaksin: '', + vaccine_name: '', total_stock: 0, - jumlah_stock: 0, + used_stock: 0, }, ], - mortalitas: initialValues?.mortalitas ?? [ + mortality: initialValues?.mortality ?? [ { - kondisi: '', - jumlah: 0, + condition: '', + count: 0, }, ], }); diff --git a/src/components/pages/flock/recording/form/RecordingForm.tsx b/src/components/pages/flock/recording/form/RecordingForm.tsx index f53123f1..afc9cd84 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.tsx +++ b/src/components/pages/flock/recording/form/RecordingForm.tsx @@ -22,6 +22,7 @@ import { FlockApi } from '@/services/api/flock'; import { isResponseSuccess } from '@/lib/api-helper'; import { RECORDING_FLAG_OPTIONS } from '@/config/constant'; import useSWR from 'swr'; +import { KandangApi, LocationApi } from '@/services/api/master-data'; interface RecordingFormProps { type?: 'add' | 'edit' | 'detail'; @@ -37,10 +38,11 @@ const DUMMY_FLOCKS = [ 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 [selectedFeed, setSelectedFeed] = useState([]); + const [selectedWeight, setSelectedWeight] = useState([]); + const [selectedVaccine, setSelectedVaccine] = useState([]); + const [selectedMortality, setSelectedMortality] = useState([]); + const [, setRecordingFormErrorMessage] = useState(''); const { deleteModal, @@ -67,28 +69,30 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { setRecordingFormErrorMessage(''); const payload: CreateRecordingPayload = { flock_id: values.flock_id, - tanggal_recording: - values.tanggal_recording instanceof Date - ? values.tanggal_recording.toISOString() + location_id: values.location_id, + coop_id: values.coop_id, + recording_date: + values.recording_date instanceof Date + ? values.recording_date.toISOString() : new Date().toISOString(), - data_pakan: (values.data_pakan ?? []).map((p) => ({ - nama_pakan: p.nama_pakan, - qty_pakan: p.qty_pakan, - stock_pakan: p.stock_pakan, + feed_data: (values.feed_data ?? []).map((p) => ({ + feed_name: p.feed_name, + feed_qty: p.feed_qty, + feed_stock: p.feed_stock, })), - 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, + body_weight: (values.body_weight ?? []).map((b) => ({ + chicken_weight: b.chicken_weight, + chicken_count: b.chicken_count, + average_chicken_weight: b.average_chicken_weight, })), - vaksinasi: (values.vaksinasi ?? []).map((v) => ({ - nama_vaksin: v.nama_vaksin, + vaccination: (values.vaccination ?? []).map((v) => ({ + vaccine_name: v.vaccine_name, total_stock: v.total_stock, - jumlah_stock: v.jumlah_stock, + used_stock: v.used_stock, })), - mortalitas: (values.mortalitas ?? []).map((m) => ({ - kondisi: m.kondisi, - jumlah: m.jumlah, + mortality: (values.mortality ?? []).map((m) => ({ + condition: m.condition, + count: m.count, })), }; @@ -115,16 +119,64 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const flockOptions = DUMMY_FLOCKS; + // Location and Coop state/handlers + const [locationSelectInputValue, setLocationSelectInputValue] = useState(''); + const [coopSelectInputValue, setCoopSelectInputValue] = useState(''); + + // Location fetch + const locationsUrl = `${LocationApi.basePath}?${new URLSearchParams({ search: locationSelectInputValue ?? '' }).toString()}`; + const { data: locations, isLoading: isLoadingLocations } = useSWR( + locationsUrl, + LocationApi.getAllFetcher + ); + const locationOptions = isResponseSuccess(locations) + ? locations?.data.map((loc) => ({ value: loc.id, label: loc.name })) + : []; + + // Coop fetch + const coopsUrl = `${KandangApi.basePath}?${new URLSearchParams({ search: coopSelectInputValue ?? '' }).toString()}`; + const { data: coops, isLoading: isLoadingCoops } = useSWR( + coopsUrl, + KandangApi.getAllFetcher + ); + + // Filter coop options based on selected location + const coopOptions = useMemo(() => { + if (!isResponseSuccess(coops) || !formik.values.location_id) return []; + return coops.data + .filter((coop) => coop.location.id === formik.values.location_id) + .map((coop) => ({ value: coop.id, label: coop.name })); + }, [coops, formik.values.location_id]); + + // Handlers + const locationChangeHandler = (val: OptionType | OptionType[] | null) => { + formik.setFieldTouched('location', true); + formik.setFieldValue('location', val); + formik.setFieldTouched('location_id', true); + formik.setFieldValue('location_id', (val as OptionType)?.value); + // Reset coop selection when location changes + formik.setFieldValue('coop', null); + formik.setFieldValue('coop_id', 0); + setCoopSelectInputValue(''); + }; + + const coopChangeHandler = (val: OptionType | OptionType[] | null) => { + formik.setFieldTouched('coop', true); + formik.setFieldValue('coop', val); + formik.setFieldTouched('coop_id', true); + formik.setFieldValue('coop_id', (val as OptionType)?.value); + }; + const isRepeaterInputError = ( arrayName: T, - field: T extends 'data_pakan' - ? keyof CreateRecordingPayload['data_pakan'][0] - : T extends 'bobot_badan' - ? keyof CreateRecordingPayload['bobot_badan'][0] - : T extends 'vaksinasi' - ? keyof CreateRecordingPayload['vaksinasi'][0] - : T extends 'mortalitas' - ? keyof CreateRecordingPayload['mortalitas'][0] + field: T extends 'feed_data' + ? keyof CreateRecordingPayload['feed_data'][0] + : T extends 'body_weight' + ? keyof CreateRecordingPayload['body_weight'][0] + : T extends 'vaccination' + ? keyof CreateRecordingPayload['vaccination'][0] + : T extends 'mortality' + ? keyof CreateRecordingPayload['mortality'][0] : never, idx: number ) => { @@ -156,111 +208,111 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { }; }; - const addDataPakan = () => { - const newDataPakan = [ - ...(formik.values.data_pakan || []), + const addFeedData = () => { + const newFeedData = [ + ...(formik.values.feed_data || []), { - nama_pakan: '', - qty_pakan: 0, - stock_pakan: 0, + feed_name: '', + feed_qty: 0, + feed_stock: 0, }, ]; - formik.setFieldValue('data_pakan', newDataPakan); + formik.setFieldValue('feed_data', newFeedData); }; - const removeDataPakan = (idx: number) => { - const updatedDataPakan = formik.values.data_pakan?.filter( + const removeFeedData = (idx: number) => { + const updatedFeedData = formik.values.feed_data?.filter( (_, i) => i !== idx ); - formik.setFieldValue('data_pakan', updatedDataPakan); + formik.setFieldValue('feed_data', updatedFeedData); }; - const removeSelectedDataPakan = () => { - const updatedDataPakan = formik.values.data_pakan?.filter( - (_, idx) => !selectedPakan.includes(idx) + const removeSelectedFeedData = () => { + const updatedFeedData = formik.values.feed_data?.filter( + (_, idx) => !selectedFeed.includes(idx) ); - formik.setFieldValue('data_pakan', updatedDataPakan); - setSelectedPakan([]); + formik.setFieldValue('feed_data', updatedFeedData); + setSelectedFeed([]); }; - const addBobotBadan = () => { - const newBobotBadan = [ - ...(formik.values.bobot_badan || []), + const addBodyWeight = () => { + const newBodyWeight = [ + ...(formik.values.body_weight || []), { - berat_ayam: 0, - jumlah_ayam: 0, - rata_rata_berat_ayam: 0, + chicken_weight: 0, + chicken_count: 0, + average_chicken_weight: 0, }, ]; - formik.setFieldValue('bobot_badan', newBobotBadan); + formik.setFieldValue('body_weight', newBodyWeight); }; - const removeBobotBadan = (idx: number) => { - const updatedBobotBadan = formik.values.bobot_badan?.filter( + const removeBodyWeight = (idx: number) => { + const updatedBodyWeight = formik.values.body_weight?.filter( (_, i) => i !== idx ); - formik.setFieldValue('bobot_badan', updatedBobotBadan); + formik.setFieldValue('body_weight', updatedBodyWeight); }; - const removeSelectedBobotBadan = () => { - const updatedBobotBadan = formik.values.bobot_badan?.filter( - (_, idx) => !selectedBobot.includes(idx) + const removeSelectedBodyWeight = () => { + const updatedBodyWeight = formik.values.body_weight?.filter( + (_, idx) => !selectedWeight.includes(idx) ); - formik.setFieldValue('bobot_badan', updatedBobotBadan); - setSelectedBobot([]); + formik.setFieldValue('body_weight', updatedBodyWeight); + setSelectedWeight([]); }; - const addVaksinasi = () => { - const newVaksinasi = [ - ...(formik.values.vaksinasi || []), + const addVaccination = () => { + const newVaccination = [ + ...(formik.values.vaccination || []), { - nama_vaksin: '', + vaccine_name: '', total_stock: 0, - jumlah_stock: 0, + used_stock: 0, }, ]; - formik.setFieldValue('vaksinasi', newVaksinasi); + formik.setFieldValue('vaccination', newVaccination); }; - const removeVaksinasi = (idx: number) => { - const updatedVaksinasi = formik.values.vaksinasi?.filter( + const removeVaccination = (idx: number) => { + const updatedVaccination = formik.values.vaccination?.filter( (_, i) => i !== idx ); - formik.setFieldValue('vaksinasi', updatedVaksinasi); + formik.setFieldValue('vaccination', updatedVaccination); }; - const removeSelectedVaksinasi = () => { - const updatedVaksinasi = formik.values.vaksinasi?.filter( - (_, idx) => !selectedVaksin.includes(idx) + const removeSelectedVaccination = () => { + const updatedVaccination = formik.values.vaccination?.filter( + (_, idx) => !selectedVaccine.includes(idx) ); - formik.setFieldValue('vaksinasi', updatedVaksinasi); - setSelectedVaksin([]); + formik.setFieldValue('vaccination', updatedVaccination); + setSelectedVaccine([]); }; - const addMortalitas = () => { - const newMortalitas = [ - ...(formik.values.mortalitas || []), + const addMortality = () => { + const newMortality = [ + ...(formik.values.mortality || []), { - kondisi: RECORDING_FLAG_OPTIONS[0].value, - jumlah: 0, + condition: RECORDING_FLAG_OPTIONS[0].value, + count: 0, }, ]; - formik.setFieldValue('mortalitas', newMortalitas); + formik.setFieldValue('mortality', newMortality); }; - const removeMortalitas = (idx: number) => { - const updatedMortalitas = formik.values.mortalitas?.filter( + const removeMortality = (idx: number) => { + const updatedMortality = formik.values.mortality?.filter( (_, i) => i !== idx ); - formik.setFieldValue('mortalitas', updatedMortalitas); + formik.setFieldValue('mortality', updatedMortality); }; - const removeSelectedMortalitas = () => { - const updatedMortalitas = formik.values.mortalitas?.filter( - (_, idx) => !selectedMortal.includes(idx) + const removeSelectedMortality = () => { + const updatedMortality = formik.values.mortality?.filter( + (_, idx) => !selectedMortality.includes(idx) ); - formik.setFieldValue('mortalitas', updatedMortalitas); - setSelectedMortal([]); + formik.setFieldValue('mortality', updatedMortality); + setSelectedMortality([]); }; return ( @@ -331,8 +383,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { type='date' name='tanggal_recording' value={ - formik.values.tanggal_recording instanceof Date - ? formik.values.tanggal_recording + formik.values.recording_date instanceof Date + ? formik.values.recording_date .toISOString() .substring(0, 10) : '' @@ -345,20 +397,59 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { }} onBlur={formik.handleBlur} isError={ - formik.touched.tanggal_recording && - Boolean(formik.errors.tanggal_recording) + formik.touched.recording_date && + Boolean(formik.errors.recording_date) } - errorMessage={formik.errors.tanggal_recording as string} + errorMessage={formik.errors.recording_date as string} readOnly={type === 'detail'} /> +
+ + + +
{' '} - {/* Data Pakan Table */} + {/* Feed Data Table */}
-

Data Pakan

+

Feed Data

@@ -369,45 +460,45 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { type='checkbox' className='checkbox' checked={ - formik.values.data_pakan?.length === - selectedPakan.length && - formik.values.data_pakan?.length > 0 + formik.values.feed_data?.length === + selectedFeed.length && + formik.values.feed_data?.length > 0 } onChange={(e) => { if (e.target.checked) { - setSelectedPakan( - formik.values.data_pakan?.map( + setSelectedFeed( + formik.values.feed_data?.map( (_, idx) => idx ) ?? [] ); } else { - setSelectedPakan([]); + setSelectedFeed([]); } }} /> )} - - - - {type !== 'detail' && } + + + + {type !== 'detail' && } - {formik.values.data_pakan?.map((pakan, idx) => ( - + {formik.values.feed_data?.map((feed, idx) => ( + {type !== 'detail' && (
Nama PakanQty PakanStock PakanAksiFeed NameFeed QtyFeed StockAction
{ if (e.target.checked) { - setSelectedPakan([...selectedPakan, idx]); + setSelectedFeed([...selectedFeed, idx]); } else { - setSelectedPakan( - selectedPakan.filter((i) => i !== idx) + setSelectedFeed( + selectedFeed.filter((i) => i !== idx) ); } }} @@ -417,21 +508,21 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { { { { )} )} - {/* Bobot Badan Table */} + {/* Body Weight Table */}
-

Bobot Badan

+

Body Weight

@@ -563,45 +648,45 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { type='checkbox' className='checkbox' checked={ - formik.values.bobot_badan?.length === - selectedBobot.length && - formik.values.bobot_badan?.length > 0 + formik.values.body_weight?.length === + selectedWeight.length && + formik.values.body_weight?.length > 0 } onChange={(e) => { if (e.target.checked) { - setSelectedBobot( - formik.values.bobot_badan?.map( + setSelectedWeight( + formik.values.body_weight?.map( (_, idx) => idx ) ?? [] ); } else { - setSelectedBobot([]); + setSelectedWeight([]); } }} /> )} - - - - {type !== 'detail' && } + + + + {type !== 'detail' && } - {formik.values.bobot_badan?.map((bobot, idx) => ( - + {formik.values.body_weight?.map((weight, idx) => ( + {type !== 'detail' && (
Berat AyamJumlah AyamRata-rata Berat AyamAksiChicken WeightChicken CountAverage WeightAction
{ if (e.target.checked) { - setSelectedBobot([...selectedBobot, idx]); + setSelectedWeight([...selectedWeight, idx]); } else { - setSelectedBobot( - selectedBobot.filter((i) => i !== idx) + setSelectedWeight( + selectedWeight.filter((i) => i !== idx) ); } }} @@ -612,21 +697,21 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { { { { )} )} - {/* Vaksinasi Table */} + {/* Vaccination Table */}
-

Vaksinasi

+

Vaccination

@@ -749,45 +834,45 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { type='checkbox' className='checkbox' checked={ - formik.values.vaksinasi?.length === - selectedVaksin.length && - formik.values.vaksinasi?.length > 0 + formik.values.vaccination?.length === + selectedVaccine.length && + formik.values.vaccination?.length > 0 } onChange={(e) => { if (e.target.checked) { - setSelectedVaksin( - formik.values.vaksinasi?.map( + setSelectedVaccine( + formik.values.vaccination?.map( (_, idx) => idx ) ?? [] ); } else { - setSelectedVaksin([]); + setSelectedVaccine([]); } }} /> )} - + - - {type !== 'detail' && } + + {type !== 'detail' && } - {formik.values.vaksinasi?.map((vaksin, idx) => ( - + {formik.values.vaccination?.map((vaccine, idx) => ( + {type !== 'detail' && (
Nama VaksinVaccine Name Total StockJumlah StockAksiUsed StockAction
{ if (e.target.checked) { - setSelectedVaksin([...selectedVaksin, idx]); + setSelectedVaccine([...selectedVaccine, idx]); } else { - setSelectedVaksin( - selectedVaksin.filter((i) => i !== idx) + setSelectedVaccine( + selectedVaccine.filter((i) => i !== idx) ); } }} @@ -797,21 +882,21 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { { { { )} )} - {/* Mortalitas Table */} + {/* Mortality Table */}
-

Mortalitas

+

Mortality

@@ -943,44 +1028,47 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { type='checkbox' className='checkbox' checked={ - formik.values.mortalitas?.length === - selectedMortal.length && - formik.values.mortalitas?.length > 0 + formik.values.mortality?.length === + selectedMortality.length && + formik.values.mortality?.length > 0 } onChange={(e) => { if (e.target.checked) { - setSelectedMortal( - formik.values.mortalitas?.map( + setSelectedMortality( + formik.values.mortality?.map( (_, idx) => idx ) ?? [] ); } else { - setSelectedMortal([]); + setSelectedMortality([]); } }} /> )} - - - {type !== 'detail' && } + + + {type !== 'detail' && } - {formik.values.mortalitas?.map((mortal, idx) => ( - + {formik.values.mortality?.map((mortality, idx) => ( + {type !== 'detail' && (
KondisiJumlahAksiConditionCountAction
{ if (e.target.checked) { - setSelectedMortal([...selectedMortal, idx]); + setSelectedMortality([ + ...selectedMortality, + idx, + ]); } else { - setSelectedMortal( - selectedMortal.filter((i) => i !== idx) + setSelectedMortality( + selectedMortality.filter((i) => i !== idx) ); } }} @@ -991,21 +1079,27 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { opt.value === mortal.kondisi + (opt) => opt.value === mortality.condition )} onChange={(val) => { formik.setFieldValue( - `mortalitas.${idx}.kondisi`, + `mortality.${idx}.condition`, (val as OptionType)?.value ); }} isError={ - isRepeaterInputError('mortalitas', 'kondisi', idx) - .isError + isRepeaterInputError( + 'mortality', + 'condition', + idx + ).isError } errorMessage={ - isRepeaterInputError('mortalitas', 'kondisi', idx) - .errorMessage + isRepeaterInputError( + 'mortality', + 'condition', + idx + ).errorMessage } options={RECORDING_FLAG_OPTIONS} isDisabled={type === 'detail'} @@ -1015,16 +1109,16 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { { )} )} diff --git a/src/types/api/flock/recording.d.ts b/src/types/api/flock/recording.d.ts index 69c2b0b5..a39588b5 100644 --- a/src/types/api/flock/recording.d.ts +++ b/src/types/api/flock/recording.d.ts @@ -1,28 +1,32 @@ import { BaseMetadata } from '@/types/api/api-general'; import { Flock } from '@/types/api/flock/flock'; +import { Location } from '@/types/api/master-data/location'; +import { Kandang } from '@/types/api/master-data/kandang'; export type BaseRecording = { id: number; flock: Flock; - tanggal_recording: string; - data_pakan: { - nama_pakan: string; - qty_pakan: number; - stock_pakan: number; + recording_date: string; + location: Location; + coop: Kandang; + feed_data: { + feed_name: string; + feed_qty: number; + feed_stock: number; }[]; - bobot_badan: { - berat_ayam: number; - jumlah_ayam: number; - rata_rata_berat_ayam: number; + body_weight: { + chicken_weight: number; + chicken_count: number; + average_chicken_weight: number; }[]; - vaksinasi: { - nama_vaksin: string; + vaccination: { + vaccine_name: string; total_stock: number; - jumlah_stock: number; + used_stock: number; }[]; - mortalitas: { - kondisi: string; - jumlah: number; + mortality: { + condition: string; + count: number; }[]; }; @@ -30,25 +34,27 @@ export type Recording = BaseMetadata & BaseRecording; export type CreateRecordingPayload = { flock_id: number; - tanggal_recording: string; - data_pakan: { - nama_pakan: string; - qty_pakan: number; - stock_pakan: number; + recording_date: string; + location_id: number; + coop_id: number; + feed_data: { + feed_name: string; + feed_qty: number; + feed_stock: number; }[]; - bobot_badan: { - berat_ayam: number; - jumlah_ayam: number; - rata_rata_berat_ayam: number; + body_weight: { + chicken_weight: number; + chicken_count: number; + average_chicken_weight: number; }[]; - vaksinasi: { - nama_vaksin: string; + vaccination: { + vaccine_name: string; total_stock: number; - jumlah_stock: number; + used_stock: number; }[]; - mortalitas: { - kondisi: string; - jumlah: number; + mortality: { + condition: string; + count: number; }[]; }; From 8bfce061e6f2cc37cfc4fa039d3776a3024510e4 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Wed, 15 Oct 2025 17:53:08 +0700 Subject: [PATCH 10/68] refactor(FE-114,136): improve location and coop field handling in RecordingForm --- .../flock/recording/form/RecordingForm.tsx | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/src/components/pages/flock/recording/form/RecordingForm.tsx b/src/components/pages/flock/recording/form/RecordingForm.tsx index afc9cd84..d8656b2b 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.tsx +++ b/src/components/pages/flock/recording/form/RecordingForm.tsx @@ -150,21 +150,42 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { // Handlers const locationChangeHandler = (val: OptionType | OptionType[] | null) => { - formik.setFieldTouched('location', true); + const locationValue = (val as OptionType)?.value; + formik.setFieldValue('location', val); - formik.setFieldTouched('location_id', true); - formik.setFieldValue('location_id', (val as OptionType)?.value); - // Reset coop selection when location changes + formik.setFieldValue('location_id', locationValue || 0); + + // Only set touched if there's a value + if (locationValue) { + formik.setFieldTouched('location', true); + formik.setFieldTouched('location_id', true); + } else { + formik.setFieldTouched('location', false); + formik.setFieldTouched('location_id', false); + } + + // Reset coop selection when location changes or is cleared formik.setFieldValue('coop', null); formik.setFieldValue('coop_id', 0); + formik.setFieldTouched('coop', false); + formik.setFieldTouched('coop_id', false); setCoopSelectInputValue(''); }; const coopChangeHandler = (val: OptionType | OptionType[] | null) => { - formik.setFieldTouched('coop', true); + const coopValue = (val as OptionType)?.value; + formik.setFieldValue('coop', val); - formik.setFieldTouched('coop_id', true); - formik.setFieldValue('coop_id', (val as OptionType)?.value); + formik.setFieldValue('coop_id', coopValue || 0); + + // Only set touched if there's a value + if (coopValue) { + formik.setFieldTouched('coop', true); + formik.setFieldTouched('coop_id', true); + } else { + formik.setFieldTouched('coop', false); + formik.setFieldTouched('coop_id', false); + } }; const isRepeaterInputError = ( @@ -423,6 +444,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { /> Date: Thu, 16 Oct 2025 08:39:32 +0700 Subject: [PATCH 11/68] feat(FE-114): implement RecordingEdit and RecordingDetail components with error handling and loading states --- src/app/flock/recording/detail/edit/page.tsx | 47 +++ src/app/flock/recording/detail/page.tsx | 47 +++ src/app/flock/recording/page.tsx | 14 +- .../pages/flock/recording/RecordingTable.tsx | 358 ++++++++++++++++++ src/config/constant.ts | 1 - 5 files changed, 460 insertions(+), 7 deletions(-) diff --git a/src/app/flock/recording/detail/edit/page.tsx b/src/app/flock/recording/detail/edit/page.tsx index e69de29b..0718731c 100644 --- a/src/app/flock/recording/detail/edit/page.tsx +++ b/src/app/flock/recording/detail/edit/page.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; +import RecordingForm from '@/components/pages/flock/recording/form/RecordingForm'; +import { RecordingApi } from '@/services/api/flock'; // Import RecordingApi +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; + +const RecordingEdit = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const recordingId = searchParams.get('recordingId'); + + const { data: recording, isLoading: isLoadingRecording } = useSWR( + recordingId, + (id: number) => RecordingApi.getSingle(id) // Gunakan RecordingApi + ); + + if (!recordingId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingRecording && (!recording || isResponseError(recording))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingRecording && ( + + )} + {!isLoadingRecording && isResponseSuccess(recording) && ( + + )} +
+ ); +}; + +export default RecordingEdit; diff --git a/src/app/flock/recording/detail/page.tsx b/src/app/flock/recording/detail/page.tsx index e69de29b..a25fe998 100644 --- a/src/app/flock/recording/detail/page.tsx +++ b/src/app/flock/recording/detail/page.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; +import RecordingForm from '@/components/pages/flock/recording/form/RecordingForm'; +import { RecordingApi } from '@/services/api/flock'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; + +const RecordingDetail = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const recordingId = searchParams.get('recordingId'); + + const { data: recording, isLoading: isLoadingRecording } = useSWR( + recordingId, + (id: number) => RecordingApi.getSingle(id) + ); + + if (!recordingId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingRecording && (!recording || isResponseError(recording))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingRecording && ( + + )} + {!isLoadingRecording && isResponseSuccess(recording) && ( + + )} +
+ ); +}; + +export default RecordingDetail; diff --git a/src/app/flock/recording/page.tsx b/src/app/flock/recording/page.tsx index 01154025..06f42789 100644 --- a/src/app/flock/recording/page.tsx +++ b/src/app/flock/recording/page.tsx @@ -1,9 +1,11 @@ -import Link from 'next/link'; +import RecordingTable from '@/components/pages/flock/recording/RecordingTable'; -export default function Page() { +const Recording = () => { return ( - <> - Recording - +
+ +
); -} +}; + +export default Recording; diff --git a/src/components/pages/flock/recording/RecordingTable.tsx b/src/components/pages/flock/recording/RecordingTable.tsx index e69de29b..ea7967a8 100644 --- a/src/components/pages/flock/recording/RecordingTable.tsx +++ b/src/components/pages/flock/recording/RecordingTable.tsx @@ -0,0 +1,358 @@ +'use client'; + +import { useCallback, useMemo, useState } from 'react'; +import { Icon } from '@iconify/react'; +import { SortingState } from '@tanstack/react-table'; +import { cn } from '@/lib/helper'; +import { useModal } from '@/components/Modal'; +import Button from '@/components/Button'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; +import { OptionType } from '@/components/input/SelectInput'; +import { ROWS_OPTIONS } from '@/config/constant'; +import { TableToolbar } from '@/components/table/TableToolbar'; +import { TableRowSizeSelector } from '@/components/table/TableRowSizeSelector'; +import Table from '@/components/Table'; +import RowDropdownOptions from '@/components/table/RowDropdownOptions'; +import RowCollapseOptions from '@/components/table/RowCollapseOptions'; +import { type CellContext } from '@tanstack/react-table'; +import { type Recording } from '@/types/api/flock/recording'; + +const dummyRecordings: Recording[] = [ + { + id: 1, + flock: { + id: 1, + name: 'Flock A', + created_at: '2024-01-01', + updated_at: '2024-01-01', + created_user: { + id: 1, + id_user: 1, + email: 'admin@example.com', + name: 'Admin', + }, + }, + recording_date: '2024-01-01', + location: { + id: 1, + name: 'Location 1', + address: 'Jl. Contoh No. 1', + area: { + id: 1, + name: 'Area 1', + }, + created_at: '2024-01-01', + updated_at: '2024-01-01', + created_user: { + id: 1, + id_user: 1, + email: 'admin@example.com', + name: 'Admin', + }, + }, + coop: { + id: 1, + name: 'Coop 1', + location: { + id: 1, + name: 'Location 1', + address: 'Jl. Contoh No. 1', + area: { + id: 1, + name: 'Area 1', + }, + }, + pic: { + id: 1, + id_user: 1, + email: 'pic@example.com', + name: 'PIC User', + }, + created_at: '2024-01-01', + updated_at: '2024-01-01', + created_user: { + id: 1, + id_user: 1, + email: 'admin@example.com', + name: 'Admin', + }, + }, + feed_data: [ + { + feed_name: 'Feed 1', + feed_qty: 100, + feed_stock: 500, + }, + ], + body_weight: [ + { + chicken_weight: 2.5, + chicken_count: 1000, + average_chicken_weight: 2.5, + }, + ], + vaccination: [ + { + vaccine_name: 'Vaccine 1', + total_stock: 200, + used_stock: 150, + }, + ], + mortality: [ + { + condition: 'NORMAL', + count: 5, + }, + ], + created_at: '2024-01-01', + updated_at: '2024-01-01', + created_user: { + id: 1, + id_user: 1, + email: 'admin@example.com', + name: 'Admin', + }, + }, +]; + +const RowOptionsMenu = ({ + type = 'dropdown', + props, + deleteClickHandler, +}: { + type: 'dropdown' | 'collapse'; + props: CellContext; + deleteClickHandler: () => void; +}) => { + return ( +
+ + + +
+ ); +}; + +const RecordingTable = () => { + const [search, setSearch] = useState(''); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [sorting, setSorting] = useState([]); + const [, setSelectedRecording] = useState(undefined); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + const deleteModal = useModal(); + + const searchChangeHandler = useCallback( + (e: React.ChangeEvent) => { + setSearch(e.target.value); + setPage(1); + }, + [] + ); + + const pageSizeChangeHandler = useCallback( + (val: OptionType | OptionType[] | null) => { + const newVal = val as OptionType; + setPageSize(newVal.value as number); + setPage(1); + }, + [] + ); + + const confirmationModalDeleteClickHandler = async () => { + setIsDeleteLoading(true); + setTimeout(() => { + setIsDeleteLoading(false); + deleteModal.closeModal(); + }, 1000); + }; + + const paginatedData = useMemo(() => { + const filteredData = dummyRecordings.filter( + (recording) => + recording.flock.name.toLowerCase().includes(search.toLowerCase()) || + recording.location.name.toLowerCase().includes(search.toLowerCase()) || + recording.coop.name.toLowerCase().includes(search.toLowerCase()) + ); + const start = (page - 1) * pageSize; + return filteredData.slice(start, start + pageSize); + }, [page, pageSize, search]); + + return ( +
+
+ + +
+ + pageSize * (page - 1) + props.row.index + 1, + }, + { + accessorKey: 'flock.name', + header: 'Flock', + }, + { + accessorKey: 'recording_date', + header: 'Tanggal Recording', + cell: (props) => + new Date(props.row.original.recording_date).toLocaleDateString(), + }, + { + accessorKey: 'location.name', + header: 'Lokasi', + }, + { + accessorKey: 'coop.name', + header: 'Kandang', + }, + { + accessorKey: 'mortality', + header: 'Total Mortality', + cell: (props) => + props.row.original.mortality.reduce( + (acc, curr) => acc + curr.count, + 0 + ), + }, + { + header: 'Aksi', + cell: (props: CellContext) => { + const currentPageSize = + props.table.getPaginationRowModel().rows.length; + const currentPageRows = + props.table.getPaginationRowModel().flatRows; + const currentRowRelativeIndex = + currentPageRows.findIndex((r) => r.id === props.row.id) + 1; + + const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2; + + const deleteClickHandler = () => { + setSelectedRecording(props.row.original); + deleteModal.openModal(); + }; + + return ( + <> + {currentPageSize > 2 && ( + + + + )} + + {currentPageSize <= 2 && ( + + + + )} + + ); + }, + }, + ]} + pageSize={pageSize} + page={page} + totalItems={dummyRecordings.length} + onPageChange={setPage} + isLoading={false} + sorting={sorting} + setSorting={setSorting} + className={{ + containerClassName: cn({ + 'mb-20': paginatedData.length === 0, + }), + tableWrapperClassName: 'overflow-x-auto min-h-full!', + tableClassName: 'font-inter w-full table-auto min-h-full!', + headerRowClassName: 'border-b border-b-gray-200', + headerColumnClassName: + 'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', + bodyRowClassName: 'border-b border-b-gray-200', + bodyColumnClassName: + 'px-6 py-3 last:flex last:flex-row last:justify-end', + }} + /> + + + + ); +}; + +export default RecordingTable; diff --git a/src/config/constant.ts b/src/config/constant.ts index 87dcd927..668d7209 100644 --- a/src/config/constant.ts +++ b/src/config/constant.ts @@ -203,5 +203,4 @@ export const RECORDING_FLAG_OPTIONS = [ { label: 'Ayam Afkir', value: 'Ayam Afkir' }, { label: 'Ayam Culling', value: 'Ayam Culling' }, { label: 'Ayam Mati', value: 'Ayam Mati' }, - { label: 'DOC', value: 'DOC' }, ]; From 64e67246647a1fe9979606377fb86627fdeeb058 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 16 Oct 2025 09:07:12 +0700 Subject: [PATCH 12/68] feat(FE-114): add bulk action functionality for approving, rejecting, and deleting recordings in RecordingTable --- .../pages/flock/recording/RecordingTable.tsx | 203 ++++++++++++++++-- 1 file changed, 191 insertions(+), 12 deletions(-) diff --git a/src/components/pages/flock/recording/RecordingTable.tsx b/src/components/pages/flock/recording/RecordingTable.tsx index ea7967a8..f2031037 100644 --- a/src/components/pages/flock/recording/RecordingTable.tsx +++ b/src/components/pages/flock/recording/RecordingTable.tsx @@ -176,10 +176,16 @@ const RecordingTable = () => { const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(10); const [sorting, setSorting] = useState([]); + const [selectedRecordings, setSelectedRecordings] = useState([]); const [, setSelectedRecording] = useState(undefined); const [isDeleteLoading, setIsDeleteLoading] = useState(false); + const [isBulkApproveLoading, setIsBulkApproveLoading] = useState(false); + const [isBulkRejectLoading, setIsBulkRejectLoading] = useState(false); - const deleteModal = useModal(); + const singleDeleteModal = useModal(); + const bulkDeleteModal = useModal(); + const bulkApproveModal = useModal(); + const bulkRejectModal = useModal(); const searchChangeHandler = useCallback( (e: React.ChangeEvent) => { @@ -198,14 +204,6 @@ const RecordingTable = () => { [] ); - const confirmationModalDeleteClickHandler = async () => { - setIsDeleteLoading(true); - setTimeout(() => { - setIsDeleteLoading(false); - deleteModal.closeModal(); - }, 1000); - }; - const paginatedData = useMemo(() => { const filteredData = dummyRecordings.filter( (recording) => @@ -217,6 +215,53 @@ const RecordingTable = () => { return filteredData.slice(start, start + pageSize); }, [page, pageSize, search]); + const bulkApproveHandler = async () => { + setIsBulkApproveLoading(true); + console.log( + 'Approved recordings:', + paginatedData.filter((_, idx) => selectedRecordings.includes(idx)) + ); + setTimeout(() => { + setIsBulkApproveLoading(false); + setSelectedRecordings([]); + bulkApproveModal.closeModal(); + }, 1000); + }; + + const bulkRejectHandler = async () => { + setIsBulkRejectLoading(true); + console.log( + 'Rejected recordings:', + paginatedData.filter((_, idx) => selectedRecordings.includes(idx)) + ); + setTimeout(() => { + setIsBulkRejectLoading(false); + setSelectedRecordings([]); + bulkRejectModal.closeModal(); + }, 1000); + }; + + const singleDeleteHandler = async () => { + setIsDeleteLoading(true); + setTimeout(() => { + setIsDeleteLoading(false); + singleDeleteModal.closeModal(); + }, 1000); + }; + + const bulkDeleteHandler = async () => { + setIsDeleteLoading(true); + console.log( + 'Deleted recordings:', + paginatedData.filter((_, idx) => selectedRecordings.includes(idx)) + ); + setTimeout(() => { + setIsDeleteLoading(false); + setSelectedRecordings([]); + bulkDeleteModal.closeModal(); + }, 1000); + }; + return (
@@ -238,9 +283,143 @@ const RecordingTable = () => { />
+ {/* Bulk action buttons */} +
+ {selectedRecordings.length > 0 && ( +
+ + + +
+ )} + + + + + + +
+
( + 0 && + table + .getRowModel() + .rows.every((row) => selectedRecordings.includes(row.index)) + } + onChange={(e) => { + if (e.target.checked) { + setSelectedRecordings( + table.getRowModel().rows.map((row) => row.index) + ); + } else { + setSelectedRecordings([]); + } + }} + /> + ), + cell: ({ row }) => ( + { + if (e.target.checked) { + setSelectedRecordings([...selectedRecordings, row.index]); + } else { + setSelectedRecordings( + selectedRecordings.filter((i) => i !== row.index) + ); + } + }} + /> + ), + }, { header: '#', cell: (props) => pageSize * (page - 1) + props.row.index + 1, @@ -286,7 +465,7 @@ const RecordingTable = () => { const deleteClickHandler = () => { setSelectedRecording(props.row.original); - deleteModal.openModal(); + singleDeleteModal.openModal(); }; return ( @@ -338,7 +517,7 @@ const RecordingTable = () => { /> { text: 'Ya', color: 'error', isLoading: isDeleteLoading, - onClick: confirmationModalDeleteClickHandler, + onClick: singleDeleteHandler, }} /> From ec387637ed488dccbde2e22e266481f1ee2cb2d6 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 16 Oct 2025 09:12:21 +0700 Subject: [PATCH 13/68] refactor(FE-114): remove bulk delete functionality from RecordingTable --- .../pages/flock/recording/RecordingTable.tsx | 43 ------------------- 1 file changed, 43 deletions(-) diff --git a/src/components/pages/flock/recording/RecordingTable.tsx b/src/components/pages/flock/recording/RecordingTable.tsx index f2031037..bcdcd4dd 100644 --- a/src/components/pages/flock/recording/RecordingTable.tsx +++ b/src/components/pages/flock/recording/RecordingTable.tsx @@ -183,7 +183,6 @@ const RecordingTable = () => { const [isBulkRejectLoading, setIsBulkRejectLoading] = useState(false); const singleDeleteModal = useModal(); - const bulkDeleteModal = useModal(); const bulkApproveModal = useModal(); const bulkRejectModal = useModal(); @@ -249,19 +248,6 @@ const RecordingTable = () => { }, 1000); }; - const bulkDeleteHandler = async () => { - setIsDeleteLoading(true); - console.log( - 'Deleted recordings:', - paginatedData.filter((_, idx) => selectedRecordings.includes(idx)) - ); - setTimeout(() => { - setIsDeleteLoading(false); - setSelectedRecordings([]); - bulkDeleteModal.closeModal(); - }, 1000); - }; - return (
@@ -313,20 +299,6 @@ const RecordingTable = () => { /> Reject ({selectedRecordings.length}) -
)} @@ -359,21 +331,6 @@ const RecordingTable = () => { onClick: bulkRejectHandler, }} /> - -
Date: Thu, 16 Oct 2025 09:24:00 +0700 Subject: [PATCH 14/68] refactor(FE-114): enhance layout and structure of RecordingForm component --- .../flock/recording/form/RecordingForm.tsx | 240 +++++++++--------- 1 file changed, 122 insertions(+), 118 deletions(-) diff --git a/src/components/pages/flock/recording/form/RecordingForm.tsx b/src/components/pages/flock/recording/form/RecordingForm.tsx index d8656b2b..a133e437 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.tsx +++ b/src/components/pages/flock/recording/form/RecordingForm.tsx @@ -346,125 +346,129 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { > {/* Basic Info Card */}
-
-
- {/* {*/} - {/* formik.setFieldValue(*/} - {/* 'flock_id',*/} - {/* (val as OptionType)?.value*/} - {/* );*/} - {/* }}*/} - {/* options={flockOptions}*/} - {/* onInputChange={setFlockSelectInputValue}*/} - {/* isLoading={isLoadingFlocks}*/} - {/* isError={*/} - {/* formik.touched.flock_id && Boolean(formik.errors.flock_id)*/} - {/* }*/} - {/* errorMessage={formik.errors.flock_id as string}*/} - {/* isDisabled={type === 'detail'}*/} - {/* isClearable*/} - {/*/>*/} - { - formik.setFieldValue('flock', val); - formik.setFieldValue( - 'flock_id', - (val as OptionType)?.value - ); - }} - options={flockOptions} - onInputChange={(val) => { - return val; - }} - isLoading={false} - isError={ - formik.touched.flock_id && Boolean(formik.errors.flock_id) - } - errorMessage={formik.errors.flock_id as string} - isDisabled={type === 'detail'} - isClearable - /> - { - const date = e.target.value - ? new Date(e.target.value) - : new Date(); - formik.setFieldValue('tanggal_recording', date); - }} - onBlur={formik.handleBlur} - isError={ - formik.touched.recording_date && - Boolean(formik.errors.recording_date) - } - errorMessage={formik.errors.recording_date as string} - readOnly={type === 'detail'} - /> -
-
- +
+

Flock

- -
{' '} +
+
+ {/* {*/} + {/* formik.setFieldValue(*/} + {/* 'flock_id',*/} + {/* (val as OptionType)?.value*/} + {/* );*/} + {/* }}*/} + {/* options={flockOptions}*/} + {/* onInputChange={setFlockSelectInputValue}*/} + {/* isLoading={isLoadingFlocks}*/} + {/* isError={*/} + {/* formik.touched.flock_id && Boolean(formik.errors.flock_id)*/} + {/* }*/} + {/* errorMessage={formik.errors.flock_id as string}*/} + {/* isDisabled={type === 'detail'}*/} + {/* isClearable*/} + {/*/>*/} + { + formik.setFieldValue('flock', val); + formik.setFieldValue( + 'flock_id', + (val as OptionType)?.value + ); + }} + options={flockOptions} + onInputChange={(val) => { + return val; + }} + isLoading={false} + isError={ + formik.touched.flock_id && Boolean(formik.errors.flock_id) + } + errorMessage={formik.errors.flock_id as string} + isDisabled={type === 'detail'} + isClearable + /> + { + const date = e.target.value + ? new Date(e.target.value) + : new Date(); + formik.setFieldValue('tanggal_recording', date); + }} + onBlur={formik.handleBlur} + isError={ + formik.touched.recording_date && + Boolean(formik.errors.recording_date) + } + errorMessage={formik.errors.recording_date as string} + readOnly={type === 'detail'} + /> +
+
+ + + +
+
From 23d5a41d56fdb973c169e97f74f846f2fd387f03 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 16 Oct 2025 10:08:49 +0700 Subject: [PATCH 15/68] refactor(FE-114): improve recording date handling in RecordingForm --- .../flock/recording/form/RecordingForm.tsx | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/src/components/pages/flock/recording/form/RecordingForm.tsx b/src/components/pages/flock/recording/form/RecordingForm.tsx index a133e437..8d145ca8 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.tsx +++ b/src/components/pages/flock/recording/form/RecordingForm.tsx @@ -74,7 +74,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { recording_date: values.recording_date instanceof Date ? values.recording_date.toISOString() - : new Date().toISOString(), + : '', feed_data: (values.feed_data ?? []).map((p) => ({ feed_name: p.feed_name, feed_qty: p.feed_qty, @@ -119,11 +119,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const flockOptions = DUMMY_FLOCKS; - // Location and Coop state/handlers const [locationSelectInputValue, setLocationSelectInputValue] = useState(''); const [coopSelectInputValue, setCoopSelectInputValue] = useState(''); - // Location fetch const locationsUrl = `${LocationApi.basePath}?${new URLSearchParams({ search: locationSelectInputValue ?? '' }).toString()}`; const { data: locations, isLoading: isLoadingLocations } = useSWR( locationsUrl, @@ -133,14 +131,12 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ? locations?.data.map((loc) => ({ value: loc.id, label: loc.name })) : []; - // Coop fetch const coopsUrl = `${KandangApi.basePath}?${new URLSearchParams({ search: coopSelectInputValue ?? '' }).toString()}`; const { data: coops, isLoading: isLoadingCoops } = useSWR( coopsUrl, KandangApi.getAllFetcher ); - // Filter coop options based on selected location const coopOptions = useMemo(() => { if (!isResponseSuccess(coops) || !formik.values.location_id) return []; return coops.data @@ -148,14 +144,12 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { .map((coop) => ({ value: coop.id, label: coop.name })); }, [coops, formik.values.location_id]); - // Handlers const locationChangeHandler = (val: OptionType | OptionType[] | null) => { const locationValue = (val as OptionType)?.value; formik.setFieldValue('location', val); formik.setFieldValue('location_id', locationValue || 0); - // Only set touched if there's a value if (locationValue) { formik.setFieldTouched('location', true); formik.setFieldTouched('location_id', true); @@ -164,7 +158,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { formik.setFieldTouched('location_id', false); } - // Reset coop selection when location changes or is cleared formik.setFieldValue('coop', null); formik.setFieldValue('coop_id', 0); formik.setFieldTouched('coop', false); @@ -178,7 +171,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { formik.setFieldValue('coop', val); formik.setFieldValue('coop_id', coopValue || 0); - // Only set touched if there's a value if (coopValue) { formik.setFieldTouched('coop', true); formik.setFieldTouched('coop_id', true); @@ -229,6 +221,21 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { }; }; + const flockChangeHandler = (val: OptionType | OptionType[] | null) => { + const flockValue = (val as OptionType)?.value; + + formik.setFieldValue('flock', val); + formik.setFieldValue('flock_id', flockValue || 0); + + if (flockValue) { + formik.setFieldTouched('flock', true); + formik.setFieldTouched('flock_id', true); + } else { + formik.setFieldTouched('flock', false); + formik.setFieldTouched('flock_id', false); + } + }; + const addFeedData = () => { const newFeedData = [ ...(formik.values.feed_data || []), @@ -382,17 +389,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { required label='Flock' value={formik.values.flock ?? undefined} - onChange={(val) => { - formik.setFieldValue('flock', val); - formik.setFieldValue( - 'flock_id', - (val as OptionType)?.value - ); - }} + onChange={flockChangeHandler} options={flockOptions} - onInputChange={(val) => { - return val; - }} + onInputChange={setFlockSelectInputValue} isLoading={false} isError={ formik.touched.flock_id && Boolean(formik.errors.flock_id) @@ -405,7 +404,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { required label='Tanggal Recording' type='date' - name='tanggal_recording' + name='recording_date' value={ formik.values.recording_date instanceof Date ? formik.values.recording_date @@ -416,8 +415,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { onChange={(e) => { const date = e.target.value ? new Date(e.target.value) - : new Date(); - formik.setFieldValue('tanggal_recording', date); + : null; + formik.setFieldValue('recording_date', date); }} onBlur={formik.handleBlur} isError={ @@ -1129,6 +1128,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { } options={RECORDING_FLAG_OPTIONS} isDisabled={type === 'detail'} + isClearable />
- - + + {type !== 'detail' && } @@ -500,27 +520,47 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { )} - + {type !== 'detail' && } @@ -874,27 +914,53 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { )} )} {type !== 'detail' && ( @@ -995,13 +1015,15 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { /> @@ -1074,7 +1071,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { productWarehouseId as number ) ?? '') : ''; - formik.setFieldValue( `vaccination.${idx}.vaccine`, val @@ -1111,7 +1107,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { isDisabled={type === 'detail'} isClearable className={{ - wrapper: 'w-full min-w-24', + wrapper: + 'w-full min-w-52 md:min-w-72 lg:min-w-80', }} /> @@ -1306,6 +1303,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { options={RECORDING_FLAG_OPTIONS} isDisabled={type === 'detail'} isClearable + className={{ + wrapper: + 'w-full min-w-52 md:min-w-72 lg:min-w-80', + }} />
From 19db9a4eacd5e37aeacea6f34d171429b21293fc Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 16 Oct 2025 10:54:36 +0700 Subject: [PATCH 16/68] refactor(FE-114,136): enhance validation and default values in RecordingForm schema --- .../recording/form/RecordingForm.schema.ts | 42 +++++++++++++++---- .../flock/recording/form/RecordingForm.tsx | 42 ++++--------------- 2 files changed, 43 insertions(+), 41 deletions(-) diff --git a/src/components/pages/flock/recording/form/RecordingForm.schema.ts b/src/components/pages/flock/recording/form/RecordingForm.schema.ts index 394b08b0..319482e7 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.schema.ts +++ b/src/components/pages/flock/recording/form/RecordingForm.schema.ts @@ -7,17 +7,41 @@ export const RecordingFormSchema = Yup.object({ value: Yup.number().min(1).required(), label: Yup.string().required(), }).nullable(), - flock_id: Yup.number().required('Flock wajib diisi!'), + flock_id: Yup.number() + .default(0) + .typeError('Flock wajib diisi!') + .test( + 'is-valid-flock', + 'Flock wajib diisi!', + (value) => value !== undefined && value !== null && value > 0 + ) + .required('Flock wajib diisi!'), location: Yup.object({ value: Yup.number().min(1).required(), label: Yup.string().required(), }).nullable(), - location_id: Yup.number().required('Lokasi wajib diisi!'), + location_id: Yup.number() + .default(0) + .typeError('Lokasi wajib diisi!') + .test( + 'is-valid-location', + 'Lokasi wajib diisi!', + (value) => value !== undefined && value !== null && value > 0 + ) + .required('Lokasi wajib diisi!'), coop: Yup.object({ value: Yup.number().min(1).required(), label: Yup.string().required(), }).nullable(), - coop_id: Yup.number().required('Kandang wajib diisi!'), + coop_id: Yup.number() + .default(0) + .typeError('Kandang wajib diisi!') + .test( + 'is-valid-coop', + 'Kandang wajib diisi!', + (value) => value !== undefined && value !== null && value > 0 + ) + .required('Kandang wajib diisi!'), recording_date: Yup.date() .required('Tanggal recording wajib diisi') .typeError('Format tanggal tidak valid'), @@ -27,10 +51,12 @@ export const RecordingFormSchema = Yup.object({ feed_name: Yup.string().required('Nama pakan wajib diisi!'), feed_qty: Yup.number() .required('Qty pakan wajib diisi!') - .min(1, 'Qty minimal 1!'), + .min(1, 'Qty minimal 1!') + .typeError('Qty pakan wajib diisi!'), feed_stock: Yup.number() .required('Stock pakan wajib diisi!') - .min(0, 'Stock minimal 0!'), + .min(1, 'Stock minimal 1!') + .typeError('Stock pakan wajib diisi!'), }) ) .min(1, 'Minimal harus ada 1 data pakan!') @@ -57,10 +83,12 @@ export const RecordingFormSchema = Yup.object({ vaccine_name: Yup.string().required('Nama vaksin wajib diisi!'), total_stock: Yup.number() .required('Total stock wajib diisi!') - .min(0, 'Total stock minimal 0!'), + .min(1, 'Total stock minimal 1!') + .typeError('Total stock wajib diisi!'), used_stock: Yup.number() .required('Jumlah stock wajib diisi!') - .min(0, 'Jumlah stock minimal 0!'), + .min(1, 'Jumlah stock minimal 1!') + .typeError('Jumlah stock wajib diisi!'), }) ) .min(1, 'Minimal harus ada 1 data vaksinasi!') diff --git a/src/components/pages/flock/recording/form/RecordingForm.tsx b/src/components/pages/flock/recording/form/RecordingForm.tsx index 8d145ca8..228683fa 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.tsx +++ b/src/components/pages/flock/recording/form/RecordingForm.tsx @@ -147,37 +147,19 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const locationChangeHandler = (val: OptionType | OptionType[] | null) => { const locationValue = (val as OptionType)?.value; - formik.setFieldValue('location', val); - formik.setFieldValue('location_id', locationValue || 0); + formik.setFieldValue('location', val, false); + formik.setFieldValue('location_id', locationValue || 0, false); - if (locationValue) { - formik.setFieldTouched('location', true); - formik.setFieldTouched('location_id', true); - } else { - formik.setFieldTouched('location', false); - formik.setFieldTouched('location_id', false); - } - - formik.setFieldValue('coop', null); - formik.setFieldValue('coop_id', 0); - formik.setFieldTouched('coop', false); - formik.setFieldTouched('coop_id', false); + formik.setFieldValue('coop', null, false); + formik.setFieldValue('coop_id', 0, false); setCoopSelectInputValue(''); }; const coopChangeHandler = (val: OptionType | OptionType[] | null) => { const coopValue = (val as OptionType)?.value; - formik.setFieldValue('coop', val); - formik.setFieldValue('coop_id', coopValue || 0); - - if (coopValue) { - formik.setFieldTouched('coop', true); - formik.setFieldTouched('coop_id', true); - } else { - formik.setFieldTouched('coop', false); - formik.setFieldTouched('coop_id', false); - } + formik.setFieldValue('coop', val, false); + formik.setFieldValue('coop_id', coopValue || 0, false); }; const isRepeaterInputError = ( @@ -224,16 +206,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const flockChangeHandler = (val: OptionType | OptionType[] | null) => { const flockValue = (val as OptionType)?.value; - formik.setFieldValue('flock', val); - formik.setFieldValue('flock_id', flockValue || 0); - - if (flockValue) { - formik.setFieldTouched('flock', true); - formik.setFieldTouched('flock_id', true); - } else { - formik.setFieldTouched('flock', false); - formik.setFieldTouched('flock_id', false); - } + formik.setFieldValue('flock', val, false); + formik.setFieldValue('flock_id', flockValue || 0, false); }; const addFeedData = () => { From c77968940e12caeb94912ee6079bd200734849ce Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 17 Oct 2025 13:16:54 +0700 Subject: [PATCH 17/68] refactor(FE-114,136): update flock references to use ProjectFlock and adjust RecordingForm for new API --- .../flock/recording/form/RecordingForm.tsx | 39 ++++++++----------- src/services/api/flock.ts | 2 +- src/services/api/production.ts | 12 ++++++ src/types/api/flock/recording.d.ts | 4 +- .../api/{flock => master-data}/flock.d.ts | 0 src/types/api/production/project-flock.d.ts | 39 +++++++++++++++++++ 6 files changed, 71 insertions(+), 25 deletions(-) create mode 100644 src/services/api/production.ts rename src/types/api/{flock => master-data}/flock.d.ts (100%) create mode 100644 src/types/api/production/project-flock.d.ts diff --git a/src/components/pages/flock/recording/form/RecordingForm.tsx b/src/components/pages/flock/recording/form/RecordingForm.tsx index 228683fa..d6e80a76 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.tsx +++ b/src/components/pages/flock/recording/form/RecordingForm.tsx @@ -18,7 +18,7 @@ import { UpdateRecordingFormSchema, } from './RecordingForm.schema'; import { useRecordingFormHandlers } from './useRecordingFormHandlers'; -import { FlockApi } from '@/services/api/flock'; +import { ProjectFlockApi } from '@/services/api/production'; import { isResponseSuccess } from '@/lib/api-helper'; import { RECORDING_FLAG_OPTIONS } from '@/config/constant'; import useSWR from 'swr'; @@ -29,15 +29,10 @@ interface RecordingFormProps { 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 [locationSelectInputValue, setLocationSelectInputValue] = useState(''); + const [coopSelectInputValue, setCoopSelectInputValue] = useState(''); const [selectedFeed, setSelectedFeed] = useState([]); const [selectedWeight, setSelectedWeight] = useState([]); const [selectedVaccine, setSelectedVaccine] = useState([]); @@ -107,20 +102,20 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { }, }); - // // 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 })) - // : []; + // Flock selection + const projectFlocksUrl = `${ProjectFlockApi.basePath}?${new URLSearchParams({ search: flockSelectInputValue }).toString()}`; + const { data: projectFlocks, isLoading: isLoadingFlocks } = useSWR( + projectFlocksUrl, + ProjectFlockApi.getAllFetcher + ); + const flockOptions = isResponseSuccess(projectFlocks) + ? projectFlocks?.data.map((flock) => ({ + value: flock.id, + label: flock.flock.name, + })) + : []; - const flockOptions = DUMMY_FLOCKS; - - const [locationSelectInputValue, setLocationSelectInputValue] = useState(''); - const [coopSelectInputValue, setCoopSelectInputValue] = useState(''); + // Pakan selection const locationsUrl = `${LocationApi.basePath}?${new URLSearchParams({ search: locationSelectInputValue ?? '' }).toString()}`; const { data: locations, isLoading: isLoadingLocations } = useSWR( @@ -366,7 +361,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { onChange={flockChangeHandler} options={flockOptions} onInputChange={setFlockSelectInputValue} - isLoading={false} + isLoading={isLoadingFlocks} isError={ formik.touched.flock_id && Boolean(formik.errors.flock_id) } diff --git a/src/services/api/flock.ts b/src/services/api/flock.ts index 83fbb0d8..ff8c641a 100644 --- a/src/services/api/flock.ts +++ b/src/services/api/flock.ts @@ -2,7 +2,7 @@ import { CreateFlockPayload, Flock, UpdateFlockPayload, -} from '@/types/api/flock/flock'; +} from '@/types/api/master-data/flock'; import { CreateRecordingPayload, Recording, diff --git a/src/services/api/production.ts b/src/services/api/production.ts new file mode 100644 index 00000000..49f0b133 --- /dev/null +++ b/src/services/api/production.ts @@ -0,0 +1,12 @@ +import { BaseApiService } from './base'; +import { + CreateProjectFlockPayload, + ProjectFlock, + UpdateProjectFlockPayload, +} from '@/types/api/production/project-flock'; + +export const ProjectFlockApi = new BaseApiService< + ProjectFlock, + CreateProjectFlockPayload, + UpdateProjectFlockPayload +>('/production/project_flocks'); diff --git a/src/types/api/flock/recording.d.ts b/src/types/api/flock/recording.d.ts index a39588b5..58fed128 100644 --- a/src/types/api/flock/recording.d.ts +++ b/src/types/api/flock/recording.d.ts @@ -1,11 +1,11 @@ import { BaseMetadata } from '@/types/api/api-general'; -import { Flock } from '@/types/api/flock/flock'; +import { ProjectFlock } from '@/types/api/production/project-flock'; import { Location } from '@/types/api/master-data/location'; import { Kandang } from '@/types/api/master-data/kandang'; export type BaseRecording = { id: number; - flock: Flock; + flock: ProjectFlock; recording_date: string; location: Location; coop: Kandang; diff --git a/src/types/api/flock/flock.d.ts b/src/types/api/master-data/flock.d.ts similarity index 100% rename from src/types/api/flock/flock.d.ts rename to src/types/api/master-data/flock.d.ts diff --git a/src/types/api/production/project-flock.d.ts b/src/types/api/production/project-flock.d.ts new file mode 100644 index 00000000..cd68df40 --- /dev/null +++ b/src/types/api/production/project-flock.d.ts @@ -0,0 +1,39 @@ +import { Flock } from '@/types/api/master-data/flock'; +import { Area } from '@/types/api/master-data/area'; +import { ProductCategory } from '@/types/api/master-data/product-category'; +import { Fcr } from '@/types/api/master-data/fcr'; +import { Kandang } from '@/types/api/master-data/kandang'; +import { BaseMetadata } from '@/types/api/api-general'; + +export type BaseProjectFlock = { + id: number; + name: string; + flock: Flock; + flock_id: number; + area: Area; + area_id: number; + product_category: ProductCategory; + product_category_id: number; + fcr: Fcr; + fcr_id: number; + location: Location; + location_id: number; + period: number; + kandang_ids: number[]; + kandangs: Kandang[]; +}; + +export type ProjectFlock = BaseMetadata & BaseProjectFlock; + +export type CreateProjectFlockPayload = { + name: string; + flock_id: number; + area_id: number; + product_category_id: number; + fcr_id: number; + location_id: number; + period: number; + kandang_ids: number[]; +}; + +export type UpdateProjectFlockPayload = CreateProjectFlockPayload; From caf68d438ff88be2b5b5debd33ea2982df6119f4 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 17 Oct 2025 13:59:28 +0700 Subject: [PATCH 18/68] refactor(FE-114,136,137): update feed and vaccination fields to use IDs instead of names and add stock validation --- .../recording/form/RecordingForm.schema.ts | 64 ++++-- .../flock/recording/form/RecordingForm.tsx | 186 ++++++++++++------ src/types/api/flock/recording.d.ts | 4 +- 3 files changed, 174 insertions(+), 80 deletions(-) diff --git a/src/components/pages/flock/recording/form/RecordingForm.schema.ts b/src/components/pages/flock/recording/form/RecordingForm.schema.ts index 319482e7..ed5507eb 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.schema.ts +++ b/src/components/pages/flock/recording/form/RecordingForm.schema.ts @@ -48,7 +48,7 @@ export const RecordingFormSchema = Yup.object({ feed_data: Yup.array() .of( Yup.object({ - feed_name: Yup.string().required('Nama pakan wajib diisi!'), + feed_id: Yup.string().required('Nama pakan wajib diisi!'), feed_qty: Yup.number() .required('Qty pakan wajib diisi!') .min(1, 'Qty minimal 1!') @@ -56,7 +56,15 @@ export const RecordingFormSchema = Yup.object({ feed_stock: Yup.number() .required('Stock pakan wajib diisi!') .min(1, 'Stock minimal 1!') - .typeError('Stock pakan wajib diisi!'), + .typeError('Stock pakan wajib diisi!') + .test( + 'is-not-exceed-qty', + 'Feed stock tidak boleh melebihi feed qty yang tersedia!', + function (value) { + const { feed_qty } = this.parent; + return value === undefined || value <= feed_qty; + } + ), }) ) .min(1, 'Minimal harus ada 1 data pakan!') @@ -80,7 +88,7 @@ export const RecordingFormSchema = Yup.object({ vaccination: Yup.array() .of( Yup.object({ - vaccine_name: Yup.string().required('Nama vaksin wajib diisi!'), + vaccine_id: Yup.string().required('Nama vaksin wajib diisi!'), total_stock: Yup.number() .required('Total stock wajib diisi!') .min(1, 'Total stock minimal 1!') @@ -88,7 +96,15 @@ export const RecordingFormSchema = Yup.object({ used_stock: Yup.number() .required('Jumlah stock wajib diisi!') .min(1, 'Jumlah stock minimal 1!') - .typeError('Jumlah stock wajib diisi!'), + .typeError('Jumlah stock wajib diisi!') + .test( + 'is-not-exceed-total', + 'Used stock tidak boleh melebihi total stock yang tersedia!', + function (value) { + const { total_stock } = this.parent; + return value === undefined || value <= total_stock; + } + ), }) ) .min(1, 'Minimal harus ada 1 data vaksinasi!') @@ -142,13 +158,19 @@ export const getRecordingFormInitialValues = ( recording_date: initialValues?.recording_date ? new Date(initialValues.recording_date) : new Date(), - feed_data: initialValues?.feed_data ?? [ - { - feed_name: '', - feed_qty: 0, - feed_stock: 0, - }, - ], + feed_data: initialValues?.feed_data + ? initialValues.feed_data.map((feed) => ({ + feed_id: feed.feed_name, + feed_qty: feed.feed_qty, + feed_stock: feed.feed_stock, + })) + : [ + { + feed_id: '', + feed_qty: 0, + feed_stock: 0, + }, + ], body_weight: initialValues?.body_weight ?? [ { chicken_weight: 0, @@ -156,13 +178,19 @@ export const getRecordingFormInitialValues = ( average_chicken_weight: 0, }, ], - vaccination: initialValues?.vaccination ?? [ - { - vaccine_name: '', - total_stock: 0, - used_stock: 0, - }, - ], + vaccination: initialValues?.vaccination + ? initialValues.vaccination.map((vaccine) => ({ + vaccine_id: vaccine.vaccine_name, + total_stock: vaccine.total_stock, + used_stock: vaccine.used_stock, + })) + : [ + { + vaccine_id: '', + total_stock: 0, + used_stock: 0, + }, + ], mortality: initialValues?.mortality ?? [ { condition: '', diff --git a/src/components/pages/flock/recording/form/RecordingForm.tsx b/src/components/pages/flock/recording/form/RecordingForm.tsx index d6e80a76..e2bd0d94 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.tsx +++ b/src/components/pages/flock/recording/form/RecordingForm.tsx @@ -23,6 +23,7 @@ import { isResponseSuccess } from '@/lib/api-helper'; import { RECORDING_FLAG_OPTIONS } from '@/config/constant'; import useSWR from 'swr'; import { KandangApi, LocationApi } from '@/services/api/master-data'; +import { ProductWarehouseApi } from '@/services/api/inventory'; interface RecordingFormProps { type?: 'add' | 'edit' | 'detail'; @@ -71,7 +72,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ? values.recording_date.toISOString() : '', feed_data: (values.feed_data ?? []).map((p) => ({ - feed_name: p.feed_name, + feed_id: p.feed_id, feed_qty: p.feed_qty, feed_stock: p.feed_stock, })), @@ -81,7 +82,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { average_chicken_weight: b.average_chicken_weight, })), vaccination: (values.vaccination ?? []).map((v) => ({ - vaccine_name: v.vaccine_name, + vaccine_id: v.vaccine_id, total_stock: v.total_stock, used_stock: v.used_stock, })), @@ -116,6 +117,50 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { : []; // Pakan selection + const pakanUrl = `${ProductWarehouseApi.basePath}?${new URLSearchParams({ flag: 'PAKAN', search: '' }).toString()}`; + const { data: pakanProducts } = useSWR( + pakanUrl, + ProductWarehouseApi.getAllFetcher + ); + const pakanOptions = isResponseSuccess(pakanProducts) + ? pakanProducts?.data.map((product) => ({ + value: product.id, + label: product.product.name, + })) + : []; + + // Create stock mapping for pakan (Feed) + const pakanStockMap = useMemo(() => { + if (!isResponseSuccess(pakanProducts)) return new Map(); + const map = new Map(); + pakanProducts.data.forEach((product) => { + map.set(product.id, product.quantity); + }); + return map; + }, [pakanProducts]); + + // OVK selection + const ovkUrl = `${ProductWarehouseApi.basePath}?${new URLSearchParams({ flag: 'OVK', search: '' }).toString()}`; + const { data: ovkProducts } = useSWR( + ovkUrl, + ProductWarehouseApi.getAllFetcher + ); + const ovkOptions = isResponseSuccess(ovkProducts) + ? ovkProducts?.data.map((product) => ({ + value: product.id, + label: product.product.name, + })) + : []; + + // Create stock mapping for OVK (Vaccination) + const ovkStockMap = useMemo(() => { + if (!isResponseSuccess(ovkProducts)) return new Map(); + const map = new Map(); + ovkProducts.data.forEach((product) => { + map.set(product.id, product.quantity); + }); + return map; + }, [ovkProducts]); const locationsUrl = `${LocationApi.basePath}?${new URLSearchParams({ search: locationSelectInputValue ?? '' }).toString()}`; const { data: locations, isLoading: isLoadingLocations } = useSWR( @@ -209,7 +254,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const newFeedData = [ ...(formik.values.feed_data || []), { - feed_name: '', + feed: null, + feed_id: 0, feed_qty: 0, feed_stock: 0, }, @@ -263,7 +309,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const newVaccination = [ ...(formik.values.vaccination || []), { - vaccine_name: '', + vaccine: null, + vaccine_id: 0, total_stock: 0, used_stock: 0, }, @@ -327,33 +374,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
- {/* {*/} - {/* formik.setFieldValue(*/} - {/* 'flock_id',*/} - {/* (val as OptionType)?.value*/} - {/* );*/} - {/* }}*/} - {/* options={flockOptions}*/} - {/* onInputChange={setFlockSelectInputValue}*/} - {/* isLoading={isLoadingFlocks}*/} - {/* isError={*/} - {/* formik.touched.flock_id && Boolean(formik.errors.flock_id)*/} - {/* }*/} - {/* errorMessage={formik.errors.flock_id as string}*/} - {/* isDisabled={type === 'detail'}*/} - {/* isClearable*/} - {/*/>*/} { )}
Feed NameFeed QtyFeed StockFeed Qty (Available Stock)Feed Stock (Used)Action
- + Number(opt.value) === Number(feed.feed_id) + ) ?? null + } + onChange={(val) => { + const productWarehouseId = + (val as OptionType)?.value ?? 0; + const stock = + pakanStockMap.get( + productWarehouseId as number + ) ?? 0; + + formik.setFieldValue( + `feed_data.${idx}.feed`, + val + ); + formik.setFieldValue( + `feed_data.${idx}.feed_id`, + productWarehouseId + ); + formik.setFieldValue( + `feed_data.${idx}.feed_qty`, + stock + ); + }} + options={pakanOptions} + isLoading={false} isError={ - isRepeaterInputError( - 'feed_data', - 'feed_name', - idx - ).isError + isRepeaterInputError('feed_data', 'feed_id', idx) + .isError } errorMessage={ - isRepeaterInputError( - 'feed_data', - 'feed_name', - idx - ).errorMessage + isRepeaterInputError('feed_data', 'feed_id', idx) + .errorMessage } - readOnly={type === 'detail'} + isDisabled={type === 'detail'} + isClearable className={{ wrapper: 'w-full min-w-24', }} @@ -542,7 +582,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { isRepeaterInputError('feed_data', 'feed_qty', idx) .errorMessage } - readOnly={type === 'detail'} + readOnly={true} className={{ wrapper: 'w-full min-w-24', }} @@ -847,7 +887,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { )} Vaccine NameTotal StockTotal Stock (Available Stock) Used StockAction
- + Number(opt.value) === + Number(vaccine.vaccine_id) + ) ?? null + } + onChange={(val) => { + const productWarehouseId = + (val as OptionType)?.value ?? 0; + const stock = + ovkStockMap.get(productWarehouseId as number) ?? + 0; + + formik.setFieldValue( + `vaccination.${idx}.vaccine`, + val + ); + formik.setFieldValue( + `vaccination.${idx}.vaccine_id`, + productWarehouseId + ); + formik.setFieldValue( + `vaccination.${idx}.total_stock`, + stock + ); + }} + options={ovkOptions} + isLoading={false} isError={ isRepeaterInputError( 'vaccination', - 'vaccine_name', + 'vaccine_id', idx ).isError } errorMessage={ isRepeaterInputError( 'vaccination', - 'vaccine_name', + 'vaccine_id', idx ).errorMessage } - readOnly={type === 'detail'} + isDisabled={type === 'detail'} + isClearable className={{ wrapper: 'w-full min-w-24', }} @@ -922,7 +988,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { idx ).errorMessage } - readOnly={type === 'detail'} + readOnly={true} className={{ wrapper: 'w-full min-w-24', }} diff --git a/src/types/api/flock/recording.d.ts b/src/types/api/flock/recording.d.ts index 58fed128..d4bf90ee 100644 --- a/src/types/api/flock/recording.d.ts +++ b/src/types/api/flock/recording.d.ts @@ -38,7 +38,7 @@ export type CreateRecordingPayload = { location_id: number; coop_id: number; feed_data: { - feed_name: string; + feed_id: string; feed_qty: number; feed_stock: number; }[]; @@ -48,7 +48,7 @@ export type CreateRecordingPayload = { average_chicken_weight: number; }[]; vaccination: { - vaccine_name: string; + vaccine_id: string; total_stock: number; used_stock: number; }[]; From 881e2bfc4ad37af1055f66b5d6c3f812d313ece7 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Sat, 18 Oct 2025 09:04:39 +0700 Subject: [PATCH 19/68] feat(FE-114,136): enhance product label display in RecordingForm with warehouse and stock information --- src/components/pages/flock/recording/form/RecordingForm.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/pages/flock/recording/form/RecordingForm.tsx b/src/components/pages/flock/recording/form/RecordingForm.tsx index e2bd0d94..65c8073b 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.tsx +++ b/src/components/pages/flock/recording/form/RecordingForm.tsx @@ -125,7 +125,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const pakanOptions = isResponseSuccess(pakanProducts) ? pakanProducts?.data.map((product) => ({ value: product.id, - label: product.product.name, + label: `${product.product.name} - ${product.warehouse.name} (Stock: ${product.quantity.toLocaleString('id-ID')})`, })) : []; @@ -148,7 +148,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const ovkOptions = isResponseSuccess(ovkProducts) ? ovkProducts?.data.map((product) => ({ value: product.id, - label: product.product.name, + label: `${product.product.name} - ${product.warehouse.name} (Stock: ${product.quantity.toLocaleString('id-ID')})`, })) : []; From c25b49c179dbff5f6394f263f45a8ccf4f64581e Mon Sep 17 00:00:00 2001 From: rstubryan Date: Sat, 18 Oct 2025 11:39:18 +0700 Subject: [PATCH 20/68] feat(FE-114): add NumberInput component and integrate into RecordingForm for enhanced numeric input handling --- src/components/input/NumberInput.tsx | 444 ++++++++++++++++++ .../flock/recording/form/RecordingForm.tsx | 42 +- 2 files changed, 476 insertions(+), 10 deletions(-) create mode 100644 src/components/input/NumberInput.tsx diff --git a/src/components/input/NumberInput.tsx b/src/components/input/NumberInput.tsx new file mode 100644 index 00000000..a9b8d9a0 --- /dev/null +++ b/src/components/input/NumberInput.tsx @@ -0,0 +1,444 @@ +'use client'; + +import { + ChangeEvent, + ChangeEventHandler, + FocusEventHandler, + ReactNode, + useEffect, + useState, +} from 'react'; + +import { Icon } from '@iconify/react'; +import { cn } from '@/lib/helper'; + +// Utility Functions +const formatNumber = ( + value: number | string, + decimals: number = 0, + thousandSeparator: string = '.', + decimalSeparator: string = ',' +): string => { + if (value === '' || value === null || value === undefined) return ''; + + const numValue = typeof value === 'string' ? parseFloat(value) : value; + if (isNaN(numValue)) return ''; + + const parts = numValue.toFixed(decimals).split('.'); + const integerPart = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, thousandSeparator); + const decimalPart = parts[1]; + + return decimals > 0 && decimalPart + ? `${integerPart}${decimalSeparator}${decimalPart}` + : integerPart; +}; + +const parseNumber = ( + value: string, + thousandSeparator: string = '.', + decimalSeparator: string = ',' +): number => { + if (!value) return 0; + + // Remove thousand separators and replace decimal separator with dot + const cleaned = value + .replace(new RegExp(`\\${thousandSeparator}`, 'g'), '') + .replace(decimalSeparator, '.'); + + const parsed = parseFloat(cleaned); + return isNaN(parsed) ? 0 : parsed; +}; + +const formatCurrency = ( + value: number | string, + prefix: string = 'Rp ', + decimals: number = 0 +): string => { + if (value === '' || value === null || value === undefined) return ''; + const formatted = formatNumber(value, decimals); + return formatted ? `${prefix}${formatted}` : ''; +}; + +const formatWeight = ( + value: number | string, + unit: string = 'kg', + decimals: number = 2 +): string => { + if (value === '' || value === null || value === undefined) return ''; + const formatted = formatNumber(value, decimals); + return formatted ? `${formatted} ${unit}` : ''; +}; + +const cleanNumericInput = ( + value: string, + allowDecimal: boolean = false, + decimalSeparator: string = ',' +): string => { + // Only allow numbers, decimal separator (if allowed), and minus sign at the start + let cleaned = value.replace(/[^\d,.-]/g, ''); + + // Handle decimal separator + if (allowDecimal) { + const parts = cleaned.split(decimalSeparator); + if (parts.length > 2) { + // Keep only first decimal separator + cleaned = parts[0] + decimalSeparator + parts.slice(1).join(''); + } + } else { + cleaned = cleaned.replace(new RegExp(decimalSeparator, 'g'), ''); + } + + // Handle minus sign (only at start) + const hasMinusAtStart = cleaned.startsWith('-'); + cleaned = cleaned.replace(/-/g, ''); + if (hasMinusAtStart) cleaned = '-' + cleaned; + + return cleaned; +}; + +// Types +export type MaskType = 'currency' | 'weight' | 'decimal' | 'number'; + +export interface NumberInputProps { + label?: string; + bottomLabel?: string; + name: string; + value?: number | string; + placeholder?: string; + className?: { + wrapper?: string; + label?: string; + inputWrapper?: string; + input?: string; + }; + isError?: boolean; + isValid?: boolean; + disabled?: boolean; + readOnly?: boolean; + required?: boolean; + isLoading?: boolean; + errorMessage?: string; + startAdornment?: ReactNode; + endAdornment?: ReactNode; + onChange?: ChangeEventHandler; + onBlur?: FocusEventHandler; + onFocus?: FocusEventHandler; + + // Masking Options + maskType?: MaskType; + decimals?: number; + thousandSeparator?: string; + decimalSeparator?: string; + + // Currency specific + currencyPrefix?: string; + + // Weight specific + weightUnit?: string; + + // Validation + min?: number; + max?: number; + allowNegative?: boolean; + + // Stepper (Increment/Decrement buttons) + showSteppers?: boolean; + step?: number; +} + +const NumberInput = ({ + label, + bottomLabel, + name, + value, + placeholder, + className, + isError, + isValid, + errorMessage, + startAdornment, + endAdornment, + disabled = false, + required = false, + onChange, + onBlur, + onFocus, + readOnly = false, + isLoading = false, + maskType = 'number', + decimals = 0, + thousandSeparator = '.', + decimalSeparator = ',', + currencyPrefix = 'Rp ', + weightUnit = 'kg', + min, + max, + allowNegative = false, + showSteppers = false, + step = 1, +}: NumberInputProps) => { + const [displayValue, setDisplayValue] = useState(''); + + // Determine if decimals are allowed based on maskType + const allowDecimal = maskType === 'decimal' || maskType === 'weight' || decimals > 0; + + // Format value for display based on maskType + const getFormattedValue = (rawValue: number | string): string => { + if (rawValue === '' || rawValue === null || rawValue === undefined) return ''; + + switch (maskType) { + case 'currency': + return formatCurrency(rawValue, currencyPrefix, decimals); + case 'weight': + return formatWeight(rawValue, weightUnit, decimals); + case 'decimal': + case 'number': + return formatNumber(rawValue, decimals, thousandSeparator, decimalSeparator); + default: + return String(rawValue); + } + }; + + // Initialize display value when value prop changes + useEffect(() => { + setDisplayValue(getFormattedValue(value || '')); + }, [value]); + + const handleInputChange = (e: ChangeEvent) => { + let inputValue = e.target.value; + + // Remove prefix/suffix for editing + if (maskType === 'currency' && inputValue.startsWith(currencyPrefix)) { + inputValue = inputValue.slice(currencyPrefix.length); + } + if (maskType === 'weight' && inputValue.endsWith(` ${weightUnit}`)) { + inputValue = inputValue.slice(0, -weightUnit.length - 1); + } + + // Clean input + const cleaned = cleanNumericInput(inputValue, allowDecimal, decimalSeparator); + + // Parse to number + let numericValue = parseNumber(cleaned, thousandSeparator, decimalSeparator); + + // Apply validation + if (!allowNegative && numericValue < 0) { + numericValue = 0; + } + if (min !== undefined && numericValue < min) { + numericValue = min; + } + if (max !== undefined && numericValue > max) { + numericValue = max; + } + + // Update display value + const formattedForDisplay = formatNumber( + numericValue, + decimals, + thousandSeparator, + decimalSeparator + ); + + setDisplayValue(formattedForDisplay); + + // Call onChange with modified event + if (onChange) { + // Create a synthetic event with the numeric value + const syntheticEvent = { + ...e, + target: { + ...e.target, + name, + value: numericValue.toString(), + }, + } as ChangeEvent; + + onChange(syntheticEvent); + } + }; + + // Handle Increment + const handleIncrement = () => { + if (disabled || readOnly) return; + + const currentValue = parseNumber(displayValue, thousandSeparator, decimalSeparator); + let newValue = currentValue + step; + + // Apply max validation + if (max !== undefined && newValue > max) { + newValue = max; + } + + // Update display + const formattedForDisplay = formatNumber( + newValue, + decimals, + thousandSeparator, + decimalSeparator + ); + setDisplayValue(formattedForDisplay); + + // Call onChange with synthetic event + if (onChange) { + const syntheticEvent = { + target: { + name, + value: newValue.toString(), + }, + } as ChangeEvent; + + onChange(syntheticEvent); + } + }; + + // Handle Decrement + const handleDecrement = () => { + if (disabled || readOnly) return; + + const currentValue = parseNumber(displayValue, thousandSeparator, decimalSeparator); + let newValue = currentValue - step; + + // Apply min validation (prevent negative if not allowed) + if (!allowNegative && newValue < 0) { + newValue = 0; + } + if (min !== undefined && newValue < min) { + newValue = min; + } + + // Update display + const formattedForDisplay = formatNumber( + newValue, + decimals, + thousandSeparator, + decimalSeparator + ); + setDisplayValue(formattedForDisplay); + + // Call onChange with synthetic event + if (onChange) { + const syntheticEvent = { + target: { + name, + value: newValue.toString(), + }, + } as ChangeEvent; + + onChange(syntheticEvent); + } + }; + + return ( +
+ {label && ( + + )} + +
+ {/* Decrement Button */} + {showSteppers && ( + + )} + + {startAdornment && startAdornment} + + + + {(isLoading || endAdornment) && ( +
+ {isLoading && } + + {endAdornment && endAdornment} +
+ )} + + {/* Increment Button */} + {showSteppers && ( + + )} +
+ + {!isError && bottomLabel && ( +

{bottomLabel}

+ )} + {isError && errorMessage && ( +

{errorMessage}

+ )} +
+ ); +}; + +export default NumberInput; diff --git a/src/components/pages/flock/recording/form/RecordingForm.tsx b/src/components/pages/flock/recording/form/RecordingForm.tsx index 65c8073b..773baab6 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.tsx +++ b/src/components/pages/flock/recording/form/RecordingForm.tsx @@ -6,6 +6,7 @@ import { Icon } from '@iconify/react'; import { toast } from 'react-hot-toast'; import Button from '@/components/Button'; import TextInput from '@/components/input/TextInput'; +import NumberInput from '@/components/input/NumberInput'; import SelectInput, { OptionType } from '@/components/input/SelectInput'; import ConfirmationModal from '@/components/modal/ConfirmationModal'; import { FormHeader } from '@/components/helper/form/FormHeader'; @@ -589,13 +590,15 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { />
- { - { ).errorMessage } readOnly={type === 'detail'} + className={{ + wrapper: 'w-full min-w-24', + }} /> - { ).errorMessage } readOnly={type === 'detail'} + className={{ + wrapper: 'w-full min-w-24', + }} /> - { ).errorMessage } readOnly={type === 'detail'} + className={{ + wrapper: 'w-full min-w-24', + }} /> - Date: Sat, 18 Oct 2025 12:25:04 +0700 Subject: [PATCH 21/68] refactor(FE-114,136): update RecordingForm validation and input handling for feed and vaccination data --- .../recording/form/RecordingForm.schema.ts | 52 ++++----- .../flock/recording/form/RecordingForm.tsx | 102 +++++++++--------- 2 files changed, 80 insertions(+), 74 deletions(-) diff --git a/src/components/pages/flock/recording/form/RecordingForm.schema.ts b/src/components/pages/flock/recording/form/RecordingForm.schema.ts index ed5507eb..16f7e690 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.schema.ts +++ b/src/components/pages/flock/recording/form/RecordingForm.schema.ts @@ -49,20 +49,19 @@ export const RecordingFormSchema = Yup.object({ .of( Yup.object({ feed_id: Yup.string().required('Nama pakan wajib diisi!'), - feed_qty: Yup.number() - .required('Qty pakan wajib diisi!') - .min(1, 'Qty minimal 1!') - .typeError('Qty pakan wajib diisi!'), + feed_qty: Yup.mixed().notRequired(), feed_stock: Yup.number() - .required('Stock pakan wajib diisi!') - .min(1, 'Stock minimal 1!') - .typeError('Stock pakan wajib diisi!') + .required('Jumlah pakan yang digunakan wajib diisi!') + .min(1, 'Jumlah pakan minimal 1 kg!') + .typeError('Jumlah pakan yang digunakan harus berupa angka!') .test( 'is-not-exceed-qty', - 'Feed stock tidak boleh melebihi feed qty yang tersedia!', + 'Jumlah pakan yang digunakan tidak boleh melebihi stok tersedia!', function (value) { const { feed_qty } = this.parent; - return value === undefined || value <= feed_qty; + if (value === undefined) return true; + if (feed_qty === undefined || feed_qty === '' || typeof feed_qty !== 'number') return true; + return value <= feed_qty; } ), }) @@ -74,13 +73,16 @@ export const RecordingFormSchema = Yup.object({ Yup.object({ chicken_weight: Yup.number() .required('Berat ayam wajib diisi!') - .min(1, 'Berat minimal 1!'), + .min(1, 'Berat ayam minimal 1 gram!') + .typeError('Berat ayam harus berupa angka!'), chicken_count: Yup.number() .required('Jumlah ayam wajib diisi!') - .min(1, 'Jumlah minimal 1!'), + .min(1, 'Jumlah ayam minimal 1 ekor!') + .typeError('Jumlah ayam harus berupa angka!'), average_chicken_weight: Yup.number() .required('Rata-rata berat ayam wajib diisi!') - .min(1, 'Rata-rata minimal 1!'), + .min(1, 'Rata-rata berat ayam minimal 1 gram!') + .typeError('Rata-rata berat ayam harus berupa angka!'), }) ) .min(1, 'Minimal harus ada 1 data bobot badan!') @@ -89,20 +91,19 @@ export const RecordingFormSchema = Yup.object({ .of( Yup.object({ vaccine_id: Yup.string().required('Nama vaksin wajib diisi!'), - total_stock: Yup.number() - .required('Total stock wajib diisi!') - .min(1, 'Total stock minimal 1!') - .typeError('Total stock wajib diisi!'), + total_stock: Yup.mixed().notRequired(), used_stock: Yup.number() - .required('Jumlah stock wajib diisi!') - .min(1, 'Jumlah stock minimal 1!') - .typeError('Jumlah stock wajib diisi!') + .required('Jumlah vaksin yang digunakan wajib diisi!') + .min(1, 'Jumlah vaksin minimal 1!') + .typeError('Jumlah vaksin yang digunakan harus berupa angka!') .test( 'is-not-exceed-total', - 'Used stock tidak boleh melebihi total stock yang tersedia!', + 'Jumlah vaksin yang digunakan tidak boleh melebihi stok tersedia!', function (value) { const { total_stock } = this.parent; - return value === undefined || value <= total_stock; + if (value === undefined) return true; + if (total_stock === undefined || total_stock === '' || typeof total_stock !== 'number') return true; + return value <= total_stock; } ), }) @@ -119,8 +120,9 @@ export const RecordingFormSchema = Yup.object({ ) .required('Kondisi wajib diisi!'), count: Yup.number() - .required('Jumlah wajib diisi!') - .min(1, 'Jumlah minimal 1!'), + .required('Jumlah mortalitas wajib diisi!') + .min(1, 'Jumlah mortalitas minimal 1 ekor!') + .typeError('Jumlah mortalitas harus berupa angka!'), }) ) .min(1, 'Minimal harus ada 1 data mortalitas!') @@ -167,7 +169,7 @@ export const getRecordingFormInitialValues = ( : [ { feed_id: '', - feed_qty: 0, + feed_qty: '', feed_stock: 0, }, ], @@ -187,7 +189,7 @@ export const getRecordingFormInitialValues = ( : [ { vaccine_id: '', - total_stock: 0, + total_stock: '', used_stock: 0, }, ], diff --git a/src/components/pages/flock/recording/form/RecordingForm.tsx b/src/components/pages/flock/recording/form/RecordingForm.tsx index 773baab6..95600120 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.tsx +++ b/src/components/pages/flock/recording/form/RecordingForm.tsx @@ -74,7 +74,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { : '', feed_data: (values.feed_data ?? []).map((p) => ({ feed_id: p.feed_id, - feed_qty: p.feed_qty, + feed_qty: typeof p.feed_qty === 'number' ? p.feed_qty : 0, feed_stock: p.feed_stock, })), body_weight: (values.body_weight ?? []).map((b) => ({ @@ -84,7 +84,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { })), vaccination: (values.vaccination ?? []).map((v) => ({ vaccine_id: v.vaccine_id, - total_stock: v.total_stock, + total_stock: typeof v.total_stock === 'number' ? v.total_stock : 0, used_stock: v.used_stock, })), mortality: (values.mortality ?? []).map((m) => ({ @@ -132,8 +132,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { // Create stock mapping for pakan (Feed) const pakanStockMap = useMemo(() => { - if (!isResponseSuccess(pakanProducts)) return new Map(); - const map = new Map(); + if (!isResponseSuccess(pakanProducts)) + return new Map(); + const map = new Map(); pakanProducts.data.forEach((product) => { map.set(product.id, product.quantity); }); @@ -155,8 +156,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { // Create stock mapping for OVK (Vaccination) const ovkStockMap = useMemo(() => { - if (!isResponseSuccess(ovkProducts)) return new Map(); - const map = new Map(); + if (!isResponseSuccess(ovkProducts)) return new Map(); + const map = new Map(); ovkProducts.data.forEach((product) => { map.set(product.id, product.quantity); }); @@ -256,8 +257,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ...(formik.values.feed_data || []), { feed: null, - feed_id: 0, - feed_qty: 0, + feed_id: '', + feed_qty: '', feed_stock: 0, }, ]; @@ -311,8 +312,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ...(formik.values.vaccination || []), { vaccine: null, - vaccine_id: 0, - total_stock: 0, + vaccine_id: '', + total_stock: '', used_stock: 0, }, ]; @@ -532,10 +533,11 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { onChange={(val) => { const productWarehouseId = (val as OptionType)?.value ?? 0; - const stock = - pakanStockMap.get( - productWarehouseId as number - ) ?? 0; + const stock = productWarehouseId + ? (pakanStockMap.get( + productWarehouseId as number + ) ?? '') + : ''; formik.setFieldValue( `feed_data.${idx}.feed`, @@ -543,12 +545,17 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ); formik.setFieldValue( `feed_data.${idx}.feed_id`, - productWarehouseId + productWarehouseId || '' ); formik.setFieldValue( `feed_data.${idx}.feed_qty`, stock ); + // Reset feed_stock when changing feed + formik.setFieldValue( + `feed_data.${idx}.feed_stock`, + 0 + ); }} options={pakanOptions} isLoading={false} @@ -569,21 +576,18 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { { onChange={(val) => { const productWarehouseId = (val as OptionType)?.value ?? 0; - const stock = - ovkStockMap.get(productWarehouseId as number) ?? - 0; + const stock = productWarehouseId + ? (ovkStockMap.get( + productWarehouseId as number + ) ?? '') + : ''; formik.setFieldValue( `vaccination.${idx}.vaccine`, @@ -956,12 +962,17 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ); formik.setFieldValue( `vaccination.${idx}.vaccine_id`, - productWarehouseId + productWarehouseId || '' ); formik.setFieldValue( `vaccination.${idx}.total_stock`, stock ); + // Reset used_stock when changing vaccine + formik.setFieldValue( + `vaccination.${idx}.used_stock`, + 0 + ); }} options={ovkOptions} isLoading={false} @@ -988,27 +999,18 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { { /> - Date: Mon, 20 Oct 2025 09:51:32 +0700 Subject: [PATCH 22/68] feat(FE-114,136): integrate location selection and update flock handling in RecordingForm --- .../flock/recording/form/RecordingForm.tsx | 344 ++++++++++++------ src/types/api/production/project-flock.d.ts | 1 + 2 files changed, 224 insertions(+), 121 deletions(-) diff --git a/src/components/pages/flock/recording/form/RecordingForm.tsx b/src/components/pages/flock/recording/form/RecordingForm.tsx index 95600120..d29e7f72 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.tsx +++ b/src/components/pages/flock/recording/form/RecordingForm.tsx @@ -23,8 +23,10 @@ import { ProjectFlockApi } from '@/services/api/production'; import { isResponseSuccess } from '@/lib/api-helper'; import { RECORDING_FLAG_OPTIONS } from '@/config/constant'; import useSWR from 'swr'; -import { KandangApi, LocationApi } from '@/services/api/master-data'; import { ProductWarehouseApi } from '@/services/api/inventory'; +import { ProjectFlock } from '@/types/api/production/project-flock'; +import { Warehouse } from '@/types/api/master-data/warehouse'; +import { LocationApi } from '@/services/api/master-data'; interface RecordingFormProps { type?: 'add' | 'edit' | 'detail'; @@ -32,9 +34,10 @@ interface RecordingFormProps { } const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { - const [flockSelectInputValue, setFlockSelectInputValue] = useState(''); const [locationSelectInputValue, setLocationSelectInputValue] = useState(''); - const [coopSelectInputValue, setCoopSelectInputValue] = useState(''); + const [flockSelectInputValue, setFlockSelectInputValue] = useState(''); + const [selectedProjectFlock, setSelectedProjectFlock] = + useState(null); const [selectedFeed, setSelectedFeed] = useState([]); const [selectedWeight, setSelectedWeight] = useState([]); const [selectedVaccine, setSelectedVaccine] = useState([]); @@ -104,87 +107,159 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { }, }); - // Flock selection - const projectFlocksUrl = `${ProjectFlockApi.basePath}?${new URLSearchParams({ search: flockSelectInputValue }).toString()}`; - const { data: projectFlocks, isLoading: isLoadingFlocks } = useSWR( - projectFlocksUrl, - ProjectFlockApi.getAllFetcher - ); - const flockOptions = isResponseSuccess(projectFlocks) - ? projectFlocks?.data.map((flock) => ({ - value: flock.id, - label: flock.flock.name, - })) - : []; - - // Pakan selection - const pakanUrl = `${ProductWarehouseApi.basePath}?${new URLSearchParams({ flag: 'PAKAN', search: '' }).toString()}`; - const { data: pakanProducts } = useSWR( - pakanUrl, - ProductWarehouseApi.getAllFetcher - ); - const pakanOptions = isResponseSuccess(pakanProducts) - ? pakanProducts?.data.map((product) => ({ - value: product.id, - label: `${product.product.name} - ${product.warehouse.name} (Stock: ${product.quantity.toLocaleString('id-ID')})`, - })) - : []; - - // Create stock mapping for pakan (Feed) - const pakanStockMap = useMemo(() => { - if (!isResponseSuccess(pakanProducts)) - return new Map(); - const map = new Map(); - pakanProducts.data.forEach((product) => { - map.set(product.id, product.quantity); - }); - return map; - }, [pakanProducts]); - - // OVK selection - const ovkUrl = `${ProductWarehouseApi.basePath}?${new URLSearchParams({ flag: 'OVK', search: '' }).toString()}`; - const { data: ovkProducts } = useSWR( - ovkUrl, - ProductWarehouseApi.getAllFetcher - ); - const ovkOptions = isResponseSuccess(ovkProducts) - ? ovkProducts?.data.map((product) => ({ - value: product.id, - label: `${product.product.name} - ${product.warehouse.name} (Stock: ${product.quantity.toLocaleString('id-ID')})`, - })) - : []; - - // Create stock mapping for OVK (Vaccination) - const ovkStockMap = useMemo(() => { - if (!isResponseSuccess(ovkProducts)) return new Map(); - const map = new Map(); - ovkProducts.data.forEach((product) => { - map.set(product.id, product.quantity); - }); - return map; - }, [ovkProducts]); - - const locationsUrl = `${LocationApi.basePath}?${new URLSearchParams({ search: locationSelectInputValue ?? '' }).toString()}`; + const locationsUrl = `${LocationApi.basePath}?${new URLSearchParams({ search: locationSelectInputValue }).toString()}`; const { data: locations, isLoading: isLoadingLocations } = useSWR( locationsUrl, LocationApi.getAllFetcher ); const locationOptions = isResponseSuccess(locations) - ? locations?.data.map((loc) => ({ value: loc.id, label: loc.name })) + ? locations.data.map((loc) => ({ value: loc.id, label: loc.name })) : []; - const coopsUrl = `${KandangApi.basePath}?${new URLSearchParams({ search: coopSelectInputValue ?? '' }).toString()}`; - const { data: coops, isLoading: isLoadingCoops } = useSWR( - coopsUrl, - KandangApi.getAllFetcher + const projectFlocksUrl = useMemo(() => { + if (!formik.values.location_id) return null; + const params = new URLSearchParams({ + search: flockSelectInputValue, + location_id: formik.values.location_id.toString(), + }); + return `${ProjectFlockApi.basePath}?${params.toString()}`; + }, [formik.values.location_id, flockSelectInputValue]); + + const { data: projectFlocks, isLoading: isLoadingFlocks } = useSWR( + projectFlocksUrl, + ProjectFlockApi.getAllFetcher ); + const flockOptions = isResponseSuccess(projectFlocks) + ? projectFlocks.data.map((flock) => ({ + value: flock.id, + label: flock.flock.name, + })) + : []; + + const buildWarehouseLabel = useCallback((warehouse: Warehouse) => { + const parts: string[] = [warehouse.name]; + + if ('kandang' in warehouse && warehouse.kandang) { + parts.push(warehouse.kandang.name); + } + + if ('location' in warehouse && warehouse.location) { + parts.push(warehouse.location.name); + } + + if (warehouse.area) { + parts.push(warehouse.area.name); + } + + return parts.join(' - '); + }, []); + + const pakanUrl = useMemo(() => { + if (!formik.values.location_id) return null; + const params = new URLSearchParams({ + flag: 'PAKAN', + search: '', + location_id: formik.values.location_id.toString(), + }); + return `${ProductWarehouseApi.basePath}?${params.toString()}`; + }, [formik.values.location_id]); + + const { data: pakanProducts, isLoading: isLoadingPakan } = useSWR( + pakanUrl, + ProductWarehouseApi.getAllFetcher + ); + + const filteredPakanProducts = useMemo(() => { + if (!isResponseSuccess(pakanProducts) || !formik.values.location_id) + return []; + + return pakanProducts.data.filter((product) => { + const warehouse = product.warehouse; + + if ('location' in warehouse && warehouse.location) { + return warehouse.location.id === formik.values.location_id; + } + + // If warehouse only has area, include it if area matches the location's area + // Note: This might need adjustment based on your business logic + return false; + }); + }, [pakanProducts, formik.values.location_id]); + + const pakanOptions = useMemo( + () => + filteredPakanProducts.map((product) => ({ + value: product.id, + label: `${product.product.name} - ${buildWarehouseLabel(product.warehouse)} (Stock: ${product.quantity.toLocaleString('id-ID')})`, + })), + [filteredPakanProducts, buildWarehouseLabel] + ); + + const pakanStockMap = useMemo(() => { + const map = new Map(); + filteredPakanProducts.forEach((product) => { + map.set(product.id, product.quantity); + }); + return map; + }, [filteredPakanProducts]); + + const ovkUrl = useMemo(() => { + if (!formik.values.location_id) return null; + const params = new URLSearchParams({ + flag: 'OVK', + search: '', + location_id: formik.values.location_id.toString(), + }); + return `${ProductWarehouseApi.basePath}?${params.toString()}`; + }, [formik.values.location_id]); + + const { data: ovkProducts, isLoading: isLoadingOvk } = useSWR( + ovkUrl, + ProductWarehouseApi.getAllFetcher + ); + + const filteredOvkProducts = useMemo(() => { + if (!isResponseSuccess(ovkProducts) || !formik.values.location_id) + return []; + + return ovkProducts.data.filter((product) => { + const warehouse = product.warehouse; + + if ('location' in warehouse && warehouse.location) { + return warehouse.location.id === formik.values.location_id; + } + + // If warehouse only has area, include it if area matches the location's area + // Note: This might need adjustment based on your business logic + return false; + }); + }, [ovkProducts, formik.values.location_id]); + + const ovkOptions = useMemo( + () => + filteredOvkProducts.map((product) => ({ + value: product.id, + label: `${product.product.name} - ${buildWarehouseLabel(product.warehouse)} (Stock: ${product.quantity.toLocaleString('id-ID')})`, + })), + [filteredOvkProducts, buildWarehouseLabel] + ); + + const ovkStockMap = useMemo(() => { + const map = new Map(); + filteredOvkProducts.forEach((product) => { + map.set(product.id, product.quantity); + }); + return map; + }, [filteredOvkProducts]); + const coopOptions = useMemo(() => { - if (!isResponseSuccess(coops) || !formik.values.location_id) return []; - return coops.data - .filter((coop) => coop.location.id === formik.values.location_id) - .map((coop) => ({ value: coop.id, label: coop.name })); - }, [coops, formik.values.location_id]); + if (!selectedProjectFlock || !selectedProjectFlock.kandangs) return []; + return selectedProjectFlock.kandangs.map((kandang) => ({ + value: kandang.id, + label: kandang.name, + })); + }, [selectedProjectFlock]); const locationChangeHandler = (val: OptionType | OptionType[] | null) => { const locationValue = (val as OptionType)?.value; @@ -192,9 +267,28 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { formik.setFieldValue('location', val, false); formik.setFieldValue('location_id', locationValue || 0, false); + formik.setFieldValue('flock', null, false); + formik.setFieldValue('flock_id', 0, false); + formik.setFieldValue('coop', null, false); + formik.setFieldValue('coop_id', 0, false); + setSelectedProjectFlock(null); + setFlockSelectInputValue(''); + }; + + const flockChangeHandler = (val: OptionType | OptionType[] | null) => { + const flockValue = (val as OptionType)?.value; + + const selected = isResponseSuccess(projectFlocks) + ? projectFlocks.data.find((flock) => flock.id === flockValue) + : null; + + setSelectedProjectFlock(selected || null); + + formik.setFieldValue('flock', val, false); + formik.setFieldValue('flock_id', flockValue || 0, false); + formik.setFieldValue('coop', null, false); formik.setFieldValue('coop_id', 0, false); - setCoopSelectInputValue(''); }; const coopChangeHandler = (val: OptionType | OptionType[] | null) => { @@ -204,6 +298,17 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { formik.setFieldValue('coop_id', coopValue || 0, false); }; + useEffect(() => { + if (initialValues?.flock && isResponseSuccess(projectFlocks)) { + const flock = projectFlocks.data.find( + (f) => f.id === initialValues.flock.id + ); + if (flock) { + setSelectedProjectFlock(flock); + } + } + }, [initialValues, projectFlocks]); + const isRepeaterInputError = ( arrayName: T, field: T extends 'feed_data' @@ -245,13 +350,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { }; }; - const flockChangeHandler = (val: OptionType | OptionType[] | null) => { - const flockValue = (val as OptionType)?.value; - - formik.setFieldValue('flock', val, false); - formik.setFieldValue('flock_id', flockValue || 0, false); - }; - const addFeedData = () => { const newFeedData = [ ...(formik.values.feed_data || []), @@ -372,25 +470,28 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { {/* Basic Info Card */}
-

Flock

+

Recording Information

+ { readOnly={type === 'detail'} />
+
- - + + @@ -551,14 +655,13 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { `feed_data.${idx}.feed_qty`, stock ); - // Reset feed_stock when changing feed formik.setFieldValue( `feed_data.${idx}.feed_stock`, 0 ); }} options={pakanOptions} - isLoading={false} + isLoading={isLoadingPakan} isError={ isRepeaterInputError('feed_data', 'feed_id', idx) .isError @@ -968,14 +1071,13 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { `vaccination.${idx}.total_stock`, stock ); - // Reset used_stock when changing vaccine formik.setFieldValue( `vaccination.${idx}.used_stock`, 0 ); }} options={ovkOptions} - isLoading={false} + isLoading={isLoadingOvk} isError={ isRepeaterInputError( 'vaccination', diff --git a/src/types/api/production/project-flock.d.ts b/src/types/api/production/project-flock.d.ts index cd68df40..7a251d38 100644 --- a/src/types/api/production/project-flock.d.ts +++ b/src/types/api/production/project-flock.d.ts @@ -3,6 +3,7 @@ import { Area } from '@/types/api/master-data/area'; import { ProductCategory } from '@/types/api/master-data/product-category'; import { Fcr } from '@/types/api/master-data/fcr'; import { Kandang } from '@/types/api/master-data/kandang'; +import { Location } from '@/types/api/master-data/location'; import { BaseMetadata } from '@/types/api/api-general'; export type BaseProjectFlock = { From 4233c19dc9294193197451575aae3ed83ba1b437 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Mon, 20 Oct 2025 10:06:26 +0700 Subject: [PATCH 23/68] refactor(FE-114): rearrange code for better readability --- .../flock/recording/form/RecordingForm.tsx | 348 +++++++++--------- 1 file changed, 178 insertions(+), 170 deletions(-) diff --git a/src/components/pages/flock/recording/form/RecordingForm.tsx b/src/components/pages/flock/recording/form/RecordingForm.tsx index d29e7f72..42edc1ca 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.tsx +++ b/src/components/pages/flock/recording/form/RecordingForm.tsx @@ -1,9 +1,8 @@ 'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import { FormikProps, useFormik } from 'formik'; +import { 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 NumberInput from '@/components/input/NumberInput'; @@ -42,8 +41,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { const [selectedWeight, setSelectedWeight] = useState([]); const [selectedVaccine, setSelectedVaccine] = useState([]); const [selectedMortality, setSelectedMortality] = useState([]); - const [, setRecordingFormErrorMessage] = useState(''); + const { deleteModal, recordingFormErrorMessage, @@ -107,15 +106,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { }, }); + // Locations const locationsUrl = `${LocationApi.basePath}?${new URLSearchParams({ search: locationSelectInputValue }).toString()}`; const { data: locations, isLoading: isLoadingLocations } = useSWR( locationsUrl, LocationApi.getAllFetcher ); - const locationOptions = isResponseSuccess(locations) - ? locations.data.map((loc) => ({ value: loc.id, label: loc.name })) - : []; + // Project Flocks const projectFlocksUrl = useMemo(() => { if (!formik.values.location_id) return null; const params = new URLSearchParams({ @@ -130,13 +128,39 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { ProjectFlockApi.getAllFetcher ); - const flockOptions = isResponseSuccess(projectFlocks) - ? projectFlocks.data.map((flock) => ({ - value: flock.id, - label: flock.flock.name, - })) - : []; + // Pakan Products + const pakanUrl = useMemo(() => { + if (!formik.values.location_id) return null; + const params = new URLSearchParams({ + flag: 'PAKAN', + search: '', + location_id: formik.values.location_id.toString(), + }); + return `${ProductWarehouseApi.basePath}?${params.toString()}`; + }, [formik.values.location_id]); + const { data: pakanProducts, isLoading: isLoadingPakan } = useSWR( + pakanUrl, + ProductWarehouseApi.getAllFetcher + ); + + // OVK Products + const ovkUrl = useMemo(() => { + if (!formik.values.location_id) return null; + const params = new URLSearchParams({ + flag: 'OVK', + search: '', + location_id: formik.values.location_id.toString(), + }); + return `${ProductWarehouseApi.basePath}?${params.toString()}`; + }, [formik.values.location_id]); + + const { data: ovkProducts, isLoading: isLoadingOvk } = useSWR( + ovkUrl, + ProductWarehouseApi.getAllFetcher + ); + + // COMPUTED VALUES const buildWarehouseLabel = useCallback((warehouse: Warehouse) => { const parts: string[] = [warehouse.name]; @@ -155,20 +179,24 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { return parts.join(' - '); }, []); - const pakanUrl = useMemo(() => { - if (!formik.values.location_id) return null; - const params = new URLSearchParams({ - flag: 'PAKAN', - search: '', - location_id: formik.values.location_id.toString(), - }); - return `${ProductWarehouseApi.basePath}?${params.toString()}`; - }, [formik.values.location_id]); + const locationOptions = isResponseSuccess(locations) + ? locations.data.map((loc) => ({ value: loc.id, label: loc.name })) + : []; - const { data: pakanProducts, isLoading: isLoadingPakan } = useSWR( - pakanUrl, - ProductWarehouseApi.getAllFetcher - ); + const flockOptions = isResponseSuccess(projectFlocks) + ? projectFlocks.data.map((flock) => ({ + value: flock.id, + label: flock.flock.name, + })) + : []; + + const coopOptions = useMemo(() => { + if (!selectedProjectFlock || !selectedProjectFlock.kandangs) return []; + return selectedProjectFlock.kandangs.map((kandang) => ({ + value: kandang.id, + label: kandang.name, + })); + }, [selectedProjectFlock]); const filteredPakanProducts = useMemo(() => { if (!isResponseSuccess(pakanProducts) || !formik.values.location_id) @@ -181,8 +209,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { return warehouse.location.id === formik.values.location_id; } - // If warehouse only has area, include it if area matches the location's area - // Note: This might need adjustment based on your business logic return false; }); }, [pakanProducts, formik.values.location_id]); @@ -204,21 +230,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { return map; }, [filteredPakanProducts]); - const ovkUrl = useMemo(() => { - if (!formik.values.location_id) return null; - const params = new URLSearchParams({ - flag: 'OVK', - search: '', - location_id: formik.values.location_id.toString(), - }); - return `${ProductWarehouseApi.basePath}?${params.toString()}`; - }, [formik.values.location_id]); - - const { data: ovkProducts, isLoading: isLoadingOvk } = useSWR( - ovkUrl, - ProductWarehouseApi.getAllFetcher - ); - const filteredOvkProducts = useMemo(() => { if (!isResponseSuccess(ovkProducts) || !formik.values.location_id) return []; @@ -230,8 +241,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { return warehouse.location.id === formik.values.location_id; } - // If warehouse only has area, include it if area matches the location's area - // Note: This might need adjustment based on your business logic return false; }); }, [ovkProducts, formik.values.location_id]); @@ -253,14 +262,19 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { return map; }, [filteredOvkProducts]); - const coopOptions = useMemo(() => { - if (!selectedProjectFlock || !selectedProjectFlock.kandangs) return []; - return selectedProjectFlock.kandangs.map((kandang) => ({ - value: kandang.id, - label: kandang.name, - })); - }, [selectedProjectFlock]); + // EFFECTS + useEffect(() => { + if (initialValues?.flock && isResponseSuccess(projectFlocks)) { + const flock = projectFlocks.data.find( + (f) => f.id === initialValues.flock.id + ); + if (flock) { + setSelectedProjectFlock(flock); + } + } + }, [initialValues, projectFlocks]); + // EVENT HANDLERS - Select Inputs const locationChangeHandler = (val: OptionType | OptionType[] | null) => { const locationValue = (val as OptionType)?.value; @@ -298,17 +312,120 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { formik.setFieldValue('coop_id', coopValue || 0, false); }; - useEffect(() => { - if (initialValues?.flock && isResponseSuccess(projectFlocks)) { - const flock = projectFlocks.data.find( - (f) => f.id === initialValues.flock.id - ); - if (flock) { - setSelectedProjectFlock(flock); - } - } - }, [initialValues, projectFlocks]); + // EVENT HANDLERS - Feed Data + const addFeedData = () => { + const newFeedData = [ + ...(formik.values.feed_data || []), + { + feed: null, + feed_id: '', + feed_qty: '', + feed_stock: 0, + }, + ]; + formik.setFieldValue('feed_data', newFeedData); + }; + const removeFeedData = (idx: number) => { + const updatedFeedData = formik.values.feed_data?.filter( + (_, i) => i !== idx + ); + formik.setFieldValue('feed_data', updatedFeedData); + }; + + const removeSelectedFeedData = () => { + const updatedFeedData = formik.values.feed_data?.filter( + (_, idx) => !selectedFeed.includes(idx) + ); + formik.setFieldValue('feed_data', updatedFeedData); + setSelectedFeed([]); + }; + + // EVENT HANDLERS - Body Weight + const addBodyWeight = () => { + const newBodyWeight = [ + ...(formik.values.body_weight || []), + { + chicken_weight: 0, + chicken_count: 0, + average_chicken_weight: 0, + }, + ]; + formik.setFieldValue('body_weight', newBodyWeight); + }; + + const removeBodyWeight = (idx: number) => { + const updatedBodyWeight = formik.values.body_weight?.filter( + (_, i) => i !== idx + ); + formik.setFieldValue('body_weight', updatedBodyWeight); + }; + + const removeSelectedBodyWeight = () => { + const updatedBodyWeight = formik.values.body_weight?.filter( + (_, idx) => !selectedWeight.includes(idx) + ); + formik.setFieldValue('body_weight', updatedBodyWeight); + setSelectedWeight([]); + }; + + // EVENT HANDLERS - Vaccination + const addVaccination = () => { + const newVaccination = [ + ...(formik.values.vaccination || []), + { + vaccine: null, + vaccine_id: '', + total_stock: '', + used_stock: 0, + }, + ]; + formik.setFieldValue('vaccination', newVaccination); + }; + + const removeVaccination = (idx: number) => { + const updatedVaccination = formik.values.vaccination?.filter( + (_, i) => i !== idx + ); + formik.setFieldValue('vaccination', updatedVaccination); + }; + + const removeSelectedVaccination = () => { + const updatedVaccination = formik.values.vaccination?.filter( + (_, idx) => !selectedVaccine.includes(idx) + ); + formik.setFieldValue('vaccination', updatedVaccination); + setSelectedVaccine([]); + }; + + // EVENT HANDLERS - Mortality + const addMortality = () => { + const newMortality = [ + ...(formik.values.mortality || []), + { + condition: RECORDING_FLAG_OPTIONS[0].value, + count: 0, + }, + ]; + formik.setFieldValue('mortality', newMortality); + }; + + const removeMortality = (idx: number) => { + const updatedMortality = formik.values.mortality?.filter( + (_, i) => i !== idx + ); + formik.setFieldValue('mortality', updatedMortality); + }; + + const removeSelectedMortality = () => { + const updatedMortality = formik.values.mortality?.filter( + (_, idx) => !selectedMortality.includes(idx) + ); + formik.setFieldValue('mortality', updatedMortality); + setSelectedMortality([]); + }; + + // HELPER FUNCTIONS const isRepeaterInputError = ( arrayName: T, field: T extends 'feed_data' @@ -350,115 +467,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { }; }; - const addFeedData = () => { - const newFeedData = [ - ...(formik.values.feed_data || []), - { - feed: null, - feed_id: '', - feed_qty: '', - feed_stock: 0, - }, - ]; - formik.setFieldValue('feed_data', newFeedData); - }; - - const removeFeedData = (idx: number) => { - const updatedFeedData = formik.values.feed_data?.filter( - (_, i) => i !== idx - ); - formik.setFieldValue('feed_data', updatedFeedData); - }; - - const removeSelectedFeedData = () => { - const updatedFeedData = formik.values.feed_data?.filter( - (_, idx) => !selectedFeed.includes(idx) - ); - formik.setFieldValue('feed_data', updatedFeedData); - setSelectedFeed([]); - }; - - const addBodyWeight = () => { - const newBodyWeight = [ - ...(formik.values.body_weight || []), - { - chicken_weight: 0, - chicken_count: 0, - average_chicken_weight: 0, - }, - ]; - formik.setFieldValue('body_weight', newBodyWeight); - }; - - const removeBodyWeight = (idx: number) => { - const updatedBodyWeight = formik.values.body_weight?.filter( - (_, i) => i !== idx - ); - formik.setFieldValue('body_weight', updatedBodyWeight); - }; - - const removeSelectedBodyWeight = () => { - const updatedBodyWeight = formik.values.body_weight?.filter( - (_, idx) => !selectedWeight.includes(idx) - ); - formik.setFieldValue('body_weight', updatedBodyWeight); - setSelectedWeight([]); - }; - - const addVaccination = () => { - const newVaccination = [ - ...(formik.values.vaccination || []), - { - vaccine: null, - vaccine_id: '', - total_stock: '', - used_stock: 0, - }, - ]; - formik.setFieldValue('vaccination', newVaccination); - }; - - const removeVaccination = (idx: number) => { - const updatedVaccination = formik.values.vaccination?.filter( - (_, i) => i !== idx - ); - formik.setFieldValue('vaccination', updatedVaccination); - }; - - const removeSelectedVaccination = () => { - const updatedVaccination = formik.values.vaccination?.filter( - (_, idx) => !selectedVaccine.includes(idx) - ); - formik.setFieldValue('vaccination', updatedVaccination); - setSelectedVaccine([]); - }; - - const addMortality = () => { - const newMortality = [ - ...(formik.values.mortality || []), - { - condition: RECORDING_FLAG_OPTIONS[0].value, - count: 0, - }, - ]; - formik.setFieldValue('mortality', newMortality); - }; - - const removeMortality = (idx: number) => { - const updatedMortality = formik.values.mortality?.filter( - (_, i) => i !== idx - ); - formik.setFieldValue('mortality', updatedMortality); - }; - - const removeSelectedMortality = () => { - const updatedMortality = formik.values.mortality?.filter( - (_, idx) => !selectedMortality.includes(idx) - ); - formik.setFieldValue('mortality', updatedMortality); - setSelectedMortality([]); - }; - return ( <>
From eb02a8b6f72121f45d1f8cc9893d2994fe3c3acf Mon Sep 17 00:00:00 2001 From: rstubryan Date: Mon, 20 Oct 2025 10:09:58 +0700 Subject: [PATCH 24/68] refactor(storyless): update border class for consistent styling --- src/components/Collapse.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Collapse.tsx b/src/components/Collapse.tsx index cb05d5b0..8506f65c 100644 --- a/src/components/Collapse.tsx +++ b/src/components/Collapse.tsx @@ -68,7 +68,7 @@ export const Collapse = ({ 'collapse', variant === 'arrow' && 'collapse-arrow', variant === 'plus' && 'collapse-plus', - bordered && 'border base-content/20 border-opacity-20 rounded-box', + bordered && 'border base-content/20 border-opacity-20 rounded', disabled && 'opacity-60 pointer-events-none', !open && 'w-fit', className From 895b7afa2589bc8cf4daee7246c49c46be64b86e Mon Sep 17 00:00:00 2001 From: rstubryan Date: Mon, 20 Oct 2025 10:38:10 +0700 Subject: [PATCH 25/68] refactor(FE-114): improve product filtering logic for location and flag validation --- .../flock/recording/form/RecordingForm.tsx | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/components/pages/flock/recording/form/RecordingForm.tsx b/src/components/pages/flock/recording/form/RecordingForm.tsx index 42edc1ca..e97b0377 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.tsx +++ b/src/components/pages/flock/recording/form/RecordingForm.tsx @@ -205,11 +205,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { return pakanProducts.data.filter((product) => { const warehouse = product.warehouse; - if ('location' in warehouse && warehouse.location) { - return warehouse.location.id === formik.values.location_id; - } + const hasLocationMatch = + 'location' in warehouse && warehouse.location + ? warehouse.location.id === formik.values.location_id + : false; - return false; + const hasPakanFlag = product.product.flags?.includes('PAKAN'); + + return hasLocationMatch && hasPakanFlag; }); }, [pakanProducts, formik.values.location_id]); @@ -237,11 +240,16 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { return ovkProducts.data.filter((product) => { const warehouse = product.warehouse; - if ('location' in warehouse && warehouse.location) { - return warehouse.location.id === formik.values.location_id; - } + // Validate location match + const hasLocationMatch = + 'location' in warehouse && warehouse.location + ? warehouse.location.id === formik.values.location_id + : false; - return false; + // Validate product has OVK flag + const hasOvkFlag = product.product.flags?.includes('OVK'); + + return hasLocationMatch && hasOvkFlag; }); }, [ovkProducts, formik.values.location_id]); From ba9ae0745583cfe933823653c8e663592eaa7218 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Mon, 20 Oct 2025 13:10:41 +0700 Subject: [PATCH 26/68] refactor(FE-114): improve validation messages and update layout for better responsiveness --- .../recording/form/RecordingForm.schema.ts | 16 +- .../flock/recording/form/RecordingForm.tsx | 183 +++++++++--------- 2 files changed, 105 insertions(+), 94 deletions(-) diff --git a/src/components/pages/flock/recording/form/RecordingForm.schema.ts b/src/components/pages/flock/recording/form/RecordingForm.schema.ts index 16f7e690..5b31e54a 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.schema.ts +++ b/src/components/pages/flock/recording/form/RecordingForm.schema.ts @@ -52,7 +52,7 @@ export const RecordingFormSchema = Yup.object({ feed_qty: Yup.mixed().notRequired(), feed_stock: Yup.number() .required('Jumlah pakan yang digunakan wajib diisi!') - .min(1, 'Jumlah pakan minimal 1 kg!') + .min(1, 'Jumlah pakan minimal 1!') .typeError('Jumlah pakan yang digunakan harus berupa angka!') .test( 'is-not-exceed-qty', @@ -60,7 +60,12 @@ export const RecordingFormSchema = Yup.object({ function (value) { const { feed_qty } = this.parent; if (value === undefined) return true; - if (feed_qty === undefined || feed_qty === '' || typeof feed_qty !== 'number') return true; + if ( + feed_qty === undefined || + feed_qty === '' || + typeof feed_qty !== 'number' + ) + return true; return value <= feed_qty; } ), @@ -102,7 +107,12 @@ export const RecordingFormSchema = Yup.object({ function (value) { const { total_stock } = this.parent; if (value === undefined) return true; - if (total_stock === undefined || total_stock === '' || typeof total_stock !== 'number') return true; + if ( + total_stock === undefined || + total_stock === '' || + typeof total_stock !== 'number' + ) + return true; return value <= total_stock; } ), diff --git a/src/components/pages/flock/recording/form/RecordingForm.tsx b/src/components/pages/flock/recording/form/RecordingForm.tsx index e97b0377..2e137031 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.tsx +++ b/src/components/pages/flock/recording/form/RecordingForm.tsx @@ -477,7 +477,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { return ( <> -
+
{

Recording Information

-
-
- +
+ - { - const date = e.target.value - ? new Date(e.target.value) - : null; - formik.setFieldValue('recording_date', date); - }} - onBlur={formik.handleBlur} - isError={ - formik.touched.recording_date && - Boolean(formik.errors.recording_date) - } - errorMessage={formik.errors.recording_date as string} - readOnly={type === 'detail'} - /> -
+ { + const date = e.target.value + ? new Date(e.target.value) + : null; + formik.setFieldValue('recording_date', date); + }} + onBlur={formik.handleBlur} + isError={ + formik.touched.recording_date && + Boolean(formik.errors.recording_date) + } + errorMessage={formik.errors.recording_date as string} + readOnly={type === 'detail'} + /> -
- + - -
+
@@ -689,7 +685,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { isDisabled={type === 'detail'} isClearable className={{ - wrapper: 'w-full min-w-24', + wrapper: + 'w-full min-w-52 md:min-w-72 lg:min-w-80', }} />
From 1bcfd9bbb42c9b656a1b148e51d7c8d2da07fd51 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Mon, 20 Oct 2025 18:54:02 +0700 Subject: [PATCH 27/68] feat(FE-Storyless): add FieldMessage component for consistent field feedback across inputs --- src/components/helper/FieldMessage.tsx | 65 +++++++++++++++++++++++ src/components/input/FileInput.tsx | 14 +++-- src/components/input/NumberInput.tsx | 57 +++++++++++++++----- src/components/input/RadioInput.tsx | 17 +++--- src/components/input/SelectInput.tsx | 36 ++++++------- src/components/input/TagInput.tsx | 14 +++-- src/components/input/TextArea.tsx | 73 +++++++++++++------------- src/components/input/TextInput.tsx | 15 +++--- 8 files changed, 195 insertions(+), 96 deletions(-) create mode 100644 src/components/helper/FieldMessage.tsx diff --git a/src/components/helper/FieldMessage.tsx b/src/components/helper/FieldMessage.tsx new file mode 100644 index 00000000..e43d0a63 --- /dev/null +++ b/src/components/helper/FieldMessage.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { ReactNode } from 'react'; + +import { cn } from '@/lib/helper'; + +type FieldMessageTone = 'error' | 'info' | 'success'; + +export interface FieldMessageProps { + message?: ReactNode; + tone?: FieldMessageTone; + isVisible?: boolean; + persistent?: boolean; + className?: string; + ariaLive?: 'off' | 'polite' | 'assertive'; +} + +const toneClassName: Record = { + error: 'text-error', + info: 'text-base-content/60', + success: 'text-success', +}; + +/** + * Shared helper to render bottom field feedback without causing layout shift. + * Keeps a minimal slot height, but expands when the content wraps onto multiple lines. + */ +export const FieldMessage = ({ + message, + tone = 'info', + isVisible, + persistent = true, + className, + ariaLive, +}: FieldMessageProps) => { + const hasMessage = Boolean(message); + const visible = isVisible ?? hasMessage; + const liveRegion = ariaLive ?? (tone === 'error' ? 'assertive' : 'polite'); + + return ( +
+ + {visible || persistent ? (message ?? '\u00A0') : message} + +
+ ); +}; + +export default FieldMessage; diff --git a/src/components/input/FileInput.tsx b/src/components/input/FileInput.tsx index 6218970c..285a3f42 100644 --- a/src/components/input/FileInput.tsx +++ b/src/components/input/FileInput.tsx @@ -2,6 +2,7 @@ import { Ref } from 'react'; import { cn } from '@/lib/helper'; import { TextInputProps } from '@/components/input/TextInput'; +import FieldMessage from '@/components/helper/FieldMessage'; interface FileInputProps extends Omit< @@ -37,6 +38,9 @@ const FileInput = ({ onBlur, readOnly = false, }: FileInputProps) => { + const showErrorMessage = Boolean(isError && errorMessage); + const feedbackMessage = showErrorMessage ? errorMessage : bottomLabel; + return (
- {bottomLabel && ( -

{bottomLabel}

- )} - - {isError &&

{errorMessage}

} +
); }; diff --git a/src/components/input/NumberInput.tsx b/src/components/input/NumberInput.tsx index a9b8d9a0..e5319e5b 100644 --- a/src/components/input/NumberInput.tsx +++ b/src/components/input/NumberInput.tsx @@ -11,6 +11,7 @@ import { import { Icon } from '@iconify/react'; import { cn } from '@/lib/helper'; +import FieldMessage from '@/components/helper/FieldMessage'; // Utility Functions const formatNumber = ( @@ -25,7 +26,10 @@ const formatNumber = ( if (isNaN(numValue)) return ''; const parts = numValue.toFixed(decimals).split('.'); - const integerPart = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, thousandSeparator); + const integerPart = parts[0].replace( + /\B(?=(\d{3})+(?!\d))/g, + thousandSeparator + ); const decimalPart = parts[1]; return decimals > 0 && decimalPart @@ -180,11 +184,13 @@ const NumberInput = ({ const [displayValue, setDisplayValue] = useState(''); // Determine if decimals are allowed based on maskType - const allowDecimal = maskType === 'decimal' || maskType === 'weight' || decimals > 0; + const allowDecimal = + maskType === 'decimal' || maskType === 'weight' || decimals > 0; // Format value for display based on maskType const getFormattedValue = (rawValue: number | string): string => { - if (rawValue === '' || rawValue === null || rawValue === undefined) return ''; + if (rawValue === '' || rawValue === null || rawValue === undefined) + return ''; switch (maskType) { case 'currency': @@ -193,7 +199,12 @@ const NumberInput = ({ return formatWeight(rawValue, weightUnit, decimals); case 'decimal': case 'number': - return formatNumber(rawValue, decimals, thousandSeparator, decimalSeparator); + return formatNumber( + rawValue, + decimals, + thousandSeparator, + decimalSeparator + ); default: return String(rawValue); } @@ -216,10 +227,18 @@ const NumberInput = ({ } // Clean input - const cleaned = cleanNumericInput(inputValue, allowDecimal, decimalSeparator); + const cleaned = cleanNumericInput( + inputValue, + allowDecimal, + decimalSeparator + ); // Parse to number - let numericValue = parseNumber(cleaned, thousandSeparator, decimalSeparator); + let numericValue = parseNumber( + cleaned, + thousandSeparator, + decimalSeparator + ); // Apply validation if (!allowNegative && numericValue < 0) { @@ -262,7 +281,11 @@ const NumberInput = ({ const handleIncrement = () => { if (disabled || readOnly) return; - const currentValue = parseNumber(displayValue, thousandSeparator, decimalSeparator); + const currentValue = parseNumber( + displayValue, + thousandSeparator, + decimalSeparator + ); let newValue = currentValue + step; // Apply max validation @@ -296,7 +319,11 @@ const NumberInput = ({ const handleDecrement = () => { if (disabled || readOnly) return; - const currentValue = parseNumber(displayValue, thousandSeparator, decimalSeparator); + const currentValue = parseNumber( + displayValue, + thousandSeparator, + decimalSeparator + ); let newValue = currentValue - step; // Apply min validation (prevent negative if not allowed) @@ -329,6 +356,9 @@ const NumberInput = ({ } }; + const showErrorMessage = Boolean(isError && errorMessage); + const feedbackMessage = showErrorMessage ? errorMessage : bottomLabel; + return (
- {!isError && bottomLabel && ( -

{bottomLabel}

- )} - {isError && errorMessage && ( -

{errorMessage}

- )} +
); }; diff --git a/src/components/input/RadioInput.tsx b/src/components/input/RadioInput.tsx index 71a731aa..65589258 100644 --- a/src/components/input/RadioInput.tsx +++ b/src/components/input/RadioInput.tsx @@ -2,6 +2,7 @@ import { ChangeEventHandler, ReactNode } from 'react'; import { cn } from '@/lib/helper'; +import FieldMessage from '@/components/helper/FieldMessage'; export interface RadioOption { label: string; @@ -47,6 +48,8 @@ const RadioInput = ({ onChange, onBlur, }: RadioInputProps) => { + const showErrorMessage = Boolean(isError && errorMessage); + const feedbackMessage = showErrorMessage ? errorMessage : bottomLabel; return (
{/* Label atas */} @@ -97,15 +100,11 @@ const RadioInput = ({ ))}
- {/* Label bawah */} - {!isError && bottomLabel && ( -

{bottomLabel}

- )} - - {/* Pesan error */} - {isError && errorMessage && ( -

{errorMessage}

- )} + ); }; diff --git a/src/components/input/SelectInput.tsx b/src/components/input/SelectInput.tsx index 43a3f622..9caf2e17 100644 --- a/src/components/input/SelectInput.tsx +++ b/src/components/input/SelectInput.tsx @@ -1,12 +1,6 @@ 'use client'; -import { - ComponentType, - ReactNode, - useEffect, - useMemo, - useState, -} from 'react'; +import { ComponentType, ReactNode, useEffect, useMemo, useState } from 'react'; import Select, { OptionProps, GroupBase, @@ -18,6 +12,7 @@ import CreatableSelect from 'react-select/creatable'; import makeAnimated from 'react-select/animated'; import { useDebounce } from 'use-debounce'; import { cn } from '@/lib/helper'; +import FieldMessage from '@/components/helper/FieldMessage'; export interface OptionType { value: string | number; @@ -98,10 +93,7 @@ const SelectInput = (props: SelectInputProps) => { return { ...base, IndicatorSeparator: () => null }; }, [isAnimated]); - const internalInputChangeHandler = ( - val: string, - meta: InputActionMeta - ) => { + const internalInputChangeHandler = (val: string, meta: InputActionMeta) => { if (meta.action === 'input-change') setInternalInputValue(val); if (meta.action === 'menu-close') setInternalInputValue(''); }; @@ -113,9 +105,7 @@ const SelectInput = (props: SelectInputProps) => { const SelectComponent = createables ? CreatableSelect : Select; /** 🎯 handleChange tanpa any */ - const handleChange = ( - val: MultiValue | SingleValue - ): void => { + const handleChange = (val: MultiValue | SingleValue): void => { if (!val) { onChange?.(null); return; @@ -128,6 +118,9 @@ const SelectInput = (props: SelectInputProps) => { } }; + const showErrorMessage = Boolean(isError && errorMessage); + const feedbackMessage = showErrorMessage ? errorMessage : bottomLabel; + return (
(props: SelectInputProps) => { > {label} {required && ( - - * + + * )} )} > - instanceId="select" + instanceId='select' value={value ?? (isMulti ? [] : null)} onChange={handleChange} options={options} @@ -225,10 +218,11 @@ const SelectInput = (props: SelectInputProps) => { }} /> - {isError &&

{errorMessage}

} - {!isError && bottomLabel && ( -

{bottomLabel}

- )} +
); }; diff --git a/src/components/input/TagInput.tsx b/src/components/input/TagInput.tsx index a14b2f63..11407ada 100644 --- a/src/components/input/TagInput.tsx +++ b/src/components/input/TagInput.tsx @@ -2,6 +2,7 @@ import React, { useState, KeyboardEvent, ChangeEvent, useEffect } from 'react'; import { cn } from '@/lib/helper'; +import FieldMessage from '@/components/helper/FieldMessage'; export interface TagInputProps { label?: string; @@ -73,6 +74,9 @@ const TagInput: React.FC = ({ setInputValue(e.target.value); }; + const showErrorMessage = Boolean(isError && errorMessage); + const feedbackMessage = showErrorMessage ? errorMessage : bottomLabel; + return (
= ({ )}
- {/* Bottom label or error message */} - {!isError && bottomLabel && ( -

{bottomLabel}

- )} - {isError &&

{errorMessage}

} + ); }; diff --git a/src/components/input/TextArea.tsx b/src/components/input/TextArea.tsx index e9517277..802e469c 100644 --- a/src/components/input/TextArea.tsx +++ b/src/components/input/TextArea.tsx @@ -1,12 +1,9 @@ 'use client'; -import { - ChangeEventHandler, - FocusEventHandler, - ReactNode, -} from 'react'; +import { ChangeEventHandler, FocusEventHandler, ReactNode } from 'react'; import { cn } from '@/lib/helper'; +import FieldMessage from '@/components/helper/FieldMessage'; export interface TextAreaProps { label?: string; @@ -52,8 +49,11 @@ const TextArea = ({ onBlur, readOnly = false, isLoading = false, - rows = 3 + rows = 3, }: TextAreaProps) => { + const showErrorMessage = Boolean(isError && errorMessage); + const feedbackMessage = showErrorMessage ? errorMessage : bottomLabel; + return (
)} - {startAdornment && startAdornment} + {startAdornment && startAdornment} -