From 01db13ed6c4d207eb5474ed06bdda3641db71152 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 31 Oct 2025 15:15:48 +0700 Subject: [PATCH] feat(FE-170,174): add GradingForm component for managing grading records --- .../production/recording/grading/add/page.tsx | 44 + .../recording/grading/detail/edit/page.tsx | 52 ++ .../recording/grading/detail/layout.tsx | 11 + .../recording/grading/detail/page.tsx | 51 ++ .../recording/grading/form/GradingForm.tsx | 780 ++++++++++++++++++ 5 files changed, 938 insertions(+) create mode 100644 src/app/production/recording/grading/add/page.tsx create mode 100644 src/app/production/recording/grading/detail/edit/page.tsx create mode 100644 src/app/production/recording/grading/detail/layout.tsx create mode 100644 src/app/production/recording/grading/detail/page.tsx create mode 100644 src/components/pages/production/recording/grading/form/GradingForm.tsx diff --git a/src/app/production/recording/grading/add/page.tsx b/src/app/production/recording/grading/add/page.tsx new file mode 100644 index 00000000..c09a816b --- /dev/null +++ b/src/app/production/recording/grading/add/page.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; +import GradingForm from '@/components/pages/production/recording/grading/form/GradingForm'; +import { RecordingApi } from '@/services/api/production'; +import { isResponseSuccess } from '@/lib/api-helper'; + +const AddGrading = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const recordingId = searchParams.get('recording_id'); + + const { data: recording, isLoading: isLoadingRecording } = useSWR( + recordingId && recordingId !== 'new' ? [recordingId] : null, + ([id]) => RecordingApi.getSingle(parseInt(id)) + ); + + if ( + recordingId && + recordingId !== 'new' && + !isLoadingRecording && + (!recording || !isResponseSuccess(recording)) + ) { + router.replace('/404'); + return; + } + + return ( +
+ {recordingId && recordingId !== 'new' && isLoadingRecording && ( + + )} + {(!recordingId || + recordingId === 'new' || + (!isLoadingRecording && recording && isResponseSuccess(recording))) && ( + + )} +
+ ); +}; + +export default AddGrading; diff --git a/src/app/production/recording/grading/detail/edit/page.tsx b/src/app/production/recording/grading/detail/edit/page.tsx new file mode 100644 index 00000000..bab82777 --- /dev/null +++ b/src/app/production/recording/grading/detail/edit/page.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; +import GradingForm from '@/components/pages/production/recording/grading/form/GradingForm'; +import { RecordingApi } from '@/services/api/production'; +import { isResponseSuccess } from '@/lib/api-helper'; + +const EditGrading = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const recordingId = searchParams.get('recordingId'); + const gradingId = searchParams.get('gradingId'); + + const { data: recording, isLoading: isLoadingRecording } = useSWR( + recordingId ? [recordingId] : null, + ([id]) => RecordingApi.getSingle(parseInt(id)) + ); + + if (!recordingId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingRecording && (!recording || !isResponseSuccess(recording))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingRecording && ( + + )} + {!isLoadingRecording && recording && isResponseSuccess(recording) && ( + egg.id === parseInt(gradingId || '0'))} + recordingData={recording.data} + /> + )} +
+ ); +}; + +export default EditGrading; diff --git a/src/app/production/recording/grading/detail/layout.tsx b/src/app/production/recording/grading/detail/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/production/recording/grading/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/production/recording/grading/detail/page.tsx b/src/app/production/recording/grading/detail/page.tsx new file mode 100644 index 00000000..05901dec --- /dev/null +++ b/src/app/production/recording/grading/detail/page.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; +import GradingForm from '@/components/pages/production/recording/grading/form/GradingForm'; +import { RecordingApi } from '@/services/api/production'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; + +const DetailGrading = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const gradingId = searchParams.get('gradingId'); + + const { data: grading, isLoading: isLoadingGrading } = useSWR( + gradingId ? [gradingId] : null, + ([id]) => RecordingApi.getSingle(parseInt(id)) + ); + + if (!gradingId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingGrading && (!grading || !isResponseSuccess(grading))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingGrading && ( + + )} + {!isLoadingGrading && grading && isResponseSuccess(grading) && ( + egg.id === parseInt(gradingId))} + recordingData={grading.data} + /> + )} +
+ ); +}; + +export default DetailGrading; \ No newline at end of file diff --git a/src/components/pages/production/recording/grading/form/GradingForm.tsx b/src/components/pages/production/recording/grading/form/GradingForm.tsx new file mode 100644 index 00000000..2cad7bdb --- /dev/null +++ b/src/components/pages/production/recording/grading/form/GradingForm.tsx @@ -0,0 +1,780 @@ +'use client'; + +import { useMemo, useState, useEffect, useCallback } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useFormik } from 'formik'; +import useSWR from 'swr'; +import { Icon } from '@iconify/react'; +import Button from '@/components/Button'; + +import NumberInput from '@/components/input/NumberInput'; +import SelectInput, { OptionType } from '@/components/input/SelectInput'; +import CheckboxInput from '@/components/input/CheckboxInput'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; +import { FormHeader } from '@/components/helper/form/FormHeader'; +import { RecordingApi } from '@/services/api/production'; +import { + CreateGradingPayload, + Recording, + RecordingEgg, + GradingEgg, +} from '@/types/api/production/recording'; +import { type BaseApiResponse, FormStepStatus } from '@/types/api/api-general'; +import { + RecordingGradingFormSchema, + RecordingGradingFormValues, + UpdateRecordingGradingFormSchema, + getRecordingGradingFormInitialValues, +} from '../../form/RecordingForm.schema'; +import { useRecordingFormHandlers } from '../../form/useRecordingFormHandlers'; +import { ProductWarehouseApi } from '@/services/api/inventory'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { cn } from '@/lib/helper'; +import { useModal } from '@/components/Modal'; +import toast from 'react-hot-toast'; + +import Card from '@/components/Card'; +import Badge from '@/components/Badge'; +import StepItem from '@/components/steps/StepItem'; + +interface GradingFormProps { + type?: 'add' | 'edit' | 'detail'; + initialValues?: RecordingEgg & { grading_eggs?: GradingEgg[] }; + recordingData?: Recording; +} + +const GradingForm = ({ + type = 'add', + initialValues, + recordingData, +}: GradingFormProps) => { + const router = useRouter(); + const searchParams = useSearchParams(); + const recordingId = searchParams.get('recording_id'); + const [selectedGradingItems, setSelectedGradingItems] = useState( + [] + ); + + const [isApproveLoading, setIsApproveLoading] = useState(false); + const [isRejectLoading, setIsRejectLoading] = useState(false); + const [formSteps, setFormSteps] = useState(null); + + const approveModal = useModal(); + const rejectModal = useModal(); + + // ===== API DATA FETCHING ===== + const eggProductsUrl = useMemo(() => { + if (!recordingData?.project_flock_kandangs_id) return null; + const params = new URLSearchParams({ + search: '', + location_id: recordingData.project_flock_kandangs_id.toString(), + }); + return `${ProductWarehouseApi.basePath}?${params.toString()}`; + }, [recordingData]); + + const { data: eggProductsData, isLoading: isLoadingEggProducts } = useSWR( + eggProductsUrl, + ProductWarehouseApi.getAllFetcher + ); + + // ===== DATA PROCESSING ===== + const eggProducts = useMemo(() => { + const options: OptionType[] = []; + if (isResponseSuccess(eggProductsData)) { + eggProductsData.data.forEach((product) => { + const productName = product.product.name; + + if ( + productName.toLowerCase().includes('telur') || + productName.toLowerCase().includes('egg') + ) { + options.push({ + value: product.id, + label: product.product.name, + }); + } + }); + } + + return options; + }, [eggProductsData]); + + // ===== FORM HANDLERS ===== + const { + deleteModal, + recordingFormErrorMessage, + isDeleteLoading, + createRecordingHandler, + updateRecordingHandler, + deleteRecordingClickHandler, + confirmationModalDeleteClickHandler, + } = useRecordingFormHandlers(initialValues?.id); + + const formikInitialValues = useMemo(() => { + return getRecordingGradingFormInitialValues({ + recording_egg_id: initialValues?.id || parseInt(recordingId || '0') || 0, + eggs_grading: + initialValues?.grading_eggs?.map((grading: GradingEgg) => ({ + grade: grading.grade, + qty: grading.qty, + })) || [], + }); + }, [initialValues, recordingId]); + + const formik = useFormik({ + initialValues: formikInitialValues, + validationSchema: (() => { + return type === 'edit' + ? UpdateRecordingGradingFormSchema + : RecordingGradingFormSchema; + })(), + validateOnChange: true, + validateOnBlur: true, + onSubmit: async (values) => { + const gradingPayload = { + recording_egg_id: values.recording_egg_id, + eggs_grading: (values.eggs_grading ?? []).map((grading) => ({ + grade: grading.grade, + qty: grading.qty || 0, + })), + }; + + switch (type) { + case 'add': + await createRecordingHandler(gradingPayload as CreateGradingPayload); + break; + case 'edit': + await updateRecordingHandler( + initialValues?.id as number, + gradingPayload as CreateGradingPayload + ); + break; + } + }, + }); + + // ===== EVENT HANDLERS ===== + const approveHandler = async () => { + setIsApproveLoading(true); + + const approveResponse = await RecordingApi.customRequest< + BaseApiResponse + >('approvals', { + method: 'POST', + payload: { + action: 'APPROVED', + approvable_ids: [initialValues?.id as number], + notes: 'Approved via Grading Form', + }, + }); + + if (isResponseSuccess(approveResponse)) { + toast.success('Grading berhasil disetujui!'); + approveModal.closeModal(); + router.push('/production/recording'); + } else { + toast.error( + (approveResponse?.message as string) || 'Gagal menyetujui grading' + ); + approveModal.closeModal(); + } + + setIsApproveLoading(false); + }; + + const rejectHandler = async () => { + setIsRejectLoading(true); + + const rejectResponse = await RecordingApi.customRequest< + BaseApiResponse + >('approvals', { + method: 'POST', + payload: { + action: 'REJECTED', + approvable_ids: [initialValues?.id as number], + notes: 'Rejected via Grading Form', + }, + }); + + if (isResponseSuccess(rejectResponse)) { + toast.success('Grading berhasil ditolak!'); + rejectModal.closeModal(); + router.push('/production/recording'); + } else { + toast.error( + (rejectResponse?.message as string) || 'Gagal menolak grading' + ); + rejectModal.closeModal(); + } + + setIsRejectLoading(false); + }; + + // Grading Handlers + const addGrading = () => { + const newGrading = [ + ...(formik.values.eggs_grading || []), + { + grade: '', + qty: 0, + }, + ]; + formik.setFieldValue('eggs_grading', newGrading); + }; + + const handleGradingGradeChangeWrapper = useCallback( + (idx: number) => (selectedOption: OptionType | OptionType[] | null) => { + const option = selectedOption as OptionType | null; + formik.setFieldValue(`eggs_grading.${idx}.grade`, option?.label || ''); + }, + [formik] + ); + + const handleGradingQtyChangeWrapper = useCallback( + (idx: number) => (e: React.ChangeEvent) => { + const value = parseFloat(e.target.value) || 0; + formik.setFieldValue(`eggs_grading.${idx}.qty`, value); + }, + [formik] + ); + + const removeGrading = (idx: number) => { + const updatedGrading = formik.values.eggs_grading?.filter( + (_, i) => i !== idx + ); + formik.setFieldValue('eggs_grading', updatedGrading); + }; + + const removeSelectedGrading = () => { + const updatedGrading = formik.values.eggs_grading?.filter( + (_, idx) => !selectedGradingItems.includes(idx) + ); + formik.setFieldValue('eggs_grading', updatedGrading); + setSelectedGradingItems([]); + }; + + const isRepeaterInputError = ( + arrayName: 'eggs_grading', + column: string, + idx: number + ) => { + const touched = formik.touched as Record; + const errors = formik.errors as Record; + + if (!touched[arrayName] || !Array.isArray(touched[arrayName])) { + return { + isError: false, + errorMessage: '', + }; + } + + const touchedField = (touched[arrayName] as unknown[])?.[idx] as Record< + string, + unknown + >; + const errorField = (errors[arrayName] as unknown[])?.[idx] as Record< + string, + unknown + >; + + return { + isError: touchedField && Boolean(errorField?.[column]), + errorMessage: + touchedField && errorField?.[column] + ? (errorField[column] as string) + : '', + }; + }; + + // ===== EFFECTS ===== + useEffect(() => { + const steps: FormStepStatus[] = [ + { + name: 'Recording', + isCompleted: true, + isCurrent: false, + }, + { + name: 'Grading', + isCompleted: false, + isCurrent: true, + }, + ]; + setFormSteps(steps); + }, []); + + useEffect(() => { + if (formik.values.eggs_grading && formik.values.eggs_grading.length === 0) { + formik.setFieldValue('eggs_grading', [{ grade: '', qty: 0 }]); + } + }, [formik]); + + return ( + <> +
+ + + {/* Project Flock Info Card */} +
+ {/* Form Steps */} + {formSteps && ( +
+
+
    + {formSteps.map((step, idx) => ( + 0 ? 'step-primary' : undefined + } + icon={ + step.isCompleted ? ( + + ) : ( + idx + 1 + ) + } + > + {step.name} + + ))} +
+
+ + {/* Action buttons for multi-form navigation */} + {type === 'detail' && ( +
+ +
+ )} +
+ )} +
+ +
+ {/* Basic Info Card */} + +
+ {type === 'detail' && recordingData ? ( + <> + Recording ID#{recordingData.id} + + ) : ( + <> + Recording Egg ID#{formik.values.recording_egg_id} + + )} +
+
+ + {/* Grading Table */} + +
+ + + + {type !== 'detail' && ( + + )} + + + {type !== 'detail' && } + + + + {formik.values.eggs_grading?.map((grading, idx) => ( + + {type !== 'detail' && ( + + )} + + + {type !== 'detail' && ( + + )} + + ))} + +
+ 0 + } + onChange={( + e: React.ChangeEvent + ) => { + if (e.target.checked) { + setSelectedGradingItems( + formik.values.eggs_grading?.map( + (_, idx) => idx + ) ?? [] + ); + } else { + setSelectedGradingItems([]); + } + }} + classNames={{ + wrapper: 'flex justify-center', + checkbox: 'checkbox checkbox-sm', + }} + /> + + Grade + + * + + + Jumlah + + * + + Action
+ + ) => { + if (e.target.checked) { + setSelectedGradingItems([ + ...selectedGradingItems, + idx, + ]); + } else { + setSelectedGradingItems( + selectedGradingItems.filter((i) => i !== idx) + ); + } + }} + classNames={{ + wrapper: 'flex justify-center', + checkbox: 'checkbox checkbox-sm', + }} + /> + + + + + +
+ +
+
+
+ {type !== 'detail' && ( +
+ {selectedGradingItems.length > 0 && ( + + )} + +
+ )} +
+ + {/* Action buttons */} +
+ {type !== 'add' && ( +
+ {deleteRecordingClickHandler && ( + + )} + {type !== 'edit' && initialValues && ( + + )} + {type === 'detail' && ( + <> + + + + )} +
+ )} + {type !== 'detail' && ( +
+ + +
+ )} +
+ {recordingFormErrorMessage && ( +
+ + {recordingFormErrorMessage} +
+ )} +
+
+ + {type !== 'add' && ( + <> + + + {/* Approve Confirmation Modal */} + {type === 'detail' && ( + + )} + + {/* Reject Confirmation Modal */} + {type === 'detail' && ( + + )} + + )} + + ); +}; + +export default GradingForm;