mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-25 15:55:48 +00:00
fix(FE-33): fix conflict git
This commit is contained in:
@@ -42,3 +42,6 @@ next-env.d.ts
|
|||||||
|
|
||||||
# prettier
|
# prettier
|
||||||
.prettierrc
|
.prettierrc
|
||||||
|
|
||||||
|
# idea
|
||||||
|
.idea
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"jsxSingleQuote": true,
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"arrowParens": "always",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"embeddedLanguageFormatting": "auto",
|
||||||
|
"htmlWhitespaceSensitivity": "css",
|
||||||
|
"printWidth": 80,
|
||||||
|
"proseWrap": "preserve",
|
||||||
|
"quoteProps": "as-needed",
|
||||||
|
"semi": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5"
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import SuspenseHelper from '@/components/helper/SuspenseHelper';
|
||||||
|
|
||||||
|
const Layout = ({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) => {
|
||||||
|
return <SuspenseHelper>{children}</SuspenseHelper>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import BankForm from '@/components/pages/master-data/bank/form/BankForm';
|
||||||
|
|
||||||
|
const AddBank = () => {
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
<BankForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddBank;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
import BankForm from '@/components/pages/master-data/bank/form/BankForm';
|
||||||
|
|
||||||
|
import { BankApi } from '@/services/api/master-data';
|
||||||
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
|
||||||
|
const BankEdit = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const bankId = searchParams.get('bankId');
|
||||||
|
|
||||||
|
const { data: bank, isLoading: isLoadingBank } = useSWR(
|
||||||
|
bankId,
|
||||||
|
(id: number) => BankApi.getSingle(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!bankId) {
|
||||||
|
router.back();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoadingBank && (!bank || isResponseError(bank))) {
|
||||||
|
router.replace('/404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
{isLoadingBank && <span className='loading loading-spinner loading-xl' />}
|
||||||
|
{!isLoadingBank && isResponseSuccess(bank) && (
|
||||||
|
<BankForm type='edit' initialValues={bank.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BankEdit;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import SuspenseHelper from '@/components/helper/SuspenseHelper';
|
||||||
|
|
||||||
|
const Layout = ({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) => {
|
||||||
|
return <SuspenseHelper>{children}</SuspenseHelper>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
import BankForm from '@/components/pages/master-data/bank/form/BankForm';
|
||||||
|
|
||||||
|
import { BankApi } from '@/services/api/master-data';
|
||||||
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
|
||||||
|
const BankDetail = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const bankId = searchParams.get('bankId');
|
||||||
|
|
||||||
|
const { data: bank, isLoading: isLoadingBank } = useSWR(
|
||||||
|
bankId,
|
||||||
|
(id: number) => BankApi.getSingle(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!bankId) {
|
||||||
|
router.back();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoadingBank && (!bank || isResponseError(bank))) {
|
||||||
|
router.replace('/404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
{isLoadingBank && <span className='loading loading-spinner loading-xl' />}
|
||||||
|
{!isLoadingBank && isResponseSuccess(bank) && (
|
||||||
|
<BankForm type='detail' initialValues={bank.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BankDetail;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import BanksTable from '@/components/pages/master-data/bank/BanksTable';
|
||||||
|
|
||||||
|
const Bank = () => {
|
||||||
|
return (
|
||||||
|
<section className='w-full p-4'>
|
||||||
|
<BanksTable />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Bank;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import FcrForm from '@/components/pages/master-data/fcr/form/FcrForm';
|
||||||
|
|
||||||
|
const AddFcr = () => {
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
<FcrForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddFcr;
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
import FcrForm from '@/components/pages/master-data/fcr/form/FcrForm';
|
||||||
|
|
||||||
|
import { FcrApi } from '@/services/api/master-data';
|
||||||
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
import { BaseApiResponse } from '@/types/api/api-general';
|
||||||
|
import { FcrWithStandards } from '@/types/api/master-data/fcr';
|
||||||
|
|
||||||
|
const FcrEdit = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const fcrId = searchParams.get('fcrId');
|
||||||
|
|
||||||
|
const { data: fcr, isLoading: isLoadingFcr } = useSWR(
|
||||||
|
fcrId,
|
||||||
|
(id: number) =>
|
||||||
|
FcrApi.getSingle(id) as Promise<
|
||||||
|
BaseApiResponse<FcrWithStandards> | undefined
|
||||||
|
>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!fcrId) {
|
||||||
|
router.back();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoadingFcr && (!fcr || isResponseError(fcr))) {
|
||||||
|
router.replace('/404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
{isLoadingFcr && <span className='loading loading-spinner loading-xl' />}
|
||||||
|
{!isLoadingFcr && isResponseSuccess(fcr) && (
|
||||||
|
<FcrForm type='edit' initialValues={fcr.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FcrEdit;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import SuspenseHelper from '@/components/helper/SuspenseHelper';
|
||||||
|
|
||||||
|
const Layout = ({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) => {
|
||||||
|
return <SuspenseHelper>{children}</SuspenseHelper>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
import FcrForm from '@/components/pages/master-data/fcr/form/FcrForm';
|
||||||
|
|
||||||
|
import { FcrApi } from '@/services/api/master-data';
|
||||||
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
import { FcrWithStandards } from '@/types/api/master-data/fcr';
|
||||||
|
import { BaseApiResponse } from '@/types/api/api-general';
|
||||||
|
|
||||||
|
const FcrDetail = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const fcrId = searchParams.get('fcrId');
|
||||||
|
|
||||||
|
const { data: fcr, isLoading: isLoadingFcr } = useSWR(
|
||||||
|
fcrId,
|
||||||
|
(id: number) =>
|
||||||
|
FcrApi.getSingle(id) as Promise<
|
||||||
|
BaseApiResponse<FcrWithStandards> | undefined
|
||||||
|
>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!fcrId) {
|
||||||
|
router.back();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoadingFcr && (!fcr || isResponseError(fcr))) {
|
||||||
|
router.replace('/404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
{isLoadingFcr && <span className='loading loading-spinner loading-xl' />}
|
||||||
|
{!isLoadingFcr && isResponseSuccess(fcr) && (
|
||||||
|
<FcrForm type='detail' initialValues={fcr.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FcrDetail;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import FcrsTable from '@/components/pages/master-data/fcr/FcrsTable';
|
||||||
|
|
||||||
|
const Fcr = () => {
|
||||||
|
return (
|
||||||
|
<section className='w-full p-4'>
|
||||||
|
<FcrsTable />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Fcr;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import SuspenseHelper from '@/components/helper/SuspenseHelper';
|
||||||
|
|
||||||
|
const Layout = ({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) => {
|
||||||
|
return <SuspenseHelper>{children}</SuspenseHelper>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import SuspenseHelper from '@/components/helper/SuspenseHelper';
|
||||||
|
|
||||||
|
const Layout = ({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) => {
|
||||||
|
return <SuspenseHelper>{children}</SuspenseHelper>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
@@ -5,8 +5,8 @@ import useSWR from 'swr';
|
|||||||
|
|
||||||
import NonstockForm from '@/components/pages/master-data/nonstock/form/NonstockForm';
|
import NonstockForm from '@/components/pages/master-data/nonstock/form/NonstockForm';
|
||||||
|
|
||||||
import { getNonstock } from '@/services/api/master-data/nonstock';
|
import { NonstockApi } from '@/services/api/master-data';
|
||||||
import { isResponseSuccess } from '@/lib/api-helper';
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
|
||||||
const NonstockEdit = () => {
|
const NonstockEdit = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -16,7 +16,7 @@ const NonstockEdit = () => {
|
|||||||
|
|
||||||
const { data: nonstock, isLoading: isLoadingNonstock } = useSWR(
|
const { data: nonstock, isLoading: isLoadingNonstock } = useSWR(
|
||||||
nonstockId,
|
nonstockId,
|
||||||
getNonstock
|
(id: number) => NonstockApi.getSingle(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!nonstockId) {
|
if (!nonstockId) {
|
||||||
@@ -29,7 +29,7 @@ const NonstockEdit = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isLoadingNonstock && !nonstock) {
|
if (!isLoadingNonstock && (!nonstock || isResponseError(nonstock))) {
|
||||||
router.replace('/404');
|
router.replace('/404');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import SuspenseHelper from '@/components/helper/SuspenseHelper';
|
||||||
|
|
||||||
|
const Layout = ({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) => {
|
||||||
|
return <SuspenseHelper>{children}</SuspenseHelper>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
@@ -5,8 +5,8 @@ import useSWR from 'swr';
|
|||||||
|
|
||||||
import NonstockForm from '@/components/pages/master-data/nonstock/form/NonstockForm';
|
import NonstockForm from '@/components/pages/master-data/nonstock/form/NonstockForm';
|
||||||
|
|
||||||
import { getNonstock } from '@/services/api/master-data/nonstock';
|
import { NonstockApi } from '@/services/api/master-data';
|
||||||
import { isResponseSuccess } from '@/lib/api-helper';
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
|
||||||
const NonstockDetail = () => {
|
const NonstockDetail = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -16,7 +16,7 @@ const NonstockDetail = () => {
|
|||||||
|
|
||||||
const { data: nonstock, isLoading: isLoadingNonstock } = useSWR(
|
const { data: nonstock, isLoading: isLoadingNonstock } = useSWR(
|
||||||
nonstockId,
|
nonstockId,
|
||||||
getNonstock
|
(id: number) => NonstockApi.getSingle(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!nonstockId) {
|
if (!nonstockId) {
|
||||||
@@ -29,7 +29,7 @@ const NonstockDetail = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isLoadingNonstock && !nonstock) {
|
if (!isLoadingNonstock && (!nonstock || isResponseError(nonstock))) {
|
||||||
router.replace('/404');
|
router.replace('/404');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import ProductCategoryForm from "@/components/pages/master-data/product-category/form/ProductCategoryForm";
|
||||||
|
|
||||||
|
const AddProductCategory = () => {
|
||||||
|
return (
|
||||||
|
<div className="w-full p-4 flex flex-row justify-center">
|
||||||
|
<ProductCategoryForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddProductCategory;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
import ProductCategoryForm from '@/components/pages/master-data/product-category/form/ProductCategoryForm';
|
||||||
|
|
||||||
|
import { ProductCategoryApi } from '@/services/api/master-data';
|
||||||
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
|
||||||
|
const ProductCategoryEdit = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const productCategoryId = searchParams.get('productCategoryId');
|
||||||
|
|
||||||
|
const { data: productCategory, isLoading: isLoadingProductCategory } = useSWR(
|
||||||
|
productCategoryId,
|
||||||
|
(id: number) => ProductCategoryApi.getSingle(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!productCategoryId) {
|
||||||
|
router.back();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoadingProductCategory && (!productCategory || isResponseError(productCategory))) {
|
||||||
|
router.replace('/404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
{isLoadingProductCategory && <span className='loading loading-spinner loading-xl' />}
|
||||||
|
{!isLoadingProductCategory && isResponseSuccess(productCategory) && (
|
||||||
|
<ProductCategoryForm type='edit' initialValues={productCategory.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProductCategoryEdit;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import SuspenseHelper from '@/components/helper/SuspenseHelper';
|
||||||
|
|
||||||
|
const Layout = ({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) => {
|
||||||
|
return <SuspenseHelper>{children}</SuspenseHelper>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
import ProductCategoryForm from '@/components/pages/master-data/product-category/form/ProductCategoryForm';
|
||||||
|
|
||||||
|
import { ProductCategoryApi } from '@/services/api/master-data';
|
||||||
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
|
||||||
|
const ProductCategoryDetail = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const productCategoryId = searchParams.get('productCategoryId');
|
||||||
|
|
||||||
|
const { data: productCategory, isLoading: isLoadingProductCategory } = useSWR(
|
||||||
|
productCategoryId,
|
||||||
|
(id: number) => ProductCategoryApi.getSingle(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!productCategoryId) {
|
||||||
|
router.back();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoadingProductCategory && (!productCategory || isResponseError(productCategory))) {
|
||||||
|
router.replace('/404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
{isLoadingProductCategory && <span className='loading loading-spinner loading-xl' />}
|
||||||
|
{!isLoadingProductCategory && isResponseSuccess(productCategory) && (
|
||||||
|
<ProductCategoryForm type='detail' initialValues={productCategory.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductCategoryDetail;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import ProductCategoryTable from "@/components/pages/master-data/product-category/ProductCategoryTable";
|
||||||
|
|
||||||
|
const ProductCategory = () => {
|
||||||
|
return (
|
||||||
|
<section className="w-full p-4">
|
||||||
|
<ProductCategoryTable />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductCategory;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import ProductForm from '@/components/pages/master-data/product/form/ProductForm';
|
||||||
|
|
||||||
|
const AddProduct = () => {
|
||||||
|
return (
|
||||||
|
<div className="w-full p-4 flex flex-row justify-center">
|
||||||
|
<ProductForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddProduct;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
import ProductForm from '@/components/pages/master-data/product/form/ProductForm';
|
||||||
|
import { ProductApi } from '@/services/api/master-data';
|
||||||
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
|
||||||
|
const ProductEdit = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const productId = searchParams.get('productId');
|
||||||
|
|
||||||
|
const { data: product, isLoading } = useSWR(
|
||||||
|
productId,
|
||||||
|
(id: number) => ProductApi.getSingle(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!productId) {
|
||||||
|
router.back();
|
||||||
|
return (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoading && (!product || isResponseError(product))) {
|
||||||
|
router.replace('/404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
{isLoading && <span className='loading loading-spinner loading-xl' />}
|
||||||
|
{!isLoading && isResponseSuccess(product) && (
|
||||||
|
<ProductForm type='edit' initialValues={product.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductEdit;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import SuspenseHelper from '@/components/helper/SuspenseHelper';
|
||||||
|
|
||||||
|
const Layout = ({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) => {
|
||||||
|
return <SuspenseHelper>{children}</SuspenseHelper>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
import ProductForm from '@/components/pages/master-data/product/form/ProductForm';
|
||||||
|
import { ProductApi } from '@/services/api/master-data';
|
||||||
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
|
||||||
|
const ProductDetail = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const productId = searchParams.get('productId');
|
||||||
|
|
||||||
|
const { data: product, isLoading } = useSWR(
|
||||||
|
productId,
|
||||||
|
(id: number) => ProductApi.getSingle(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!productId) {
|
||||||
|
router.back();
|
||||||
|
return (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoading && (!product || isResponseError(product))) {
|
||||||
|
router.replace('/404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
{isLoading && <span className='loading loading-spinner loading-xl' />}
|
||||||
|
{!isLoading && isResponseSuccess(product) && (
|
||||||
|
<ProductForm type='detail' initialValues={product.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductDetail;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import ProductsTable from "@/components/pages/master-data/product/ProductTable";
|
||||||
|
|
||||||
|
const Product = () => {
|
||||||
|
return (
|
||||||
|
<section className="w-full p-4">
|
||||||
|
<ProductsTable />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Product;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import SuspenseHelper from '@/components/helper/SuspenseHelper';
|
||||||
|
|
||||||
|
const Layout = ({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) => {
|
||||||
|
return <SuspenseHelper>{children}</SuspenseHelper>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import SuspenseHelper from '@/components/helper/SuspenseHelper';
|
||||||
|
|
||||||
|
const Layout = ({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) => {
|
||||||
|
return <SuspenseHelper>{children}</SuspenseHelper>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
@@ -199,7 +199,7 @@ const MainDrawer = ({
|
|||||||
|
|
||||||
if (!hasSubmenu) return;
|
if (!hasSubmenu) return;
|
||||||
|
|
||||||
const activeSubmenu = menu.submenu.find((item) =>
|
const activeSubmenu = menu.submenu?.find((item) =>
|
||||||
isPathActive(pathname, item.link)
|
isPathActive(pathname, item.link)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -122,7 +122,9 @@ const TextInput = ({
|
|||||||
{!isError && bottomLabel && (
|
{!isError && bottomLabel && (
|
||||||
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
|
<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>
|
</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';
|
'use client';
|
||||||
|
|
||||||
import { ChangeEventHandler, useState } from 'react';
|
import { ChangeEventHandler, useCallback, useEffect, useState } from 'react';
|
||||||
import useSWR from 'swr';
|
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 { Icon } from '@iconify/react';
|
||||||
import Table from '@/components/Table';
|
import Table from '@/components/Table';
|
||||||
import TextInput from '@/components/input/TextInput';
|
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||||
import Button from '@/components/Button';
|
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 } from '@/types/api/master-data/nonstock';
|
||||||
import { Nonstock, NonstocksResponse } from '@/types/api/master-data/nonstock';
|
import { NonstockApi } from '@/services/api/master-data';
|
||||||
import { cn } from '@/lib/helper';
|
import { cn } from '@/lib/helper';
|
||||||
import { deleteNonstock } from '@/services/api/master-data/nonstock';
|
|
||||||
import { isResponseSuccess } from '@/lib/api-helper';
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||||
|
import { ROWS_OPTIONS } from '@/config/constant';
|
||||||
|
|
||||||
const RowOptionsMenu = ({
|
const RowOptionsMenu = ({
|
||||||
type = 'dropdown',
|
type = 'dropdown',
|
||||||
@@ -23,7 +34,7 @@ const RowOptionsMenu = ({
|
|||||||
}: {
|
}: {
|
||||||
type: 'dropdown' | 'collapse';
|
type: 'dropdown' | 'collapse';
|
||||||
props: CellContext<Nonstock, unknown>;
|
props: CellContext<Nonstock, unknown>;
|
||||||
deleteClickHandler: () => Promise<void>;
|
deleteClickHandler: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<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 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 {
|
const {
|
||||||
data: nonstocks,
|
data: nonstocks,
|
||||||
isLoading,
|
isLoading,
|
||||||
mutate: refreshNonstocks,
|
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>[] = [
|
const nonstocksColumns: ColumnDef<Nonstock>[] = [
|
||||||
{
|
{
|
||||||
header: '#',
|
header: '#',
|
||||||
cell: (props) => props.row.index + 1,
|
cell: (props) =>
|
||||||
|
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
||||||
|
props.row.index +
|
||||||
|
1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Nama',
|
|
||||||
accessorKey: 'name',
|
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',
|
header: 'Aksi',
|
||||||
@@ -157,33 +164,31 @@ const NonstocksTable = () => {
|
|||||||
|
|
||||||
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
|
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
|
||||||
|
|
||||||
const deleteClickHandler = async () => {
|
const deleteClickHandler = () => {
|
||||||
const confirmation = confirm(
|
setSelectedNonstock(props.row.original);
|
||||||
'Apakah anda yakin untuk menghapus non stock ini?'
|
deleteModal.openModal();
|
||||||
);
|
|
||||||
|
|
||||||
if (confirmation) {
|
|
||||||
await deleteNonstock(props.row.original.id);
|
|
||||||
refreshNonstocks();
|
|
||||||
alert('Nonstock berhasil dihapus!');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{currentPageSize > 2 && (
|
{currentPageSize > 2 && (
|
||||||
<RowDropdownOptions
|
<RowDropdownOptions isLast2Rows={isLast2Rows}>
|
||||||
|
<RowOptionsMenu
|
||||||
|
type='dropdown'
|
||||||
props={props}
|
props={props}
|
||||||
isLast2Rows={isLast2Rows}
|
|
||||||
deleteClickHandler={deleteClickHandler}
|
deleteClickHandler={deleteClickHandler}
|
||||||
/>
|
/>
|
||||||
|
</RowDropdownOptions>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentPageSize <= 2 && (
|
{currentPageSize <= 2 && (
|
||||||
<RowCollapseOptions
|
<RowCollapseOptions>
|
||||||
|
<RowOptionsMenu
|
||||||
|
type='dropdown'
|
||||||
props={props}
|
props={props}
|
||||||
deleteClickHandler={deleteClickHandler}
|
deleteClickHandler={deleteClickHandler}
|
||||||
/>
|
/>
|
||||||
|
</RowCollapseOptions>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -191,13 +196,59 @@ const NonstocksTable = () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
const confirmationModalDeleteClickHandler = async () => {
|
||||||
setSearchValue(e.target.value);
|
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 (
|
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='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'>
|
<div className='flex flex-row'>
|
||||||
<Button href='/master-data/nonstock/add' color='primary'>
|
<Button href='/master-data/nonstock/add' color='primary'>
|
||||||
<Icon icon='ic:round-plus' width={24} height={24} />
|
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||||
@@ -205,22 +256,41 @@ const NonstocksTable = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TextInput
|
<DebouncedTextInput
|
||||||
name='search'
|
name='search'
|
||||||
placeholder='Cari Non Stock'
|
placeholder='Cari Nonstock'
|
||||||
value={searchValue}
|
value={tableFilterState.search}
|
||||||
onChange={searchChangeHandler}
|
onChange={searchChangeHandler}
|
||||||
className={{ wrapper: 'sm:max-w-3xs' }}
|
className={{ wrapper: 'sm:max-w-3xs' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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<Nonstock>
|
<Table<Nonstock>
|
||||||
data={isResponseSuccess(nonstocks) ? nonstocks?.data : []}
|
data={isResponseSuccess(nonstocks) ? nonstocks?.data : []}
|
||||||
columns={nonstocksColumns}
|
columns={nonstocksColumns}
|
||||||
pageSize={10}
|
pageSize={tableFilterState.pageSize}
|
||||||
fuzzySearchValue={searchValue}
|
page={isResponseSuccess(nonstocks) ? nonstocks?.meta?.page : 0}
|
||||||
onFuzzySearchValueChange={setSearchValue}
|
totalItems={
|
||||||
|
isResponseSuccess(nonstocks) ? nonstocks?.meta?.total_results : 0
|
||||||
|
}
|
||||||
|
onPageChange={setPage}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
sorting={sorting}
|
||||||
|
setSorting={setSorting}
|
||||||
className={{
|
className={{
|
||||||
containerClassName: cn({
|
containerClassName: cn({
|
||||||
'mb-20':
|
'mb-20':
|
||||||
@@ -237,6 +307,22 @@ const NonstocksTable = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,22 @@ import * as Yup from 'yup';
|
|||||||
|
|
||||||
export const NonstockFormSchema = Yup.object({
|
export const NonstockFormSchema = Yup.object({
|
||||||
name: Yup.string().required('Nama wajib diisi!'),
|
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;
|
export const UpdateNonstockFormSchema = NonstockFormSchema;
|
||||||
|
|||||||
@@ -3,26 +3,31 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useFormik } from 'formik';
|
import { useFormik } from 'formik';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import TextInput from '@/components/input/TextInput';
|
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 {
|
import {
|
||||||
NonstockFormSchema,
|
NonstockFormSchema,
|
||||||
NonstockFormValues,
|
NonstockFormValues,
|
||||||
UpdateNonstockFormSchema,
|
UpdateNonstockFormSchema,
|
||||||
} from '@/components/pages/master-data/nonstock/form/NonstockForm.schema';
|
} from '@/components/pages/master-data/nonstock/form/NonstockForm.schema';
|
||||||
import { isResponseError } from '@/lib/api-helper';
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
import {
|
import {
|
||||||
CreateNonstockPayload,
|
|
||||||
Nonstock,
|
Nonstock,
|
||||||
|
CreateNonstockPayload,
|
||||||
UpdateNonstockPayload,
|
UpdateNonstockPayload,
|
||||||
} from '@/types/api/master-data/nonstock';
|
} from '@/types/api/master-data/nonstock';
|
||||||
import {
|
import { NonstockApi, SupplierApi, UomApi } from '@/services/api/master-data';
|
||||||
createNonstock,
|
import { cn } from '@/lib/helper';
|
||||||
updateNonstock,
|
import { flags } from '@/types/api/api-general';
|
||||||
} from '@/services/api/master-data/nonstock';
|
import { SUPPLIER_FLAG_OPTIONS } from '@/config/constant';
|
||||||
|
|
||||||
interface NonstockFormProps {
|
interface NonstockFormProps {
|
||||||
type?: 'add' | 'edit' | 'detail';
|
type?: 'add' | 'edit' | 'detail';
|
||||||
@@ -31,19 +36,21 @@ interface NonstockFormProps {
|
|||||||
|
|
||||||
const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
|
const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const deleteModal = useModal();
|
||||||
|
|
||||||
const [nonstockFormErrorMessage, setNonstockFormErrorMessage] = useState('');
|
const [nonstockFormErrorMessage, setNonstockFormErrorMessage] = useState('');
|
||||||
|
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||||
|
|
||||||
const createNonstockHandler = useCallback(
|
const createNonstockHandler = useCallback(
|
||||||
async (payload: CreateNonstockPayload) => {
|
async (payload: CreateNonstockPayload) => {
|
||||||
const createNonstockRes = await createNonstock(payload);
|
const createNonstockRes = await NonstockApi.create(payload);
|
||||||
|
|
||||||
if (isResponseError(createNonstockRes)) {
|
if (isResponseError(createNonstockRes)) {
|
||||||
setNonstockFormErrorMessage(createNonstockRes.message);
|
setNonstockFormErrorMessage(createNonstockRes.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
alert(createNonstockRes?.message);
|
toast.success(createNonstockRes?.message as string);
|
||||||
router.push('/master-data/nonstock');
|
router.push('/master-data/nonstock');
|
||||||
},
|
},
|
||||||
[router]
|
[router]
|
||||||
@@ -51,14 +58,14 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
|
|||||||
|
|
||||||
const updateNonstockHandler = useCallback(
|
const updateNonstockHandler = useCallback(
|
||||||
async (nonstockId: number, payload: UpdateNonstockPayload) => {
|
async (nonstockId: number, payload: UpdateNonstockPayload) => {
|
||||||
const updateNonstockRes = await updateNonstock(nonstockId, payload);
|
const updateNonstockRes = await NonstockApi.update(nonstockId, payload);
|
||||||
|
|
||||||
if (updateNonstockRes?.status === 'error') {
|
if (updateNonstockRes?.status === 'error') {
|
||||||
setNonstockFormErrorMessage(updateNonstockRes.message);
|
setNonstockFormErrorMessage(updateNonstockRes.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
alert(updateNonstockRes?.message);
|
toast.success(updateNonstockRes?.message as string);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
router.push('/master-data/nonstock');
|
router.push('/master-data/nonstock');
|
||||||
},
|
},
|
||||||
@@ -68,6 +75,22 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
|
|||||||
const formikInitialValues = useMemo<NonstockFormValues>(() => {
|
const formikInitialValues = useMemo<NonstockFormValues>(() => {
|
||||||
return {
|
return {
|
||||||
name: initialValues?.name ?? '',
|
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]);
|
}, [initialValues]);
|
||||||
|
|
||||||
@@ -80,6 +103,9 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
|
|||||||
|
|
||||||
const nonstockPayload: CreateNonstockPayload = {
|
const nonstockPayload: CreateNonstockPayload = {
|
||||||
name: values.name,
|
name: values.name,
|
||||||
|
uom_id: values.uomId,
|
||||||
|
supplier_ids: values.supplierIds as number[],
|
||||||
|
flags: values.flags as flags[],
|
||||||
};
|
};
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@@ -97,11 +123,97 @@ 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(() => {
|
useEffect(() => {
|
||||||
formik.setValues(formikInitialValues);
|
formikSetValues(formikInitialValues);
|
||||||
}, [formikInitialValues]);
|
}, [formikSetValues, formikInitialValues]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<section className='w-full max-w-xl'>
|
<section className='w-full max-w-xl'>
|
||||||
<header className='flex flex-col gap-4'>
|
<header className='flex flex-col gap-4'>
|
||||||
<Button
|
<Button
|
||||||
@@ -114,9 +226,9 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<h1 className='text-2xl font-bold text-center'>
|
<h1 className='text-2xl font-bold text-center'>
|
||||||
{type === 'add' && 'Tambah Non Stock'}
|
{type === 'add' && 'Tambah Nonstock'}
|
||||||
{type === 'edit' && 'Edit Non Stock'}
|
{type === 'edit' && 'Edit Nonstock'}
|
||||||
{type === 'detail' && 'Detail Non Stock'}
|
{type === 'detail' && 'Detail Nonstock'}
|
||||||
</h1>
|
</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -130,7 +242,7 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
|
|||||||
required
|
required
|
||||||
label='Nama'
|
label='Nama'
|
||||||
name='name'
|
name='name'
|
||||||
placeholder='Masukkan nama nonstock'
|
placeholder='Masukkan nama lokasi'
|
||||||
value={formik.values.name}
|
value={formik.values.name}
|
||||||
onChange={formik.handleChange}
|
onChange={formik.handleChange}
|
||||||
onBlur={formik.handleBlur}
|
onBlur={formik.handleBlur}
|
||||||
@@ -138,12 +250,95 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
|
|||||||
errorMessage={formik.errors.name}
|
errorMessage={formik.errors.name}
|
||||||
readOnly={type === 'detail'}
|
readOnly={type === 'detail'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<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
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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>
|
</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>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
{type !== 'detail' && (
|
{type !== 'detail' && (
|
||||||
<>
|
<div
|
||||||
<div className='flex flex-row justify-end gap-2'>
|
className={cn('flex flex-row justify-end gap-2', {
|
||||||
<Button type='reset' color='error' className='px-4'>
|
'w-full': type === 'add',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Button type='reset' color='warning' className='px-4'>
|
||||||
Reset
|
Reset
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -157,6 +352,8 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
|
|||||||
Submit
|
Submit
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{nonstockFormErrorMessage && (
|
{nonstockFormErrorMessage && (
|
||||||
<div role='alert' className='alert alert-error'>
|
<div role='alert' className='alert alert-error'>
|
||||||
@@ -168,10 +365,26 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
|
|||||||
<span>{nonstockFormErrorMessage}</span>
|
<span>{nonstockFormErrorMessage}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</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 { useRouter } from 'next/navigation';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import toast from 'react-hot-toast';
|
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 { useFormik } from 'formik';
|
||||||
import SelectInput, { OptionType } from '@/components/input/SelectInput';
|
import SelectInput, { OptionType } from '@/components/input/SelectInput';
|
||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
@@ -127,7 +131,8 @@ const SupplierForm = ({
|
|||||||
const formik = useFormik<SupplierFormValues>({
|
const formik = useFormik<SupplierFormValues>({
|
||||||
initialValues: formikInitialValues,
|
initialValues: formikInitialValues,
|
||||||
enableReinitialize: true,
|
enableReinitialize: true,
|
||||||
validationSchema: formType === 'edit' ? UpdateSupplierFormSchema : SupplierFormSchema,
|
validationSchema:
|
||||||
|
formType === 'edit' ? UpdateSupplierFormSchema : SupplierFormSchema,
|
||||||
onSubmit: async (values) => {
|
onSubmit: async (values) => {
|
||||||
// reset error message
|
// reset error message
|
||||||
setSupplierFormErrorMessage('');
|
setSupplierFormErrorMessage('');
|
||||||
@@ -274,7 +279,9 @@ const SupplierForm = ({
|
|||||||
onChange={categoryChangeHandler}
|
onChange={categoryChangeHandler}
|
||||||
options={categoryOptions}
|
options={categoryOptions}
|
||||||
onInputChange={setCategorySelectInputValue}
|
onInputChange={setCategorySelectInputValue}
|
||||||
isError={formik.touched.category && Boolean(formik.errors.category)}
|
isError={
|
||||||
|
formik.touched.category && Boolean(formik.errors.category)
|
||||||
|
}
|
||||||
errorMessage={formik.errors.category as string}
|
errorMessage={formik.errors.category as string}
|
||||||
isDisabled={formType === 'detail'}
|
isDisabled={formType === 'detail'}
|
||||||
isClearable
|
isClearable
|
||||||
@@ -285,7 +292,9 @@ const SupplierForm = ({
|
|||||||
label='Hatchery'
|
label='Hatchery'
|
||||||
value={hatcheryTagInputValue}
|
value={hatcheryTagInputValue}
|
||||||
onChange={(value) => formik.setFieldValue('hatchery', value)}
|
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}
|
errorMessage={formik.errors.hatchery}
|
||||||
readOnly={formType === 'detail'}
|
readOnly={formType === 'detail'}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+25
-2
@@ -1,4 +1,11 @@
|
|||||||
export const MAIN_DRAWER_LINKS = [
|
type MAIN_DRAWER_MENU = {
|
||||||
|
title: string;
|
||||||
|
link: string;
|
||||||
|
icon: string;
|
||||||
|
submenu?: MAIN_DRAWER_MENU[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MAIN_DRAWER_LINKS: MAIN_DRAWER_MENU[] = [
|
||||||
{
|
{
|
||||||
title: 'Dashboard',
|
title: 'Dashboard',
|
||||||
link: '/dashboard',
|
link: '/dashboard',
|
||||||
@@ -62,7 +69,7 @@ export const MAIN_DRAWER_LINKS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'FCR',
|
title: 'FCR',
|
||||||
link: '/master-data/FCR',
|
link: '/master-data/fcr',
|
||||||
icon: 'fluent:food-chicken-leg-16-regular',
|
icon: 'fluent:food-chicken-leg-16-regular',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -129,3 +136,19 @@ export const CATEGORY_OPTIONS = [
|
|||||||
value: 'SAPRONAK',
|
value: 'SAPRONAK',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const PRODUCT_FLAG_OPTIONS = [
|
||||||
|
{ label: 'DOC', value: 'DOC' },
|
||||||
|
{ label: 'PAKAN', value: 'PAKAN' },
|
||||||
|
{ label: 'PRE-STARTER', value: 'PRE-STARTER' },
|
||||||
|
{ label: 'STARTER', value: 'STARTER' },
|
||||||
|
{ label: 'FINISHER', value: 'FINISHER' },
|
||||||
|
{ label: 'OVK', value: 'OVK' },
|
||||||
|
{ label: 'OBAT', value: 'OBAT' },
|
||||||
|
{ label: 'VITAMIN', value: 'VITAMIN' },
|
||||||
|
{ label: 'KIMIA', value: 'KIMIA' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SUPPLIER_FLAG_OPTIONS = [
|
||||||
|
{ label: 'EKSPEDISI', value: 'EKSPEDISI' },
|
||||||
|
];
|
||||||
|
|||||||
@@ -29,11 +29,36 @@ import {
|
|||||||
Customer,
|
Customer,
|
||||||
UpdateCustomerPayload,
|
UpdateCustomerPayload,
|
||||||
} from '@/types/api/master-data/customer';
|
} from '@/types/api/master-data/customer';
|
||||||
|
import {
|
||||||
|
CreateProductCategoryPayload,
|
||||||
|
ProductCategory,
|
||||||
|
UpdateProductCategoryPayload,
|
||||||
|
} from '@/types/api/master-data/product-category';
|
||||||
|
import {
|
||||||
|
CreateProductPayload,
|
||||||
|
Product,
|
||||||
|
UpdateProductPayload,
|
||||||
|
} from '@/types/api/master-data/product';
|
||||||
import {
|
import {
|
||||||
CreateSupplierPayload,
|
CreateSupplierPayload,
|
||||||
Supplier,
|
Supplier,
|
||||||
UpdateSupplierPayload,
|
UpdateSupplierPayload,
|
||||||
} from '@/types/api/master-data/supplier';
|
} from '@/types/api/master-data/supplier';
|
||||||
|
import {
|
||||||
|
CreateNonstockPayload,
|
||||||
|
Nonstock,
|
||||||
|
UpdateNonstockPayload,
|
||||||
|
} from '@/types/api/master-data/nonstock';
|
||||||
|
import {
|
||||||
|
Bank,
|
||||||
|
CreateBankPayload,
|
||||||
|
UpdateBankPayload,
|
||||||
|
} from '@/types/api/master-data/bank';
|
||||||
|
import {
|
||||||
|
CreateFcrPayload,
|
||||||
|
Fcr,
|
||||||
|
UpdateFcrPayload,
|
||||||
|
} from '@/types/api/master-data/fcr';
|
||||||
|
|
||||||
export const UomApi = new BaseApiService<
|
export const UomApi = new BaseApiService<
|
||||||
Uom,
|
Uom,
|
||||||
@@ -70,9 +95,38 @@ export const CustomerApi = new BaseApiService<
|
|||||||
CreateCustomerPayload,
|
CreateCustomerPayload,
|
||||||
UpdateCustomerPayload
|
UpdateCustomerPayload
|
||||||
>('/master-data/customers');
|
>('/master-data/customers');
|
||||||
|
export const ProductCategoryApi = new BaseApiService<
|
||||||
|
ProductCategory,
|
||||||
|
CreateProductCategoryPayload,
|
||||||
|
UpdateProductCategoryPayload
|
||||||
|
>('/master-data/product-categories');
|
||||||
|
|
||||||
|
export const ProductApi = new BaseApiService<
|
||||||
|
Product,
|
||||||
|
CreateProductPayload,
|
||||||
|
UpdateProductPayload
|
||||||
|
>('/master-data/products');
|
||||||
|
|
||||||
export const SupplierApi = new BaseApiService<
|
export const SupplierApi = new BaseApiService<
|
||||||
Supplier,
|
Supplier,
|
||||||
CreateSupplierPayload,
|
CreateSupplierPayload,
|
||||||
UpdateSupplierPayload
|
UpdateSupplierPayload
|
||||||
>('/master-data/suppliers');
|
>('/master-data/suppliers');
|
||||||
|
|
||||||
|
export const NonstockApi = new BaseApiService<
|
||||||
|
Nonstock,
|
||||||
|
CreateNonstockPayload,
|
||||||
|
UpdateNonstockPayload
|
||||||
|
>('/master-data/nonstocks');
|
||||||
|
|
||||||
|
export const BankApi = new BaseApiService<
|
||||||
|
Bank,
|
||||||
|
CreateBankPayload,
|
||||||
|
UpdateBankPayload
|
||||||
|
>('/master-data/banks');
|
||||||
|
|
||||||
|
export const FcrApi = new BaseApiService<
|
||||||
|
Fcr,
|
||||||
|
CreateFcrPayload,
|
||||||
|
UpdateFcrPayload
|
||||||
|
>('/master-data/fcrs');
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
import axios from 'axios';
|
|
||||||
import { httpClient } from '@/services/http/client';
|
|
||||||
|
|
||||||
import {
|
|
||||||
CreateNonstockPayload,
|
|
||||||
DeleteNonstockResponse,
|
|
||||||
NonstockResponse,
|
|
||||||
UpdateNonstockPayload,
|
|
||||||
} from '@/types/api/master-data/nonstock';
|
|
||||||
|
|
||||||
export const getNonstock = async (nonstockId: number) => {
|
|
||||||
try {
|
|
||||||
const getNonstockRes = await httpClient<NonstockResponse>(
|
|
||||||
`/master-data/nonstocks/${nonstockId}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return getNonstockRes;
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (axios.isAxiosError<NonstockResponse>(error)) {
|
|
||||||
return error.response?.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createNonstock = async (payload: CreateNonstockPayload) => {
|
|
||||||
try {
|
|
||||||
const createNonstockRes = await httpClient<NonstockResponse>(
|
|
||||||
'/master-data/nonstocks',
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
body: payload,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return createNonstockRes;
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (axios.isAxiosError<NonstockResponse>(error)) {
|
|
||||||
return error.response?.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateNonstock = async (
|
|
||||||
nonstockId: number,
|
|
||||||
payload: UpdateNonstockPayload
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
const updateNonstockRes = await httpClient<NonstockResponse>(
|
|
||||||
`/master-data/nonstocks/${nonstockId}`,
|
|
||||||
{
|
|
||||||
method: 'PATCH',
|
|
||||||
body: payload,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return updateNonstockRes;
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (axios.isAxiosError<NonstockResponse>(error)) {
|
|
||||||
return error.response?.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteNonstock = async (nonstockId: number) => {
|
|
||||||
try {
|
|
||||||
const deleteNonstockRes = await httpClient<DeleteNonstockResponse>(
|
|
||||||
`/master-data/nonstocks/${nonstockId}`,
|
|
||||||
{
|
|
||||||
method: 'DELETE',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return deleteNonstockRes;
|
|
||||||
} catch (error) {
|
|
||||||
if (axios.isAxiosError<DeleteNonstockResponse>(error)) {
|
|
||||||
return error.response?.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Vendored
+13
@@ -53,3 +53,16 @@ export type BaseMetadata = {
|
|||||||
|
|
||||||
export type Override<BaseType, Overrides> = Omit<BaseType, keyof Overrides> &
|
export type Override<BaseType, Overrides> = Omit<BaseType, keyof Overrides> &
|
||||||
Overrides;
|
Overrides;
|
||||||
|
|
||||||
|
export type flags =
|
||||||
|
| 'PAKAN'
|
||||||
|
| 'OBAT'
|
||||||
|
| 'VITAMIN'
|
||||||
|
| 'KIMIA'
|
||||||
|
| 'EKSPEDISI'
|
||||||
|
| 'IS_ACTIVE'
|
||||||
|
| 'DOC'
|
||||||
|
| 'PRE-STARTER'
|
||||||
|
| 'STARTER'
|
||||||
|
| 'FINISHER'
|
||||||
|
| 'OVK';
|
||||||
|
|||||||
Vendored
+20
@@ -0,0 +1,20 @@
|
|||||||
|
import { BaseMetadata } from '@/types/api/api-general';
|
||||||
|
|
||||||
|
export type BaseBank = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
alias: string;
|
||||||
|
owner?: string;
|
||||||
|
account_number: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Bank = BaseMetadata & BaseBank;
|
||||||
|
|
||||||
|
export type CreateBankPayload = {
|
||||||
|
name: string;
|
||||||
|
alias: string;
|
||||||
|
account_number: string;
|
||||||
|
owner?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateBankPayload = CreateBankPayload;
|
||||||
Vendored
+30
@@ -0,0 +1,30 @@
|
|||||||
|
import { BaseMetadata } from '@/types/api/api-general';
|
||||||
|
|
||||||
|
export type BaseFcr = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FcrStandard = {
|
||||||
|
id: number;
|
||||||
|
weight: number;
|
||||||
|
fcr_number: number;
|
||||||
|
mortality: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Fcr = BaseMetadata & BaseFcr;
|
||||||
|
|
||||||
|
export type FcrWithStandards = Fcr & {
|
||||||
|
fcr_standards: FcrStandard[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateFcrPayload = {
|
||||||
|
name: string;
|
||||||
|
fcr_standards: {
|
||||||
|
weight: number;
|
||||||
|
fcr_number: number;
|
||||||
|
mortality: number;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateFcrPayload = CreateFcrPayload;
|
||||||
+13
-8
@@ -1,18 +1,23 @@
|
|||||||
import { BaseApiResponse } from '@/types/api/api-general';
|
import { BaseApiResponse, BaseMetadata, flags } from '@/types/api/api-general';
|
||||||
|
import { BaseUom } from '@/types/api/master-data/uom';
|
||||||
|
import { BaseSupplier } from '@/types/api/master-data/supplier';
|
||||||
|
|
||||||
export type Nonstock = {
|
export type BaseNonstock = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
uom_id: number;
|
||||||
|
uom: BaseUom;
|
||||||
|
suppliers: BaseSupplier[];
|
||||||
|
flags: flags[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Nonstock = BaseMetadata & BaseNonstock;
|
||||||
|
|
||||||
export type CreateNonstockPayload = {
|
export type CreateNonstockPayload = {
|
||||||
name: string;
|
name: string;
|
||||||
|
uom_id: number;
|
||||||
|
supplier_ids: number[];
|
||||||
|
flags: flags[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UpdateNonstockPayload = CreateNonstockPayload;
|
export type UpdateNonstockPayload = CreateNonstockPayload;
|
||||||
|
|
||||||
export type NonstockResponse = BaseApiResponse<Nonstock>;
|
|
||||||
|
|
||||||
export type NonstocksResponse = BaseApiResponse<Nonstock[]>;
|
|
||||||
|
|
||||||
export type DeleteNonstockResponse = BaseApiResponse;
|
|
||||||
|
|||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
import { BaseMetadata } from '@/types/api/api-general';
|
||||||
|
|
||||||
|
export type BaseProductCategory = {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProductCategory = BaseMetadata & BaseProductCategory;
|
||||||
|
|
||||||
|
export type CreateProductCategoryPayload = {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateProductCategoryPayload = CreateProductCategoryPayload;
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
import { BaseMetadata } from '@/types/api/api-general';
|
||||||
|
import { Uom } from '@/types/api/master-data/uom';
|
||||||
|
import { ProductCategory } from '@/types/api/master-data/product-category';
|
||||||
|
import { Supplier } from '@/types/api/master-data/supplier';
|
||||||
|
|
||||||
|
export type BaseProduct = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
brand: string;
|
||||||
|
sku: string;
|
||||||
|
product_price: number;
|
||||||
|
selling_price?: number;
|
||||||
|
tax?: number;
|
||||||
|
expiry_period: number;
|
||||||
|
uom: Uom;
|
||||||
|
product_category: ProductCategory;
|
||||||
|
suppliers: Supplier[];
|
||||||
|
flags: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Product = BaseMetadata & BaseProduct;
|
||||||
|
|
||||||
|
export type CreateProductPayload = {
|
||||||
|
name: string;
|
||||||
|
brand: string;
|
||||||
|
sku: string;
|
||||||
|
uom_id: number;
|
||||||
|
product_category_id: number;
|
||||||
|
product_price: number;
|
||||||
|
selling_price: number;
|
||||||
|
tax: number;
|
||||||
|
expiry_period: number;
|
||||||
|
supplier_ids: number[];
|
||||||
|
flags: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateProductPayload = CreateProductPayload;
|
||||||
+3
-18
@@ -1,21 +1,4 @@
|
|||||||
// {
|
import { BaseMetadata } from '@/types/api/api-general';
|
||||||
// // "name": "PT CHAROEN POKPHAND INDONESIA Tbk",
|
|
||||||
// "name": "BOP Vendor",
|
|
||||||
// // "alias": "CPI",
|
|
||||||
// "alias": "BOP",
|
|
||||||
// "pic": "Super Admin",
|
|
||||||
// "type": "BISNIS", // "BISNIS" | "INDIVIDUAL"
|
|
||||||
// // "category": "SAPRONAK", // "BOP" | "SAPRONAK"
|
|
||||||
// "category": "BOP", // "BOP" | "SAPRONAK"
|
|
||||||
// "hatchery": "Kopo,Tasik", // Comma Separated // nullable
|
|
||||||
// "phone": "086172527361",
|
|
||||||
// "email": "abdulazis@gmail.com",
|
|
||||||
// "address": "Banten",
|
|
||||||
// "npwp": "0197239080712", // nullable
|
|
||||||
// "account_number": "192039801283", // nullable
|
|
||||||
// "due_date": 1 // day
|
|
||||||
// }
|
|
||||||
import { BaseMetadata, CreatedUser } from "@/types/api/api-general";
|
|
||||||
|
|
||||||
export type BaseSupplier = {
|
export type BaseSupplier = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -31,6 +14,7 @@ export type BaseSupplier = {
|
|||||||
npwp: string;
|
npwp: string;
|
||||||
account_number: string;
|
account_number: string;
|
||||||
due_date: number;
|
due_date: number;
|
||||||
|
balance?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Supplier = BaseMetadata & BaseSupplier;
|
export type Supplier = BaseMetadata & BaseSupplier;
|
||||||
@@ -48,6 +32,7 @@ export type CreateSupplierPayload = {
|
|||||||
npwp: string;
|
npwp: string;
|
||||||
account_number: string;
|
account_number: string;
|
||||||
due_date: number;
|
due_date: number;
|
||||||
|
balance?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UpdateSupplierPayload = CreateSupplierPayload;
|
export type UpdateSupplierPayload = CreateSupplierPayload;
|
||||||
Reference in New Issue
Block a user