mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 13:32:00 +00:00
feat(FE-316): Add Uniformity form with validation and upload
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user