feat(FE-316): Add Uniformity form with validation and upload

This commit is contained in:
rstubryan
2025-12-26 16:08:04 +07:00
parent f1227c9dcb
commit 580c357667
4 changed files with 447 additions and 17 deletions
@@ -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<File>()
.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<UniformityFormSchemaType> =
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<typeof UniformityFormSchema>;
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,
};
};
@@ -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<UniformityFormValues>(
() => getUniformityFormInitialValues(initialValues),
[initialValues]
);
const formik = useFormik<UniformityFormValues>({
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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 (
<div className='flex h-screen'>
{/* Primary Drawer Content */}
<section className='w-full'>
{/* Header */}
<DrawerHeader
leftIcon={formType == 'add' ? 'mdi:close' : 'mdi:arrow-left'}
leftIconSize={24}
@@ -45,28 +212,175 @@ const UniformityForm = ({ formType = 'add' }: UniformityFormProps) => {
subtitleClassName='text-sm text-neutral'
showDivider
/>
{/* Form Section */}
<div className='divider mt-3'></div>
<section className='w-full px-6'>
<h2 className='text-2xl font-semibold'>Informasi Umum</h2>
<form
onSubmit={(e) => {
e.preventDefault();
}}
>
<h2 className='text-2xl font-semibold mb-6'>Informasi Umum</h2>
<form onSubmit={formik.handleSubmit} className='flex flex-col gap-6'>
{uniformityFormErrorMessage && (
<div className='alert alert-error' role='alert'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{uniformityFormErrorMessage}</span>
</div>
)}
<DateInput
required
label='Tanggal'
name='date'
value={formik.values.date}
onChange={handleDateChange}
onBlur={formik.handleBlur}
isError={formik.touched.date && Boolean(formik.errors.date)}
errorMessage={formik.errors.date as string}
/>
<SelectInput
required
label='Lokasi Farm'
placeholder='Pilih Lokasi...'
value={formik.values.location}
onChange={handleLocationChange}
options={locationOptions}
onInputChange={setLocationSelectInputValue}
isLoading={isLoadingLocations}
isError={
formik.touched.location_id && Boolean(formik.errors.location_id)
}
errorMessage={formik.errors.location_id as string}
isClearable
className={{ wrapper: 'w-full' }}
/>
<SelectInput
required
label='Project Flock Kandang'
placeholder='Pilih Project Flock Kandang...'
value={
formik.values.project_flock_kandang_id
? {
value: formik.values.project_flock_kandang_id,
label: formik.values.project_flock_kandang_id.toString(),
}
: null
}
onChange={(val) => {
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' }}
/>
<SelectInput
required
label='Kandang'
placeholder='Pilih Kandang...'
value={formik.values.kandang}
onChange={handleKandangChange}
options={kandangOptions}
onInputChange={setKandangSelectInputValue}
isLoading={isLoadingKandangs}
isError={
formik.touched.kandang_id && Boolean(formik.errors.kandang_id)
}
errorMessage={formik.errors.kandang_id as string}
isClearable
className={{ wrapper: 'w-full' }}
/>
<div>
<FileInput
required
name='files'
label='Upload File'
onChange={handleFileChange}
accept='application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/csv'
isError={formik.touched.files && Boolean(formik.errors.files)}
errorMessage={formik.errors.files as string}
className={{ wrapper: 'w-full' }}
/>
{formik.values.files && (
<div className='mt-4 flex flex-col gap-2'>
<label className='text-sm font-semibold'>
File yang dipilih:
</label>
<div className='flex items-center justify-between gap-4 p-3 bg-base-200 rounded-lg'>
<div className='flex items-center gap-2'>
<Icon
icon='material-symbols:attach-file'
width={20}
height={20}
/>
<span className='text-sm'>
{formik.values.files.name}
</span>
<span className='text-xs text-base-content/60'>
({(formik.values.files.size / 1024).toFixed(2)} KB)
</span>
</div>
<Button
type='button'
color='error'
onClick={handleRemoveFile}
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={18}
height={18}
/>
</Button>
</div>
</div>
)}
</div>
<Button
type='submit'
color='primary'
className='w-full'
disabled={formik.isSubmitting}
>
{formik.isSubmitting ? (
<span className='loading loading-spinner'></span>
) : (
'Simpan'
)}
</Button>
</form>
{formType === 'add' && (
<Button
color='primary'
onClick={handleOpenExpandedDrawer}
className='mt-6 w-full'
className='mt-4 w-full'
>
<Icon icon='ic:round-plus' width={18} height={18} />
Expand Drawer
</Button>
</form>
)}
</section>
</section>
{/* Expanded Drawer - shown when open */}
{expandedDrawerOpen && <ExpandedDrawerForm />}
</div>
);
+24 -2
View File
@@ -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<BaseApiResponse<Uniformity> | undefined> {
return await this.customRequest<BaseApiResponse<Uniformity>>('');
}
async createUniformity(
payload: CreateUniformityPayload
): Promise<BaseApiResponse<Uniformity> | 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');
+8
View File
@@ -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;
};