fix(FE-33): fix conflict git

This commit is contained in:
randy-ar
2025-10-09 13:35:30 +07:00
59 changed files with 4048 additions and 327 deletions
+1 -1
View File
@@ -199,7 +199,7 @@ const MainDrawer = ({
if (!hasSubmenu) return;
const activeSubmenu = menu.submenu.find((item) =>
const activeSubmenu = menu.submenu?.find((item) =>
isPathActive(pathname, item.link)
);
+23
View File
@@ -0,0 +1,23 @@
'use client';
import { Suspense } from 'react';
const SuspenseHelper = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return (
<Suspense
fallback={
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
}
>
{children}
</Suspense>
);
};
export default SuspenseHelper;
+3 -1
View File
@@ -122,7 +122,9 @@ const TextInput = ({
{!isError && bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
)}
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
{isError && errorMessage && (
<p className='w-full text-sm text-error'>{errorMessage}</p>
)}
</div>
);
};
@@ -0,0 +1,289 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import { Bank } from '@/types/api/master-data/bank';
import { BankApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
const RowOptionsMenu = ({
type = 'dropdown',
props,
deleteClickHandler,
}: {
type: 'dropdown' | 'collapse';
props: CellContext<Bank, unknown>;
deleteClickHandler: () => void;
}) => {
return (
<div
tabIndex={type === 'dropdown' ? 0 : undefined}
className={cn(
{
'dropdown-content': type === 'dropdown',
'mt-2': type === 'collapse',
},
'p-2.5 mr-2 flex flex-col gap-1 bg-base-100 rounded-box z-10 border border-black/10 shadow'
)}
>
<Button
href={`/master-data/bank/detail/?bankId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
<Button
href={`/master-data/bank/detail/edit/?bankId=${props.row.original.id}`}
variant='ghost'
color='warning'
className='justify-start text-sm'
>
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
Edit
</Button>
<Button
onClick={deleteClickHandler}
variant='ghost'
color='error'
className='text-error hover:text-inherit'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={16}
height={16}
className='justify-start text-sm'
/>
Delete
</Button>
</div>
);
};
const BanksTable = () => {
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: { search: '', nameSort: '' },
paramMap: { page: 'page', pageSize: 'limit', nameSort: 'sort_name' },
});
const {
data: banks,
isLoading,
mutate: refreshBanks,
} = useSWR(
`${BankApi.basePath}${getTableFilterQueryString()}`,
BankApi.getAllFetcher
);
const deleteModal = useModal();
const [selectedBank, setSelectedBank] = useState<Bank | undefined>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [sorting, setSorting] = useState<SortingState>([]);
const banksColumns: ColumnDef<Bank>[] = [
{
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorKey: 'alias',
header: 'Alias',
},
{
accessorKey: 'account_number',
header: 'No. Rekening',
},
{
accessorKey: 'owner',
header: 'Pemilik',
cell: (props) => (props.getValue() ? props.getValue() : '-'),
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize = props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedBank(props.row.original);
deleteModal.openModal();
};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
);
},
},
];
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await BankApi.delete(selectedBank?.id as number);
refreshBanks();
deleteModal.closeModal();
toast.success('Successfully delete Bank!');
setIsDeleteLoading(false);
};
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
setPageSize(newVal.value as number);
};
// track sorting
useEffect(() => {
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
if (!isNameSorted) {
updateFilter('nameSort', '');
} else {
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
}
}, [sorting]);
return (
<>
<div className='w-full p-0 sm:p-4'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'>
<div className='flex flex-row'>
<Button href='/master-data/bank/add' color='primary'>
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah Bank
</Button>
</div>
<DebouncedTextInput
name='search'
placeholder='Cari Bank'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div>
<div className='flex flex-row justify-end'>
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
value={{
label: String(tableFilterState.pageSize),
value: tableFilterState.pageSize,
}}
onChange={pageSizeChangeHandler}
className={{ wrapper: 'max-w-28' }}
/>
</div>
</div>
<Table<Bank>
data={isResponseSuccess(banks) ? banks?.data : []}
columns={banksColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(banks) ? banks?.meta?.page : 0}
totalItems={isResponseSuccess(banks) ? banks?.meta?.total_results : 0}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn({
'mb-20': isResponseSuccess(banks) && banks?.data?.length === 0,
}),
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
}}
/>
</div>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Bank ini (${selectedBank?.name})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
</>
);
};
export default BanksTable;
@@ -0,0 +1,14 @@
import * as Yup from 'yup';
export const BankFormSchema = Yup.object({
name: Yup.string().required('Nama wajib diisi!'),
alias: Yup.string()
.max(5, 'Maksimal 5 karakter!')
.required('Alias wajib diisi!'),
account_number: Yup.string().required('Rekening wajib diisi!'),
owner: Yup.string(),
});
export const UpdateBankFormSchema = BankFormSchema;
export type BankFormValues = Yup.InferType<typeof BankFormSchema>;
@@ -0,0 +1,301 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useFormik } from 'formik';
import { toast } from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import TextInput from '@/components/input/TextInput';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import {
BankFormSchema,
BankFormValues,
UpdateBankFormSchema,
} from '@/components/pages/master-data/bank/form/BankForm.schema';
import { isResponseError } from '@/lib/api-helper';
import {
CreateBankPayload,
Bank,
UpdateBankPayload,
} from '@/types/api/master-data/bank';
import { BankApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
interface BankFormProps {
type?: 'add' | 'edit' | 'detail';
initialValues?: Bank;
}
const BankForm = ({ type = 'add', initialValues }: BankFormProps) => {
const router = useRouter();
const deleteModal = useModal();
const [bankFormErrorMessage, setBankFormErrorMessage] = useState('');
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const createBankHandler = useCallback(
async (payload: CreateBankPayload) => {
const createBankRes = await BankApi.create(payload);
if (isResponseError(createBankRes)) {
setBankFormErrorMessage(createBankRes.message);
return;
}
toast.success(createBankRes?.message as string);
router.push('/master-data/bank');
},
[router]
);
const updateBankHandler = useCallback(
async (bankId: number, payload: UpdateBankPayload) => {
const updateBankRes = await BankApi.update(bankId, payload);
if (updateBankRes?.status === 'error') {
setBankFormErrorMessage(updateBankRes.message);
return;
}
toast.success(updateBankRes?.message as string);
router.refresh();
router.push('/master-data/bank');
},
[router]
);
const formikInitialValues = useMemo<BankFormValues>(() => {
return {
name: initialValues?.name ?? '',
alias: initialValues?.alias ?? '',
account_number: initialValues?.account_number ?? '',
owner: initialValues?.owner,
};
}, [initialValues]);
const formik = useFormik<BankFormValues>({
initialValues: formikInitialValues,
validationSchema: type === 'edit' ? UpdateBankFormSchema : BankFormSchema,
onSubmit: async (values) => {
setBankFormErrorMessage('');
const bankPayload: CreateBankPayload = {
name: values.name,
alias: values.alias,
account_number: values.account_number.toString(),
owner: values.owner ? values.owner : '',
};
switch (type) {
case 'add':
await createBankHandler(bankPayload);
break;
case 'edit':
await updateBankHandler(initialValues?.id as number, bankPayload);
break;
}
},
});
const { setValues: formikSetValues } = formik;
const deleteBankClickHandler = () => {
deleteModal.openModal();
};
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await BankApi.delete(initialValues?.id as number);
deleteModal.closeModal();
toast.success('Successfully delete Bank!');
setIsDeleteLoading(false);
router.push('/master-data/bank');
};
useEffect(() => {
formikSetValues(formikInitialValues);
}, [formikSetValues, formikInitialValues]);
return (
<>
<section className='w-full max-w-xl'>
<header className='flex flex-col gap-4'>
<Button
href='/master-data/bank'
variant='link'
className='w-fit p-0 text-primary'
>
<Icon icon='uil:arrow-left' width={24} height={24} />
Kembali
</Button>
<h1 className='text-2xl font-bold text-center'>
{type === 'add' && 'Tambah Bank'}
{type === 'edit' && 'Edit Bank'}
{type === 'detail' && 'Detail Bank'}
</h1>
</header>
<form
onSubmit={formik.handleSubmit}
onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6'
>
<div className='flex flex-col gap-4'>
<TextInput
required
label='Nama'
name='name'
placeholder='Masukkan nama Bank'
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={formik.touched.name && Boolean(formik.errors.name)}
errorMessage={formik.errors.name}
readOnly={type === 'detail'}
/>
<TextInput
required
label='Alias'
name='alias'
placeholder='Masukkan alias'
value={formik.values.alias}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={formik.touched.alias && Boolean(formik.errors.alias)}
errorMessage={formik.errors.alias}
readOnly={type === 'detail'}
/>
<TextInput
required
type='number'
label='No. Rekening'
name='account_number'
placeholder='Masukkan no. rekening'
value={formik.values.account_number}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={
formik.touched.account_number &&
Boolean(formik.errors.account_number)
}
errorMessage={formik.errors.account_number}
readOnly={type === 'detail'}
/>
<TextInput
label='Pemilik'
name='owner'
placeholder='Masukkan nama pemilik'
value={formik.values.owner}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={formik.touched.owner && Boolean(formik.errors.owner)}
errorMessage={formik.errors.owner}
readOnly={type === 'detail'}
/>
</div>
<div className='flex flex-row justify-between gap-2 flex-wrap'>
{type !== 'add' && (
<div className='flex flex-row justify-start gap-2'>
<Button
type='button'
color='error'
onClick={deleteBankClickHandler}
className='px-4'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
className='justify-start text-sm'
/>
Delete
</Button>
{type !== 'edit' && (
<Button
type='button'
color='warning'
href={`/master-data/bank/detail/edit/?bankId=${initialValues?.id}`}
className='px-4'
>
<Icon
icon='material-symbols:edit-outline'
width={24}
height={24}
className='justify-start text-sm'
/>
Edit
</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'>
Reset
</Button>
<Button
type='submit'
color='primary'
isLoading={formik.isSubmitting}
disabled={!formik.isValid || formik.isSubmitting}
className='px-4'
>
Submit
</Button>
</div>
)}
</div>
{bankFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{bankFormErrorMessage}</span>
</div>
)}
</form>
</section>
{type !== 'add' && (
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Bank ini (${initialValues?.name})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
)}
</>
);
};
export default BankForm;
@@ -0,0 +1,276 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import { Fcr } from '@/types/api/master-data/fcr';
import { FcrApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
const RowOptionsMenu = ({
type = 'dropdown',
props,
deleteClickHandler,
}: {
type: 'dropdown' | 'collapse';
props: CellContext<Fcr, unknown>;
deleteClickHandler: () => void;
}) => {
return (
<div
tabIndex={type === 'dropdown' ? 0 : undefined}
className={cn(
{
'dropdown-content': type === 'dropdown',
'mt-2': type === 'collapse',
},
'p-2.5 mr-2 flex flex-col gap-1 bg-base-100 rounded-box z-10 border border-black/10 shadow'
)}
>
<Button
href={`/master-data/fcr/detail/?fcrId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
<Button
href={`/master-data/fcr/detail/edit/?fcrId=${props.row.original.id}`}
variant='ghost'
color='warning'
className='justify-start text-sm'
>
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
Edit
</Button>
<Button
onClick={deleteClickHandler}
variant='ghost'
color='error'
className='text-error hover:text-inherit'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={16}
height={16}
className='justify-start text-sm'
/>
Delete
</Button>
</div>
);
};
const FcrsTable = () => {
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: { search: '', nameSort: '' },
paramMap: { page: 'page', pageSize: 'limit', nameSort: 'sort_name' },
});
const {
data: fcrs,
isLoading,
mutate: refreshFcrs,
} = useSWR(
`${FcrApi.basePath}${getTableFilterQueryString()}`,
FcrApi.getAllFetcher
);
const deleteModal = useModal();
const [selectedFcr, setSelectedFcr] = useState<Fcr | undefined>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [sorting, setSorting] = useState<SortingState>([]);
const fcrsColumns: ColumnDef<Fcr>[] = [
{
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize = props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedFcr(props.row.original);
deleteModal.openModal();
};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
);
},
},
];
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await FcrApi.delete(selectedFcr?.id as number);
refreshFcrs();
deleteModal.closeModal();
toast.success('Successfully delete FCR!');
setIsDeleteLoading(false);
};
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
setPageSize(newVal.value as number);
};
// track sorting
useEffect(() => {
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
if (!isNameSorted) {
updateFilter('nameSort', '');
} else {
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
}
}, [sorting]);
return (
<>
<div className='w-full p-0 sm:p-4'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'>
<div className='flex flex-row'>
<Button href='/master-data/fcr/add' color='primary'>
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah FCR
</Button>
</div>
<DebouncedTextInput
name='search'
placeholder='Cari FCR'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div>
<div className='flex flex-row justify-end'>
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
value={{
label: String(tableFilterState.pageSize),
value: tableFilterState.pageSize,
}}
onChange={pageSizeChangeHandler}
className={{ wrapper: 'max-w-28' }}
/>
</div>
</div>
<Table<Fcr>
data={isResponseSuccess(fcrs) ? fcrs?.data : []}
columns={fcrsColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(fcrs) ? fcrs?.meta?.page : 0}
totalItems={isResponseSuccess(fcrs) ? fcrs?.meta?.total_results : 0}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn({
'mb-20': isResponseSuccess(fcrs) && fcrs?.data?.length === 0,
}),
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
}}
/>
</div>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data FCR ini (${selectedFcr?.name})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
</>
);
};
export default FcrsTable;
@@ -0,0 +1,26 @@
import * as Yup from 'yup';
const FcrStandardSchema: Yup.ObjectSchema<{
weight: number | string;
fcr_number: number | string;
mortality: number | string;
}> = Yup.object({
weight: Yup.number().nullable().required('Bobot wajib diisi!'),
fcr_number: Yup.number()
.nullable()
.typeError('FCR harus angka!')
.required('FCR harus diisi!'),
mortality: Yup.number().nullable().required('Mortalitas wajib diisi!'),
});
export const FcrFormSchema = Yup.object({
name: Yup.string().required('Nama wajib diisi!'),
fcrStandards: Yup.array()
.of(FcrStandardSchema)
.min(1, 'Minimal 1 FCR Standard diisi1')
.required('FCR wajib diisi!'),
});
export const UpdateFcrFormSchema = FcrFormSchema;
export type FcrFormValues = Yup.InferType<typeof FcrFormSchema>;
@@ -0,0 +1,389 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useFormik } from 'formik';
import { toast } from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import TextInput from '@/components/input/TextInput';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import {
FcrFormSchema,
FcrFormValues,
UpdateFcrFormSchema,
} from '@/components/pages/master-data/fcr/form/FcrForm.schema';
import { isResponseError } from '@/lib/api-helper';
import {
CreateFcrPayload,
Fcr,
FcrWithStandards,
UpdateFcrPayload,
} from '@/types/api/master-data/fcr';
import { FcrApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
interface FcrFormProps {
type?: 'add' | 'edit' | 'detail';
initialValues?: FcrWithStandards;
}
const FcrForm = ({ type = 'add', initialValues }: FcrFormProps) => {
const router = useRouter();
const deleteModal = useModal();
const [fcrFormErrorMessage, setFcrFormErrorMessage] = useState('');
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const createFcrHandler = useCallback(
async (payload: CreateFcrPayload) => {
const createFcrRes = await FcrApi.create(payload);
if (isResponseError(createFcrRes)) {
setFcrFormErrorMessage(createFcrRes.message);
return;
}
toast.success(createFcrRes?.message as string);
router.push('/master-data/fcr');
},
[router]
);
const updateFcrHandler = useCallback(
async (fcrId: number, payload: UpdateFcrPayload) => {
const updateFcrRes = await FcrApi.update(fcrId, payload);
if (updateFcrRes?.status === 'error') {
setFcrFormErrorMessage(updateFcrRes.message);
return;
}
toast.success(updateFcrRes?.message as string);
router.refresh();
router.push('/master-data/fcr');
},
[router]
);
const formikInitialValues = useMemo<FcrFormValues>(() => {
return {
name: initialValues?.name ?? '',
fcrStandards: initialValues?.fcr_standards
? initialValues?.fcr_standards
: [
{
weight: '',
fcr_number: '',
mortality: '',
},
],
};
}, [initialValues]);
const formik = useFormik<FcrFormValues>({
initialValues: formikInitialValues,
validationSchema: type === 'edit' ? UpdateFcrFormSchema : FcrFormSchema,
onSubmit: async (values) => {
setFcrFormErrorMessage('');
const fcrPayload: CreateFcrPayload = {
name: values.name,
fcr_standards: values.fcrStandards as CreateFcrPayload['fcr_standards'],
};
switch (type) {
case 'add':
await createFcrHandler(fcrPayload);
break;
case 'edit':
await updateFcrHandler(initialValues?.id as number, fcrPayload);
break;
}
},
});
const { setValues: formikSetValues } = formik;
const addFcrStandard = () =>
formik.setFieldValue('fcrStandards', [
...formik.values.fcrStandards,
{
weight: '',
fcr_number: '',
mortality: '',
},
]);
const removeFcrStandard = (i: number) =>
formik.setFieldValue(
'fcrStandards',
formik.values.fcrStandards.filter((_, idx) => idx !== i)
);
const deleteFcrClickHandler = () => {
deleteModal.openModal();
};
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await FcrApi.delete(initialValues?.id as number);
deleteModal.closeModal();
toast.success('Successfully delete FCR!');
setIsDeleteLoading(false);
router.push('/master-data/fcr');
};
const isRepeaterInputError = (
column: keyof CreateFcrPayload['fcr_standards'][0],
idx: number
) => {
return (
formik.touched.fcrStandards?.[idx]?.[column] &&
Boolean(
formik.errors.fcrStandards?.[idx] instanceof Object &&
formik.errors.fcrStandards?.[idx]?.[column]
)
);
};
useEffect(() => {
formikSetValues(formikInitialValues);
}, [formikSetValues, formikInitialValues]);
return (
<>
<section className='w-full max-w-5xl'>
<header className='flex flex-col gap-4'>
<Button
href='/master-data/fcr'
variant='link'
className='w-fit p-0 text-primary'
>
<Icon icon='uil:arrow-left' width={24} height={24} />
Kembali
</Button>
<h1 className='text-2xl font-bold text-center'>
{type === 'add' && 'Tambah FCR'}
{type === 'edit' && 'Edit FCR'}
{type === 'detail' && 'Detail FCR'}
</h1>
</header>
<form
onSubmit={formik.handleSubmit}
onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6'
>
<div className='flex flex-col gap-4'>
<TextInput
required
label='Nama'
name='name'
placeholder='Masukkan nama FCR'
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={formik.touched.name && Boolean(formik.errors.name)}
errorMessage={formik.errors.name}
readOnly={type === 'detail'}
/>
<div>
<div className='overflow-x-auto'>
<table className='table'>
<thead>
<tr>
<th>Bobot</th>
<th>FCR</th>
<th>Mortalitas</th>
{type !== 'detail' && <th>Aksi</th>}
</tr>
</thead>
<tbody>
{formik.values.fcrStandards.map((fcrStandard, idx) => (
<tr key={idx}>
<td>
<TextInput
required
type='number'
name={`fcrStandards[${idx}].weight`}
placeholder='Masukkan bobot'
value={fcrStandard.weight}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={isRepeaterInputError('weight', idx)}
readOnly={type === 'detail'}
className={{
wrapper: 'w-full min-w-24',
}}
/>
</td>
<td>
<TextInput
required
type='number'
name={`fcrStandards[${idx}].fcr_number`}
placeholder='Masukkan FCR'
value={fcrStandard.fcr_number}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={isRepeaterInputError('fcr_number', idx)}
readOnly={type === 'detail'}
className={{
wrapper: 'w-full min-w-24',
}}
/>
</td>
<td>
<TextInput
required
type='number'
name={`fcrStandards[${idx}].mortality`}
placeholder='Masukkan mortalitas'
value={fcrStandard.mortality}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={isRepeaterInputError('mortality', idx)}
readOnly={type === 'detail'}
className={{
wrapper: 'w-full min-w-24',
}}
/>
</td>
{type !== 'detail' && (
<td>
<Button
type='button'
color='error'
onClick={() => removeFcrStandard(idx)}
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
/>
</Button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
{type !== 'detail' && (
<Button
type='button'
color='success'
onClick={addFcrStandard}
className='w-fit mx-auto'
>
<Icon icon='ic:round-plus' width={24} height={24} /> Tambah FCR
</Button>
)}
</div>
<div className='flex flex-row justify-between gap-2 flex-wrap'>
{type !== 'add' && (
<div className='flex flex-row justify-start gap-2'>
<Button
type='button'
color='error'
onClick={deleteFcrClickHandler}
className='px-4'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
className='justify-start text-sm'
/>
Delete
</Button>
{type !== 'edit' && (
<Button
type='button'
color='warning'
href={`/master-data/fcr/detail/edit/?fcrId=${initialValues?.id}`}
className='px-4'
>
<Icon
icon='material-symbols:edit-outline'
width={24}
height={24}
className='justify-start text-sm'
/>
Edit
</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'>
Reset
</Button>
<Button
type='submit'
color='primary'
isLoading={formik.isSubmitting}
disabled={!formik.isValid || formik.isSubmitting}
className='px-4'
>
Submit
</Button>
</div>
)}
</div>
{fcrFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{fcrFormErrorMessage}</span>
</div>
)}
</form>
</section>
{type !== 'add' && (
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data FCR ini (${initialValues?.name})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
)}
</>
);
};
export default FcrForm;
@@ -1,20 +1,31 @@
'use client';
import { ChangeEventHandler, useState } from 'react';
import { ChangeEventHandler, useCallback, useEffect, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef } from '@tanstack/react-table';
import {
CellContext,
ColumnDef,
ColumnSort,
SortingState,
} from '@tanstack/react-table';
import toast from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import TextInput from '@/components/input/TextInput';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button';
import Collapse from '@/components/Collapse';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import { httpClientFetcher } from '@/services/http/client';
import { Nonstock, NonstocksResponse } from '@/types/api/master-data/nonstock';
import { Nonstock } from '@/types/api/master-data/nonstock';
import { NonstockApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { deleteNonstock } from '@/services/api/master-data/nonstock';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
const RowOptionsMenu = ({
type = 'dropdown',
@@ -23,7 +34,7 @@ const RowOptionsMenu = ({
}: {
type: 'dropdown' | 'collapse';
props: CellContext<Nonstock, unknown>;
deleteClickHandler: () => Promise<void>;
deleteClickHandler: () => void;
}) => {
return (
<div
@@ -74,78 +85,74 @@ const RowOptionsMenu = ({
);
};
const RowDropdownOptions = ({
props,
isLast2Rows,
deleteClickHandler,
}: {
props: CellContext<Nonstock, unknown>;
isLast2Rows: boolean;
deleteClickHandler: () => Promise<void>;
}) => {
return (
<div
className={cn('dropdown dropdown-left', {
'dropdown-start': !isLast2Rows,
'dropdown-end': isLast2Rows,
})}
>
<Button tabIndex={0}>
<Icon icon='material-symbols:more-vert' width={16} height={16} />
</Button>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</div>
);
};
const RowCollapseOptions = ({
props,
deleteClickHandler,
}: {
props: CellContext<Nonstock, unknown>;
deleteClickHandler: () => Promise<void>;
}) => {
return (
<Collapse
title={
<Button>
<Icon icon='material-symbols:more-vert' width={16} height={16} />
</Button>
}
className='w-fit'
titleClassName='p-0! justify-self-end'
>
<RowOptionsMenu
type='collapse'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</Collapse>
);
};
const NonstocksTable = () => {
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: { search: '', nameSort: '', locationSort: '', picSort: '' },
paramMap: {
page: 'page',
pageSize: 'limit',
nameSort: 'sort_name',
locationSort: 'sort_location',
picSort: ' sort_pic',
},
});
const {
data: nonstocks,
isLoading,
mutate: refreshNonstocks,
} = useSWR<NonstocksResponse>('/master-data/nonstocks', httpClientFetcher);
} = useSWR(
`${NonstockApi.basePath}${getTableFilterQueryString()}`,
NonstockApi.getAllFetcher
);
const [searchValue, setSearchValue] = useState('');
const deleteModal = useModal();
const [selectedNonstock, setSelectedNonstock] = useState<
Nonstock | undefined
>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [sorting, setSorting] = useState<SortingState>([]);
const nonstocksColumns: ColumnDef<Nonstock>[] = [
{
header: '#',
cell: (props) => props.row.index + 1,
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
header: 'Nama',
accessorKey: 'name',
header: 'Nama',
},
{
accessorKey: 'uom',
header: 'UOM',
cell: (props) => props.row.original.uom.name,
},
{
accessorKey: 'suppliers',
header: 'Supplier',
cell: (props) => {
const supplierNames = props.row.original.suppliers.map(
(supplier) => supplier.name
);
return supplierNames.join(', ') || '-';
},
},
{
accessorKey: 'flags',
header: 'Flag',
cell: (props) => props.row.original.flags?.join(', ') || '-',
},
{
header: 'Aksi',
@@ -157,33 +164,31 @@ const NonstocksTable = () => {
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = async () => {
const confirmation = confirm(
'Apakah anda yakin untuk menghapus non stock ini?'
);
if (confirmation) {
await deleteNonstock(props.row.original.id);
refreshNonstocks();
alert('Nonstock berhasil dihapus!');
}
const deleteClickHandler = () => {
setSelectedNonstock(props.row.original);
deleteModal.openModal();
};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions
props={props}
isLast2Rows={isLast2Rows}
deleteClickHandler={deleteClickHandler}
/>
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions
props={props}
deleteClickHandler={deleteClickHandler}
/>
<RowCollapseOptions>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
);
@@ -191,52 +196,133 @@ const NonstocksTable = () => {
},
];
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await NonstockApi.delete(selectedNonstock?.id as number);
refreshNonstocks();
deleteModal.closeModal();
toast.success('Successfully delete Nonstock!');
setIsDeleteLoading(false);
};
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
setPageSize(newVal.value as number);
};
const updateSortingFilter = useCallback(
(
sortName: Exclude<keyof typeof tableFilterState, 'page' | 'pageSize'>,
sortFilter: ColumnSort | undefined
) => {
if (!sortFilter) {
updateFilter(sortName, '');
} else {
updateFilter(sortName, sortFilter.desc ? 'desc' : 'asc');
}
},
[updateFilter]
);
// track sorting
useEffect(() => {
const nameSortFilter = sorting.find((sortItem) => sortItem.id === 'name');
const locationSortFilter = sorting.find(
(sortItem) => sortItem.id === 'location'
);
const picSortFilter = sorting.find((sortItem) => sortItem.id === 'pic');
updateSortingFilter('nameSort', nameSortFilter);
updateSortingFilter('locationSort', locationSortFilter);
updateSortingFilter('picSort', picSortFilter);
}, [sorting]);
return (
<div className='w-full p-4'>
<div className='w-full mb-4 flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'>
<div className='flex flex-row'>
<Button href='/master-data/nonstock/add' color='primary'>
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah Nonstock
</Button>
<>
<div className='w-full p-0 sm:p-4'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'>
<div className='flex flex-row'>
<Button href='/master-data/nonstock/add' color='primary'>
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah Nonstock
</Button>
</div>
<DebouncedTextInput
name='search'
placeholder='Cari Nonstock'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div>
<div className='flex flex-row justify-end'>
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
value={{
label: String(tableFilterState.pageSize),
value: tableFilterState.pageSize,
}}
onChange={pageSizeChangeHandler}
className={{ wrapper: 'max-w-28' }}
/>
</div>
</div>
<TextInput
name='search'
placeholder='Cari Non Stock'
value={searchValue}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
<Table<Nonstock>
data={isResponseSuccess(nonstocks) ? nonstocks?.data : []}
columns={nonstocksColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(nonstocks) ? nonstocks?.meta?.page : 0}
totalItems={
isResponseSuccess(nonstocks) ? nonstocks?.meta?.total_results : 0
}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn({
'mb-20':
isResponseSuccess(nonstocks) && nonstocks?.data?.length === 0,
}),
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
}}
/>
</div>
<Table<Nonstock>
data={isResponseSuccess(nonstocks) ? nonstocks?.data : []}
columns={nonstocksColumns}
pageSize={10}
fuzzySearchValue={searchValue}
onFuzzySearchValueChange={setSearchValue}
isLoading={isLoading}
className={{
containerClassName: cn({
'mb-20':
isResponseSuccess(nonstocks) && nonstocks?.data?.length === 0,
}),
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Nonstock ini (${selectedNonstock?.name})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
</div>
</>
);
};
@@ -2,6 +2,22 @@ import * as Yup from 'yup';
export const NonstockFormSchema = Yup.object({
name: Yup.string().required('Nama wajib diisi!'),
uomId: Yup.number().min(1, 'UOM wajib diisi!').required('UOM wajib diisi!'),
uom: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
supplierIds: Yup.array().of(Yup.number().min(0, 'Supplier wajib diisi!')),
suppliers: Yup.array().of(
Yup.object({
value: Yup.number().min(0).required(),
label: Yup.string().required(),
})
),
flags: Yup.array().of(Yup.string()).notRequired(),
});
export const UpdateNonstockFormSchema = NonstockFormSchema;
@@ -3,26 +3,31 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useFormik } from 'formik';
import { toast } from 'react-hot-toast';
import useSWR from 'swr';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import TextInput from '@/components/input/TextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import {
NonstockFormSchema,
NonstockFormValues,
UpdateNonstockFormSchema,
} from '@/components/pages/master-data/nonstock/form/NonstockForm.schema';
import { isResponseError } from '@/lib/api-helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import {
CreateNonstockPayload,
Nonstock,
CreateNonstockPayload,
UpdateNonstockPayload,
} from '@/types/api/master-data/nonstock';
import {
createNonstock,
updateNonstock,
} from '@/services/api/master-data/nonstock';
import { NonstockApi, SupplierApi, UomApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { flags } from '@/types/api/api-general';
import { SUPPLIER_FLAG_OPTIONS } from '@/config/constant';
interface NonstockFormProps {
type?: 'add' | 'edit' | 'detail';
@@ -31,19 +36,21 @@ interface NonstockFormProps {
const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
const router = useRouter();
const deleteModal = useModal();
const [nonstockFormErrorMessage, setNonstockFormErrorMessage] = useState('');
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const createNonstockHandler = useCallback(
async (payload: CreateNonstockPayload) => {
const createNonstockRes = await createNonstock(payload);
const createNonstockRes = await NonstockApi.create(payload);
if (isResponseError(createNonstockRes)) {
setNonstockFormErrorMessage(createNonstockRes.message);
return;
}
alert(createNonstockRes?.message);
toast.success(createNonstockRes?.message as string);
router.push('/master-data/nonstock');
},
[router]
@@ -51,14 +58,14 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
const updateNonstockHandler = useCallback(
async (nonstockId: number, payload: UpdateNonstockPayload) => {
const updateNonstockRes = await updateNonstock(nonstockId, payload);
const updateNonstockRes = await NonstockApi.update(nonstockId, payload);
if (updateNonstockRes?.status === 'error') {
setNonstockFormErrorMessage(updateNonstockRes.message);
return;
}
alert(updateNonstockRes?.message);
toast.success(updateNonstockRes?.message as string);
router.refresh();
router.push('/master-data/nonstock');
},
@@ -68,6 +75,22 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
const formikInitialValues = useMemo<NonstockFormValues>(() => {
return {
name: initialValues?.name ?? '',
uomId: initialValues?.uom_id ?? 0,
uom: initialValues?.uom
? {
value: initialValues?.uom.id,
label: initialValues?.uom.name,
}
: null,
supplierIds:
initialValues?.suppliers.map((supplier) => supplier.id) ?? [],
suppliers:
initialValues?.suppliers.map((supplier) => ({
value: supplier.id,
label: supplier.name,
})) ?? [],
flags: initialValues?.flags ?? [],
};
}, [initialValues]);
@@ -80,6 +103,9 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
const nonstockPayload: CreateNonstockPayload = {
name: values.name,
uom_id: values.uomId,
supplier_ids: values.supplierIds as number[],
flags: values.flags as flags[],
};
switch (type) {
@@ -97,81 +123,268 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
},
});
const { setValues: formikSetValues } = formik;
// UOM
const [uomSelectInputValue, setUomSelectInputValue] = useState('');
const uomsUrl = `${UomApi.basePath}?${new URLSearchParams({
search: uomSelectInputValue ?? '',
}).toString()}`;
const { data: uoms, isLoading: isLoadingUoms } = useSWR(
uomsUrl,
UomApi.getAllFetcher
);
const uomOptions = isResponseSuccess(uoms)
? uoms?.data.map((uom) => ({
value: uom.id,
label: uom.name,
}))
: [];
const uomChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('uom', true);
formik.setFieldValue('uom', val);
formik.setFieldTouched('uomId', true);
formik.setFieldValue('uomId', (val as OptionType)?.value);
};
// supplier
const [supplierSelectInputValue, setSupplierSelectInputValue] = useState('');
const suppliersUrl = `${SupplierApi.basePath}?${new URLSearchParams({
search: supplierSelectInputValue ?? '',
}).toString()}`;
const { data: suppliers, isLoading: isLoadingSuppliers } = useSWR(
suppliersUrl,
SupplierApi.getAllFetcher
);
const supplierOptions = isResponseSuccess(suppliers)
? suppliers?.data
.filter((sup) => sup.category === 'BOP')
.map((supplier) => ({
value: supplier.id,
label: supplier.name,
}))
: [];
const supplierChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('suppliers', true);
formik.setFieldValue('suppliers', val);
const supplierIds = (val as OptionType[]).map(
(supplier) => supplier.value as number
);
formik.setFieldTouched('supplierIds', true);
formik.setFieldValue('supplierIds', supplierIds);
};
const deleteNonstockClickHandler = () => {
deleteModal.openModal();
};
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await NonstockApi.delete(initialValues?.id as number);
deleteModal.closeModal();
toast.success('Successfully delete Nonstock!');
setIsDeleteLoading(false);
router.push('/master-data/nonstock');
};
const flagsChangeHandler = (val: OptionType | OptionType[] | null) => {
const formattedFlags = (val as OptionType[]).map(
(flag) => flag.value as string
);
formik.setFieldValue('flags', formattedFlags);
};
useEffect(() => {
formik.setValues(formikInitialValues);
}, [formikInitialValues]);
formikSetValues(formikInitialValues);
}, [formikSetValues, formikInitialValues]);
return (
<section className='w-full max-w-xl'>
<header className='flex flex-col gap-4'>
<Button
href='/master-data/nonstock'
variant='link'
className='w-fit p-0 text-primary'
<>
<section className='w-full max-w-xl'>
<header className='flex flex-col gap-4'>
<Button
href='/master-data/nonstock'
variant='link'
className='w-fit p-0 text-primary'
>
<Icon icon='uil:arrow-left' width={24} height={24} />
Kembali
</Button>
<h1 className='text-2xl font-bold text-center'>
{type === 'add' && 'Tambah Nonstock'}
{type === 'edit' && 'Edit Nonstock'}
{type === 'detail' && 'Detail Nonstock'}
</h1>
</header>
<form
onSubmit={formik.handleSubmit}
onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6'
>
<Icon icon='uil:arrow-left' width={24} height={24} />
Kembali
</Button>
<div className='flex flex-col gap-4'>
<TextInput
required
label='Nama'
name='name'
placeholder='Masukkan nama lokasi'
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={formik.touched.name && Boolean(formik.errors.name)}
errorMessage={formik.errors.name}
readOnly={type === 'detail'}
/>
<h1 className='text-2xl font-bold text-center'>
{type === 'add' && 'Tambah Non Stock'}
{type === 'edit' && 'Edit Non Stock'}
{type === 'detail' && 'Detail Non Stock'}
</h1>
</header>
<SelectInput
required
label='UOM'
value={formik.values.uom ?? undefined}
onChange={uomChangeHandler}
options={uomOptions}
onInputChange={setUomSelectInputValue}
isLoading={isLoadingUoms}
isError={formik.touched.uomId && Boolean(formik.errors.uomId)}
errorMessage={formik.errors.uomId as string}
isDisabled={type === 'detail'}
isClearable
/>
<form
onSubmit={formik.handleSubmit}
onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6'
>
<div className='flex flex-col gap-4'>
<TextInput
required
label='Nama'
name='name'
placeholder='Masukkan nama nonstock'
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={formik.touched.name && Boolean(formik.errors.name)}
errorMessage={formik.errors.name}
readOnly={type === 'detail'}
/>
</div>
<SelectInput
label='Supplier'
isMulti
value={formik.values.suppliers}
onChange={supplierChangeHandler}
options={supplierOptions ?? []}
onInputChange={setSupplierSelectInputValue}
isLoading={isLoadingSuppliers}
isError={
formik.touched.suppliers && Boolean(formik.errors.suppliers)
}
errorMessage={formik.errors.suppliers as string}
isDisabled={type === 'detail'}
/>
{type !== 'detail' && (
<>
<div className='flex flex-row justify-end gap-2'>
<Button type='reset' color='error' className='px-4'>
Reset
</Button>
<SelectInput
label='Flags'
isMulti
value={SUPPLIER_FLAG_OPTIONS.filter((opt) =>
formik.values.flags?.includes(opt.value)
)}
onChange={flagsChangeHandler}
options={SUPPLIER_FLAG_OPTIONS}
isError={formik.touched.flags && Boolean(formik.errors.flags)}
errorMessage={formik.errors.flags as string}
isDisabled={type === 'detail'}
isClearable
/>
</div>
<Button
type='submit'
color='primary'
isLoading={formik.isSubmitting}
disabled={!formik.isValid || formik.isSubmitting}
className='px-4'
>
Submit
</Button>
</div>
<div className='flex flex-row justify-between gap-2 flex-wrap'>
{type !== 'add' && (
<div className='flex flex-row justify-start gap-2'>
<Button
type='button'
color='error'
onClick={deleteNonstockClickHandler}
className='px-4'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
className='justify-start text-sm'
/>
Delete
</Button>
{nonstockFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{nonstockFormErrorMessage}</span>
{type !== 'edit' && (
<Button
type='button'
color='warning'
href={`/master-data/nonstock/detail/edit/?nonstockId=${initialValues?.id}`}
className='px-4'
>
<Icon
icon='material-symbols:edit-outline'
width={24}
height={24}
className='justify-start text-sm'
/>
Edit
</Button>
)}
</div>
)}
</>
)}
</form>
</section>
{type !== 'detail' && (
<div
className={cn('flex flex-row justify-end gap-2', {
'w-full': type === 'add',
})}
>
<Button type='reset' color='warning' className='px-4'>
Reset
</Button>
<Button
type='submit'
color='primary'
isLoading={formik.isSubmitting}
disabled={!formik.isValid || formik.isSubmitting}
className='px-4'
>
Submit
</Button>
</div>
)}
</div>
{nonstockFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{nonstockFormErrorMessage}</span>
</div>
)}
</form>
</section>
{type !== 'add' && (
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Nonstock ini (${initialValues?.name})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
)}
</>
);
};
@@ -0,0 +1,266 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import { ProductCategory } from '@/types/api/master-data/product-category';
import { ProductCategoryApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
const RowOptionsMenu = ({
type = 'dropdown',
props,
deleteClickHandler,
}: {
type: 'dropdown' | 'collapse';
props: CellContext<ProductCategory, unknown>;
deleteClickHandler: () => void;
}) => {
return (
<div
tabIndex={type === 'dropdown' ? 0 : undefined}
className={cn(
{
'dropdown-content': type === 'dropdown',
'mt-2': type === 'collapse',
},
'p-2.5 mr-2 flex flex-col gap-1 bg-base-100 rounded-box z-10 border border-black/10 shadow'
)}
>
<Button
href={`/master-data/product-category/detail/?productCategoryId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
<Button
href={`/master-data/product-category/detail/edit/?productCategoryId=${props.row.original.id}`}
variant='ghost'
color='warning'
className='justify-start text-sm'
>
<Icon icon='mdi:pencil-outline' width={16} height={16} />
Edit
</Button>
<Button
onClick={deleteClickHandler}
variant='ghost'
color='error'
className='text-error hover:text-inherit'
>
<Icon icon='mdi:delete-outline' width={16} height={16} className='justify-start text-sm' />
Delete
</Button>
</div>
);
};
const ProductCategoryTable = () => {
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: { search: '', nameSort: '' },
paramMap: { page: 'page', pageSize: 'limit', nameSort: 'sort_name' },
});
const {
data: productCategories,
isLoading,
mutate: refreshProductCategories,
} = useSWR(
`${ProductCategoryApi.basePath}${getTableFilterQueryString()}`,
ProductCategoryApi.getAllFetcher
);
const deleteModal = useModal();
const [selectedProductCategory, setSelectedProductCategory] = useState<ProductCategory | undefined>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [sorting, setSorting] = useState<SortingState>([]);
const productCategoryColumns: ColumnDef<ProductCategory>[] = [
{
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'code',
header: 'Code',
},
{
accessorKey: 'name',
header: 'Nama',
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize = props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedProductCategory(props.row.original);
deleteModal.openModal();
};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
);
},
},
];
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await ProductCategoryApi.delete(selectedProductCategory?.id as number);
refreshProductCategories();
deleteModal.closeModal();
toast.success('Successfully delete Product Category!');
setIsDeleteLoading(false);
};
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
setPageSize(newVal.value as number);
};
useEffect(() => {
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
if (!isNameSorted) {
updateFilter('nameSort', '');
} else {
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
}
}, [sorting]);
return (
<>
<div className='w-full p-0 sm:p-4'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'>
<div className='flex flex-row'>
<Button href='/master-data/product-category/add' color='primary'>
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah Product Category
</Button>
</div>
<DebouncedTextInput
name='search'
placeholder='Cari Product Category'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div>
<div className='flex flex-row justify-end'>
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
value={{
label: String(tableFilterState.pageSize),
value: tableFilterState.pageSize,
}}
onChange={pageSizeChangeHandler}
className={{ wrapper: 'max-w-28' }}
/>
</div>
</div>
<Table<ProductCategory>
data={isResponseSuccess(productCategories) ? productCategories?.data : []}
columns={productCategoryColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(productCategories) ? productCategories?.meta?.page : 0}
totalItems={isResponseSuccess(productCategories) ? productCategories?.meta?.total_results : 0}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn({
'mb-20': isResponseSuccess(productCategories) && productCategories?.data?.length === 0,
}),
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
}}
/>
</div>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Product Category ini (${selectedProductCategory?.name})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
</>
);
};
export default ProductCategoryTable;
@@ -0,0 +1,10 @@
import * as Yup from 'yup';
export const ProductCategoryFormSchema = Yup.object({
code: Yup.string().required('Kode wajib diisi!').max(3, 'Kode kategori produk melebihi 3 karakter!'),
name: Yup.string().required('Nama wajib diisi!'),
});
export const UpdateProductCategoryFormSchema = ProductCategoryFormSchema;
export type ProductCategoryFormValues = Yup.InferType<typeof ProductCategoryFormSchema>;
@@ -0,0 +1,266 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useFormik } from 'formik';
import { toast } from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import TextInput from '@/components/input/TextInput';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import {
ProductCategoryFormSchema,
ProductCategoryFormValues,
UpdateProductCategoryFormSchema,
} from '@/components/pages/master-data/product-category/form/ProductCategoryForm.schema';
import { isResponseError } from '@/lib/api-helper';
import {
ProductCategory,
CreateProductCategoryPayload,
UpdateProductCategoryPayload,
} from '@/types/api/master-data/product-category';
import { ProductCategoryApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
interface ProductCategoryFormProps {
type?: 'add' | 'edit' | 'detail';
initialValues?: ProductCategory;
}
const ProductCategoryForm = ({ type = 'add', initialValues }: ProductCategoryFormProps) => {
const router = useRouter();
const deleteModal = useModal();
const [formErrorMessage, setFormErrorMessage] = useState('');
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const createProductCategoryHandler = useCallback(
async (payload: CreateProductCategoryPayload) => {
const res = await ProductCategoryApi.create(payload);
if (isResponseError(res)) {
setFormErrorMessage(res.message);
return;
}
toast.success(res?.message as string);
router.push('/master-data/product-category');
},
[router]
);
const updateProductCategoryHandler = useCallback(
async (id: number, payload: UpdateProductCategoryPayload) => {
const res = await ProductCategoryApi.update(id, payload);
if (res?.status === 'error') {
setFormErrorMessage(res.message);
return;
}
toast.success(res?.message as string);
router.refresh();
router.push('/master-data/product-category');
},
[router]
);
const formikInitialValues = useMemo<ProductCategoryFormValues>(() => {
return {
code: initialValues?.code ?? '',
name: initialValues?.name ?? '',
};
}, [initialValues]);
const formik = useFormik<ProductCategoryFormValues>({
initialValues: formikInitialValues,
validationSchema: type === 'edit' ? UpdateProductCategoryFormSchema : ProductCategoryFormSchema,
onSubmit: async (values) => {
setFormErrorMessage('');
const payload: CreateProductCategoryPayload = {
code: values.code,
name: values.name,
};
switch (type) {
case 'add':
await createProductCategoryHandler(payload);
break;
case 'edit':
await updateProductCategoryHandler(initialValues?.id as number, payload);
break;
}
},
});
const { setValues: formikSetValues } = formik;
const deleteProductCategoryClickHandler = () => {
deleteModal.openModal();
};
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await ProductCategoryApi.delete(initialValues?.id as number);
deleteModal.closeModal();
toast.success('Successfully delete Product Category!');
setIsDeleteLoading(false);
router.push('/master-data/product-category');
};
useEffect(() => {
formikSetValues(formikInitialValues);
}, [formikSetValues, formikInitialValues]);
return (
<>
<section className='w-full max-w-xl'>
<header className='flex flex-col gap-4'>
<Button
href='/master-data/product-category'
variant='link'
className='w-fit p-0 text-primary'
>
<Icon icon='uil:arrow-left' width={24} height={24} />
Kembali
</Button>
<h1 className='text-2xl font-bold text-center'>
{type === 'add' && 'Tambah Product Category'}
{type === 'edit' && 'Edit Product Category'}
{type === 'detail' && 'Detail Product Category'}
</h1>
</header>
<form
onSubmit={formik.handleSubmit}
onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6'
>
<div className='flex flex-col gap-4'>
<TextInput
required
label='Kode'
name='code'
placeholder='Masukkan kode kategori produk'
value={formik.values.code}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={formik.touched.code && Boolean(formik.errors.code)}
errorMessage={formik.errors.code}
readOnly={type === 'detail'}
/>
<TextInput
required
label='Nama'
name='name'
placeholder='Masukkan nama kategori produk'
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={formik.touched.name && Boolean(formik.errors.name)}
errorMessage={formik.errors.name}
readOnly={type === 'detail'}
/>
</div>
<div className='flex flex-row justify-between gap-2 flex-wrap'>
{type !== 'add' && (
<div className='flex flex-row justify-start gap-2'>
<Button
type='button'
color='error'
onClick={deleteProductCategoryClickHandler}
className='px-4'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
className='justify-start text-sm'
/>
Delete
</Button>
{type !== 'edit' && (
<Button
type='button'
color='warning'
href={`/master-data/product-category/detail/edit/?productCategoryId=${initialValues?.id}`}
className='px-4'
>
<Icon
icon='material-symbols:edit-outline'
width={24}
height={24}
className='justify-start text-sm'
/>
Edit
</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'>
Reset
</Button>
<Button
type='submit'
color='primary'
isLoading={formik.isSubmitting}
disabled={!formik.isValid || formik.isSubmitting}
className='px-4'
>
Submit
</Button>
</div>
)}
</div>
{formErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{formErrorMessage}</span>
</div>
)}
</form>
</section>
{type !== 'add' && (
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Product Category ini (${initialValues?.name})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
)}
</>
);
};
export default ProductCategoryForm;
@@ -0,0 +1,350 @@
'use client';
import { ChangeEventHandler, useCallback, useEffect, useState } from 'react';
import useSWR from 'swr';
import {
CellContext,
ColumnDef,
ColumnSort,
SortingState,
} from '@tanstack/react-table';
import toast from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import { Product } from '@/types/api/master-data/product';
import { ProductApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
const RowOptionsMenu = ({
type = 'dropdown',
props,
deleteClickHandler,
}: {
type: 'dropdown' | 'collapse';
props: CellContext<Product, unknown>;
deleteClickHandler: () => void;
}) => (
<div
tabIndex={type === 'dropdown' ? 0 : undefined}
className={cn(
{
'dropdown-content': type === 'dropdown',
'mt-2': type === 'collapse',
},
'p-2.5 mr-2 flex flex-col gap-1 bg-base-100 rounded-box z-10 border border-black/10 shadow'
)}
>
<Button
href={`/master-data/product/detail/?productId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
<Button
href={`/master-data/product/detail/edit/?productId=${props.row.original.id}`}
variant='ghost'
color='warning'
className='justify-start text-sm'
>
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
Edit
</Button>
<Button
onClick={deleteClickHandler}
variant='ghost'
color='error'
className='text-error hover:text-inherit'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={16}
height={16}
className='justify-start text-sm'
/>
Delete
</Button>
</div>
);
const ProductsTable = () => {
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: '',
nameSort: '',
skuSort: '',
brandSort: '',
categorySort: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
nameSort: 'sort_name',
skuSort: 'sort_sku',
brandSort: 'sort_brand',
categorySort: 'sort_category',
},
});
const {
data: products,
isLoading,
mutate: refreshProducts,
} = useSWR(
`${ProductApi.basePath}${getTableFilterQueryString()}`,
ProductApi.getAllFetcher
);
const deleteModal = useModal();
const [selectedProduct, setSelectedProduct] = useState<Product | undefined>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [sorting, setSorting] = useState<SortingState>([]);
const productsColumns: ColumnDef<Product>[] = [
{
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorKey: 'sku',
header: 'SKU',
},
{
accessorKey: 'brand',
header: 'Merek',
},
{
accessorKey: 'product_category',
header: 'Kategori',
cell: (props) => props.row.original.product_category?.name ?? '-',
},
{
accessorKey: 'uom',
header: 'Satuan',
cell: (props) => props.row.original.uom?.name ?? '-',
},
{
accessorKey: 'product_price',
header: 'Harga Produk',
cell: (props) => props.row.original.product_price?.toLocaleString() ?? '-',
},
{
accessorKey: 'selling_price',
header: 'Harga Jual',
cell: (props) => props.row.original.selling_price?.toLocaleString() ?? '-',
},
{
accessorKey: 'tax',
header: 'Pajak (%)',
cell: (props) => props.row.original.tax ?? '-',
},
{
accessorKey: 'expiry_period',
header: 'Kadaluarsa (hari)',
cell: (props) => props.row.original.expiry_period ?? '-',
},
{
accessorKey: 'suppliers',
header: 'Supplier',
cell: (props) =>
props.row.original.suppliers?.map((s) => s.name).join(', ') || '-',
},
{
accessorKey: 'flags',
header: 'Flags',
cell: (props) =>
props.row.original.flags?.length
? props.row.original.flags.join(', ')
: '-',
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize = props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedProduct(props.row.original);
deleteModal.openModal();
};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
);
},
},
];
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await ProductApi.delete(selectedProduct?.id as number);
refreshProducts();
deleteModal.closeModal();
toast.success('Successfully delete Product!');
setIsDeleteLoading(false);
};
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
setPageSize(newVal.value as number);
};
const updateSortingFilter = useCallback(
(
sortName: Exclude<keyof typeof tableFilterState, 'page' | 'pageSize'>,
sortFilter: ColumnSort | undefined
) => {
if (!sortFilter) {
updateFilter(sortName, '');
} else {
updateFilter(sortName, sortFilter.desc ? 'desc' : 'asc');
}
},
[updateFilter]
);
useEffect(() => {
const nameSortFilter = sorting.find((sortItem) => sortItem.id === 'name');
const skuSortFilter = sorting.find((sortItem) => sortItem.id === 'sku');
const brandSortFilter = sorting.find((sortItem) => sortItem.id === 'brand');
const categorySortFilter = sorting.find((sortItem) => sortItem.id === 'product_category');
updateSortingFilter('nameSort', nameSortFilter);
updateSortingFilter('skuSort', skuSortFilter);
updateSortingFilter('brandSort', brandSortFilter);
updateSortingFilter('categorySort', categorySortFilter);
}, [sorting]);
return (
<>
<div className='w-full p-0 sm:p-4'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'>
<div className='flex flex-row'>
<Button href='/master-data/product/add' color='primary'>
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah Produk
</Button>
</div>
<DebouncedTextInput
name='search'
placeholder='Cari Produk'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div>
<div className='flex flex-row justify-end'>
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
value={{
label: String(tableFilterState.pageSize),
value: tableFilterState.pageSize,
}}
onChange={pageSizeChangeHandler}
className={{ wrapper: 'max-w-28' }}
/>
</div>
</div>
<Table<Product>
data={isResponseSuccess(products) ? products?.data : []}
columns={productsColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(products) ? products?.meta?.page : 0}
totalItems={
isResponseSuccess(products) ? products?.meta?.total_results : 0
}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn({
'mb-20':
isResponseSuccess(products) && products?.data?.length === 0,
}),
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
}}
/>
</div>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Produk ini (${selectedProduct?.name})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
</>
);
};
export default ProductsTable;
@@ -0,0 +1,53 @@
import * as Yup from 'yup';
export const ProductFormSchema = Yup.object({
name: Yup.string().required('Nama wajib diisi!'),
brand: Yup.string().required('Merek wajib diisi!'),
sku: Yup.string().required('SKU wajib diisi!'),
uom: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
uom_id: Yup.number().required('Satuan wajib diisi!').typeError('Satuan wajib diisi!'),
product_category: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
product_category_id: Yup.number()
.required('Kategori produk wajib diisi!')
.typeError('Kategori produk wajib diisi!'),
product_price: Yup.number()
.required('Harga produk wajib diisi!')
.typeError('Harga produk wajib diisi!')
.min(0, 'Harga produk tidak boleh kurang dari 0!'),
selling_price: Yup.number()
.required('Harga jual wajib diisi!')
.typeError('Harga jual wajib diisi!')
.min(0, 'Harga jual tidak boleh kurang dari 0!'),
tax: Yup.number()
.required('Pajak wajib diisi!')
.typeError('Pajak wajib diisi!')
.min(0, 'Pajak tidak boleh kurang dari 0!')
.max(100, 'Pajak tidak boleh lebih dari 100%!'),
expiry_period: Yup.number()
.required('Periode kadaluarsa wajib diisi!')
.typeError('Periode kadaluarsa wajib diisi!')
.min(0, 'Periode kadaluarsa tidak boleh kurang dari 0!'),
supplier: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
supplier_ids: Yup.array()
.of(Yup.number().typeError('Supplier tidak valid!'))
.min(1, 'Minimal harus ada 1 supplier!')
.required('Supplier wajib diisi!'),
flags: Yup.array()
.of(Yup.string())
.min(1, 'Minimal harus ada 1 flag!')
.required('Flag wajib diisi!'),
});
export const UpdateProductFormSchema = ProductFormSchema;
export type ProductFormValues = Yup.InferType<typeof ProductFormSchema>;
@@ -0,0 +1,438 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useFormik } from 'formik';
import { toast } from 'react-hot-toast';
import useSWR from 'swr';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import TextInput from '@/components/input/TextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import {
ProductFormSchema,
ProductFormValues,
UpdateProductFormSchema,
} from '@/components/pages/master-data/product/form/ProductForm.schema';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import {
Product,
CreateProductPayload,
UpdateProductPayload,
} from '@/types/api/master-data/product';
import { UomApi, ProductCategoryApi, SupplierApi, ProductApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { PRODUCT_FLAG_OPTIONS } from '@/config/constant';
interface ProductFormProps {
type?: 'add' | 'edit' | 'detail';
initialValues?: Product;
}
const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
const router = useRouter();
const deleteModal = useModal();
const [productFormErrorMessage, setProductFormErrorMessage] = useState('');
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const createProductHandler = useCallback(
async (payload: CreateProductPayload) => {
const res = await ProductApi.create(payload);
if (isResponseError(res)) {
setProductFormErrorMessage(res.message);
return;
}
toast.success(res?.message as string);
router.push('/master-data/product');
},
[router]
);
const updateProductHandler = useCallback(
async (productId: number, payload: UpdateProductPayload) => {
const res = await ProductApi.update(productId, payload);
if (res?.status === 'error') {
setProductFormErrorMessage(res.message);
return;
}
toast.success(res?.message as string);
router.refresh();
router.push('/master-data/product');
},
[router]
);
const formikInitialValues = useMemo<ProductFormValues>(() => ({
name: initialValues?.name ?? '',
brand: initialValues?.brand ?? '',
sku: initialValues?.sku ?? '',
uom: initialValues?.uom
? { value: initialValues.uom.id, label: initialValues.uom.name }
: null,
uom_id: initialValues?.uom?.id ?? 0,
product_category: initialValues?.product_category
? { value: initialValues.product_category.id, label: initialValues.product_category.name }
: null,
product_category_id: initialValues?.product_category?.id ?? 0,
product_price: initialValues?.product_price ?? 0,
selling_price: initialValues?.selling_price ?? 0,
tax: initialValues?.tax ?? 0,
expiry_period: initialValues?.expiry_period ?? 0,
supplier: null, // not used for payload, just for UI
supplier_ids: initialValues?.suppliers?.map(s => s.id) ?? [],
flags: initialValues?.flags ?? [],
}), [initialValues]);
const formik = useFormik<ProductFormValues>({
initialValues: formikInitialValues,
validationSchema: type === 'edit' ? UpdateProductFormSchema : ProductFormSchema,
onSubmit: async (values) => {
setProductFormErrorMessage('');
const payload: CreateProductPayload = {
name: values.name,
brand: values.brand,
sku: values.sku,
uom_id: values.uom_id,
product_category_id: values.product_category_id,
product_price: values.product_price,
selling_price: values.selling_price,
tax: values.tax,
expiry_period: values.expiry_period,
supplier_ids: (values.supplier_ids ?? []).filter((id): id is number => typeof id === 'number'),
flags: (values.flags ?? []).filter((f): f is string => typeof f === 'string'),
};
switch (type) {
case 'add':
await createProductHandler(payload);
break;
case 'edit':
await updateProductHandler(initialValues?.id as number, payload);
break;
}
},
});
const { setValues: formikSetValues } = formik;
// UOM
const [uomSelectInputValue, setUomSelectInputValue] = useState('');
const uomsUrl = `${UomApi.basePath}?${new URLSearchParams({ search: uomSelectInputValue ?? '' }).toString()}`;
const { data: uoms, isLoading: isLoadingUoms } = useSWR(uomsUrl, UomApi.getAllFetcher);
const uomOptions = isResponseSuccess(uoms)
? uoms?.data.map((uom) => ({ value: uom.id, label: uom.name }))
: [];
const uomChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('uom', true);
formik.setFieldValue('uom', val);
formik.setFieldTouched('uom_id', true);
formik.setFieldValue('uom_id', (val as OptionType)?.value);
};
// Product Category
const [categorySelectInputValue, setCategorySelectInputValue] = useState('');
const categoriesUrl = `${ProductCategoryApi.basePath}?${new URLSearchParams({ search: categorySelectInputValue ?? '' }).toString()}`;
const { data: categories, isLoading: isLoadingCategories } = useSWR(categoriesUrl, ProductCategoryApi.getAllFetcher);
const categoryOptions = isResponseSuccess(categories)
? categories?.data.map((cat) => ({ value: cat.id, label: cat.name }))
: [];
const categoryChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('product_category', true);
formik.setFieldValue('product_category', val);
formik.setFieldTouched('product_category_id', true);
formik.setFieldValue('product_category_id', (val as OptionType)?.value);
};
// Supplier (multi select)
const [supplierSelectInputValue, setSupplierSelectInputValue] = useState('');
const suppliersUrl = `${SupplierApi.basePath}?${new URLSearchParams({ search: supplierSelectInputValue ?? '' }).toString()}`;
const { data: suppliers, isLoading: isLoadingSuppliers } = useSWR(suppliersUrl, SupplierApi.getAllFetcher);
const supplierOptions = isResponseSuccess(suppliers)
? suppliers?.data
.filter((sup) => sup.category === 'SAPRONAK')
.map((sup) => ({ value: sup.id, label: sup.name }))
: [];
const supplierChangeHandler = (val: OptionType | OptionType[] | null) => {
const arr = Array.isArray(val) ? val : val ? [val] : [];
formik.setFieldTouched('supplier_ids', true);
formik.setFieldValue('supplier_ids', arr.map((v) => (v as OptionType).value));
};
const deleteProductClickHandler = () => {
deleteModal.openModal();
};
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await ProductApi.delete(initialValues?.id as number);
deleteModal.closeModal();
toast.success('Successfully delete Product!');
setIsDeleteLoading(false);
router.push('/master-data/product');
};
useEffect(() => {
formikSetValues(formikInitialValues);
}, [formikSetValues, formikInitialValues]);
return (
<>
<section className='w-full max-w-xl'>
<header className='flex flex-col gap-4'>
<Button
href='/master-data/product'
variant='link'
className='w-fit p-0 text-primary'
>
<Icon icon='uil:arrow-left' width={24} height={24} />
Kembali
</Button>
<h1 className='text-2xl font-bold text-center'>
{type === 'add' && 'Tambah Produk'}
{type === 'edit' && 'Edit Produk'}
{type === 'detail' && 'Detail Produk'}
</h1>
</header>
<form
onSubmit={formik.handleSubmit}
onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6'
>
<div className='flex flex-col gap-4'>
<TextInput
required
label='Nama'
name='name'
placeholder='Masukkan nama produk'
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={formik.touched.name && Boolean(formik.errors.name)}
errorMessage={formik.errors.name}
readOnly={type === 'detail'}
/>
<TextInput
required
label='Merek'
name='brand'
placeholder='Masukkan merek produk'
value={formik.values.brand}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={formik.touched.brand && Boolean(formik.errors.brand)}
errorMessage={formik.errors.brand}
readOnly={type === 'detail'}
/>
<TextInput
required
label='SKU'
name='sku'
placeholder='Masukkan SKU produk'
value={formik.values.sku}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={formik.touched.sku && Boolean(formik.errors.sku)}
errorMessage={formik.errors.sku}
readOnly={type === 'detail'}
/>
<SelectInput
required
label='Satuan'
value={formik.values.uom ?? undefined}
onChange={uomChangeHandler}
options={uomOptions}
onInputChange={setUomSelectInputValue}
isLoading={isLoadingUoms}
isError={formik.touched.uom_id && Boolean(formik.errors.uom_id)}
errorMessage={formik.errors.uom_id as string}
isDisabled={type === 'detail'}
isClearable
/>
<SelectInput
required
label='Kategori Produk'
value={formik.values.product_category ?? undefined}
onChange={categoryChangeHandler}
options={categoryOptions}
onInputChange={setCategorySelectInputValue}
isLoading={isLoadingCategories}
isError={formik.touched.product_category_id && Boolean(formik.errors.product_category_id)}
errorMessage={formik.errors.product_category_id as string}
isDisabled={type === 'detail'}
isClearable
/>
<TextInput
required
label='Harga Produk'
name='product_price'
type='number'
placeholder='Masukkan harga produk'
value={formik.values.product_price}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={formik.touched.product_price && Boolean(formik.errors.product_price)}
errorMessage={formik.errors.product_price as string}
readOnly={type === 'detail'}
/>
<TextInput
required
label='Harga Jual'
name='selling_price'
type='number'
placeholder='Masukkan harga jual'
value={formik.values.selling_price}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={formik.touched.selling_price && Boolean(formik.errors.selling_price)}
errorMessage={formik.errors.selling_price as string}
readOnly={type === 'detail'}
/>
<TextInput
required
label='Pajak (%)'
name='tax'
type='number'
placeholder='Masukkan pajak'
value={formik.values.tax}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={formik.touched.tax && Boolean(formik.errors.tax)}
errorMessage={formik.errors.tax as string}
readOnly={type === 'detail'}
/>
<TextInput
required
label='Periode Kadaluarsa (hari)'
name='expiry_period'
type='number'
placeholder='Masukkan periode kadaluarsa'
value={formik.values.expiry_period}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={formik.touched.expiry_period && Boolean(formik.errors.expiry_period)}
errorMessage={formik.errors.expiry_period as string}
readOnly={type === 'detail'}
/>
<SelectInput
required
label='Supplier'
isMulti
value={supplierOptions.filter(opt => formik.values.supplier_ids.includes(opt.value))}
onChange={supplierChangeHandler}
options={supplierOptions}
onInputChange={setSupplierSelectInputValue}
isLoading={isLoadingSuppliers}
isError={formik.touched.supplier_ids && Boolean(formik.errors.supplier_ids)}
errorMessage={formik.errors.supplier_ids as string}
isDisabled={type === 'detail'}
isClearable
/>
<SelectInput
required
label='Flags'
isMulti
value={PRODUCT_FLAG_OPTIONS.filter(opt => formik.values.flags.includes(opt.value))}
onChange={val => {
const arr = Array.isArray(val) ? val : val ? [val] : [];
formik.setFieldValue('flags', arr.map((v) => (v as OptionType).value));
}}
options={PRODUCT_FLAG_OPTIONS}
isError={formik.touched.flags && Boolean(formik.errors.flags)}
errorMessage={formik.errors.flags as string}
isDisabled={type === 'detail'}
isClearable
/>
</div>
<div className='flex flex-row justify-between gap-2 flex-wrap'>
{type !== 'add' && (
<div className='flex flex-row justify-start gap-2'>
<Button
type='button'
color='error'
onClick={deleteProductClickHandler}
className='px-4'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
className='justify-start text-sm'
/>
Delete
</Button>
{type !== 'edit' && (
<Button
type='button'
color='warning'
href={`/master-data/product/detail/edit/?productId=${initialValues?.id}`}
className='px-4'
>
<Icon
icon='material-symbols:edit-outline'
width={24}
height={24}
className='justify-start text-sm'
/>
Edit
</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'>
Reset
</Button>
<Button
type='submit'
color='primary'
isLoading={formik.isSubmitting}
disabled={!formik.isValid || formik.isSubmitting}
className='px-4'
>
Submit
</Button>
</div>
)}
</div>
{productFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{productFormErrorMessage}</span>
</div>
)}
</form>
</section>
{type !== 'add' && (
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Produk ini (${initialValues?.name})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
)}
</>
);
};
export default ProductForm;
@@ -11,7 +11,11 @@ import {
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useMemo, useState } from 'react';
import toast from 'react-hot-toast';
import { SupplierFormSchema, SupplierFormValues, UpdateSupplierFormSchema } from './SupplierForm.schema';
import {
SupplierFormSchema,
SupplierFormValues,
UpdateSupplierFormSchema,
} from './SupplierForm.schema';
import { useFormik } from 'formik';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import { Icon } from '@iconify/react';
@@ -127,7 +131,8 @@ const SupplierForm = ({
const formik = useFormik<SupplierFormValues>({
initialValues: formikInitialValues,
enableReinitialize: true,
validationSchema: formType === 'edit' ? UpdateSupplierFormSchema : SupplierFormSchema,
validationSchema:
formType === 'edit' ? UpdateSupplierFormSchema : SupplierFormSchema,
onSubmit: async (values) => {
// reset error message
setSupplierFormErrorMessage('');
@@ -274,7 +279,9 @@ const SupplierForm = ({
onChange={categoryChangeHandler}
options={categoryOptions}
onInputChange={setCategorySelectInputValue}
isError={formik.touched.category && Boolean(formik.errors.category)}
isError={
formik.touched.category && Boolean(formik.errors.category)
}
errorMessage={formik.errors.category as string}
isDisabled={formType === 'detail'}
isClearable
@@ -285,7 +292,9 @@ const SupplierForm = ({
label='Hatchery'
value={hatcheryTagInputValue}
onChange={(value) => formik.setFieldValue('hatchery', value)}
isError={formik.touched.hatchery && Boolean(formik.errors.hatchery)}
isError={
formik.touched.hatchery && Boolean(formik.errors.hatchery)
}
errorMessage={formik.errors.hatchery}
readOnly={formType === 'detail'}
/>