mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 05:22:02 +00:00
Merge branch 'feat/FE/US-33/master-data-management' into 'development'
[FEAT/FE][US#33/TASK#40-41-42-43] Master Data Management See merge request mbugroup/lti-web-client!8
This commit is contained in:
@@ -39,3 +39,9 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# prettier
|
||||
.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"
|
||||
}
|
||||
Generated
+40
@@ -17,9 +17,11 @@
|
||||
"next": "15.5.3",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-select": "^5.10.2",
|
||||
"swr": "^2.3.6",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"use-debounce": "^10.0.6",
|
||||
"yup": "^1.7.0",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
@@ -4039,6 +4041,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/goober": {
|
||||
"version": "2.1.18",
|
||||
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz",
|
||||
"integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"csstype": "^3.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@@ -5760,6 +5771,23 @@
|
||||
"integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-hot-toast": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
|
||||
"integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.1.3",
|
||||
"goober": "^2.1.16"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16",
|
||||
"react-dom": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
@@ -6792,6 +6820,18 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-debounce": {
|
||||
"version": "10.0.6",
|
||||
"resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.0.6.tgz",
|
||||
"integrity": "sha512-C5OtPyhAZgVoteO9heXMTdW7v/IbFI+8bSVKYCJrSmiWWCLsbUxiBSp4t9v0hNBTGY97bT72ydDIDyGSFWfwXg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/use-isomorphic-layout-effect": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
|
||||
|
||||
+3
-1
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"dev": "eslint && next dev --turbopack",
|
||||
"build": "next build --turbopack",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
@@ -18,9 +18,11 @@
|
||||
"next": "15.5.3",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-select": "^5.10.2",
|
||||
"swr": "^2.3.6",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"use-debounce": "^10.0.6",
|
||||
"yup": "^1.7.0",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
const Dashboard = () => {
|
||||
return (
|
||||
<section className='w-full p-4'>
|
||||
<h1 className='text-3xl font-bold text-primary'>Dashboard</h1>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
+10
-2
@@ -1,6 +1,10 @@
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import './globals.css';
|
||||
import '@/app/globals.css';
|
||||
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import MainDrawer from '@/components/MainDrawer';
|
||||
import RequireAuth from '@/components/helper/RequireAuth';
|
||||
|
||||
const inter = Inter({
|
||||
variable: '--font-inter',
|
||||
@@ -26,7 +30,11 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang='en'>
|
||||
<body className={`${inter.variable} antialiased font-inter`}>
|
||||
{children}
|
||||
<RequireAuth>
|
||||
<MainDrawer>{children}</MainDrawer>
|
||||
</RequireAuth>
|
||||
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import AreaForm from '@/components/pages/master-data/area/form/AreaForm';
|
||||
|
||||
const AddNonstock = () => {
|
||||
return (
|
||||
<div className='w-full p-4 flex flex-row justify-center'>
|
||||
<AreaForm />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddNonstock;
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import AreaForm from '@/components/pages/master-data/area/form/AreaForm';
|
||||
|
||||
import { AreaApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
|
||||
const AreaEdit = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const areaId = searchParams.get('areaId');
|
||||
|
||||
const { data: area, isLoading: isLoadingArea } = useSWR(
|
||||
areaId,
|
||||
(id: number) => AreaApi.getSingle(id)
|
||||
);
|
||||
|
||||
if (!areaId) {
|
||||
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 (!isLoadingArea && (!area || isResponseError(area))) {
|
||||
router.replace('/404');
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-full p-4 flex flex-row justify-center'>
|
||||
{isLoadingArea && <span className='loading loading-spinner loading-xl' />}
|
||||
{!isLoadingArea && isResponseSuccess(area) && (
|
||||
<AreaForm type='edit' initialValues={area.data} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AreaEdit;
|
||||
@@ -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 AreaForm from '@/components/pages/master-data/area/form/AreaForm';
|
||||
|
||||
import { AreaApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
|
||||
const AreaDetail = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const areaId = searchParams.get('areaId');
|
||||
|
||||
const { data: area, isLoading: isLoadingArea } = useSWR(
|
||||
areaId,
|
||||
(id: number) => AreaApi.getSingle(id)
|
||||
);
|
||||
|
||||
if (!areaId) {
|
||||
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 (!isLoadingArea && (!area || isResponseError(area))) {
|
||||
router.replace('/404');
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-full p-4 flex flex-row justify-center'>
|
||||
{isLoadingArea && <span className='loading loading-spinner loading-xl' />}
|
||||
{!isLoadingArea && isResponseSuccess(area) && (
|
||||
<AreaForm type='detail' initialValues={area.data} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AreaDetail;
|
||||
@@ -0,0 +1,11 @@
|
||||
import AreasTable from '@/components/pages/master-data/area/AreasTable';
|
||||
|
||||
const Nonstock = () => {
|
||||
return (
|
||||
<section className='w-full p-4'>
|
||||
<AreasTable />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Nonstock;
|
||||
@@ -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 CustomerForm from "@/components/pages/master-data/customer/form/CustomerForm";
|
||||
|
||||
const AddCustomer = () => {
|
||||
return (
|
||||
<section className="w-full p-4 flex flex-row justify-center">
|
||||
<CustomerForm/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddCustomer;
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
import { CustomerApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import CustomerForm from '@/components/pages/master-data/customer/form/CustomerForm';
|
||||
|
||||
const CustomerEdit = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const costumerId = searchParams.get('customerId');
|
||||
|
||||
const { data: costumer, isLoading: isLoadingCostumer } = useSWR(
|
||||
costumerId,
|
||||
(id: number) => CustomerApi.getSingle(id)
|
||||
);
|
||||
|
||||
if (!costumerId) {
|
||||
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 (!isLoadingCostumer && (!costumer || isResponseError(costumer))) {
|
||||
router.replace('/404');
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-full p-4 flex flex-row justify-center'>
|
||||
{isLoadingCostumer && (
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
)}
|
||||
{!isLoadingCostumer && isResponseSuccess(costumer) && (
|
||||
<CustomerForm formType='edit' initialValues={costumer.data} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomerEdit;
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
import { CustomerApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from "@/lib/api-helper";
|
||||
import CustomerForm from "@/components/pages/master-data/customer/form/CustomerForm";
|
||||
|
||||
const CustomerDetail = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const costumerId = searchParams.get("customerId");
|
||||
|
||||
const { data: costumer, isLoading: isLoadingCostumer } = useSWR(
|
||||
costumerId,
|
||||
(id: number) => CustomerApi.getSingle(id)
|
||||
);
|
||||
|
||||
if(!costumerId){
|
||||
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(!isLoadingCostumer && (!costumer || isResponseError(costumer))){
|
||||
router.replace("/404");
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full p-4 flex flex-row justify-center">
|
||||
{isLoadingCostumer && <span className="loading loading-spinner loading-xl" />}
|
||||
{!isLoadingCostumer && isResponseSuccess(costumer) && (
|
||||
<CustomerForm formType="detail" initialValues={costumer.data} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
};
|
||||
|
||||
export default CustomerDetail;
|
||||
@@ -0,0 +1,11 @@
|
||||
import CustomersTable from "@/components/pages/master-data/customer/CustomersTable";
|
||||
|
||||
const Customer = () => {
|
||||
return (
|
||||
<section className="w-full p-4">
|
||||
<CustomersTable />
|
||||
</section>
|
||||
)
|
||||
};
|
||||
|
||||
export default Customer;
|
||||
@@ -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 KandangForm from '@/components/pages/master-data/kandang/form/KandangForm';
|
||||
|
||||
const AddNonstock = () => {
|
||||
return (
|
||||
<div className='w-full p-4 flex flex-row justify-center'>
|
||||
<KandangForm />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddNonstock;
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import KandangForm from '@/components/pages/master-data/kandang/form/KandangForm';
|
||||
|
||||
import { KandangApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
|
||||
const KandangEdit = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const kandangId = searchParams.get('kandangId');
|
||||
|
||||
const { data: kandang, isLoading: isLoadingKandang } = useSWR(
|
||||
kandangId,
|
||||
(id: number) => KandangApi.getSingle(id)
|
||||
);
|
||||
|
||||
if (!kandangId) {
|
||||
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 (!isLoadingKandang && (!kandang || isResponseError(kandang))) {
|
||||
router.replace('/404');
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-full p-4 flex flex-row justify-center'>
|
||||
{isLoadingKandang && (
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
)}
|
||||
{!isLoadingKandang && isResponseSuccess(kandang) && (
|
||||
<KandangForm type='edit' initialValues={kandang.data} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KandangEdit;
|
||||
@@ -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,49 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import KandangForm from '@/components/pages/master-data/kandang/form/KandangForm';
|
||||
|
||||
import { KandangApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
|
||||
const KandangDetail = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const kandangId = searchParams.get('kandangId');
|
||||
|
||||
const { data: kandang, isLoading: isLoadingKandang } = useSWR(
|
||||
kandangId,
|
||||
(id: number) => KandangApi.getSingle(id)
|
||||
);
|
||||
|
||||
if (!kandangId) {
|
||||
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 (!isLoadingKandang && (!kandang || isResponseError(kandang))) {
|
||||
router.replace('/404');
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-full p-4 flex flex-row justify-center'>
|
||||
{isLoadingKandang && (
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
)}
|
||||
{!isLoadingKandang && isResponseSuccess(kandang) && (
|
||||
<KandangForm type='detail' initialValues={kandang.data} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KandangDetail;
|
||||
@@ -0,0 +1,11 @@
|
||||
import KandangsTable from '@/components/pages/master-data/kandang/KandangsTable';
|
||||
|
||||
const Nonstock = () => {
|
||||
return (
|
||||
<section className='w-full p-4'>
|
||||
<KandangsTable />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Nonstock;
|
||||
@@ -0,0 +1,11 @@
|
||||
import LocationForm from '@/components/pages/master-data/location/form/LocationForm';
|
||||
|
||||
const AddNonstock = () => {
|
||||
return (
|
||||
<div className='w-full p-4 flex flex-row justify-center'>
|
||||
<LocationForm />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddNonstock;
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import LocationForm from '@/components/pages/master-data/location/form/LocationForm';
|
||||
|
||||
import { LocationApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
|
||||
const LocationEdit = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const locationId = searchParams.get('locationId');
|
||||
|
||||
const { data: location, isLoading: isLoadingLocation } = useSWR(
|
||||
locationId,
|
||||
(id: number) => LocationApi.getSingle(id)
|
||||
);
|
||||
|
||||
if (!locationId) {
|
||||
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 (!isLoadingLocation && (!location || isResponseError(location))) {
|
||||
router.replace('/404');
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-full p-4 flex flex-row justify-center'>
|
||||
{isLoadingLocation && (
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
)}
|
||||
{!isLoadingLocation && isResponseSuccess(location) && (
|
||||
<LocationForm type='edit' initialValues={location.data} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LocationEdit;
|
||||
@@ -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,49 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import LocationForm from '@/components/pages/master-data/location/form/LocationForm';
|
||||
|
||||
import { LocationApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
|
||||
const LocationDetail = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const locationId = searchParams.get('locationId');
|
||||
|
||||
const { data: location, isLoading: isLoadingLocation } = useSWR(
|
||||
locationId,
|
||||
(id: number) => LocationApi.getSingle(id)
|
||||
);
|
||||
|
||||
if (!locationId) {
|
||||
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 (!isLoadingLocation && (!location || isResponseError(location))) {
|
||||
router.replace('/404');
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-full p-4 flex flex-row justify-center'>
|
||||
{isLoadingLocation && (
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
)}
|
||||
{!isLoadingLocation && isResponseSuccess(location) && (
|
||||
<LocationForm type='detail' initialValues={location.data} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LocationDetail;
|
||||
@@ -0,0 +1,11 @@
|
||||
import LocationsTable from '@/components/pages/master-data/location/LocationsTable';
|
||||
|
||||
const Nonstock = () => {
|
||||
return (
|
||||
<section className='w-full p-4'>
|
||||
<LocationsTable />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Nonstock;
|
||||
@@ -0,0 +1,11 @@
|
||||
import NonstockForm from '@/components/pages/master-data/nonstock/form/NonstockForm';
|
||||
|
||||
const AddNonstock = () => {
|
||||
return (
|
||||
<div className='w-full p-4 flex flex-row justify-center'>
|
||||
<NonstockForm />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddNonstock;
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import NonstockForm from '@/components/pages/master-data/nonstock/form/NonstockForm';
|
||||
|
||||
import { NonstockApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
|
||||
const NonstockEdit = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const nonstockId = searchParams.get('nonstockId');
|
||||
|
||||
const { data: nonstock, isLoading: isLoadingNonstock } = useSWR(
|
||||
nonstockId,
|
||||
(id: number) => NonstockApi.getSingle(id)
|
||||
);
|
||||
|
||||
if (!nonstockId) {
|
||||
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 (!isLoadingNonstock && (!nonstock || isResponseError(nonstock))) {
|
||||
router.replace('/404');
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-full p-4 flex flex-row justify-center'>
|
||||
{isLoadingNonstock && (
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
)}
|
||||
{!isLoadingNonstock && isResponseSuccess(nonstock) && (
|
||||
<NonstockForm type='edit' initialValues={nonstock.data} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NonstockEdit;
|
||||
@@ -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,49 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import NonstockForm from '@/components/pages/master-data/nonstock/form/NonstockForm';
|
||||
|
||||
import { NonstockApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
|
||||
const NonstockDetail = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const nonstockId = searchParams.get('nonstockId');
|
||||
|
||||
const { data: nonstock, isLoading: isLoadingNonstock } = useSWR(
|
||||
nonstockId,
|
||||
(id: number) => NonstockApi.getSingle(id)
|
||||
);
|
||||
|
||||
if (!nonstockId) {
|
||||
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 (!isLoadingNonstock && (!nonstock || isResponseError(nonstock))) {
|
||||
router.replace('/404');
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-full p-4 flex flex-row justify-center'>
|
||||
{isLoadingNonstock && (
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
)}
|
||||
{!isLoadingNonstock && isResponseSuccess(nonstock) && (
|
||||
<NonstockForm type='detail' initialValues={nonstock.data} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NonstockDetail;
|
||||
@@ -0,0 +1,11 @@
|
||||
import NonstocksTable from '@/components/pages/master-data/nonstock/NonstocksTable';
|
||||
|
||||
const Nonstock = () => {
|
||||
return (
|
||||
<section className='w-full p-4'>
|
||||
<NonstocksTable />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Nonstock;
|
||||
@@ -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 SupplierForm from '@/components/pages/master-data/supplier/form/SupplierForm';
|
||||
|
||||
const AddSupplier = () => {
|
||||
return (
|
||||
<section className='w-full p-4 flex flex-row justify-center'>
|
||||
<SupplierForm />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddSupplier;
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import SupplierForm from '@/components/pages/master-data/supplier/form/SupplierForm';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { SupplierApi } from '@/services/api/master-data';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const SupplierEdit = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Get Query Params
|
||||
const supplierId = searchParams.get('supplierId');
|
||||
|
||||
// Fetch Data
|
||||
const { data: supplier, isLoading: isLoadingSupplier } = useSWR(
|
||||
supplierId,
|
||||
(id: number) => SupplierApi.getSingle(id)
|
||||
);
|
||||
|
||||
if (!supplierId) {
|
||||
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 (!isLoadingSupplier && (!supplier || isResponseError(supplier))) {
|
||||
router.replace('/404');
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-full p-4 flex flex-row justify-center'>
|
||||
{isLoadingSupplier && (
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
)}
|
||||
{!isLoadingSupplier && isResponseSuccess(supplier) && (
|
||||
<SupplierForm formType='edit' initialValues={supplier.data} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SupplierEdit;
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import SupplierForm from '@/components/pages/master-data/supplier/form/SupplierForm';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { SupplierApi } from '@/services/api/master-data';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const SupplierDetail = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Get Query Params
|
||||
const supplierId = searchParams.get('supplierId');
|
||||
|
||||
// Fetch Data
|
||||
const { data: supplier, isLoading: isLoadingSupplier } = useSWR(
|
||||
supplierId,
|
||||
(id: number) => SupplierApi.getSingle(id)
|
||||
);
|
||||
|
||||
if (!supplierId) {
|
||||
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 (!isLoadingSupplier && (!supplier || isResponseError(supplier))) {
|
||||
router.replace('/404');
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-full p-4 flex flex-row justify-center'>
|
||||
{isLoadingSupplier && (
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
)}
|
||||
{!isLoadingSupplier && isResponseSuccess(supplier) && (
|
||||
<SupplierForm formType='detail' initialValues={supplier.data} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SupplierDetail;
|
||||
@@ -0,0 +1,11 @@
|
||||
import SuppliersTable from "@/components/pages/master-data/supplier/SupplierTable";
|
||||
|
||||
const Supplier = () => {
|
||||
return (
|
||||
<section className='w-full p-4'>
|
||||
<SuppliersTable />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Supplier;
|
||||
@@ -0,0 +1,11 @@
|
||||
import UomForm from '@/components/pages/master-data/uom/form/UomForm';
|
||||
|
||||
const AddNonstock = () => {
|
||||
return (
|
||||
<div className='w-full p-4 flex flex-row justify-center'>
|
||||
<UomForm />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddNonstock;
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import UomForm from '@/components/pages/master-data/uom/form/UomForm';
|
||||
|
||||
import { UomApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
|
||||
const UomEdit = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const uomId = searchParams.get('uomId');
|
||||
|
||||
const { data: uom, isLoading: isLoadingUom } = useSWR(uomId, (id: number) =>
|
||||
UomApi.getSingle(id)
|
||||
);
|
||||
|
||||
if (!uomId) {
|
||||
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 (!isLoadingUom && (!uom || isResponseError(uom))) {
|
||||
router.replace('/404');
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-full p-4 flex flex-row justify-center'>
|
||||
{isLoadingUom && <span className='loading loading-spinner loading-xl' />}
|
||||
{!isLoadingUom && isResponseSuccess(uom) && (
|
||||
<UomForm type='edit' initialValues={uom.data} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UomEdit;
|
||||
@@ -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,46 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import UomForm from '@/components/pages/master-data/uom/form/UomForm';
|
||||
|
||||
import { UomApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
|
||||
const UomDetail = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const uomId = searchParams.get('uomId');
|
||||
|
||||
const { data: uom, isLoading: isLoadingUom } = useSWR(uomId, (id: number) =>
|
||||
UomApi.getSingle(id)
|
||||
);
|
||||
|
||||
if (!uomId) {
|
||||
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 (!isLoadingUom && (!uom || isResponseError(uom))) {
|
||||
router.replace('/404');
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-full p-4 flex flex-row justify-center'>
|
||||
{isLoadingUom && <span className='loading loading-spinner loading-xl' />}
|
||||
{!isLoadingUom && isResponseSuccess(uom) && (
|
||||
<UomForm type='detail' initialValues={uom.data} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UomDetail;
|
||||
@@ -0,0 +1,11 @@
|
||||
import UomsTable from '@/components/pages/master-data/uom/UomsTable';
|
||||
|
||||
const Nonstock = () => {
|
||||
return (
|
||||
<section className='w-full p-4'>
|
||||
<UomsTable />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Nonstock;
|
||||
@@ -0,0 +1,11 @@
|
||||
import WarehouseForm from '@/components/pages/master-data/warehouse/form/WarehouseForm';
|
||||
|
||||
const AddNonstock = () => {
|
||||
return (
|
||||
<div className='w-full p-4 flex flex-row justify-center'>
|
||||
<WarehouseForm />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddNonstock;
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import WarehouseForm from '@/components/pages/master-data/warehouse/form/WarehouseForm';
|
||||
|
||||
import { WarehouseApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
|
||||
const WarehouseEdit = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const warehouseId = searchParams.get('warehouseId');
|
||||
|
||||
const { data: warehouse, isLoading: isLoadingWarehouse } = useSWR(
|
||||
warehouseId,
|
||||
(id: number) => WarehouseApi.getSingle(id)
|
||||
);
|
||||
|
||||
if (!warehouseId) {
|
||||
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 (!isLoadingWarehouse && (!warehouse || isResponseError(warehouse))) {
|
||||
router.replace('/404');
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-full p-4 flex flex-row justify-center'>
|
||||
{isLoadingWarehouse && (
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
)}
|
||||
{!isLoadingWarehouse && isResponseSuccess(warehouse) && (
|
||||
<WarehouseForm type='edit' initialValues={warehouse.data} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WarehouseEdit;
|
||||
@@ -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,49 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import WarehouseForm from '@/components/pages/master-data/warehouse/form/WarehouseForm';
|
||||
|
||||
import { WarehouseApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
|
||||
const WarehouseDetail = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const warehouseId = searchParams.get('warehouseId');
|
||||
|
||||
const { data: warehouse, isLoading: isLoadingWarehouse } = useSWR(
|
||||
warehouseId,
|
||||
(id: number) => WarehouseApi.getSingle(id)
|
||||
);
|
||||
|
||||
if (!warehouseId) {
|
||||
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 (!isLoadingWarehouse && (!warehouse || isResponseError(warehouse))) {
|
||||
router.replace('/404');
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-full p-4 flex flex-row justify-center'>
|
||||
{isLoadingWarehouse && (
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
)}
|
||||
{!isLoadingWarehouse && isResponseSuccess(warehouse) && (
|
||||
<WarehouseForm type='detail' initialValues={warehouse.data} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WarehouseDetail;
|
||||
@@ -0,0 +1,11 @@
|
||||
import WarehousesTable from '@/components/pages/master-data/warehouse/WarehousesTable';
|
||||
|
||||
const Warehouse = () => {
|
||||
return (
|
||||
<section className='w-full p-4'>
|
||||
<WarehousesTable />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Warehouse;
|
||||
@@ -1,4 +1,8 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Home() {
|
||||
redirect('/dashboard');
|
||||
|
||||
return (
|
||||
<main className='w-full h-full min-h-screen flex flex-row justify-center items-center'>
|
||||
<h1>LTI ERP</h1>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { cn } from '@/lib/helper';
|
||||
|
||||
interface AlertProps {
|
||||
variant?: 'outline' | 'dash' | 'soft';
|
||||
color?: 'info' | 'success' | 'warning' | 'error';
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Alert = ({ children, variant, color, className }: AlertProps) => {
|
||||
const alertBaseClassName = cn('alert', {
|
||||
'alert-soft': variant === 'soft',
|
||||
'alert-outline': variant === 'outline',
|
||||
'alert-dash': variant === 'dash',
|
||||
|
||||
'alert-info': color === 'info',
|
||||
'alert-success': color === 'success',
|
||||
'alert-warning': color === 'warning',
|
||||
'alert-error': color === 'error',
|
||||
});
|
||||
|
||||
return <div className={cn(alertBaseClassName, className)}>{children}</div>;
|
||||
};
|
||||
|
||||
export default Alert;
|
||||
@@ -1,4 +1,4 @@
|
||||
import react, { JSX } from 'react';
|
||||
import react from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
@@ -17,11 +17,12 @@ const Button = ({
|
||||
type,
|
||||
href,
|
||||
variant,
|
||||
color,
|
||||
color = 'primary',
|
||||
isLoading,
|
||||
className,
|
||||
disabled,
|
||||
onClick,
|
||||
...props
|
||||
}: ButtonProps) => {
|
||||
const btnBaseClassName = cn(
|
||||
'btn',
|
||||
@@ -49,6 +50,7 @@ const Button = ({
|
||||
<>
|
||||
{!href && (
|
||||
<button
|
||||
{...props}
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
@@ -74,7 +76,7 @@ const Button = ({
|
||||
)}
|
||||
>
|
||||
{!isLoading && children}
|
||||
{isLoading && <span className='loading loading-dots loading-xl' />}
|
||||
{isLoading && <span className='loading loading-dots loading-md' />}
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import React, { useCallback, useId, useMemo, useState } from 'react';
|
||||
import { cn } from '@/lib/helper';
|
||||
|
||||
export type CollapseVariant = 'default' | 'arrow' | 'plus';
|
||||
|
||||
export type CollapseProps = {
|
||||
/** Unique name used when `asRadio` is true (Accordion single-open). */
|
||||
name?: string;
|
||||
/** If provided, component is controlled. */
|
||||
open?: boolean;
|
||||
/** Initial open state for uncontrolled usage. */
|
||||
defaultOpen?: boolean;
|
||||
/** Callback when open state changes. */
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/** Title row content. Accepts string or custom node. */
|
||||
title?: React.ReactNode;
|
||||
/** Optional secondary text displayed under/next to title. */
|
||||
subtitle?: React.ReactNode;
|
||||
/** Content of the panel. */
|
||||
children?: React.ReactNode;
|
||||
/** Visual variant: default / arrow / plus */
|
||||
variant?: CollapseVariant;
|
||||
/** Add a bordered look */
|
||||
bordered?: boolean;
|
||||
/** Disable interactions */
|
||||
disabled?: boolean;
|
||||
/** Allow only one open at a time by switching to radio input */
|
||||
asRadio?: boolean;
|
||||
/** Extra classnames */
|
||||
className?: string;
|
||||
titleClassName?: string;
|
||||
contentClassName?: string;
|
||||
};
|
||||
|
||||
export const Collapse = ({
|
||||
name,
|
||||
open,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
variant = 'default',
|
||||
bordered,
|
||||
disabled,
|
||||
asRadio = false,
|
||||
className,
|
||||
titleClassName,
|
||||
contentClassName,
|
||||
}: CollapseProps) => {
|
||||
const inputId = useId();
|
||||
const isControlled = typeof open === 'boolean';
|
||||
const [internalOpen, setInternalOpen] = useState(!!defaultOpen);
|
||||
const isOpen = isControlled ? !!open : internalOpen;
|
||||
|
||||
// Manage change from checkbox/radio
|
||||
const handleChange = useCallback(
|
||||
(next: boolean) => {
|
||||
if (!isControlled) setInternalOpen(next);
|
||||
onOpenChange?.(next);
|
||||
},
|
||||
[isControlled, onOpenChange]
|
||||
);
|
||||
|
||||
const inputType = asRadio ? 'radio' : 'checkbox';
|
||||
|
||||
const rootClass = cn(
|
||||
'collapse',
|
||||
variant === 'arrow' && 'collapse-arrow',
|
||||
variant === 'plus' && 'collapse-plus',
|
||||
bordered && 'border base-content/20 border-opacity-20 rounded-box',
|
||||
disabled && 'opacity-60 pointer-events-none',
|
||||
!open && 'w-fit',
|
||||
className
|
||||
);
|
||||
|
||||
const titleNode = useMemo(() => {
|
||||
if (subtitle) {
|
||||
return (
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<span>{title}</span>
|
||||
<span className='text-sm opacity-70'>{subtitle}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return title;
|
||||
}, [title, subtitle]);
|
||||
|
||||
return (
|
||||
<div className={rootClass} data-open={isOpen}>
|
||||
<input
|
||||
id={inputId}
|
||||
type={inputType}
|
||||
name={asRadio ? name : undefined}
|
||||
className='peer p-0 hidden'
|
||||
checked={isOpen}
|
||||
onChange={(e) => handleChange(e.currentTarget.checked)}
|
||||
aria-controls={`${inputId}-content`}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<div
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'collapse-title w-fit p-0',
|
||||
'focus:outline-none focus-visible:ring focus-visible:ring-primary/40',
|
||||
titleClassName
|
||||
)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleChange(!isOpen);
|
||||
}
|
||||
}}
|
||||
onClick={() => handleChange(!isOpen)}
|
||||
>
|
||||
{titleNode}
|
||||
</div>
|
||||
|
||||
<div
|
||||
id={`${inputId}-content`}
|
||||
className={cn('collapse-content p-0!', contentClassName)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Collapse;
|
||||
@@ -0,0 +1,238 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import Image from 'next/image';
|
||||
import { Icon } from '@iconify/react';
|
||||
import Drawer from '@/components/Drawer';
|
||||
import Menu from '@/components/menu/Menu';
|
||||
import MenuItem from '@/components/menu/MenuItem';
|
||||
import Navbar from '@/components/Navbar';
|
||||
import Collapse from '@/components/Collapse';
|
||||
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import { MAIN_DRAWER_LINKS } from '@/config/constant';
|
||||
import { cn } from '@/lib/helper';
|
||||
|
||||
type CollapseMenuProps = {
|
||||
title: string;
|
||||
link: string;
|
||||
icon: string;
|
||||
submenu?: CollapseMenuProps[];
|
||||
depth?: number;
|
||||
};
|
||||
|
||||
const isPathActive = (pathname: string, link?: string) => {
|
||||
if (!link) return false;
|
||||
|
||||
const splittedPathname = pathname.split('/');
|
||||
const splittedLink = link.split('/');
|
||||
|
||||
const isActiveLinkValid = splittedLink.every((linkChunk, idx) => {
|
||||
return linkChunk === splittedPathname[idx];
|
||||
});
|
||||
|
||||
return pathname.startsWith(link) && isActiveLinkValid;
|
||||
};
|
||||
|
||||
const CollapseMenu = ({
|
||||
title,
|
||||
link,
|
||||
icon,
|
||||
submenu,
|
||||
depth = 0,
|
||||
}: CollapseMenuProps) => {
|
||||
const pathname = usePathname();
|
||||
const isActive = isPathActive(pathname, link);
|
||||
const [open, setOpen] = useState(isActive);
|
||||
|
||||
const menuCollapseTitle = (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full px-3 py-2 rounded-md text-base font-semibold transition-colors flex flex-row justify-between items-center gap-2 hover:bg-primary/10 opacity-40',
|
||||
{
|
||||
'bg-primary/10 opacity-100': open || isActive,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className='flex flex-row items-center gap-2'>
|
||||
<Icon icon={icon} width={20} height={20} />
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
|
||||
<Icon
|
||||
icon='cuida:caret-up-outline'
|
||||
width={20}
|
||||
height={20}
|
||||
className={cn('transition-transform', {
|
||||
'rotate-90': !open,
|
||||
'rotate-180': open,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapse
|
||||
open={open}
|
||||
title={menuCollapseTitle}
|
||||
onOpenChange={setOpen}
|
||||
className='w-full'
|
||||
titleClassName='w-full p-0!'
|
||||
>
|
||||
<Menu>
|
||||
<div
|
||||
className='w-full py-0.5 flex flex-col gap-0.5'
|
||||
style={{
|
||||
paddingLeft: `${0.5 * (depth + 1)}rem`,
|
||||
}}
|
||||
>
|
||||
{submenu?.map((item, idx) => {
|
||||
const hasSubmenu = item.submenu && item.submenu.length > 0;
|
||||
|
||||
if (!hasSubmenu) {
|
||||
return (
|
||||
<MenuItem
|
||||
key={idx}
|
||||
title={item.title}
|
||||
href={item.link}
|
||||
icon={item.icon}
|
||||
active={isPathActive(pathname, item.link)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CollapseMenu
|
||||
key={idx}
|
||||
title={item.title}
|
||||
link={item.link}
|
||||
icon={item.icon}
|
||||
submenu={item.submenu}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Menu>
|
||||
</Collapse>
|
||||
);
|
||||
};
|
||||
|
||||
const MainDrawerMenu = () => {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<Menu>
|
||||
{MAIN_DRAWER_LINKS.map((item, idx) => {
|
||||
const hasSubmenu = item.submenu && item.submenu.length > 0;
|
||||
|
||||
if (!hasSubmenu) {
|
||||
return (
|
||||
<MenuItem
|
||||
key={idx}
|
||||
title={item.title}
|
||||
href={item.link}
|
||||
icon={item.icon}
|
||||
active={pathname.startsWith(item.link)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CollapseMenu
|
||||
key={idx}
|
||||
title={item.title}
|
||||
link={item.link}
|
||||
icon={item.icon}
|
||||
submenu={item.submenu}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
const MainDrawerContent = () => {
|
||||
return (
|
||||
<div className='w-full p-4 flex flex-col gap-4'>
|
||||
<div className='flex items-center gap-4'>
|
||||
<Image
|
||||
src='/assets/img/lti-logo.png'
|
||||
alt='MBU Logo'
|
||||
width={256}
|
||||
height={256}
|
||||
className='w-full max-w-16 h-auto'
|
||||
/>
|
||||
|
||||
<h1 className='text-xl font-bold'>LTI ERP</h1>
|
||||
</div>
|
||||
|
||||
<MainDrawerMenu />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const MainDrawer = ({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) => {
|
||||
const { mainDrawerOpen, setMainDrawerOpen } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const getPageTitle = useCallback(() => {
|
||||
let title = '';
|
||||
|
||||
const activeMenu = MAIN_DRAWER_LINKS.find((item) =>
|
||||
isPathActive(pathname, item.link)
|
||||
);
|
||||
|
||||
const traverseMenuTitle = (menu: typeof activeMenu) => {
|
||||
if (!menu) return;
|
||||
|
||||
const hasSubmenu = menu?.submenu && menu?.submenu.length > 0;
|
||||
|
||||
if (!title) {
|
||||
title += menu?.title;
|
||||
} else {
|
||||
title += ' - ' + menu?.title;
|
||||
}
|
||||
|
||||
if (!hasSubmenu || !menu.submenu) return;
|
||||
|
||||
const activeSubmenu = menu.submenu?.find((item) =>
|
||||
isPathActive(pathname, item.link)
|
||||
);
|
||||
|
||||
traverseMenuTitle(activeSubmenu);
|
||||
};
|
||||
|
||||
traverseMenuTitle(activeMenu);
|
||||
|
||||
return title;
|
||||
}, [pathname]);
|
||||
|
||||
const pageTitle = getPageTitle();
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setMainDrawerOpen(!mainDrawerOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={mainDrawerOpen}
|
||||
setOpen={setMainDrawerOpen}
|
||||
openOnLarge
|
||||
sidebarContent={<MainDrawerContent />}
|
||||
>
|
||||
<main className='w-full h-full flex flex-col'>
|
||||
<Navbar title={pageTitle as string} toggleSidebar={toggleSidebar} />
|
||||
|
||||
{children}
|
||||
</main>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainDrawer;
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode, RefObject, useCallback, useRef, useState } from 'react';
|
||||
import { cn } from '@/lib/helper';
|
||||
|
||||
export const useModal = () => {
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const openModal = useCallback(() => {
|
||||
setOpen(true);
|
||||
|
||||
ref.current?.showModal();
|
||||
}, []);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setOpen(false);
|
||||
ref.current?.close();
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (open) {
|
||||
closeModal();
|
||||
} else {
|
||||
openModal();
|
||||
}
|
||||
}, [open, closeModal, openModal]);
|
||||
|
||||
if (ref.current) {
|
||||
ref.current.addEventListener('close', () => {
|
||||
closeModal();
|
||||
});
|
||||
}
|
||||
|
||||
return { ref, open, setOpen, openModal, closeModal, toggle } as const;
|
||||
};
|
||||
|
||||
interface ModalProps {
|
||||
ref: RefObject<HTMLDialogElement | null>;
|
||||
children?: ReactNode;
|
||||
closeOnBackdrop?: boolean;
|
||||
className?: {
|
||||
modal?: string;
|
||||
modalBox?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const Modal = ({ ref, children, closeOnBackdrop, className }: ModalProps) => {
|
||||
return (
|
||||
<dialog ref={ref} className={cn('modal', className?.modal)}>
|
||||
<div className={cn('modal-box', className?.modalBox)}>{children}</div>
|
||||
|
||||
{closeOnBackdrop && (
|
||||
<form method='dialog' className='modal-backdrop'>
|
||||
<button>close</button>
|
||||
</form>
|
||||
)}
|
||||
</dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
@@ -32,7 +32,11 @@ const PaginationButton = ({
|
||||
const EtcPaginationButton = ({
|
||||
startPage = 0,
|
||||
endPage = 0,
|
||||
onPageItemClick = (pageNumber: number) => {},
|
||||
onPageItemClick,
|
||||
}: {
|
||||
startPage: number;
|
||||
endPage: number;
|
||||
onPageItemClick: (a: number) => void;
|
||||
}) => {
|
||||
const pages = range(startPage, endPage);
|
||||
|
||||
@@ -86,9 +90,16 @@ const Pagination = ({
|
||||
currentPage = 1,
|
||||
totalItems = 0,
|
||||
itemsPerPage = 10,
|
||||
onPageChange = (pageNumber: number) => {},
|
||||
onPageChange,
|
||||
onPrevPage = () => {},
|
||||
onNextPage = () => {},
|
||||
}: {
|
||||
currentPage: number;
|
||||
totalItems: number;
|
||||
itemsPerPage: number;
|
||||
onPageChange: (pageNumber: number) => void;
|
||||
onPrevPage: () => void;
|
||||
onNextPage: () => void;
|
||||
}) => {
|
||||
const totalPages =
|
||||
Math.ceil(totalItems / itemsPerPage) === 0
|
||||
|
||||
+96
-23
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
TableOptions,
|
||||
useReactTable,
|
||||
ColumnDef,
|
||||
FilterFn,
|
||||
SortingState,
|
||||
OnChangeFn,
|
||||
} from '@tanstack/react-table';
|
||||
import { rankItem } from '@tanstack/match-sorter-utils';
|
||||
import { Icon } from '@iconify/react';
|
||||
@@ -30,37 +33,43 @@ interface TableClassNames {
|
||||
paginationClassName?: string;
|
||||
}
|
||||
|
||||
// Type for the Table component props
|
||||
interface TableProps<TData extends object> {
|
||||
export interface TableProps<TData extends object> {
|
||||
data: TData[];
|
||||
columns: ColumnDef<TData, any>[];
|
||||
columns: ColumnDef<TData, unknown>[];
|
||||
pageSize?: number;
|
||||
totalItems?: number;
|
||||
page?: number;
|
||||
onPageChange?: (page: number) => void;
|
||||
isLoading?: boolean;
|
||||
fuzzySearchValue?: string | null;
|
||||
onFuzzySearchValueChange?: (value: string) => void;
|
||||
className?: TableClassNames;
|
||||
emptyContent?: ReactNode;
|
||||
sorting?: SortingState;
|
||||
setSorting?: OnChangeFn<SortingState>;
|
||||
manualSorting?: boolean;
|
||||
}
|
||||
|
||||
const DUMMY_SKELETON_DATA = [{}, {}, {}, {}, {}];
|
||||
|
||||
const fuzzyFilter = (
|
||||
row: any,
|
||||
columnId: string,
|
||||
value: string,
|
||||
addMeta: (meta: any) => void
|
||||
) => {
|
||||
const itemRank = rankItem(row.getValue(columnId), value);
|
||||
addMeta({ itemRank });
|
||||
return itemRank.passed;
|
||||
};
|
||||
const emptyContentDefaultValue = (
|
||||
<div className='w-full p-5 text-center'>
|
||||
<span className='text-lg opacity-50'>
|
||||
Tidak ada data yang dapat ditampilkan...
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Table = <TData extends object>({
|
||||
data = [],
|
||||
columns = [],
|
||||
pageSize = 10,
|
||||
totalItems,
|
||||
page,
|
||||
onPageChange,
|
||||
isLoading = false,
|
||||
fuzzySearchValue = null,
|
||||
onFuzzySearchValueChange = () => {},
|
||||
fuzzySearchValue,
|
||||
onFuzzySearchValueChange,
|
||||
className = {
|
||||
containerClassName: '',
|
||||
tableWrapperClassName: '',
|
||||
@@ -73,12 +82,30 @@ const Table = <TData extends object>({
|
||||
bodyColumnClassName: '',
|
||||
paginationClassName: '',
|
||||
},
|
||||
emptyContent = emptyContentDefaultValue,
|
||||
sorting,
|
||||
setSorting,
|
||||
manualSorting = false,
|
||||
}: TableProps<TData>) => {
|
||||
const isServerSideTable =
|
||||
totalItems !== undefined &&
|
||||
page !== undefined &&
|
||||
onPageChange !== undefined;
|
||||
|
||||
const [pagination, setPagination] = useState({
|
||||
pageIndex: 0,
|
||||
pageSize: pageSize,
|
||||
});
|
||||
|
||||
const fuzzyFilter: FilterFn<TData> = useCallback(
|
||||
(row, columnId, value, addMeta) => {
|
||||
const itemRank = rankItem(row.getValue(columnId), value);
|
||||
addMeta({ itemRank });
|
||||
return itemRank.passed;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const tableOptions: TableOptions<TData> = {
|
||||
columns,
|
||||
data: isLoading ? (DUMMY_SKELETON_DATA as TData[]) : data, // Type assertion
|
||||
@@ -86,6 +113,7 @@ const Table = <TData extends object>({
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onPaginationChange: setPagination,
|
||||
manualSorting,
|
||||
state: {
|
||||
pagination,
|
||||
globalFilter: fuzzySearchValue,
|
||||
@@ -101,7 +129,46 @@ const Table = <TData extends object>({
|
||||
tableOptions.getFilteredRowModel = getFilteredRowModel();
|
||||
}
|
||||
|
||||
if (sorting && setSorting) {
|
||||
tableOptions.onSortingChange = setSorting;
|
||||
tableOptions.state = {
|
||||
...tableOptions.state,
|
||||
sorting,
|
||||
};
|
||||
}
|
||||
|
||||
const table = useReactTable(tableOptions);
|
||||
const { setPageSize } = table;
|
||||
|
||||
const prevPageClickHandler = () => {
|
||||
table.previousPage();
|
||||
|
||||
if (isServerSideTable) {
|
||||
onPageChange(page - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const nextPageClickHandler = () => {
|
||||
table.nextPage();
|
||||
|
||||
if (isServerSideTable) {
|
||||
onPageChange(page + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const pageChangeHandler = (pageNumber: number) => {
|
||||
const currentPage = pageNumber - 1;
|
||||
|
||||
table.setPageIndex(pageNumber ? currentPage : 0);
|
||||
|
||||
if (isServerSideTable) {
|
||||
onPageChange(pageNumber);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setPageSize(pageSize);
|
||||
}, [pageSize, setPageSize]);
|
||||
|
||||
return (
|
||||
<div className={className.containerClassName}>
|
||||
@@ -178,17 +245,23 @@ const Table = <TData extends object>({
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{data.length > 0 && !isLoading && (
|
||||
{(data.length === 0 || table.getRowModel().rows.length === 0) &&
|
||||
!isLoading &&
|
||||
emptyContent}
|
||||
|
||||
{data.length > 0 && table.getRowModel().rows.length > 0 && !isLoading && (
|
||||
<div className={cn('mt-5', className.paginationClassName)}>
|
||||
<Pagination
|
||||
totalItems={table.getRowCount()}
|
||||
totalItems={isServerSideTable ? totalItems : table.getRowCount()}
|
||||
itemsPerPage={table.getState().pagination.pageSize}
|
||||
currentPage={table.getState().pagination.pageIndex + 1}
|
||||
onPrevPage={() => table.previousPage()}
|
||||
onNextPage={() => table.nextPage()}
|
||||
onPageChange={(pageNumber) =>
|
||||
table.setPageIndex(pageNumber ? pageNumber - 1 : 0)
|
||||
currentPage={
|
||||
isServerSideTable
|
||||
? page
|
||||
: table.getState().pagination.pageIndex + 1
|
||||
}
|
||||
onPrevPage={prevPageClickHandler}
|
||||
onNextPage={nextPageClickHandler}
|
||||
onPageChange={pageChangeHandler}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import useSWRImmutable from 'swr/immutable';
|
||||
|
||||
import { useAuth } from '@/services/hooks/useAuth';
|
||||
import { httpClientFetcher, SWRHttpKey } from '@/services/http/client';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { GetMeResponse } from '@/types/api/api-general';
|
||||
|
||||
// TODO: delete this later, DONT HARDCODE USER DATA
|
||||
const DUMMY_USER = {
|
||||
id: 1,
|
||||
email: 'admin@mbugroup.id',
|
||||
npk: '0001',
|
||||
name: 'Super Admin',
|
||||
image: null,
|
||||
created_at: '2025-09-30T03:24:20.899229Z',
|
||||
updated_at: '2025-09-30T03:24:20.899229Z',
|
||||
roles: [
|
||||
{
|
||||
id: 1,
|
||||
key: 'mbu.super_admin',
|
||||
name: 'MBU Administrator',
|
||||
client: {
|
||||
id: 1,
|
||||
name: 'PT Mitra Berlian Unggas',
|
||||
alias: 'MBU',
|
||||
},
|
||||
permissions: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'mbu:purchase:read',
|
||||
action: 'read',
|
||||
client: {
|
||||
id: 1,
|
||||
name: 'PT Mitra Berlian Unggas',
|
||||
alias: 'MBU',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'mbu:purchase:create',
|
||||
action: 'create',
|
||||
client: {
|
||||
id: 1,
|
||||
name: 'PT Mitra Berlian Unggas',
|
||||
alias: 'MBU',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'mbu:purchase:approve',
|
||||
action: 'approve',
|
||||
client: {
|
||||
id: 1,
|
||||
name: 'PT Mitra Berlian Unggas',
|
||||
alias: 'MBU',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
key: 'lti.super_admin',
|
||||
name: 'LTI Administrator',
|
||||
client: {
|
||||
id: 2,
|
||||
name: 'PT Lumbung Telur Indonesia',
|
||||
alias: 'LTI',
|
||||
},
|
||||
permissions: [
|
||||
{
|
||||
id: 4,
|
||||
name: 'lti:purchase:read',
|
||||
action: 'read',
|
||||
client: {
|
||||
id: 2,
|
||||
name: 'PT Lumbung Telur Indonesia',
|
||||
alias: 'LTI',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'lti:purchase:create',
|
||||
action: 'create',
|
||||
client: {
|
||||
id: 2,
|
||||
name: 'PT Lumbung Telur Indonesia',
|
||||
alias: 'LTI',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'lti:purchase:approve',
|
||||
action: 'approve',
|
||||
client: {
|
||||
id: 2,
|
||||
name: 'PT Lumbung Telur Indonesia',
|
||||
alias: 'LTI',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
key: 'manbu.super_admin',
|
||||
name: 'MANBU Administrator',
|
||||
client: {
|
||||
id: 3,
|
||||
name: 'PT Mandiri Berlian Unggas',
|
||||
alias: 'MANBU',
|
||||
},
|
||||
permissions: [
|
||||
{
|
||||
id: 7,
|
||||
name: 'manbu:purchase:read',
|
||||
action: 'read',
|
||||
client: {
|
||||
id: 3,
|
||||
name: 'PT Mandiri Berlian Unggas',
|
||||
alias: 'MANBU',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: 'manbu:purchase:create',
|
||||
action: 'create',
|
||||
client: {
|
||||
id: 3,
|
||||
name: 'PT Mandiri Berlian Unggas',
|
||||
alias: 'MANBU',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
name: 'manbu:purchase:approve',
|
||||
action: 'approve',
|
||||
client: {
|
||||
id: 3,
|
||||
name: 'PT Mandiri Berlian Unggas',
|
||||
alias: 'MANBU',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
interface RequireAuthProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const RequireAuth = ({ children }: RequireAuthProps) => {
|
||||
const router = useRouter();
|
||||
const { setUser, setIsLoadingUser } = useAuth();
|
||||
|
||||
const { data: userResponse, isLoading: isLoadingUserResponse } =
|
||||
useSWRImmutable<GetMeResponse & { ok?: boolean }, unknown, SWRHttpKey>(
|
||||
'/auth/get-me',
|
||||
httpClientFetcher,
|
||||
{
|
||||
shouldRetryOnError: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
refreshInterval: 0,
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoadingUser(isLoadingUserResponse);
|
||||
}, [isLoadingUserResponse, setIsLoadingUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isResponseSuccess(userResponse)) {
|
||||
setUser(userResponse.data);
|
||||
} else {
|
||||
// router.replace(process.env.NEXT_PUBLIC_SSO_LOGIN_URL as string);
|
||||
// TODO: remove this later, DONT HARDCODE USER DATA
|
||||
setUser(DUMMY_USER);
|
||||
}
|
||||
}, [userResponse, setIsLoadingUser, setUser]);
|
||||
|
||||
// TODO: uncomment this later
|
||||
// if (isLoadingUserResponse && !userResponse) {
|
||||
// return (
|
||||
// <div className='w-full flex flex-row justify-center items-center p-4'>
|
||||
// <span className='loading loading-spinner loading-xl' />
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default RequireAuth;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEvent, ChangeEventHandler, useEffect, useState } from 'react';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
|
||||
import TextInput, { TextInputProps } from '@/components/input/TextInput';
|
||||
|
||||
interface DebouncedTextInputProps extends TextInputProps {
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
const DebouncedTextInput = (props: DebouncedTextInputProps) => {
|
||||
const { delay, onChange } = props;
|
||||
|
||||
const [internalChangeEvent, setInternalChangeEvent] =
|
||||
useState<ChangeEvent<HTMLInputElement>>();
|
||||
const [internalValue, setInternalValue] = useState(props.value);
|
||||
|
||||
const [debouncedChangeEvent] = useDebounce(internalChangeEvent, delay ?? 300);
|
||||
const [debouncedValue] = useDebounce(internalValue, delay ?? 300);
|
||||
|
||||
const internalChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setInternalValue(e.target.value);
|
||||
setInternalChangeEvent(e);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedChangeEvent) {
|
||||
onChange?.(debouncedChangeEvent);
|
||||
}
|
||||
}, [debouncedValue]);
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
{...props}
|
||||
value={internalValue}
|
||||
onChange={internalChangeHandler}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DebouncedTextInput;
|
||||
@@ -6,8 +6,10 @@ import { Icon } from '@iconify/react';
|
||||
import TextInput, { TextInputProps } from '@/components/input/TextInput';
|
||||
import Button from '@/components/Button';
|
||||
|
||||
interface PasswordInputProps
|
||||
extends Omit<TextInputProps, 'type' | 'startAdornment' | 'endAdornment'> {}
|
||||
type PasswordInputProps = Omit<
|
||||
TextInputProps,
|
||||
'type' | 'startAdornment' | 'endAdornment'
|
||||
>;
|
||||
|
||||
const PasswordInput = (props: PasswordInputProps) => {
|
||||
const [type, setType] = useState('password');
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { ComponentType, ReactNode, useMemo } from 'react';
|
||||
import Select, { OptionProps, GroupBase } from 'react-select';
|
||||
import { ComponentType, ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
import Select, { OptionProps, GroupBase, InputActionMeta } from 'react-select';
|
||||
import makeAnimated from 'react-select/animated';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
|
||||
import { cn } from '@/lib/helper';
|
||||
|
||||
@@ -41,6 +42,8 @@ interface SelectInputProps<T = OptionType> {
|
||||
errorMessage?: string;
|
||||
isAnimated?: boolean;
|
||||
openMenu?: boolean;
|
||||
delay?: number;
|
||||
onInputChange?: (search: string) => void;
|
||||
}
|
||||
|
||||
const animatedComponents = makeAnimated();
|
||||
@@ -65,7 +68,13 @@ const SelectInput = <T extends OptionType>({
|
||||
errorMessage,
|
||||
isAnimated = true,
|
||||
openMenu,
|
||||
delay = 300,
|
||||
onInputChange,
|
||||
}: SelectInputProps) => {
|
||||
const [internalInputValue, setInternalInputValue] = useState('');
|
||||
|
||||
const [debouncedInputValue] = useDebounce(internalInputValue, delay ?? 300);
|
||||
|
||||
const components = useMemo(() => {
|
||||
const base = isAnimated ? animatedComponents : {};
|
||||
|
||||
@@ -75,6 +84,14 @@ const SelectInput = <T extends OptionType>({
|
||||
};
|
||||
}, [isAnimated]);
|
||||
|
||||
const internalInputChangeHandler = (value: string, meta: InputActionMeta) => {
|
||||
if (meta.action === 'input-change') setInternalInputValue(value);
|
||||
if (meta.action === 'menu-close') setInternalInputValue('');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onInputChange?.(debouncedInputValue);
|
||||
}, [debouncedInputValue]);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -110,6 +127,8 @@ const SelectInput = <T extends OptionType>({
|
||||
onChange={(val) => onChange?.(val as T)}
|
||||
options={options}
|
||||
menuIsOpen={openMenu}
|
||||
inputValue={internalInputValue}
|
||||
onInputChange={internalInputChangeHandler}
|
||||
isMulti={isMulti}
|
||||
isDisabled={isDisabled}
|
||||
isLoading={isLoading}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, KeyboardEvent, ChangeEvent, useEffect } from 'react';
|
||||
import { cn } from '@/lib/helper';
|
||||
|
||||
export interface TagInputProps {
|
||||
label?: string;
|
||||
bottomLabel?: string;
|
||||
name: string;
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
className?: {
|
||||
wrapper?: string;
|
||||
label?: string;
|
||||
inputWrapper?: string;
|
||||
input?: string;
|
||||
};
|
||||
isError?: boolean;
|
||||
isValid?: boolean;
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
required?: boolean;
|
||||
isLoading?: boolean;
|
||||
errorMessage?: string;
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
const TagInput: React.FC<TagInputProps> = ({
|
||||
label,
|
||||
bottomLabel,
|
||||
name,
|
||||
value = '',
|
||||
placeholder,
|
||||
className,
|
||||
isError,
|
||||
isValid,
|
||||
errorMessage,
|
||||
disabled = false,
|
||||
readOnly = false,
|
||||
required = false,
|
||||
onChange,
|
||||
}) => {
|
||||
const [tags, setTags] = useState<string[]>(value ? value.split(',') : []);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (value !== undefined && value !== tags.join(',')) {
|
||||
setTags(value ? value.split(',') : []);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
const newTag = inputValue.trim();
|
||||
if (newTag && !tags.includes(newTag)) {
|
||||
const updatedTags = [...tags, newTag];
|
||||
setTags(updatedTags);
|
||||
onChange?.(updatedTags.join(','));
|
||||
}
|
||||
setInputValue('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveTag = (tagToRemove: string) => {
|
||||
const updatedTags = tags.filter((t) => t !== tagToRemove);
|
||||
setTags(updatedTags);
|
||||
onChange?.(updatedTags.join(','));
|
||||
};
|
||||
|
||||
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full flex flex-col gap-2 text-start',
|
||||
className?.wrapper
|
||||
)}
|
||||
>
|
||||
{/* Label */}
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={name}
|
||||
className={cn(
|
||||
'w-full text-sm font-normal leading-5',
|
||||
{ 'text-error': isError },
|
||||
className?.label
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{required && (
|
||||
<>
|
||||
{' '}
|
||||
<span className='tooltip tooltip-error' data-tip='required'>
|
||||
<span className='text-error'> *</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Input wrapper */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-wrap items-start gap-2 border border-gray-400 rounded-md p-2 focus-within:ring-2 focus-within:ring-blue-500 min-h-[42px] transition-all',
|
||||
{
|
||||
'border-error': isError,
|
||||
'border-success!': isValid,
|
||||
'opacity-70 cursor-not-allowed': disabled,
|
||||
},
|
||||
className?.inputWrapper
|
||||
)}
|
||||
onClick={() => {
|
||||
// Fokuskan input saat area diklik
|
||||
const inputEl = document.getElementById(name);
|
||||
inputEl?.focus();
|
||||
}}
|
||||
>
|
||||
{tags.map((tag) => (
|
||||
<div
|
||||
key={tag}
|
||||
className={cn(
|
||||
'badge badge-primary gap-1 px-3 py-3 text-white flex items-center'
|
||||
)}
|
||||
>
|
||||
<span>{tag}</span>
|
||||
{!readOnly && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => handleRemoveTag(tag)}
|
||||
className='ml-1 text-white hover:text-red-200 focus:outline-none'
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!readOnly && (
|
||||
<input
|
||||
type='text'
|
||||
id={name}
|
||||
name={name}
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex-1 min-w-[120px] border-none outline-none p-1 size-min',
|
||||
className?.input
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom label or error message */}
|
||||
{!isError && bottomLabel && (
|
||||
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
|
||||
)}
|
||||
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagInput;
|
||||
@@ -0,0 +1,124 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
ChangeEventHandler,
|
||||
FocusEventHandler,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
|
||||
import { cn } from '@/lib/helper';
|
||||
|
||||
export interface TextAreaProps {
|
||||
label?: string;
|
||||
bottomLabel?: string;
|
||||
name: string;
|
||||
value?: string | number;
|
||||
placeholder?: string;
|
||||
className?: {
|
||||
wrapper?: string;
|
||||
label?: string;
|
||||
inputWrapper?: string;
|
||||
input?: string;
|
||||
};
|
||||
isError?: boolean;
|
||||
isValid?: boolean;
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
required?: boolean;
|
||||
isLoading?: boolean;
|
||||
errorMessage?: string;
|
||||
startAdornment?: ReactNode;
|
||||
endAdornment?: ReactNode;
|
||||
onChange?: ChangeEventHandler<HTMLTextAreaElement>;
|
||||
onBlur?: FocusEventHandler<HTMLTextAreaElement>;
|
||||
cols?: number;
|
||||
}
|
||||
|
||||
const TextArea = ({
|
||||
label,
|
||||
bottomLabel,
|
||||
name,
|
||||
value,
|
||||
placeholder,
|
||||
className,
|
||||
isError,
|
||||
isValid,
|
||||
errorMessage,
|
||||
startAdornment,
|
||||
endAdornment,
|
||||
disabled = false,
|
||||
required = false,
|
||||
onChange,
|
||||
onBlur,
|
||||
readOnly = false,
|
||||
isLoading = false,
|
||||
cols = 3
|
||||
}: TextAreaProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full flex flex-col gap-2 text-start',
|
||||
className?.wrapper
|
||||
)}
|
||||
>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={name}
|
||||
className={cn(
|
||||
'w-full text-sm font-normal leading-5',
|
||||
{
|
||||
'text-error': isError,
|
||||
},
|
||||
className?.label
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{required && (
|
||||
<>
|
||||
{' '}
|
||||
<span className='tooltip tooltip-error' data-tip='required'>
|
||||
<span className='text-error'> *</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
{startAdornment && startAdornment}
|
||||
|
||||
<textarea
|
||||
className={cn(
|
||||
'input h-12 px-4 py-2 text-base font-normal leading-6 w-full rounded-lg! outline-none! transition-all',
|
||||
{
|
||||
'border-error': isError,
|
||||
'border-success!': isValid,
|
||||
},
|
||||
className?.inputWrapper
|
||||
)}
|
||||
id={name}
|
||||
name={name}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
cols={cols}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
|
||||
{(isLoading || endAdornment) && (
|
||||
<div className='flex flex-row gap-2'>
|
||||
{isLoading && <span className='loading loading-spinner' />}
|
||||
|
||||
{endAdornment && endAdornment}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isError && bottomLabel && (
|
||||
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
|
||||
)}
|
||||
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextArea;
|
||||
@@ -122,7 +122,9 @@ const TextInput = ({
|
||||
{!isError && bottomLabel && (
|
||||
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
|
||||
)}
|
||||
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
|
||||
{isError && errorMessage && (
|
||||
<p className='w-full text-sm text-error'>{errorMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -33,7 +33,7 @@ const MenuItem = ({
|
||||
icon={icon}
|
||||
width={20}
|
||||
height={20}
|
||||
className={cn({
|
||||
className={cn('group-active:text-[inherit]', {
|
||||
'text-gray-400': !active,
|
||||
'text-black': active,
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
'use client';
|
||||
|
||||
import { RefObject } from 'react';
|
||||
|
||||
import { Icon } from '@iconify/react';
|
||||
import Modal from '@/components/Modal';
|
||||
import Button from '@/components/Button';
|
||||
|
||||
import { cn } from '@/lib/helper';
|
||||
import { Color } from '@/types/theme';
|
||||
|
||||
interface ConfirmationModalProps {
|
||||
ref: RefObject<HTMLDialogElement | null>;
|
||||
type?: 'info' | 'success' | 'error';
|
||||
text?: string;
|
||||
closeOnBackdrop?: boolean;
|
||||
primaryButton?: {
|
||||
text?: string;
|
||||
color?: Color;
|
||||
isLoading?: boolean;
|
||||
onClick?: () => void;
|
||||
};
|
||||
secondaryButton?: {
|
||||
text?: string;
|
||||
color?: Color;
|
||||
isLoading?: boolean;
|
||||
onClick?: () => void;
|
||||
};
|
||||
className?: {
|
||||
modal?: string;
|
||||
modalBox?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const ConfirmationModal = ({
|
||||
ref,
|
||||
type = 'info',
|
||||
text,
|
||||
closeOnBackdrop,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
className,
|
||||
}: ConfirmationModalProps) => {
|
||||
const closeModalHandler = () => {
|
||||
ref.current?.close();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal ref={ref} closeOnBackdrop={closeOnBackdrop} className={className}>
|
||||
<div className='w-full flex flex-col gap-4'>
|
||||
<div
|
||||
className={cn(
|
||||
'w-fit p-4 mx-auto flex flex-row justify-center items-center rounded-full',
|
||||
{
|
||||
'bg-error': type === 'error',
|
||||
'bg-info': type === 'info',
|
||||
'bg-success': type === 'success',
|
||||
}
|
||||
)}
|
||||
>
|
||||
{type === 'info' && (
|
||||
<Icon
|
||||
icon='material-symbols:info-outline-rounded'
|
||||
width={64}
|
||||
height={64}
|
||||
className='text-info-content'
|
||||
/>
|
||||
)}
|
||||
|
||||
{type === 'success' && (
|
||||
<Icon
|
||||
icon='qlementine-icons:success-12'
|
||||
width={64}
|
||||
height={64}
|
||||
className='text-success-content'
|
||||
/>
|
||||
)}
|
||||
|
||||
{type === 'error' && (
|
||||
<Icon
|
||||
icon='solar:danger-triangle-linear'
|
||||
width={64}
|
||||
height={64}
|
||||
className='text-error-content'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className='text-center font-medium'>
|
||||
{text ?? 'Apakah anda yakin ingin melakukan hal ini?'}
|
||||
</p>
|
||||
|
||||
<div className='w-full flex flex-row gap-2'>
|
||||
{secondaryButton && secondaryButton.text && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
color={secondaryButton?.color ?? 'none'}
|
||||
isLoading={secondaryButton?.isLoading}
|
||||
disabled={secondaryButton?.isLoading}
|
||||
onClick={closeModalHandler}
|
||||
className='grow'
|
||||
>
|
||||
{secondaryButton?.text ?? 'Tidak'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{primaryButton && primaryButton.text && (
|
||||
<Button
|
||||
color={primaryButton?.color ?? 'info'}
|
||||
onClick={primaryButton?.onClick}
|
||||
isLoading={primaryButton?.isLoading}
|
||||
disabled={primaryButton?.isLoading}
|
||||
className='grow'
|
||||
>
|
||||
{primaryButton?.text ?? 'Ya'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmationModal;
|
||||
@@ -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 { Area } from '@/types/api/master-data/area';
|
||||
import { AreaApi } 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<Area, 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/area/detail/?areaId=${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/area/detail/edit/?areaId=${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 AreasTable = () => {
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
initial: { search: '', nameSort: '' },
|
||||
paramMap: { page: 'page', pageSize: 'limit', nameSort: 'sort_name' },
|
||||
});
|
||||
|
||||
const {
|
||||
data: areas,
|
||||
isLoading,
|
||||
mutate: refreshAreas,
|
||||
} = useSWR(
|
||||
`${AreaApi.basePath}${getTableFilterQueryString()}`,
|
||||
AreaApi.getAllFetcher
|
||||
);
|
||||
|
||||
const deleteModal = useModal();
|
||||
|
||||
const [selectedArea, setSelectedArea] = useState<Area | undefined>(undefined);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const areasColumns: ColumnDef<Area>[] = [
|
||||
{
|
||||
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 = () => {
|
||||
setSelectedArea(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 AreaApi.delete(selectedArea?.id as number);
|
||||
refreshAreas();
|
||||
|
||||
deleteModal.closeModal();
|
||||
toast.success('Successfully delete Area!');
|
||||
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, updateFilter]);
|
||||
|
||||
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/area/add' color='primary'>
|
||||
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||
Tambah Area
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DebouncedTextInput
|
||||
name='search'
|
||||
placeholder='Cari Area'
|
||||
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<Area>
|
||||
data={isResponseSuccess(areas) ? areas?.data : []}
|
||||
columns={areasColumns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
page={isResponseSuccess(areas) ? areas?.meta?.page : 0}
|
||||
totalItems={isResponseSuccess(areas) ? areas?.meta?.total_results : 0}
|
||||
onPageChange={setPage}
|
||||
isLoading={isLoading}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
className={{
|
||||
containerClassName: cn({
|
||||
'mb-20': isResponseSuccess(areas) && areas?.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 Area ini (${selectedArea?.name})?`}
|
||||
secondaryButton={{
|
||||
text: 'Tidak',
|
||||
}}
|
||||
primaryButton={{
|
||||
text: 'Ya',
|
||||
color: 'error',
|
||||
isLoading: isDeleteLoading,
|
||||
onClick: confirmationModalDeleteClickHandler,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AreasTable;
|
||||
@@ -0,0 +1,9 @@
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export const AreaFormSchema = Yup.object({
|
||||
name: Yup.string().required('Nama wajib diisi!'),
|
||||
});
|
||||
|
||||
export const UpdateAreaFormSchema = AreaFormSchema;
|
||||
|
||||
export type AreaFormValues = Yup.InferType<typeof AreaFormSchema>;
|
||||
@@ -0,0 +1,253 @@
|
||||
'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 {
|
||||
AreaFormSchema,
|
||||
AreaFormValues,
|
||||
UpdateAreaFormSchema,
|
||||
} from '@/components/pages/master-data/area/form/AreaForm.schema';
|
||||
import { isResponseError } from '@/lib/api-helper';
|
||||
import {
|
||||
Area,
|
||||
CreateAreaPayload,
|
||||
UpdateAreaPayload,
|
||||
} from '@/types/api/master-data/area';
|
||||
import { AreaApi } from '@/services/api/master-data';
|
||||
import { cn } from '@/lib/helper';
|
||||
|
||||
interface AreaFormProps {
|
||||
type?: 'add' | 'edit' | 'detail';
|
||||
initialValues?: Area;
|
||||
}
|
||||
|
||||
const AreaForm = ({ type = 'add', initialValues }: AreaFormProps) => {
|
||||
const router = useRouter();
|
||||
const deleteModal = useModal();
|
||||
|
||||
const [areaFormErrorMessage, setAreaFormErrorMessage] = useState('');
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const createAreaHandler = useCallback(
|
||||
async (payload: CreateAreaPayload) => {
|
||||
const createAreaRes = await AreaApi.create(payload);
|
||||
|
||||
if (isResponseError(createAreaRes)) {
|
||||
setAreaFormErrorMessage(createAreaRes.message);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(createAreaRes?.message as string);
|
||||
router.push('/master-data/area');
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const updateAreaHandler = useCallback(
|
||||
async (areaId: number, payload: UpdateAreaPayload) => {
|
||||
const updateAreaRes = await AreaApi.update(areaId, payload);
|
||||
|
||||
if (updateAreaRes?.status === 'error') {
|
||||
setAreaFormErrorMessage(updateAreaRes.message);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(updateAreaRes?.message as string);
|
||||
router.refresh();
|
||||
router.push('/master-data/area');
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const formikInitialValues = useMemo<AreaFormValues>(() => {
|
||||
return {
|
||||
name: initialValues?.name ?? '',
|
||||
};
|
||||
}, [initialValues]);
|
||||
|
||||
const formik = useFormik<AreaFormValues>({
|
||||
initialValues: formikInitialValues,
|
||||
validationSchema: type === 'edit' ? UpdateAreaFormSchema : AreaFormSchema,
|
||||
onSubmit: async (values) => {
|
||||
setAreaFormErrorMessage('');
|
||||
|
||||
const areaPayload: CreateAreaPayload = {
|
||||
name: values.name,
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case 'add':
|
||||
await createAreaHandler(areaPayload);
|
||||
break;
|
||||
|
||||
case 'edit':
|
||||
await updateAreaHandler(initialValues?.id as number, areaPayload);
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const { setValues: formikSetValues } = formik;
|
||||
|
||||
const deleteAreaClickHandler = () => {
|
||||
deleteModal.openModal();
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
|
||||
await AreaApi.delete(initialValues?.id as number);
|
||||
|
||||
deleteModal.closeModal();
|
||||
toast.success('Successfully delete Area!');
|
||||
setIsDeleteLoading(false);
|
||||
router.push('/master-data/area');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
formikSetValues(formikInitialValues);
|
||||
}, [formikSetValues, formikInitialValues]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className='w-full max-w-xl'>
|
||||
<header className='flex flex-col gap-4'>
|
||||
<Button
|
||||
href='/master-data/area'
|
||||
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 Area'}
|
||||
{type === 'edit' && 'Edit Area'}
|
||||
{type === 'detail' && 'Detail Area'}
|
||||
</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 area'
|
||||
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={deleteAreaClickHandler}
|
||||
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/area/detail/edit/?areaId=${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>
|
||||
|
||||
{areaFormErrorMessage && (
|
||||
<div role='alert' className='alert alert-error'>
|
||||
<Icon
|
||||
icon='material-symbols:error-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span>{areaFormErrorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{type !== 'add' && (
|
||||
<ConfirmationModal
|
||||
ref={deleteModal.ref}
|
||||
type='error'
|
||||
text={`Apakah anda yakin ingin menghapus data Area ini (${initialValues?.name})?`}
|
||||
secondaryButton={{
|
||||
text: 'Tidak',
|
||||
}}
|
||||
primaryButton={{
|
||||
text: 'Ya',
|
||||
color: 'error',
|
||||
isLoading: isDeleteLoading,
|
||||
onClick: confirmationModalDeleteClickHandler,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AreaForm;
|
||||
@@ -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,288 @@
|
||||
'use client';
|
||||
|
||||
import Button from '@/components/Button';
|
||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||
import SelectInput, { OptionType } from '@/components/input/SelectInput';
|
||||
import { useModal } from '@/components/Modal';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import Table from '@/components/Table';
|
||||
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
|
||||
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
|
||||
import { ROWS_OPTIONS } from '@/config/constant';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { cn } from '@/lib/helper';
|
||||
import { CustomerApi } from '@/services/api/master-data';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { Customer } from '@/types/api/master-data/customer';
|
||||
import { Icon } from '@iconify/react';
|
||||
import {
|
||||
CellContext,
|
||||
ColumnDef,
|
||||
} from '@tanstack/react-table';
|
||||
import { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
type = 'dropdown',
|
||||
props,
|
||||
deleteClickHandler,
|
||||
}: {
|
||||
type: 'dropdown' | 'collapse';
|
||||
props: CellContext<Customer, 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/customer/detail/?customerId=${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
|
||||
className='justify-start text-sm'
|
||||
href={`/master-data/customer/detail/edit/?customerId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='warning'
|
||||
>
|
||||
<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 CustomersTable = () => {
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
initial: { search: '', nameSort: '', picSort: '' },
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
nameSort: 'sort_name',
|
||||
picSort: 'sort_pic',
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch Data
|
||||
const {
|
||||
data: customers,
|
||||
isLoading,
|
||||
mutate: refreshCustomers,
|
||||
} = useSWR(
|
||||
`${CustomerApi.basePath}${getTableFilterQueryString()}`,
|
||||
CustomerApi.getAllFetcher
|
||||
);
|
||||
|
||||
// State
|
||||
const deleteModal = useModal();
|
||||
const [selectedCustomer, setSelectedCustomer] = useState<
|
||||
Customer | undefined
|
||||
>(undefined);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
// Columns Definition
|
||||
const customersColumns: ColumnDef<Customer>[] = [
|
||||
{
|
||||
header: '#',
|
||||
cell: (props) =>
|
||||
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
||||
props.row.index +
|
||||
1,
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Nama',
|
||||
},
|
||||
{
|
||||
accessorKey: 'pic',
|
||||
header: 'PIC',
|
||||
cell: (props) => props.row.original.pic.name,
|
||||
},
|
||||
{
|
||||
accessorKey: 'type',
|
||||
header: 'Type',
|
||||
cell: (props) => props.row.original.type,
|
||||
},
|
||||
{
|
||||
accessorKey: 'phone',
|
||||
header: 'Phone',
|
||||
},
|
||||
{
|
||||
accessorKey: 'email',
|
||||
header: 'Email',
|
||||
},
|
||||
{
|
||||
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 = () => {
|
||||
setSelectedCustomer(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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Handler
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
|
||||
await CustomerApi.delete(selectedCustomer?.id as number);
|
||||
refreshCustomers();
|
||||
|
||||
deleteModal.closeModal();
|
||||
toast.success('Successfully delete Customer!');
|
||||
setIsDeleteLoading(false);
|
||||
};
|
||||
const searchChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
updateFilter('search', e.target.value);
|
||||
};
|
||||
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
const newVal = val as OptionType;
|
||||
setPageSize(newVal.value as number);
|
||||
};
|
||||
|
||||
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/customer/add' color='primary'>
|
||||
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||
Tambah Customer
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DebouncedTextInput
|
||||
name='search'
|
||||
placeholder='Cari Kandang'
|
||||
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<Customer>
|
||||
data={isResponseSuccess(customers) ? customers?.data : []}
|
||||
columns={customersColumns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
page={isResponseSuccess(customers) ? customers?.meta?.page : 0}
|
||||
totalItems={
|
||||
isResponseSuccess(customers) ? customers?.meta?.total_results : 0
|
||||
}
|
||||
onPageChange={setPage}
|
||||
isLoading={isLoading}
|
||||
className={{
|
||||
containerClassName: cn({
|
||||
'mb-20':
|
||||
isResponseSuccess(customers) && customers?.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 Customer ini (${selectedCustomer?.name})?`}
|
||||
secondaryButton={{
|
||||
text: 'Tidak',
|
||||
}}
|
||||
primaryButton={{
|
||||
text: 'Ya',
|
||||
color: 'error',
|
||||
isLoading: isDeleteLoading,
|
||||
onClick: confirmationModalDeleteClickHandler,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomersTable;
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export const CustomerFormSchema = Yup.object({
|
||||
name: Yup.string().required('Nama wajib diisi!'),
|
||||
|
||||
picId: Yup.number().min(1, 'PIC wajib diisi!').required('PIC wajib diisi!'),
|
||||
|
||||
pic: Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
}).required('PIC wajib diisi!'),
|
||||
|
||||
type: Yup.object({
|
||||
value: Yup.string().required(),
|
||||
label: Yup.string().required(),
|
||||
}).required('Tipe wajib diisi!'),
|
||||
|
||||
address: Yup.string().required('Alamat wajib diisi!'),
|
||||
|
||||
phone: Yup.string()
|
||||
.matches(/^[0-9]+$/, 'Nomor telepon hanya boleh berisi angka!')
|
||||
.min(10, 'Nomor telepon minimal 10 digit!')
|
||||
.max(12, 'Nomor telepon maksimal 12 digit!')
|
||||
.required('Nomor telepon wajib diisi!'),
|
||||
|
||||
email: Yup.string()
|
||||
.email('Format email tidak valid!')
|
||||
.required('Email wajib diisi!'),
|
||||
|
||||
account_number: Yup.string()
|
||||
.matches(/^[0-9]+$/, 'Nomor rekening hanya boleh berisi angka!')
|
||||
.required('Nomor rekening wajib diisi!'),
|
||||
});
|
||||
|
||||
export const UpdateCustomerFormSchema = CustomerFormSchema;
|
||||
|
||||
export type CustomerFormValues = Yup.InferType<typeof CustomerFormSchema>;
|
||||
@@ -0,0 +1,410 @@
|
||||
'use client';
|
||||
|
||||
import { useModal } from '@/components/Modal';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { CustomerApi } from '@/services/api/master-data';
|
||||
import {
|
||||
CreateCustomerPayload,
|
||||
Customer,
|
||||
UpdateCustomerPayload,
|
||||
} from '@/types/api/master-data/customer';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { CustomerFormSchema, CustomerFormValues, UpdateCustomerFormSchema } from './CustomerForm.schema';
|
||||
import { useFormik } from 'formik';
|
||||
import Button from '@/components/Button';
|
||||
import { Icon } from '@iconify/react';
|
||||
import TextInput from '@/components/input/TextInput';
|
||||
import { cn } from '@/lib/helper';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import TextArea from '@/components/input/TextArea';
|
||||
import SelectInput, { OptionType } from '@/components/input/SelectInput';
|
||||
import useSWR from 'swr';
|
||||
import { UserApi } from '@/services/api/user';
|
||||
import { TYPE_OPTIONS } from '@/config/constant';
|
||||
|
||||
interface CustomerFormProps {
|
||||
formType?: 'add' | 'edit' | 'detail';
|
||||
initialValues?: Customer;
|
||||
}
|
||||
|
||||
const CustomerForm = ({
|
||||
formType = 'add',
|
||||
initialValues,
|
||||
}: CustomerFormProps) => {
|
||||
// Setup Kebutuhan Form
|
||||
const router = useRouter();
|
||||
const deleteModal = useModal();
|
||||
|
||||
// Setup State
|
||||
const [customerFormErrorMessage, setCustomerFormErrorMessage] = useState('');
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
const [picSelectInputValue, setPicSelectInputValue] = useState('');
|
||||
const [typeSelectInputValue, setTypeSelectInputValue] = useState('');
|
||||
|
||||
// Fetch Data
|
||||
const picUrl = `${UserApi.basePath}?${new URLSearchParams({
|
||||
search: picSelectInputValue ?? '',
|
||||
})}`;
|
||||
|
||||
const { data: pic, isLoading: isLoadingPic } = useSWR(
|
||||
picUrl,
|
||||
UserApi.getAllFetcher
|
||||
);
|
||||
|
||||
// -- Options data mapping
|
||||
const picOptions = isResponseSuccess(pic)
|
||||
? pic?.data.map((area) => ({
|
||||
value: area.id,
|
||||
label: area.name,
|
||||
}))
|
||||
: [];
|
||||
const typeOptions = TYPE_OPTIONS;
|
||||
|
||||
// Handler Event
|
||||
const createCustomerHandler = useCallback(
|
||||
async (payload: CreateCustomerPayload) => {
|
||||
const createCustomerRes = await CustomerApi.create(payload);
|
||||
|
||||
if (isResponseError(createCustomerRes)) {
|
||||
setCustomerFormErrorMessage(createCustomerRes.message);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(createCustomerRes?.message as string);
|
||||
router.push('/master-data/customer');
|
||||
},
|
||||
[router]
|
||||
);
|
||||
const updateCustomerHandler = useCallback(
|
||||
async (customerId: number, payload: UpdateCustomerPayload) => {
|
||||
const updateCustomerRes = await CustomerApi.update(customerId, payload);
|
||||
|
||||
if (isResponseError(updateCustomerRes)) {
|
||||
setCustomerFormErrorMessage(updateCustomerRes.message);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(updateCustomerRes?.message as string);
|
||||
router.push('/master-data/customer');
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const deleteCustomerHandler = () => {
|
||||
deleteModal.openModal();
|
||||
};
|
||||
|
||||
const confirmationModalDeleteclickHandler = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
|
||||
await CustomerApi.delete(initialValues?.id as number);
|
||||
|
||||
deleteModal.closeModal();
|
||||
setIsDeleteLoading(false);
|
||||
router.push('/master-data/customer');
|
||||
};
|
||||
|
||||
// -- Option Handler
|
||||
const picChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
formik.setFieldTouched('pic', true);
|
||||
formik.setFieldValue('pic', val);
|
||||
|
||||
formik.setFieldTouched('picId', true);
|
||||
formik.setFieldValue('picId', (val as OptionType)?.value);
|
||||
};
|
||||
const typeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
formik.setFieldTouched('type', true);
|
||||
formik.setFieldValue('type', val);
|
||||
};
|
||||
|
||||
// Utils Functions
|
||||
const normalizeType = (type?: string | { value: string; label: string }) => {
|
||||
if (!type) return TYPE_OPTIONS[0];
|
||||
return typeof type === 'string' ? { value: type, label: type } : type;
|
||||
};
|
||||
|
||||
// Memo untuk simpan input sebelumnya
|
||||
const formikInitialValues = useMemo<CustomerFormValues>(() => {
|
||||
return {
|
||||
name: initialValues?.name ?? '',
|
||||
email: initialValues?.email ?? '',
|
||||
phone: initialValues?.phone ?? '',
|
||||
picId: initialValues?.pic?.id ?? 0,
|
||||
pic: initialValues?.pic
|
||||
? {
|
||||
value: initialValues.pic.id,
|
||||
label: initialValues.pic.name,
|
||||
}
|
||||
: {
|
||||
value: 0,
|
||||
label: '',
|
||||
},
|
||||
type: normalizeType(initialValues?.type),
|
||||
address: initialValues?.address ?? '',
|
||||
account_number: initialValues?.account_number ?? '',
|
||||
};
|
||||
}, [initialValues]);
|
||||
|
||||
// Formik
|
||||
const formik = useFormik<CustomerFormValues>({
|
||||
initialValues: formikInitialValues,
|
||||
enableReinitialize: true,
|
||||
validationSchema: formType === 'edit' ? UpdateCustomerFormSchema : CustomerFormSchema,
|
||||
onSubmit: async (values) => {
|
||||
// reset error message
|
||||
setCustomerFormErrorMessage('');
|
||||
|
||||
// create payload
|
||||
const payload: CreateCustomerPayload = {
|
||||
name: values.name,
|
||||
email: values.email,
|
||||
phone: values.phone,
|
||||
pic_id: values.picId,
|
||||
type: (values.type as OptionType).value as string,
|
||||
address: values.address,
|
||||
account_number: values.account_number,
|
||||
};
|
||||
|
||||
// cek type form yang disubmit
|
||||
switch (formType) {
|
||||
case 'add':
|
||||
await createCustomerHandler(payload);
|
||||
break;
|
||||
case 'edit':
|
||||
await updateCustomerHandler(initialValues?.id as number, payload);
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const { setValues: formikSetValues } = formik;
|
||||
|
||||
// Initialize Formik
|
||||
useEffect(() => {
|
||||
formikSetValues(formikInitialValues);
|
||||
}, [formikSetValues, formikInitialValues]);
|
||||
|
||||
// Render
|
||||
return (
|
||||
<>
|
||||
<section className='w-full max-w-xl'>
|
||||
<header className='flex flex-col gap-4'>
|
||||
<Button
|
||||
href='/master-data/customer'
|
||||
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'>
|
||||
{formType === 'add' && 'Tambah Customer'}
|
||||
{formType === 'edit' && 'Ubah Customer'}
|
||||
{formType === 'detail' && 'Detail Customer'}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
onReset={formik.handleReset}
|
||||
className='w-full mt-8 flex flex-col gap-6'
|
||||
>
|
||||
{/* Fields Form */}
|
||||
<div className='flex flex-col gap-4'>
|
||||
<TextInput
|
||||
required
|
||||
label='Nama'
|
||||
name='name'
|
||||
placeholder='Masukkan nama customer'
|
||||
value={formik.values.name}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={formik.touched.name && Boolean(formik.errors.name)}
|
||||
errorMessage={formik.errors.name}
|
||||
readOnly={formType === 'detail'}
|
||||
/>
|
||||
<SelectInput
|
||||
required
|
||||
placeholder='Pilih PIC'
|
||||
label='PIC'
|
||||
value={formik.values.pic ?? undefined}
|
||||
onChange={picChangeHandler}
|
||||
options={picOptions}
|
||||
onInputChange={setPicSelectInputValue}
|
||||
isLoading={isLoadingPic}
|
||||
isError={formik.touched.picId && Boolean(formik.errors.picId)}
|
||||
errorMessage={formik.errors.picId as string}
|
||||
isDisabled={formType === 'detail'}
|
||||
isClearable
|
||||
isSearchable={true}
|
||||
/>
|
||||
<SelectInput
|
||||
required
|
||||
placeholder='Pilih Tipe'
|
||||
label='Tipe'
|
||||
value={
|
||||
typeOptions.find(
|
||||
(item) => item.value === formik.values.type?.value
|
||||
) ?? undefined
|
||||
}
|
||||
onChange={typeChangeHandler}
|
||||
options={typeOptions}
|
||||
onInputChange={setTypeSelectInputValue}
|
||||
isError={formik.touched.type && Boolean(formik.errors.type)}
|
||||
errorMessage={formik.errors.type as string}
|
||||
isDisabled={formType === 'detail'}
|
||||
isClearable
|
||||
isSearchable={true}
|
||||
/>
|
||||
<TextInput
|
||||
required
|
||||
label='Email'
|
||||
name='email'
|
||||
placeholder='Masukkan email customer'
|
||||
value={formik.values.email}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={formik.touched.email && Boolean(formik.errors.email)}
|
||||
errorMessage={formik.errors.email}
|
||||
readOnly={formType === 'detail'}
|
||||
/>
|
||||
<TextInput
|
||||
required
|
||||
label='Nomor Telepon'
|
||||
name='phone'
|
||||
placeholder='Masukkan nomor telepon customer'
|
||||
value={formik.values.phone}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={formik.touched.phone && Boolean(formik.errors.phone)}
|
||||
errorMessage={formik.errors.phone}
|
||||
readOnly={formType === 'detail'}
|
||||
/>
|
||||
<TextInput
|
||||
required
|
||||
label='Nomor Rekening'
|
||||
name='account_number'
|
||||
placeholder='Masukkan nomor rekening customer'
|
||||
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={formType === 'detail'}
|
||||
/>
|
||||
<TextArea
|
||||
required
|
||||
label='Alamat'
|
||||
name='address'
|
||||
placeholder='Masukkan alamat customer'
|
||||
value={formik.values.address}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={formik.touched.address && Boolean(formik.errors.address)}
|
||||
errorMessage={formik.errors.address}
|
||||
readOnly={formType === 'detail'}
|
||||
cols={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Button */}
|
||||
<div className='flex flex-row justify-between gap-2 flex-wrap'>
|
||||
{formType !== 'add' && (
|
||||
<div className='flex flex-row justify-start gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
color='error'
|
||||
onClick={deleteCustomerHandler}
|
||||
className='px-4'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:delete-outline-rounded'
|
||||
width={24}
|
||||
height={24}
|
||||
className='justify-start text-sm'
|
||||
/>
|
||||
Delete
|
||||
</Button>
|
||||
|
||||
{formType !== 'edit' && (
|
||||
<Button
|
||||
type='button'
|
||||
color='warning'
|
||||
href={`/master-data/customer/detail/edit/?customerId=${initialValues?.id}`}
|
||||
className='px-4'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:edit-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
className='justify-start text-sm'
|
||||
/>
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formType !== 'detail' && (
|
||||
<div
|
||||
className={cn('flex flex-row justify-end gap-2', {
|
||||
'w-full': formType === '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>
|
||||
|
||||
{customerFormErrorMessage && (
|
||||
<div role='alert' className='alert alert-error'>
|
||||
<Icon
|
||||
icon='material-symbols:error-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span>{customerFormErrorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{formType !== 'add' && (
|
||||
<ConfirmationModal
|
||||
ref={deleteModal.ref}
|
||||
type='error'
|
||||
text={`Apakah anda yakin ingin menghapus data Customer ini (${initialValues?.name})?`}
|
||||
secondaryButton={{
|
||||
text: 'Tidak',
|
||||
}}
|
||||
primaryButton={{
|
||||
text: 'Ya',
|
||||
color: 'error',
|
||||
onClick: confirmationModalDeleteclickHandler,
|
||||
isLoading: isDeleteLoading,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomerForm;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,318 @@
|
||||
'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 { Kandang } from '@/types/api/master-data/kandang';
|
||||
import { KandangApi } 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<Kandang, 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/kandang/detail/?kandangId=${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/kandang/detail/edit/?kandangId=${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 KandangsTable = () => {
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
initial: { search: '', nameSort: '', locationSort: '', picSort: '' },
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
nameSort: 'sort_name',
|
||||
locationSort: 'sort_location',
|
||||
picSort: ' sort_pic',
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
data: kandangs,
|
||||
isLoading,
|
||||
mutate: refreshKandangs,
|
||||
} = useSWR(
|
||||
`${KandangApi.basePath}${getTableFilterQueryString()}`,
|
||||
KandangApi.getAllFetcher
|
||||
);
|
||||
|
||||
const deleteModal = useModal();
|
||||
|
||||
const [selectedKandang, setSelectedKandang] = useState<Kandang | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const kandangsColumns: ColumnDef<Kandang>[] = [
|
||||
{
|
||||
header: '#',
|
||||
cell: (props) =>
|
||||
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
||||
props.row.index +
|
||||
1,
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Nama',
|
||||
},
|
||||
{
|
||||
accessorKey: 'location',
|
||||
header: 'Lokasi',
|
||||
cell: (props) => props.row.original.location.name,
|
||||
},
|
||||
{
|
||||
accessorKey: 'pic',
|
||||
header: 'PIC',
|
||||
cell: (props) => props.row.original.pic.name,
|
||||
},
|
||||
{
|
||||
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 = () => {
|
||||
setSelectedKandang(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 KandangApi.delete(selectedKandang?.id as number);
|
||||
refreshKandangs();
|
||||
|
||||
deleteModal.closeModal();
|
||||
toast.success('Successfully delete Kandang!');
|
||||
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, updateSortingFilter]);
|
||||
|
||||
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/kandang/add' color='primary'>
|
||||
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||
Tambah Kandang
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DebouncedTextInput
|
||||
name='search'
|
||||
placeholder='Cari Kandang'
|
||||
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<Kandang>
|
||||
data={isResponseSuccess(kandangs) ? kandangs?.data : []}
|
||||
columns={kandangsColumns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
page={isResponseSuccess(kandangs) ? kandangs?.meta?.page : 0}
|
||||
totalItems={
|
||||
isResponseSuccess(kandangs) ? kandangs?.meta?.total_results : 0
|
||||
}
|
||||
onPageChange={setPage}
|
||||
isLoading={isLoading}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
className={{
|
||||
containerClassName: cn({
|
||||
'mb-20':
|
||||
isResponseSuccess(kandangs) && kandangs?.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 Kandang ini (${selectedKandang?.name})?`}
|
||||
secondaryButton={{
|
||||
text: 'Tidak',
|
||||
}}
|
||||
primaryButton={{
|
||||
text: 'Ya',
|
||||
color: 'error',
|
||||
isLoading: isDeleteLoading,
|
||||
onClick: confirmationModalDeleteClickHandler,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default KandangsTable;
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export const KandangFormSchema = Yup.object({
|
||||
name: Yup.string().required('Nama wajib diisi!'),
|
||||
|
||||
locationId: Yup.number()
|
||||
.min(1, 'Lokasi wajib diisi!')
|
||||
.required('Lokasi wajib diisi!'),
|
||||
location: Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
}).nullable(),
|
||||
|
||||
picId: Yup.number().min(1, 'PIC wajib diisi!').required('PIC wajib diisi!'),
|
||||
pic: Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
}).nullable(),
|
||||
});
|
||||
|
||||
export const UpdateKandangFormSchema = KandangFormSchema;
|
||||
|
||||
export type KandangFormValues = Yup.InferType<typeof KandangFormSchema>;
|
||||
@@ -0,0 +1,360 @@
|
||||
'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 {
|
||||
KandangFormSchema,
|
||||
KandangFormValues,
|
||||
UpdateKandangFormSchema,
|
||||
} from '@/components/pages/master-data/kandang/form/KandangForm.schema';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import {
|
||||
Kandang,
|
||||
CreateKandangPayload,
|
||||
UpdateKandangPayload,
|
||||
} from '@/types/api/master-data/kandang';
|
||||
import { LocationApi, KandangApi } from '@/services/api/master-data';
|
||||
import { cn } from '@/lib/helper';
|
||||
import { UserApi } from '@/services/api/user';
|
||||
|
||||
interface KandangFormProps {
|
||||
type?: 'add' | 'edit' | 'detail';
|
||||
initialValues?: Kandang;
|
||||
}
|
||||
|
||||
const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
|
||||
const router = useRouter();
|
||||
const deleteModal = useModal();
|
||||
|
||||
const [kandangFormErrorMessage, setKandangFormErrorMessage] = useState('');
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const createKandangHandler = useCallback(
|
||||
async (payload: CreateKandangPayload) => {
|
||||
const createKandangRes = await KandangApi.create(payload);
|
||||
|
||||
if (isResponseError(createKandangRes)) {
|
||||
setKandangFormErrorMessage(createKandangRes.message);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(createKandangRes?.message as string);
|
||||
router.push('/master-data/kandang');
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const updateKandangHandler = useCallback(
|
||||
async (kandangId: number, payload: UpdateKandangPayload) => {
|
||||
const updateKandangRes = await KandangApi.update(kandangId, payload);
|
||||
|
||||
if (updateKandangRes?.status === 'error') {
|
||||
setKandangFormErrorMessage(updateKandangRes.message);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(updateKandangRes?.message as string);
|
||||
router.refresh();
|
||||
router.push('/master-data/kandang');
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const formikInitialValues = useMemo<KandangFormValues>(() => {
|
||||
return {
|
||||
name: initialValues?.name ?? '',
|
||||
locationId: initialValues?.location?.id ?? 0,
|
||||
location: initialValues?.location
|
||||
? {
|
||||
value: initialValues.location.id,
|
||||
label: initialValues.location.name,
|
||||
}
|
||||
: null,
|
||||
picId: initialValues?.pic?.id ?? 0,
|
||||
pic: initialValues?.pic
|
||||
? {
|
||||
value: initialValues.pic.id,
|
||||
label: initialValues.pic.name,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}, [initialValues]);
|
||||
|
||||
const formik = useFormik<KandangFormValues>({
|
||||
initialValues: formikInitialValues,
|
||||
validationSchema:
|
||||
type === 'edit' ? UpdateKandangFormSchema : KandangFormSchema,
|
||||
onSubmit: async (values) => {
|
||||
setKandangFormErrorMessage('');
|
||||
|
||||
const kandangPayload: CreateKandangPayload = {
|
||||
name: values.name,
|
||||
location_id: values.locationId,
|
||||
pic_id: values.picId,
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case 'add':
|
||||
await createKandangHandler(kandangPayload);
|
||||
break;
|
||||
|
||||
case 'edit':
|
||||
await updateKandangHandler(
|
||||
initialValues?.id as number,
|
||||
kandangPayload
|
||||
);
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const { setValues: formikSetValues } = formik;
|
||||
|
||||
// location
|
||||
const [locationSelectInputValue, setLocationSelectInputValue] = useState('');
|
||||
|
||||
const locationsUrl = `${LocationApi.basePath}?${new URLSearchParams({
|
||||
search: locationSelectInputValue ?? '',
|
||||
}).toString()}`;
|
||||
|
||||
const { data: locations, isLoading: isLoadingLocations } = useSWR(
|
||||
locationsUrl,
|
||||
LocationApi.getAllFetcher
|
||||
);
|
||||
|
||||
const locationOptions = isResponseSuccess(locations)
|
||||
? locations?.data.map((location) => ({
|
||||
value: location.id,
|
||||
label: location.name,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
formik.setFieldTouched('location', true);
|
||||
formik.setFieldValue('location', val);
|
||||
|
||||
formik.setFieldTouched('locationId', true);
|
||||
formik.setFieldValue('locationId', (val as OptionType)?.value);
|
||||
};
|
||||
|
||||
// PIC
|
||||
const [picSelectInputValue, setPicSelectInputValue] = useState('');
|
||||
|
||||
const picsUrl = `${UserApi.basePath}?${new URLSearchParams({
|
||||
search: picSelectInputValue ?? '',
|
||||
}).toString()}`;
|
||||
|
||||
const { data: pics, isLoading: isLoadingPics } = useSWR(
|
||||
picsUrl,
|
||||
LocationApi.getAllFetcher
|
||||
);
|
||||
|
||||
const picOptions = isResponseSuccess(pics)
|
||||
? pics?.data.map((pic) => ({
|
||||
value: pic.id,
|
||||
label: pic.name,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const picChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
formik.setFieldTouched('pic', true);
|
||||
formik.setFieldValue('pic', val);
|
||||
|
||||
formik.setFieldTouched('picId', true);
|
||||
formik.setFieldValue('picId', (val as OptionType)?.value);
|
||||
};
|
||||
|
||||
const deleteKandangClickHandler = () => {
|
||||
deleteModal.openModal();
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
|
||||
await KandangApi.delete(initialValues?.id as number);
|
||||
|
||||
deleteModal.closeModal();
|
||||
toast.success('Successfully delete Kandang!');
|
||||
setIsDeleteLoading(false);
|
||||
router.push('/master-data/kandang');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
formikSetValues(formikInitialValues);
|
||||
}, [formikSetValues, formikInitialValues]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className='w-full max-w-xl'>
|
||||
<header className='flex flex-col gap-4'>
|
||||
<Button
|
||||
href='/master-data/kandang'
|
||||
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 Kandang'}
|
||||
{type === 'edit' && 'Edit Kandang'}
|
||||
{type === 'detail' && 'Detail Kandang'}
|
||||
</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 lokasi'
|
||||
value={formik.values.name}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={formik.touched.name && Boolean(formik.errors.name)}
|
||||
errorMessage={formik.errors.name}
|
||||
readOnly={type === 'detail'}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
required
|
||||
label='Lokasi'
|
||||
value={formik.values.location ?? undefined}
|
||||
onChange={locationChangeHandler}
|
||||
options={locationOptions}
|
||||
onInputChange={setLocationSelectInputValue}
|
||||
isLoading={isLoadingLocations}
|
||||
isError={
|
||||
formik.touched.locationId && Boolean(formik.errors.locationId)
|
||||
}
|
||||
errorMessage={formik.errors.locationId as string}
|
||||
isDisabled={type === 'detail'}
|
||||
isClearable
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
required
|
||||
label='PIC'
|
||||
value={formik.values.pic ?? undefined}
|
||||
onChange={picChangeHandler}
|
||||
options={picOptions}
|
||||
onInputChange={setPicSelectInputValue}
|
||||
isLoading={isLoadingPics}
|
||||
isError={formik.touched.picId && Boolean(formik.errors.picId)}
|
||||
errorMessage={formik.errors.picId 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={deleteKandangClickHandler}
|
||||
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/kandang/detail/edit/?kandangId=${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>
|
||||
|
||||
{kandangFormErrorMessage && (
|
||||
<div role='alert' className='alert alert-error'>
|
||||
<Icon
|
||||
icon='material-symbols:error-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span>{kandangFormErrorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{type !== 'add' && (
|
||||
<ConfirmationModal
|
||||
ref={deleteModal.ref}
|
||||
type='error'
|
||||
text={`Apakah anda yakin ingin menghapus data Kandang ini (${initialValues?.name})?`}
|
||||
secondaryButton={{
|
||||
text: 'Tidak',
|
||||
}}
|
||||
primaryButton={{
|
||||
text: 'Ya',
|
||||
color: 'error',
|
||||
isLoading: isDeleteLoading,
|
||||
onClick: confirmationModalDeleteClickHandler,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default KandangForm;
|
||||
@@ -0,0 +1,317 @@
|
||||
'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 { Location } from '@/types/api/master-data/location';
|
||||
import { LocationApi } 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<Location, 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/location/detail/?locationId=${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/location/detail/edit/?locationId=${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 LocationsTable = () => {
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
initial: { search: '', nameSort: '', addressSort: '', areaSort: '' },
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
nameSort: 'sort_name',
|
||||
addressSort: 'sort_address',
|
||||
areaSort: ' sort_area',
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
data: locations,
|
||||
isLoading,
|
||||
mutate: refreshLocations,
|
||||
} = useSWR(
|
||||
`${LocationApi.basePath}${getTableFilterQueryString()}`,
|
||||
LocationApi.getAllFetcher
|
||||
);
|
||||
|
||||
const deleteModal = useModal();
|
||||
|
||||
const [selectedLocation, setSelectedLocation] = useState<
|
||||
Location | undefined
|
||||
>(undefined);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const locationsColumns: ColumnDef<Location>[] = [
|
||||
{
|
||||
header: '#',
|
||||
cell: (props) =>
|
||||
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
||||
props.row.index +
|
||||
1,
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Nama',
|
||||
},
|
||||
{
|
||||
accessorKey: 'address',
|
||||
header: 'Alamat',
|
||||
},
|
||||
{
|
||||
accessorKey: 'area',
|
||||
header: 'Area',
|
||||
cell: (props) => props.row.original.area.name,
|
||||
},
|
||||
{
|
||||
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 = () => {
|
||||
setSelectedLocation(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 LocationApi.delete(selectedLocation?.id as number);
|
||||
refreshLocations();
|
||||
|
||||
deleteModal.closeModal();
|
||||
toast.success('Successfully delete Location!');
|
||||
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 addressSortFilter = sorting.find(
|
||||
(sortItem) => sortItem.id === 'address'
|
||||
);
|
||||
const areaSortFilter = sorting.find((sortItem) => sortItem.id === 'area');
|
||||
|
||||
updateSortingFilter('nameSort', nameSortFilter);
|
||||
updateSortingFilter('addressSort', addressSortFilter);
|
||||
updateSortingFilter('areaSort', areaSortFilter);
|
||||
}, [sorting, updateSortingFilter]);
|
||||
|
||||
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/location/add' color='primary'>
|
||||
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||
Tambah Location
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DebouncedTextInput
|
||||
name='search'
|
||||
placeholder='Cari Location'
|
||||
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<Location>
|
||||
data={isResponseSuccess(locations) ? locations?.data : []}
|
||||
columns={locationsColumns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
page={isResponseSuccess(locations) ? locations?.meta?.page : 0}
|
||||
totalItems={
|
||||
isResponseSuccess(locations) ? locations?.meta?.total_results : 0
|
||||
}
|
||||
onPageChange={setPage}
|
||||
isLoading={isLoading}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
className={{
|
||||
containerClassName: cn({
|
||||
'mb-20':
|
||||
isResponseSuccess(locations) && locations?.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 Location ini (${selectedLocation?.name})?`}
|
||||
secondaryButton={{
|
||||
text: 'Tidak',
|
||||
}}
|
||||
primaryButton={{
|
||||
text: 'Ya',
|
||||
color: 'error',
|
||||
isLoading: isDeleteLoading,
|
||||
onClick: confirmationModalDeleteClickHandler,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LocationsTable;
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export const LocationFormSchema = Yup.object({
|
||||
name: Yup.string().required('Nama wajib diisi!'),
|
||||
address: Yup.string().required('Alamat wajib diisi!'),
|
||||
|
||||
areaId: Yup.number()
|
||||
.min(1, 'Area wajib diisi!')
|
||||
.required('Area wajib diisi!'),
|
||||
area: Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
}).nullable(),
|
||||
});
|
||||
|
||||
export const UpdateLocationFormSchema = LocationFormSchema;
|
||||
|
||||
export type LocationFormValues = Yup.InferType<typeof LocationFormSchema>;
|
||||
@@ -0,0 +1,322 @@
|
||||
'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 {
|
||||
LocationFormSchema,
|
||||
LocationFormValues,
|
||||
UpdateLocationFormSchema,
|
||||
} from '@/components/pages/master-data/location/form/LocationForm.schema';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import {
|
||||
Location,
|
||||
CreateLocationPayload,
|
||||
UpdateLocationPayload,
|
||||
} from '@/types/api/master-data/location';
|
||||
import { AreaApi, LocationApi } from '@/services/api/master-data';
|
||||
import { cn } from '@/lib/helper';
|
||||
|
||||
interface LocationFormProps {
|
||||
type?: 'add' | 'edit' | 'detail';
|
||||
initialValues?: Location;
|
||||
}
|
||||
|
||||
const LocationForm = ({ type = 'add', initialValues }: LocationFormProps) => {
|
||||
const router = useRouter();
|
||||
const deleteModal = useModal();
|
||||
|
||||
const [locationFormErrorMessage, setLocationFormErrorMessage] = useState('');
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const createLocationHandler = useCallback(
|
||||
async (payload: CreateLocationPayload) => {
|
||||
const createLocationRes = await LocationApi.create(payload);
|
||||
|
||||
if (isResponseError(createLocationRes)) {
|
||||
setLocationFormErrorMessage(createLocationRes.message);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(createLocationRes?.message as string);
|
||||
router.push('/master-data/location');
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const updateLocationHandler = useCallback(
|
||||
async (locationId: number, payload: UpdateLocationPayload) => {
|
||||
const updateLocationRes = await LocationApi.update(locationId, payload);
|
||||
|
||||
if (updateLocationRes?.status === 'error') {
|
||||
setLocationFormErrorMessage(updateLocationRes.message);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(updateLocationRes?.message as string);
|
||||
router.refresh();
|
||||
router.push('/master-data/location');
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const formikInitialValues = useMemo<LocationFormValues>(() => {
|
||||
return {
|
||||
name: initialValues?.name ?? '',
|
||||
address: initialValues?.address ?? '',
|
||||
areaId: initialValues?.area?.id ?? 0,
|
||||
area: initialValues?.area
|
||||
? {
|
||||
value: initialValues.area.id,
|
||||
label: initialValues.area.name,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}, [initialValues]);
|
||||
|
||||
const formik = useFormik<LocationFormValues>({
|
||||
initialValues: formikInitialValues,
|
||||
validationSchema:
|
||||
type === 'edit' ? UpdateLocationFormSchema : LocationFormSchema,
|
||||
onSubmit: async (values) => {
|
||||
setLocationFormErrorMessage('');
|
||||
|
||||
const locationPayload: CreateLocationPayload = {
|
||||
name: values.name,
|
||||
address: values.address,
|
||||
area_id: values.areaId,
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case 'add':
|
||||
await createLocationHandler(locationPayload);
|
||||
break;
|
||||
|
||||
case 'edit':
|
||||
await updateLocationHandler(
|
||||
initialValues?.id as number,
|
||||
locationPayload
|
||||
);
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const { setValues: formikSetValues } = formik;
|
||||
|
||||
const [areaSelectInputValue, setAreaSelectInputValue] = useState('');
|
||||
|
||||
const areasUrl = `${AreaApi.basePath}?${new URLSearchParams({
|
||||
search: areaSelectInputValue ?? '',
|
||||
}).toString()}`;
|
||||
|
||||
const { data: areas, isLoading: isLoadingAreas } = useSWR(
|
||||
areasUrl,
|
||||
AreaApi.getAllFetcher
|
||||
);
|
||||
|
||||
const areaOptions = isResponseSuccess(areas)
|
||||
? areas?.data.map((area) => ({
|
||||
value: area.id,
|
||||
label: area.name,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const areaChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
formik.setFieldTouched('area', true);
|
||||
formik.setFieldValue('area', val);
|
||||
|
||||
formik.setFieldTouched('areaId', true);
|
||||
formik.setFieldValue('areaId', (val as OptionType)?.value);
|
||||
};
|
||||
|
||||
const deleteLocationClickHandler = () => {
|
||||
deleteModal.openModal();
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
|
||||
await LocationApi.delete(initialValues?.id as number);
|
||||
|
||||
deleteModal.closeModal();
|
||||
toast.success('Successfully delete Location!');
|
||||
setIsDeleteLoading(false);
|
||||
router.push('/master-data/location');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
formikSetValues(formikInitialValues);
|
||||
}, [formikSetValues, formikInitialValues]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className='w-full max-w-xl'>
|
||||
<header className='flex flex-col gap-4'>
|
||||
<Button
|
||||
href='/master-data/location'
|
||||
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 Location'}
|
||||
{type === 'edit' && 'Edit Location'}
|
||||
{type === 'detail' && 'Detail Location'}
|
||||
</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 lokasi'
|
||||
value={formik.values.name}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={formik.touched.name && Boolean(formik.errors.name)}
|
||||
errorMessage={formik.errors.name}
|
||||
readOnly={type === 'detail'}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
required
|
||||
label='Alamat'
|
||||
name='address'
|
||||
placeholder='Masukkan alamat lokasi '
|
||||
value={formik.values.address}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={formik.touched.address && Boolean(formik.errors.address)}
|
||||
errorMessage={formik.errors.address}
|
||||
readOnly={type === 'detail'}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
required
|
||||
label='Area'
|
||||
value={formik.values.area ?? undefined}
|
||||
onChange={areaChangeHandler}
|
||||
options={areaOptions}
|
||||
onInputChange={setAreaSelectInputValue}
|
||||
isLoading={isLoadingAreas}
|
||||
isError={formik.touched.areaId && Boolean(formik.errors.areaId)}
|
||||
errorMessage={formik.errors.areaId 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={deleteLocationClickHandler}
|
||||
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/location/detail/edit/?locationId=${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>
|
||||
|
||||
{locationFormErrorMessage && (
|
||||
<div role='alert' className='alert alert-error'>
|
||||
<Icon
|
||||
icon='material-symbols:error-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span>{locationFormErrorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{type !== 'add' && (
|
||||
<ConfirmationModal
|
||||
ref={deleteModal.ref}
|
||||
type='error'
|
||||
text={`Apakah anda yakin ingin menghapus data Location ini (${initialValues?.name})?`}
|
||||
secondaryButton={{
|
||||
text: 'Tidak',
|
||||
}}
|
||||
primaryButton={{
|
||||
text: 'Ya',
|
||||
color: 'error',
|
||||
isLoading: isDeleteLoading,
|
||||
onClick: confirmationModalDeleteClickHandler,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LocationForm;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user