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:
Adnan Zahir
2025-10-09 17:43:27 +07:00
147 changed files with 12456 additions and 91 deletions
+6
View File
@@ -39,3 +39,9 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# prettier
.prettierrc
# idea
.idea
+15
View File
@@ -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"
}
+40
View File
@@ -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
View File
@@ -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"
},
+9
View File
@@ -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
View File
@@ -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>
);
+11
View File
@@ -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;
+47
View File
@@ -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;
+11
View File
@@ -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;
+11
View File
@@ -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;
+47
View File
@@ -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;
+11
View File
@@ -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;
+11
View File
@@ -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;
+11
View File
@@ -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;
+11
View File
@@ -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;
+11
View File
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
+52
View File
@@ -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;
+11
View File
@@ -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;
+11
View File
@@ -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;
+11
View File
@@ -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;
+11
View File
@@ -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;
+11
View File
@@ -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;
+11
View File
@@ -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;
+11
View File
@@ -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;
+11
View File
@@ -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;
+11
View File
@@ -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;
+11
View File
@@ -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;
+11
View File
@@ -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;
+11
View File
@@ -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;
+11
View File
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
+46
View File
@@ -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;
+11
View File
@@ -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;
+11
View File
@@ -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;
+4
View File
@@ -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>
+27
View File
@@ -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;
+5 -3
View File
@@ -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>
)}
</>
+131
View File
@@ -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;
+238
View File
@@ -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;
+62
View File
@@ -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;
+13 -2
View File
@@ -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
View File
@@ -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>
)}
+197
View File
@@ -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;
+23
View File
@@ -0,0 +1,23 @@
'use client';
import { Suspense } from 'react';
const SuspenseHelper = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return (
<Suspense
fallback={
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
}
>
{children}
</Suspense>
);
};
export default SuspenseHelper;
@@ -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;
+4 -2
View File
@@ -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');
+21 -2
View File
@@ -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}
+169
View File
@@ -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;
+124
View File
@@ -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;
+3 -1
View File
@@ -122,7 +122,9 @@ const TextInput = ({
{!isError && bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
)}
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
{isError && errorMessage && (
<p className='w-full text-sm text-error'>{errorMessage}</p>
)}
</div>
);
};
+1 -1
View File
@@ -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,
})}
+124
View File
@@ -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