From 580c35766720fc1511506aefc4175d4fa501d091 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 26 Dec 2025 16:08:04 +0700 Subject: [PATCH] feat(FE-316): Add Uniformity form with validation and upload --- .../uniformity/form/UniformityForm.schema.ts | 86 +++++ .../pages/uniformity/form/UniformityForm.tsx | 344 +++++++++++++++++- src/services/api/uniformity.ts | 26 +- src/types/api/uniformity/uniformity.d.ts | 8 + 4 files changed, 447 insertions(+), 17 deletions(-) diff --git a/src/components/pages/uniformity/form/UniformityForm.schema.ts b/src/components/pages/uniformity/form/UniformityForm.schema.ts index e69de29b..87e79aa3 100644 --- a/src/components/pages/uniformity/form/UniformityForm.schema.ts +++ b/src/components/pages/uniformity/form/UniformityForm.schema.ts @@ -0,0 +1,86 @@ +import * as Yup from 'yup'; +import { Uniformity } from '@/types/api/uniformity/uniformity'; + +type UniformityFormSchemaType = { + date: string; + location?: { + value: number; + label: string; + } | null; + location_id: number; + project_flock_kandang_id: number; + kandang?: { + value: number; + label: string; + } | null; + kandang_id: number; + files: File | undefined; +}; + +const FileSchema = Yup.mixed() + .test('fileSize', 'Ukuran file maksimal 2 MB', (value): boolean => { + if (!value) return true; + if (value instanceof File) return value.size <= 2 * 1024 * 1024; + return false; + }) + .test('fileType', 'Format file harus Excel', (value): boolean => { + if (!value) return true; + if (value instanceof File) { + const allowedTypes = [ + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'text/csv', + ]; + return allowedTypes.includes(value.type); + } + return false; + }); + +export const UniformityFormSchema: Yup.ObjectSchema = + Yup.object({ + date: Yup.string().required('Tanggal wajib diisi!'), + location: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), + location_id: Yup.number() + .required('Location wajib diisi!') + .typeError('Location wajib diisi!'), + project_flock_kandang_id: Yup.number() + .required('Project flock kandang wajib diisi!') + .typeError('Project flock kandang wajib diisi!'), + kandang: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), + kandang_id: Yup.number() + .required('Kandang wajib diisi!') + .typeError('Kandang wajib diisi!'), + files: FileSchema.required('File wajib diisi!'), + }); + +export type UniformityFormValues = Yup.InferType; + +export const getUniformityFormInitialValues = ( + initialValues?: Uniformity +): UniformityFormValues => { + return { + date: initialValues?.week ? '' : '', + location: initialValues?.location + ? { + value: initialValues.location.id, + label: initialValues.location.name, + } + : null, + location_id: initialValues?.location?.id ?? 0, + project_flock_kandang_id: initialValues?.project_flock_kandang_id ?? 0, + kandang: initialValues?.kandang + ? { + value: initialValues.kandang.id, + label: initialValues.kandang.name, + } + : null, + kandang_id: initialValues?.kandang?.id ?? 0, + files: undefined, + }; +}; diff --git a/src/components/pages/uniformity/form/UniformityForm.tsx b/src/components/pages/uniformity/form/UniformityForm.tsx index 74138b5b..33bcb7cc 100644 --- a/src/components/pages/uniformity/form/UniformityForm.tsx +++ b/src/components/pages/uniformity/form/UniformityForm.tsx @@ -1,22 +1,191 @@ 'use client'; -import { useEffect } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useFormik } from 'formik'; +import useSWR from 'swr'; +import { useRouter } from 'next/navigation'; +import { Icon } from '@iconify/react'; +import { toast } from 'react-hot-toast'; import DrawerHeader from '@/components/helper/drawer/DrawerHeader'; import { useUiStore } from '@/stores/ui/ui.store'; import Button from '@/components/Button'; -import { Icon } from '@iconify/react'; +import DateInput from '@/components/input/DateInput'; + +import SelectInput, { + OptionType, + useSelect, +} from '@/components/input/SelectInput'; +import FileInput from '@/components/input/FileInput'; + +import { + UniformityFormSchema, + UniformityFormValues, + getUniformityFormInitialValues, +} from '@/components/pages/uniformity/form/UniformityForm.schema'; +import { LocationApi, KandangApi } from '@/services/api/master-data'; +import { UniformityApi } from '@/services/api/uniformity'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import { + Uniformity, + CreateUniformityPayload, +} from '@/types/api/uniformity/uniformity'; import ExpandedDrawerForm from '@/components/pages/uniformity/form/ExpandedDrawerForm'; interface UniformityFormProps { formType?: 'add' | 'edit'; + initialValues?: Uniformity; } -const UniformityForm = ({ formType = 'add' }: UniformityFormProps) => { +const UniformityForm = ({ + formType = 'add', + initialValues, +}: UniformityFormProps) => { + const router = useRouter(); const subscribeValidate = useUiStore((s) => s.subscribeValidate); const setIsValid = useUiStore((s) => s.setIsValid); const expandedDrawerOpen = useUiStore((s) => s.expandedDrawerOpen); const setExpandedDrawerOpen = useUiStore((s) => s.setExpandedDrawerOpen); + const [locationSelectInputValue, setLocationSelectInputValue] = useState(''); + const [uniformityFormErrorMessage, setUniformityFormErrorMessage] = + useState(''); + + // ===== SELECT INPUT DATA ===== + const { + setInputValue: setKandangSelectInputValue, + options: kandangOptions, + isLoadingOptions: isLoadingKandangs, + } = useSelect(KandangApi.basePath, 'id', 'name', 'search'); + + // ===== FORM CONFIGURATION ===== + const formikInitialValues = useMemo( + () => getUniformityFormInitialValues(initialValues), + [initialValues] + ); + + const formik = useFormik({ + initialValues: formikInitialValues, + validationSchema: UniformityFormSchema, + validateOnChange: true, + validateOnBlur: true, + validateOnMount: false, + enableReinitialize: true, + onSubmit: async (values) => { + const formData = new FormData(); + formData.append('date', values.date); + formData.append('location_id', values.location_id.toString()); + formData.append( + 'project_flock_kandang_id', + values.project_flock_kandang_id.toString() + ); + formData.append('kandang_id', values.kandang_id.toString()); + + if (values.files) { + formData.append('files[]', values.files); + } + + const res = await UniformityApi.create( + formData as unknown as CreateUniformityPayload + ); + + if (isResponseError(res)) { + setUniformityFormErrorMessage(res.message); + return; + } + + toast.success(res?.message as string); + router.push('/uniformity'); + }, + }); + + // ===== API DATA FETCHING ===== + const locationsUrl = useMemo(() => { + const params = new URLSearchParams({ + search: locationSelectInputValue, + }); + return `${LocationApi.basePath}?${params.toString()}`; + }, [locationSelectInputValue]); + + const { data: locations, isLoading: isLoadingLocations } = useSWR( + locationsUrl, + LocationApi.getAllFetcher + ); + + const locationOptions = useMemo(() => { + if (!locations || !isResponseSuccess(locations)) return []; + return ( + locations.data.map((location) => ({ + value: location.id, + label: location.name, + })) || [] + ); + }, [locations]); + + // ===== FORM HANDLERS ===== + const handleLocationChange = useCallback( + (val: OptionType | OptionType[] | null) => { + const location = val as OptionType | null; + formik.setFieldTouched('location', true); + formik.setFieldValue('location', location); + formik.setFieldTouched('location_id', true); + formik.setFieldValue('location_id', (location as OptionType)?.value || 0); + }, + [formik] + ); + + const handleKandangChange = useCallback( + (val: OptionType | OptionType[] | null) => { + const kandang = val as OptionType | null; + formik.setFieldTouched('kandang', true); + formik.setFieldValue('kandang', kandang); + formik.setFieldTouched('kandang_id', true); + formik.setFieldValue('kandang_id', (kandang as OptionType)?.value || 0); + }, + [formik] + ); + + const handleFileChange = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + + if (!file) { + formik.setFieldValue('files', undefined); + return; + } + + if (file.size > 2 * 1024 * 1024) { + toast.error(`Ukuran file ${file.name} maksimal 2 MB!`); + return; + } + + const allowedTypes = [ + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'text/csv', + ]; + + if (!allowedTypes.includes(file.type)) { + toast.error(`Format file ${file.name} harus Excel atau CSV!`); + return; + } + + formik.setFieldValue('files', file); + }, + [formik] + ); + + const handleDateChange = useCallback( + (e: React.ChangeEvent) => { + formik.setFieldValue('date', e.target.value); + }, + [formik] + ); + + const handleRemoveFile = useCallback(() => { + formik.setFieldValue('files', undefined); + }, [formik]); + + // ===== EFFECTS ===== useEffect(() => { const unsub = subscribeValidate(() => { setIsValid(true); @@ -31,9 +200,7 @@ const UniformityForm = ({ formType = 'add' }: UniformityFormProps) => { return (
- {/* Primary Drawer Content */}
- {/* Header */} { subtitleClassName='text-sm text-neutral' showDivider /> - {/* Form Section */} +
-

Informasi Umum

-
{ - e.preventDefault(); - }} - > +

Informasi Umum

+ + + {uniformityFormErrorMessage && ( +
+ + {uniformityFormErrorMessage} +
+ )} + + + + + + { + const option = val as OptionType | null; + formik.setFieldValue( + 'project_flock_kandang_id', + option?.value || 0 + ); + }} + options={[ + { value: 1, label: '1' }, + { value: 2, label: '2' }, + { value: 3, label: '3' }, + ]} + isError={ + formik.touched.project_flock_kandang_id && + Boolean(formik.errors.project_flock_kandang_id) + } + errorMessage={formik.errors.project_flock_kandang_id as string} + isClearable + className={{ wrapper: 'w-full' }} + /> + + + +
+ + + {formik.values.files && ( +
+ +
+
+ + + {formik.values.files.name} + + + ({(formik.values.files.size / 1024).toFixed(2)} KB) + +
+ +
+
+ )} +
+ + + + + {formType === 'add' && ( - + )}
- {/* Expanded Drawer - shown when open */} {expandedDrawerOpen && }
); diff --git a/src/services/api/uniformity.ts b/src/services/api/uniformity.ts index 4cdca280..e732d48b 100644 --- a/src/services/api/uniformity.ts +++ b/src/services/api/uniformity.ts @@ -1,10 +1,13 @@ import { BaseApiService } from '@/services/api/base'; import { BaseApiResponse } from '@/types/api/api-general'; -import { Uniformity } from '@/types/api/uniformity/uniformity'; +import { + CreateUniformityPayload, + Uniformity, +} from '@/types/api/uniformity/uniformity'; export class UniformityApiService extends BaseApiService< Uniformity, - unknown, + CreateUniformityPayload, unknown > { constructor(basePath: string) { @@ -14,6 +17,25 @@ export class UniformityApiService extends BaseApiService< async getUniformity(): Promise | undefined> { return await this.customRequest>(''); } + + async createUniformity( + payload: CreateUniformityPayload + ): Promise | undefined> { + const formData = new FormData(); + formData.append('date', payload.date); + formData.append('location_id', payload.location_id.toString()); + formData.append( + 'project_flock_kandang_id', + payload.project_flock_kandang_id.toString() + ); + formData.append('kandang_id', payload.kandang_id.toString()); + + if (payload.files) { + formData.append('files[]', payload.files); + } + + return await this.create(formData as unknown as CreateUniformityPayload); + } } // export const UniformityApi = new UniformityApiService('uniformity'); diff --git a/src/types/api/uniformity/uniformity.d.ts b/src/types/api/uniformity/uniformity.d.ts index 97d2463a..8815b198 100644 --- a/src/types/api/uniformity/uniformity.d.ts +++ b/src/types/api/uniformity/uniformity.d.ts @@ -11,3 +11,11 @@ export type Uniformity = BaseMetadata & { status: 'CREATED' | 'APPROVED' | 'REJECTED'; uniformity: number; }; + +export type CreateUniformityPayload = { + date: string; + location_id: number; + project_flock_kandang_id: number; + kandang_id: number; + files: File; +};