feat(FE-170,174): add GradingForm component for managing grading records

This commit is contained in:
rstubryan
2025-10-31 15:15:48 +07:00
parent 4a1f775c85
commit 01db13ed6c
5 changed files with 938 additions and 0 deletions
@@ -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 (
<div className='w-full p-4 flex flex-row justify-center'>
{recordingId && recordingId !== 'new' && isLoadingRecording && (
<span className='loading loading-spinner loading-xl' />
)}
{(!recordingId ||
recordingId === 'new' ||
(!isLoadingRecording && recording && isResponseSuccess(recording))) && (
<GradingForm type='add' recordingData={isResponseSuccess(recording) ? recording.data : undefined} />
)}
</div>
);
};
export default AddGrading;
@@ -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 (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
if (!isLoadingRecording && (!recording || !isResponseSuccess(recording))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingRecording && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingRecording && recording && isResponseSuccess(recording) && (
<GradingForm
type='edit'
initialValues={recording.data.recording_eggs?.find(egg => egg.id === parseInt(gradingId || '0'))}
recordingData={recording.data}
/>
)}
</div>
);
};
export default EditGrading;
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
@@ -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 (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
if (!isLoadingGrading && (!grading || !isResponseSuccess(grading))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingGrading && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingGrading && grading && isResponseSuccess(grading) && (
<GradingForm
type='detail'
initialValues={grading.data.recording_eggs?.find(egg => egg.id === parseInt(gradingId))}
recordingData={grading.data}
/>
)}
</div>
);
};
export default DetailGrading;
@@ -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<number[]>(
[]
);
const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isRejectLoading, setIsRejectLoading] = useState(false);
const [formSteps, setFormSteps] = useState<FormStepStatus[] | null>(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<RecordingGradingFormValues>({
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<Recording[]>
>('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<Recording[]>
>('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<HTMLInputElement>) => {
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<string, unknown>;
const errors = formik.errors as Record<string, unknown>;
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 (
<>
<section className='w-full'>
<FormHeader
type={type}
title='Grading Telur'
backUrl='/production/recording'
/>
{/* Project Flock Info Card */}
<div className='flex items-center gap-2 mb-4'>
{/* Form Steps */}
{formSteps && (
<div className='flex-1 mt-4'>
<div className='w-full'>
<ul className='steps w-full'>
{formSteps.map((step, idx) => (
<StepItem
key={idx}
color={
step.isCompleted
? 'success'
: step.isCurrent
? 'primary'
: undefined
}
className={
step.isCurrent && idx > 0 ? 'step-primary' : undefined
}
icon={
step.isCompleted ? (
<Icon
icon='material-symbols:check-rounded'
width={24}
height={24}
/>
) : (
idx + 1
)
}
>
{step.name}
</StepItem>
))}
</ul>
</div>
{/* Action buttons for multi-form navigation */}
{type === 'detail' && (
<div className='mt-4 flex gap-2'>
<Button
type='button'
color='primary'
onClick={() => {
toast.success('Semua form telah selesai!');
router.push('/production/recording');
}}
>
Kembali ke Recording
</Button>
</div>
)}
</div>
)}
</div>
<form
onSubmit={formik.handleSubmit}
className='w-full mt-8 flex flex-col gap-6'
>
{/* Basic Info Card */}
<Card
title='Informasi Grading'
className={{
wrapper: 'w-full mb-4 shadow',
body: 'flex flex-col gap-6',
}}
>
<div
className={
type === 'detail'
? 'flex flex-col gap-6'
: 'grid grid-cols-3 gap-4'
}
>
{type === 'detail' && recordingData ? (
<>
<span>Recording ID</span>#{recordingData.id}
</>
) : (
<>
<span>Recording Egg ID</span>#{formik.values.recording_egg_id}
</>
)}
</div>
</Card>
{/* Grading Table */}
<Card
title='Grading Telur'
className={{
wrapper: 'w-full mb-4 shadow',
title: 'mb-4',
}}
>
<div className='overflow-x-auto'>
<table className='table'>
<thead>
<tr>
{type !== 'detail' && (
<th>
<CheckboxInput
name='select-all-grading'
checked={
formik.values.eggs_grading?.length ===
selectedGradingItems.length &&
formik.values.eggs_grading?.length > 0
}
onChange={(
e: React.ChangeEvent<HTMLInputElement>
) => {
if (e.target.checked) {
setSelectedGradingItems(
formik.values.eggs_grading?.map(
(_, idx) => idx
) ?? []
);
} else {
setSelectedGradingItems([]);
}
}}
classNames={{
wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm',
}}
/>
</th>
)}
<th>
Grade
<span
className='tooltip tooltip-error tooltip-bottom z-[9999]'
data-tip='required'
>
<span className='text-error'>*</span>
</span>
</th>
<th>
Jumlah
<span
className='tooltip tooltip-error tooltip-bottom z-[9999]'
data-tip='required'
>
<span className='text-error'>*</span>
</span>
</th>
{type !== 'detail' && <th>Action</th>}
</tr>
</thead>
<tbody>
{formik.values.eggs_grading?.map((grading, idx) => (
<tr key={`grading-${idx}`}>
{type !== 'detail' && (
<td className='!align-middle'>
<CheckboxInput
name={`grading-${idx}`}
checked={selectedGradingItems.includes(idx)}
onChange={(
e: React.ChangeEvent<HTMLInputElement>
) => {
if (e.target.checked) {
setSelectedGradingItems([
...selectedGradingItems,
idx,
]);
} else {
setSelectedGradingItems(
selectedGradingItems.filter((i) => i !== idx)
);
}
}}
classNames={{
wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm',
}}
/>
</td>
)}
<td>
<SelectInput
required
value={
grading.grade
? { value: grading.grade, label: grading.grade }
: null
}
onChange={handleGradingGradeChangeWrapper(idx)}
options={[
{ value: 'Grade A', label: 'Grade A' },
{ value: 'Grade B', label: 'Grade B' },
{ value: 'Grade C', label: 'Grade C' },
{ value: 'Grade D', label: 'Grade D' },
{ value: 'Extra', label: 'Extra' },
]}
placeholder='Pilih Grade'
isError={
isRepeaterInputError('eggs_grading', 'grade', idx)
.isError
}
errorMessage={
isRepeaterInputError('eggs_grading', 'grade', idx)
.errorMessage
}
isDisabled={type === 'detail'}
className={{
wrapper: 'w-full min-w-32',
}}
isSearchable={false}
isClearable={type !== 'detail'}
/>
</td>
<td>
<NumberInput
required
name={`eggs_grading.${idx}.qty`}
value={grading.qty}
onChange={handleGradingQtyChangeWrapper(idx)}
onBlur={formik.handleBlur}
decimalScale={0}
allowNegative={false}
thousandSeparator=','
decimalSeparator='.'
isError={
isRepeaterInputError('eggs_grading', 'qty', idx)
.isError
}
errorMessage={
isRepeaterInputError('eggs_grading', 'qty', idx)
.errorMessage
}
readOnly={type === 'detail'}
className={{
wrapper: 'w-full min-w-24',
}}
placeholder='Jumlah'
/>
</td>
{type !== 'detail' && (
<td>
<div className='flex items-center'>
<Button
type='button'
color='error'
onClick={() => removeGrading(idx)}
>
<Icon
icon='mdi:trash-can'
width={24}
height={24}
/>
</Button>
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
{type !== 'detail' && (
<div className='flex justify-center items-center mt-4 gap-4'>
{selectedGradingItems.length > 0 && (
<Button
type='button'
color='error'
onClick={removeSelectedGrading}
disabled={selectedGradingItems.length === 0}
className='w-fit'
>
<Icon icon='mdi:trash-can' width={24} height={24} />
Hapus Terpilih ({selectedGradingItems.length})
</Button>
)}
<Button
type='button'
color='success'
onClick={addGrading}
className='w-fit'
>
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah Grading
</Button>
</div>
)}
</Card>
{/* Action buttons */}
<div className='flex flex-row justify-between gap-2 flex-wrap'>
{type !== 'add' && (
<div className='flex flex-row justify-start gap-2'>
{deleteRecordingClickHandler && (
<Button
type='button'
color='error'
onClick={deleteRecordingClickHandler}
className='px-4'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
className='justify-start text-sm'
/>
Delete
</Button>
)}
{type !== 'edit' && initialValues && (
<Button
type='button'
color='warning'
href={`/production/recording/grading/edit/?gradingId=${initialValues.id}`}
className='px-4'
>
<Icon
icon='material-symbols:edit-outline'
width={24}
height={24}
className='justify-start text-sm'
/>
Edit
</Button>
)}
{type === 'detail' && (
<>
<Button
type='button'
color='success'
onClick={() => approveModal.openModal()}
className='px-4'
isLoading={isApproveLoading}
>
<Icon
icon='material-symbols:check-circle-outline'
width={24}
height={24}
className='justify-start text-sm'
/>
Approve
</Button>
<Button
type='button'
color='error'
onClick={() => rejectModal.openModal()}
className='px-4'
isLoading={isRejectLoading}
>
<Icon
icon='material-symbols:cancel-outline'
width={24}
height={24}
className='justify-start text-sm'
/>
Reject
</Button>
</>
)}
</div>
)}
{type !== 'detail' && (
<div
className={cn('flex flex-row justify-end gap-2', {
'w-full': type === 'add',
})}
>
<Button
type='reset'
color='warning'
className='px-4'
onClick={(e) => {
formik.handleReset(e);
formik.validateForm();
}}
>
Reset
</Button>
<Button
type='submit'
color='primary'
className='px-4'
isLoading={formik.isSubmitting}
disabled={!formik.isValid || formik.isSubmitting}
>
Submit
</Button>
</div>
)}
</div>
{recordingFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{recordingFormErrorMessage}</span>
</div>
)}
</form>
</section>
{type !== 'add' && (
<>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text='Apakah anda yakin ingin menghapus data Grading ini?'
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
{/* Approve Confirmation Modal */}
{type === 'detail' && (
<ConfirmationModal
ref={approveModal.ref}
type='success'
text='Apakah anda yakin ingin menyetujui data Grading ini?'
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'success',
isLoading: isApproveLoading,
onClick: approveHandler,
}}
/>
)}
{/* Reject Confirmation Modal */}
{type === 'detail' && (
<ConfirmationModal
ref={rejectModal.ref}
type='error'
text='Apakah anda yakin ingin menolak data Grading ini?'
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isRejectLoading,
onClick: rejectHandler,
}}
/>
)}
</>
)}
</>
);
};
export default GradingForm;