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' && (
+
+
+
+ )}
+
+ )}
+
+
+
+
+
+ {type !== 'add' && (
+ <>
+
+
+ {/* Approve Confirmation Modal */}
+ {type === 'detail' && (
+
+ )}
+
+ {/* Reject Confirmation Modal */}
+ {type === 'detail' && (
+
+ )}
+ >
+ )}
+ >
+ );
+};
+
+export default GradingForm;