fix(FE): resolve merge conflict checkbox and constant

This commit is contained in:
randy-ar
2025-12-10 10:09:56 +07:00
24 changed files with 1733 additions and 525 deletions
+52 -52
View File
@@ -15,7 +15,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"formik": "^2.4.6", "formik": "^2.4.6",
"moment": "^2.30.1", "moment": "^2.30.1",
"next": "15.5.3", "next": "^15.5.7",
"react": "19.1.0", "react": "19.1.0",
"react-day-picker": "^9.11.1", "react-day-picker": "^9.11.1",
"react-dom": "19.1.0", "react-dom": "19.1.0",
@@ -36,9 +36,9 @@
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"daisyui": "^5.1.12", "daisyui": "^5.5.8",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.5.3", "eslint-config-next": "^15.5.7",
"husky": "^9.1.7", "husky": "^9.1.7",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"tailwindcss": "^4", "tailwindcss": "^4",
@@ -1082,15 +1082,15 @@
} }
}, },
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "15.5.3", "version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.3.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.7.tgz",
"integrity": "sha512-RSEDTRqyihYXygx/OJXwvVupfr9m04+0vH8vyy0HfZ7keRto6VX9BbEk0J2PUk0VGy6YhklJUSrgForov5F9pw==", "integrity": "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@next/eslint-plugin-next": { "node_modules/@next/eslint-plugin-next": {
"version": "15.5.3", "version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.3.tgz", "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.7.tgz",
"integrity": "sha512-SdhaKdko6dpsSr0DldkESItVrnPYB1NS2NpShCSX5lc7SSQmLZt5Mug6t2xbiuVWEVDLZSuIAoQyYVBYp0dR5g==", "integrity": "sha512-DtRU2N7BkGr8r+pExfuWHwMEPX5SD57FeA6pxdgCHODo+b/UgIgjE+rgWKtJAbEbGhVZ2jtHn4g3wNhWFoNBQQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1098,9 +1098,9 @@
} }
}, },
"node_modules/@next/swc-darwin-arm64": { "node_modules/@next/swc-darwin-arm64": {
"version": "15.5.3", "version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz",
"integrity": "sha512-nzbHQo69+au9wJkGKTU9lP7PXv0d1J5ljFpvb+LnEomLtSbJkbZyEs6sbF3plQmiOB2l9OBtN2tNSvCH1nQ9Jg==", "integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1114,9 +1114,9 @@
} }
}, },
"node_modules/@next/swc-darwin-x64": { "node_modules/@next/swc-darwin-x64": {
"version": "15.5.3", "version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz",
"integrity": "sha512-w83w4SkOOhekJOcA5HBvHyGzgV1W/XvOfpkrxIse4uPWhYTTRwtGEM4v/jiXwNSJvfRvah0H8/uTLBKRXlef8g==", "integrity": "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1130,9 +1130,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-gnu": { "node_modules/@next/swc-linux-arm64-gnu": {
"version": "15.5.3", "version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz",
"integrity": "sha512-+m7pfIs0/yvgVu26ieaKrifV8C8yiLe7jVp9SpcIzg7XmyyNE7toC1fy5IOQozmr6kWl/JONC51osih2RyoXRw==", "integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1146,9 +1146,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-musl": { "node_modules/@next/swc-linux-arm64-musl": {
"version": "15.5.3", "version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz",
"integrity": "sha512-u3PEIzuguSenoZviZJahNLgCexGFhso5mxWCrrIMdvpZn6lkME5vc/ADZG8UUk5K1uWRy4hqSFECrON6UKQBbQ==", "integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1162,9 +1162,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-gnu": { "node_modules/@next/swc-linux-x64-gnu": {
"version": "15.5.3", "version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz",
"integrity": "sha512-lDtOOScYDZxI2BENN9m0pfVPJDSuUkAD1YXSvlJF0DKwZt0WlA7T7o3wrcEr4Q+iHYGzEaVuZcsIbCps4K27sA==", "integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1178,9 +1178,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-musl": { "node_modules/@next/swc-linux-x64-musl": {
"version": "15.5.3", "version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz",
"integrity": "sha512-9vWVUnsx9PrY2NwdVRJ4dUURAQ8Su0sLRPqcCCxtX5zIQUBES12eRVHq6b70bbfaVaxIDGJN2afHui0eDm+cLg==", "integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1194,9 +1194,9 @@
} }
}, },
"node_modules/@next/swc-win32-arm64-msvc": { "node_modules/@next/swc-win32-arm64-msvc": {
"version": "15.5.3", "version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz",
"integrity": "sha512-1CU20FZzY9LFQigRi6jM45oJMU3KziA5/sSG+dXeVaTm661snQP6xu3ykGxxwU5sLG3sh14teO/IOEPVsQMRfA==", "integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1210,9 +1210,9 @@
} }
}, },
"node_modules/@next/swc-win32-x64-msvc": { "node_modules/@next/swc-win32-x64-msvc": {
"version": "15.5.3", "version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz",
"integrity": "sha512-JMoLAq3n3y5tKXPQwCK5c+6tmwkuFDa2XAxz8Wm4+IVthdBZdZGh+lmiLUHg9f9IDwIQpUjp+ysd6OkYTyZRZw==", "integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -3063,9 +3063,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/daisyui": { "node_modules/daisyui": {
"version": "5.3.10", "version": "5.5.8",
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.3.10.tgz", "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.8.tgz",
"integrity": "sha512-vmjyPmm0hvFhA95KB6uiGmWakziB2pBv6CUcs5Ka/3iMBMn9S+C3SZYx9G9l2JrgTZ1EFn61F/HrPcwaUm2kLQ==", "integrity": "sha512-6psL9jIEOFOw68V10j/BKCWcRgx8dh81mmNxShr+g7HDM6UHNoPharlp9zq/PQkHNuGU1ZQsajR3HgpvavbRKQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
@@ -3571,13 +3571,13 @@
} }
}, },
"node_modules/eslint-config-next": { "node_modules/eslint-config-next": {
"version": "15.5.3", "version": "15.5.7",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.3.tgz", "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.7.tgz",
"integrity": "sha512-e6j+QhQFOr5pfsc8VJbuTD9xTXJaRvMHYjEeLPA2pFkheNlgPLCkxdvhxhfuM4KGcqSZj2qEnpHisdTVs3BxuQ==", "integrity": "sha512-nU/TRGHHeG81NeLW5DeQT5t6BDUqbpsNQTvef1ld/tqHT+/zTx60/TIhKnmPISTTe++DVo+DLxDmk4rnwHaZVw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@next/eslint-plugin-next": "15.5.3", "@next/eslint-plugin-next": "15.5.7",
"@rushstack/eslint-patch": "^1.10.3", "@rushstack/eslint-patch": "^1.10.3",
"@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
"@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
@@ -5654,12 +5654,12 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/next": { "node_modules/next": {
"version": "15.5.3", "version": "15.5.7",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.3.tgz", "resolved": "https://registry.npmjs.org/next/-/next-15.5.7.tgz",
"integrity": "sha512-r/liNAx16SQj4D+XH/oI1dlpv9tdKJ6cONYPwwcCC46f2NjpaRWY+EKCzULfgQYV6YKXjHBchff2IZBSlZmJNw==", "integrity": "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@next/env": "15.5.3", "@next/env": "15.5.7",
"@swc/helpers": "0.5.15", "@swc/helpers": "0.5.15",
"caniuse-lite": "^1.0.30001579", "caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31", "postcss": "8.4.31",
@@ -5672,14 +5672,14 @@
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0" "node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@next/swc-darwin-arm64": "15.5.3", "@next/swc-darwin-arm64": "15.5.7",
"@next/swc-darwin-x64": "15.5.3", "@next/swc-darwin-x64": "15.5.7",
"@next/swc-linux-arm64-gnu": "15.5.3", "@next/swc-linux-arm64-gnu": "15.5.7",
"@next/swc-linux-arm64-musl": "15.5.3", "@next/swc-linux-arm64-musl": "15.5.7",
"@next/swc-linux-x64-gnu": "15.5.3", "@next/swc-linux-x64-gnu": "15.5.7",
"@next/swc-linux-x64-musl": "15.5.3", "@next/swc-linux-x64-musl": "15.5.7",
"@next/swc-win32-arm64-msvc": "15.5.3", "@next/swc-win32-arm64-msvc": "15.5.7",
"@next/swc-win32-x64-msvc": "15.5.3", "@next/swc-win32-x64-msvc": "15.5.7",
"sharp": "^0.34.3" "sharp": "^0.34.3"
}, },
"peerDependencies": { "peerDependencies": {
+3 -3
View File
@@ -18,7 +18,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"formik": "^2.4.6", "formik": "^2.4.6",
"moment": "^2.30.1", "moment": "^2.30.1",
"next": "15.5.3", "next": "^15.5.7",
"react": "19.1.0", "react": "19.1.0",
"react-day-picker": "^9.11.1", "react-day-picker": "^9.11.1",
"react-dom": "19.1.0", "react-dom": "19.1.0",
@@ -39,9 +39,9 @@
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"daisyui": "^5.1.12", "daisyui": "^5.5.8",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.5.3", "eslint-config-next": "^15.5.7",
"husky": "^9.1.7", "husky": "^9.1.7",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"tailwindcss": "^4", "tailwindcss": "^4",
+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;
+50
View File
@@ -0,0 +1,50 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import ClosingDetail from '@/components/pages/closing/ClosingDetail';
import { ClosingApi } from '@/services/api/closing';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
const ClosingDetailPage = () => {
const router = useRouter();
const searchParams = useSearchParams();
const closingId = searchParams.get('closingId');
const { data: closing, isLoading: isLoadingClosing } = useSWR(
closingId,
(id: number) => ClosingApi.getGeneralInfo(id)
);
if (!closingId) {
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 (!isLoadingClosing && (!closing || isResponseError(closing))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingClosing && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingClosing && isResponseSuccess(closing) && (
<ClosingDetail id={Number(closingId)} initialValue={closing.data} />
)}
</div>
);
};
export default ClosingDetailPage;
+11
View File
@@ -0,0 +1,11 @@
import ClosingsTable from '@/components/pages/closing/ClosingsTable';
const Closing = () => {
return (
<section className='w-full p-4'>
<ClosingsTable />
</section>
);
};
export default Closing;
+6
View File
@@ -56,6 +56,12 @@
@theme { @theme {
--font-inter: var(--font-inter); --font-inter: var(--font-inter);
--container-sm: 40rem;
--container-md: 48rem;
--container-lg: 64rem;
--container-xl: 80rem;
--container-2xl: 96rem;
} }
html { html {
+7 -147
View File
@@ -1,161 +1,21 @@
'use client'; 'use client';
import { useCallback, useState } from 'react'; import { useCallback } from 'react';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import Image from 'next/image'; import Image from 'next/image';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Drawer from '@/components/Drawer'; import Drawer from '@/components/Drawer';
import Menu from '@/components/menu/Menu';
import MenuItem from '@/components/menu/MenuItem';
import Navbar from '@/components/Navbar'; import Navbar from '@/components/Navbar';
import Collapse from '@/components/Collapse';
import Button from '@/components/Button'; import Button from '@/components/Button';
import SidebarMenu from '@/components/molecules/SidebarMenu';
import { useUiStore } from '@/stores/ui/ui.store'; import { useUiStore } from '@/stores/ui/ui.store';
import { MAIN_DRAWER_LINKS } from '@/config/constant'; import { MAIN_DRAWER_LINKS } from '@/config/constant';
import { cn } from '@/lib/helper'; import { isPathActive } 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 = () => { const MainDrawerContent = () => {
const pathname = usePathname();
const { setMainDrawerOpen } = useUiStore(); const { setMainDrawerOpen } = useUiStore();
const closeMainDrawerHandler = () => { const closeMainDrawerHandler = () => {
@@ -191,7 +51,7 @@ const MainDrawerContent = () => {
</div> </div>
</div> </div>
<MainDrawerMenu /> <SidebarMenu menu={MAIN_DRAWER_LINKS} activeLink={pathname} />
</div> </div>
); );
}; };
@@ -216,9 +76,9 @@ const MainDrawer = ({
const hasSubmenu = menu?.submenu && menu?.submenu.length > 0; const hasSubmenu = menu?.submenu && menu?.submenu.length > 0;
if (!title) { if (!title) {
title += menu?.title; title += menu?.text;
} else { } else {
title += ' - ' + menu?.title; title += ' - ' + menu?.text;
} }
if (!hasSubmenu || !menu.submenu) return; if (!hasSubmenu || !menu.submenu) return;
+302 -212
View File
@@ -1,7 +1,9 @@
'use client'; 'use client';
import { ReactNode } from 'react'; import { ChangeEventHandler, ReactNode } from 'react';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
@@ -17,16 +19,18 @@ const PaginationButton = ({
disabled?: boolean; disabled?: boolean;
onClick?: () => void; onClick?: () => void;
}) => ( }) => (
<button <Button
className={cn( variant='ghost'
'join-item btn btn-ghost p-2.5 rounded-lg text-sm font-medium text-gray-500 aspect-square', color='none'
'disabled:text-gray-700 disabled:pointer-events-auto! disabled:cursor-not-allowed! disabled:bg-gray-50 disabled:active:translate-y-0'
)}
disabled={disabled} disabled={disabled}
onClick={onClick} onClick={onClick}
className={cn(
'join-item w-10 h-10 grid place-items-center p-2.5 rounded-lg! text-sm font-semibold text-base-content/50 aspect-square',
'disabled:text-primary disabled:pointer-events-auto! disabled:cursor-not-allowed! disabled:bg-primary/10 disabled:active:translate-y-0'
)}
> >
{content} {content}
</button> </Button>
); );
const EtcPaginationButton = ({ const EtcPaginationButton = ({
@@ -48,7 +52,7 @@ const EtcPaginationButton = ({
tabIndex={0} tabIndex={0}
role='button' role='button'
className={cn( className={cn(
'join-item btn btn-ghost p-2.5 rounded-lg text-sm font-medium text-gray-500 aspect-square' 'join-item btn btn-ghost p-2.5 rounded-lg! text-sm font-medium text-gray-500 aspect-square'
)} )}
> >
... ...
@@ -57,7 +61,7 @@ const EtcPaginationButton = ({
<div className='dropdown-content'> <div className='dropdown-content'>
<ul <ul
tabIndex={0} tabIndex={0}
className='menu bg-base-100 rounded-lg z-1 w-fit min-w-max max-h-64 p-1 shadow-sm mb-2 overflow-y-auto flex-nowrap' className='menu bg-base-100 rounded-lg! z-1 w-fit min-w-max max-h-64 p-1 shadow-sm mb-2 overflow-y-auto flex-nowrap'
> >
{pages.map((pageNumber) => ( {pages.map((pageNumber) => (
<li key={pageNumber}> <li key={pageNumber}>
@@ -76,7 +80,7 @@ const EtcPaginationButton = ({
<button <button
disabled disabled
className={cn( className={cn(
'join-item btn btn-ghost p-2.5 rounded-lg text-sm font-medium text-gray-500 aspect-square' 'join-item btn btn-ghost p-2.5 rounded-lg! text-sm font-medium text-gray-500 aspect-square'
)} )}
> >
... ...
@@ -90,16 +94,20 @@ const Pagination = ({
currentPage = 1, currentPage = 1,
totalItems = 0, totalItems = 0,
itemsPerPage = 10, itemsPerPage = 10,
rowOptions = [10, 20, 50, 100],
onPageChange, onPageChange,
onPrevPage = () => {}, onPrevPage = () => {},
onNextPage = () => {}, onNextPage = () => {},
onRowChange,
}: { }: {
currentPage: number; currentPage: number;
totalItems: number; totalItems: number;
itemsPerPage: number; itemsPerPage: number;
rowOptions?: number[];
onPageChange: (pageNumber: number) => void; onPageChange: (pageNumber: number) => void;
onPrevPage: () => void; onPrevPage: () => void;
onNextPage: () => void; onNextPage: () => void;
onRowChange?: (row: number) => void;
}) => { }) => {
const totalPages = const totalPages =
Math.ceil(totalItems / itemsPerPage) === 0 Math.ceil(totalItems / itemsPerPage) === 0
@@ -107,30 +115,139 @@ const Pagination = ({
: Math.ceil(totalItems / itemsPerPage); : Math.ceil(totalItems / itemsPerPage);
const pageChangeHandler = (pageNumber: number) => onPageChange(pageNumber); const pageChangeHandler = (pageNumber: number) => onPageChange(pageNumber);
const firstPageClickHandler = () => onPageChange(1);
const lastPageClickHandler = () => onPageChange(totalPages);
const rowChangeHandler: ChangeEventHandler<HTMLSelectElement> = (e) => {
onRowChange?.(Number(e.target.value));
};
const DisplayedRowCountSelect = () => (
<div className='flex flex-row items-center gap-4'>
<span className='text-sm font-medium text-base-content/50'>Showing</span>
<select
defaultValue={itemsPerPage}
onChange={rowChangeHandler}
className='select select-xs w-fit pl-3 pr-7 text-base-content/50'
>
{rowOptions.map((rowOption, rowOptionIdx) => (
<option
key={rowOptionIdx}
value={rowOption}
className='text-base-content active:text-neutral-content'
>
{rowOption} Per page
</option>
))}
</select>
</div>
);
const GoToFirstPageButton = () => (
<Button
disabled={currentPage === 1}
onClick={firstPageClickHandler}
variant='ghost'
color='none'
className={cn(
'join-item w-10 h-10 grid place-items-center p-2.5 rounded-lg! text-sm font-semibold text-base-content/50 aspect-square',
'disabled:bg-[initial]! disabled:text-base-content disabled:pointer-events-auto! disabled:cursor-not-allowed disabled:active:translate-y-0'
)}
>
<Icon
icon='heroicons:chevron-double-left'
width={20}
height={20}
className='text-gray-400 group-disabled:text-gray-300'
/>
</Button>
);
const PrevPageButton = () => (
<Button
disabled={currentPage === 1}
onClick={onPrevPage}
variant='ghost'
color='none'
className={cn(
'join-item w-10 h-10 grid place-items-center p-2.5 rounded-lg! text-sm font-semibold text-base-content/50 aspect-square',
'disabled:bg-[initial]! disabled:text-base-content disabled:pointer-events-auto! disabled:cursor-not-allowed disabled:active:translate-y-0'
)}
>
<Icon
icon='heroicons:chevron-left'
width={20}
height={20}
className='text-gray-400 group-disabled:text-gray-300'
/>
</Button>
);
const GoToLastPageButton = () => (
<Button
variant='ghost'
color='none'
disabled={currentPage === totalPages}
onClick={lastPageClickHandler}
className={cn(
'join-item w-10 h-10 grid place-items-center p-2.5 rounded-lg! text-sm font-semibold text-base-content/50 aspect-square',
'disabled:bg-[initial]! disabled:text-base-content disabled:pointer-events-auto! disabled:cursor-not-allowed disabled:active:translate-y-0'
)}
>
<Icon
icon='heroicons:chevron-double-right'
width={20}
height={20}
className='text-gray-400 group-disabled:text-gray-300'
/>
</Button>
);
const NextPageButton = () => (
<Button
variant='ghost'
color='none'
disabled={currentPage === totalPages}
onClick={onNextPage}
className={cn(
'join-item w-10 h-10 grid place-items-center p-2.5 rounded-lg! text-sm font-semibold text-base-content/50 aspect-square',
'disabled:bg-[initial]! disabled:text-base-content disabled:pointer-events-auto! disabled:cursor-not-allowed disabled:active:translate-y-0'
)}
>
<Icon
icon='heroicons:chevron-right'
width={20}
height={20}
className='text-gray-400 group-disabled:text-gray-300'
/>
</Button>
);
const PageInfo = () => (
<span className='text-nowrap text-sm font-medium text-base-content/50'>
Page {currentPage} of {totalPages}
</span>
);
return ( return (
<div> <div className='@container'>
<div className='join w-full justify-between items-center gap-3'> <div className='flex flex-row justify-center items-center'>
<button <div className='hidden @md:block'>
disabled={currentPage === 1} <DisplayedRowCountSelect />
onClick={onPrevPage} </div>
className={cn(
'join-item btn btn-outline group px-3 py-2 text-sm font-semibold rounded-lg border border-gray-300 shadow-xs hidden sm:flex justify-center items-center gap-1.5',
'disabled:bg-[initial]! disabled:text-gray-400 disabled:pointer-events-auto! disabled:cursor-not-allowed disabled:active:translate-y-0'
)}
>
<Icon
icon='uil:arrow-left'
width={20}
height={20}
className='text-gray-400 group-disabled:text-gray-300'
/>{' '}
Previous
</button>
{totalPages <= 7 && ( <div className='join w-full justify-end @md:justify-center items-center gap-0.5'>
<div className='join-item join gap-0.5'> <div className='hidden @md:block'>
{range(1, totalPages).map((pageNumber) => ( <GoToFirstPageButton />
</div>
<div className='hidden @md:block'>
<PrevPageButton />
</div>
{totalPages <= 7 &&
range(1, totalPages).map((pageNumber) => (
<PaginationButton <PaginationButton
key={pageNumber} key={pageNumber}
content={pageNumber} content={pageNumber}
@@ -138,195 +255,168 @@ const Pagination = ({
onClick={() => pageChangeHandler(pageNumber)} onClick={() => pageChangeHandler(pageNumber)}
/> />
))} ))}
</div>
)}
{totalPages > 7 && ( {totalPages > 7 && (
<div className='join-item join gap-0.5'> <>
<PaginationButton
content={1}
disabled={currentPage === 1}
onClick={() => pageChangeHandler(1)}
/>
{totalPages >= 2 &&
(currentPage <= 3 || currentPage >= totalPages - 2) && (
<PaginationButton
content={2}
disabled={currentPage === 2}
onClick={() => pageChangeHandler(2)}
/>
)}
{totalPages >= 2 &&
currentPage > 3 &&
currentPage < totalPages - 2 && (
<EtcPaginationButton
startPage={2}
endPage={currentPage - 2}
onPageItemClick={pageChangeHandler}
/>
)}
{totalPages >= 3 &&
(currentPage <= 4 || currentPage >= totalPages - 2) &&
currentPage !== totalPages - 2 && (
<PaginationButton
content={3}
disabled={currentPage === 3}
onClick={() => pageChangeHandler(3)}
/>
)}
{totalPages >= 7 &&
(currentPage <= 2 || currentPage >= totalPages - 2) && (
<EtcPaginationButton
startPage={
currentPage <= 2
? currentPage + 2
: currentPage === totalPages - 2
? 3
: currentPage >= totalPages - 1
? 4
: 1
}
endPage={
currentPage <= 2 || currentPage >= totalPages - 1
? totalPages - 3
: currentPage === totalPages - 2
? totalPages - 4
: 2
}
onPageItemClick={pageChangeHandler}
/>
)}
{totalPages >= 3 &&
currentPage > 4 &&
currentPage < totalPages - 1 && (
<PaginationButton
content={currentPage - 1}
onClick={() => pageChangeHandler(currentPage - 1)}
/>
)}
{totalPages >= 7 &&
currentPage > 3 &&
currentPage < totalPages - 2 && (
<PaginationButton content={currentPage} disabled />
)}
{totalPages >= 5 &&
currentPage > 2 &&
currentPage < totalPages - 2 && (
<PaginationButton
content={currentPage + 1}
onClick={() => pageChangeHandler(currentPage + 1)}
/>
)}
{totalPages >= 5 &&
(currentPage <= 2 || currentPage >= totalPages - 2) && (
<PaginationButton
content={totalPages - 2}
disabled={currentPage === totalPages - 2}
onClick={() => pageChangeHandler(totalPages - 2)}
/>
)}
{totalPages >= 6 &&
currentPage > 2 &&
currentPage < totalPages - 3 && (
<EtcPaginationButton
startPage={
currentPage <= 3
? currentPage + 2
: currentPage >= 4
? currentPage + 2
: 1
}
endPage={
currentPage <= 3
? totalPages - 2
: currentPage >= 4
? totalPages - 1
: 0
}
onPageItemClick={pageChangeHandler}
/>
)}
{totalPages >= 6 &&
(currentPage <= 3 || currentPage >= totalPages - 3) && (
<PaginationButton
content={totalPages - 1}
disabled={currentPage === totalPages - 1}
onClick={() => pageChangeHandler(totalPages - 1)}
/>
)}
{totalPages >= 7 && (
<PaginationButton <PaginationButton
content={totalPages} content={1}
disabled={currentPage === totalPages} disabled={currentPage === 1}
onClick={() => pageChangeHandler(totalPages)} onClick={() => pageChangeHandler(1)}
/> />
)}
</div>
)}
<button {totalPages >= 2 &&
disabled={currentPage === totalPages} (currentPage <= 3 || currentPage >= totalPages - 2) && (
onClick={onNextPage} <PaginationButton
className={cn( content={2}
'join-item btn btn-outline group px-3 py-2 text-sm font-semibold rounded-lg border border-gray-300 shadow-xs hidden sm:flex justify-center items-center gap-1.5', disabled={currentPage === 2}
'disabled:bg-[initial]! disabled:text-gray-400 disabled:pointer-events-auto! disabled:cursor-not-allowed disabled:active:translate-y-0' onClick={() => pageChangeHandler(2)}
/>
)}
{totalPages >= 2 &&
currentPage > 3 &&
currentPage < totalPages - 2 && (
<EtcPaginationButton
startPage={2}
endPage={currentPage - 2}
onPageItemClick={pageChangeHandler}
/>
)}
{totalPages >= 3 &&
(currentPage <= 4 || currentPage >= totalPages - 2) &&
currentPage !== totalPages - 2 && (
<PaginationButton
content={3}
disabled={currentPage === 3}
onClick={() => pageChangeHandler(3)}
/>
)}
{totalPages >= 7 &&
(currentPage <= 2 || currentPage >= totalPages - 2) && (
<EtcPaginationButton
startPage={
currentPage <= 2
? currentPage + 2
: currentPage === totalPages - 2
? 3
: currentPage >= totalPages - 1
? 4
: 1
}
endPage={
currentPage <= 2 || currentPage >= totalPages - 1
? totalPages - 3
: currentPage === totalPages - 2
? totalPages - 4
: 2
}
onPageItemClick={pageChangeHandler}
/>
)}
{totalPages >= 3 &&
currentPage > 4 &&
currentPage < totalPages - 1 && (
<PaginationButton
content={currentPage - 1}
onClick={() => pageChangeHandler(currentPage - 1)}
/>
)}
{totalPages >= 7 &&
currentPage > 3 &&
currentPage < totalPages - 2 && (
<PaginationButton content={currentPage} disabled />
)}
{totalPages >= 5 &&
currentPage > 2 &&
currentPage < totalPages - 2 && (
<PaginationButton
content={currentPage + 1}
onClick={() => pageChangeHandler(currentPage + 1)}
/>
)}
{totalPages >= 5 &&
(currentPage <= 2 || currentPage >= totalPages - 2) && (
<PaginationButton
content={totalPages - 2}
disabled={currentPage === totalPages - 2}
onClick={() => pageChangeHandler(totalPages - 2)}
/>
)}
{totalPages >= 6 &&
currentPage > 2 &&
currentPage < totalPages - 3 && (
<EtcPaginationButton
startPage={
currentPage <= 3
? currentPage + 2
: currentPage >= 4
? currentPage + 2
: 1
}
endPage={
currentPage <= 3
? totalPages - 2
: currentPage >= 4
? totalPages - 1
: 0
}
onPageItemClick={pageChangeHandler}
/>
)}
{totalPages >= 6 &&
(currentPage <= 3 || currentPage >= totalPages - 3) && (
<PaginationButton
content={totalPages - 1}
disabled={currentPage === totalPages - 1}
onClick={() => pageChangeHandler(totalPages - 1)}
/>
)}
{totalPages >= 7 && (
<PaginationButton
content={totalPages}
disabled={currentPage === totalPages}
onClick={() => pageChangeHandler(totalPages)}
/>
)}
</>
)} )}
>
Next{' '} <div className='hidden @md:block'>
<Icon <NextPageButton />
icon='uil:arrow-right' </div>
width={20}
height={20} <div className='hidden @md:block'>
className='text-gray-400 group-disabled:text-gray-300' <GoToLastPageButton />
/> </div>
</button> </div>
<div className='hidden @md:block'>
<PageInfo />
</div>
</div> </div>
<div className='flex gap-2 mt-2 sm:hidden'> <div className='flex @md:hidden flex-col justify-center items-end gap-2'>
<button <div className='flex flex-row items-center gap-0.5'>
disabled={currentPage === 1} <GoToFirstPageButton />
onClick={onPrevPage} <PrevPageButton />
className={cn( <NextPageButton />
'join-item btn btn-outline group px-3 py-2 text-sm font-semibold rounded-lg border border-gray-300 shadow-xs flex justify-center items-center gap-1.5', <GoToLastPageButton />
'disabled:bg-[initial]! disabled:text-gray-400 disabled:pointer-events-auto! disabled:cursor-not-allowed disabled:active:translate-y-0' </div>
)}
>
<Icon
icon='uil:arrow-left'
width={20}
height={20}
className='text-gray-400 group-disabled:text-gray-300'
/>{' '}
Previous
</button>
<button <div className='flex flex-row items-center gap-4'>
disabled={currentPage === totalPages} <DisplayedRowCountSelect />
onClick={onNextPage}
className={cn( <PageInfo />
'join-item btn btn-outline group px-3 py-2 text-sm font-semibold rounded-lg border border-gray-300 shadow-xs flex justify-center items-center gap-1.5', </div>
'disabled:bg-[initial]! disabled:text-gray-400 disabled:pointer-events-auto! disabled:cursor-not-allowed disabled:active:translate-y-0'
)}
>
Next{' '}
<Icon
icon='uil:arrow-right'
width={20}
height={20}
className='text-gray-400 group-disabled:text-gray-300'
/>
</button>
</div> </div>
</div> </div>
); );
+59 -29
View File
@@ -38,6 +38,7 @@ export interface TableProps<TData extends object> {
data: TData[]; data: TData[];
columns: ColumnDef<TData, unknown>[]; columns: ColumnDef<TData, unknown>[];
pageSize?: number; pageSize?: number;
onPageSizeChange?: (pageSize: number) => void;
totalItems?: number; totalItems?: number;
page?: number; page?: number;
onPageChange?: (page: number) => void; onPageChange?: (page: number) => void;
@@ -52,6 +53,8 @@ export interface TableProps<TData extends object> {
rowSelection?: Record<string, boolean>; rowSelection?: Record<string, boolean>;
setRowSelection?: OnChangeFn<Record<string, boolean>>; setRowSelection?: OnChangeFn<Record<string, boolean>>;
enableRowSelection?: boolean | ((row: Row<TData>) => boolean); enableRowSelection?: boolean | ((row: Row<TData>) => boolean);
withCheckbox?: boolean;
rowOptions?: number[];
} }
const DUMMY_SKELETON_DATA = [{}, {}, {}, {}, {}]; const DUMMY_SKELETON_DATA = [{}, {}, {}, {}, {}];
@@ -64,28 +67,32 @@ const emptyContentDefaultValue = (
</div> </div>
); );
const TABLE_DEFAULT_STYLING = {
containerClassName: 'w-full mb-20',
tableWrapperClassName:
'overflow-x-auto border border-solid border-base-content/10 rounded-lg',
tableClassName: 'font-inter w-full table-auto text-sm font-medium',
tableHeaderClassName: '',
headerRowClassName: '',
headerColumnClassName: 'px-4 py-3 text-base-content/50',
tableBodyClassName: '',
bodyRowClassName: 'border-t border-t-base-content/10',
bodyColumnClassName: 'px-4 py-3 text-base-content',
paginationClassName: '',
};
const Table = <TData extends object>({ const Table = <TData extends object>({
data = [], data = [],
columns = [], columns = [],
pageSize = 10, pageSize = 10,
onPageSizeChange,
totalItems, totalItems,
page, page,
onPageChange, onPageChange,
isLoading = false, isLoading = false,
fuzzySearchValue, fuzzySearchValue,
onFuzzySearchValueChange, onFuzzySearchValueChange,
className = { className = TABLE_DEFAULT_STYLING,
containerClassName: '',
tableWrapperClassName: '',
tableClassName: '',
tableHeaderClassName: '',
headerRowClassName: '',
headerColumnClassName: '',
tableBodyClassName: '',
bodyRowClassName: '',
bodyColumnClassName: '',
paginationClassName: '',
},
emptyContent = emptyContentDefaultValue, emptyContent = emptyContentDefaultValue,
sorting, sorting,
setSorting, setSorting,
@@ -93,12 +100,19 @@ const Table = <TData extends object>({
rowSelection, rowSelection,
setRowSelection, setRowSelection,
enableRowSelection, enableRowSelection,
withCheckbox = false,
rowOptions = [10, 20, 50, 100],
}: TableProps<TData>) => { }: TableProps<TData>) => {
const isServerSideTable = const isServerSideTable =
totalItems !== undefined && totalItems !== undefined &&
page !== undefined && page !== undefined &&
onPageChange !== undefined; onPageChange !== undefined;
const tableClassNames = {
...TABLE_DEFAULT_STYLING,
...className,
};
const [pagination, setPagination] = useState({ const [pagination, setPagination] = useState({
pageIndex: 0, pageIndex: 0,
pageSize: pageSize, pageSize: pageSize,
@@ -191,12 +205,15 @@ const Table = <TData extends object>({
}, [pageSize, setPageSize]); }, [pageSize, setPageSize]);
return ( return (
<div className={className.containerClassName}> <div className={tableClassNames.containerClassName}>
<div className={className.tableWrapperClassName}> <div className={tableClassNames.tableWrapperClassName}>
<table className={className.tableClassName}> <table className={tableClassNames.tableClassName}>
<thead className={className.tableHeaderClassName}> <thead className={tableClassNames.tableHeaderClassName}>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className={className.headerRowClassName}> <tr
key={headerGroup.id}
className={tableClassNames.headerRowClassName}
>
{headerGroup.headers.map((header) => ( {headerGroup.headers.map((header) => (
<th <th
key={header.id} key={header.id}
@@ -206,7 +223,10 @@ const Table = <TData extends object>({
header.column.getCanSort() header.column.getCanSort()
? 'cursor-pointer select-none' ? 'cursor-pointer select-none'
: '', : '',
className.headerColumnClassName {
'first:w-9 first:pr-0': withCheckbox,
},
tableClassNames.headerColumnClassName
)} )}
> >
<div className='flex items-center gap-1'> <div className='flex items-center gap-1'>
@@ -216,12 +236,13 @@ const Table = <TData extends object>({
)} )}
{header.column.getCanSort() && ( {header.column.getCanSort() && (
<div className='flex items-center'> <div className='w-4 h-4 relative flex flex-col items-center'>
<Icon <Icon
icon='lucide:arrow-up' icon='heroicons:chevron-up-16-solid'
width={12} width={18}
height={12} height={18}
className={cn( className={cn(
'absolute -top-1',
'transition-all ease-in-out duration-200', 'transition-all ease-in-out duration-200',
header.column.getIsSorted() === 'asc' header.column.getIsSorted() === 'asc'
? 'text-black' ? 'text-black'
@@ -229,10 +250,11 @@ const Table = <TData extends object>({
)} )}
/> />
<Icon <Icon
icon='lucide:arrow-down' icon='heroicons:chevron-down-16-solid'
width={12} width={18}
height={12} height={18}
className={cn( className={cn(
'absolute -bottom-1.5',
'transition-all ease-in-out duration-200', 'transition-all ease-in-out duration-200',
header.column.getIsSorted() === 'desc' header.column.getIsSorted() === 'desc'
? 'text-black' ? 'text-black'
@@ -248,11 +270,17 @@ const Table = <TData extends object>({
))} ))}
</thead> </thead>
<tbody className={className.tableBodyClassName}> <tbody className={tableClassNames.tableBodyClassName}>
{table.getRowModel().rows.map((row) => ( {table.getRowModel().rows.map((row) => (
<tr key={row.id} className={className.bodyRowClassName}> <tr key={row.id} className={tableClassNames.bodyRowClassName}>
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<td key={cell.id} className={className.bodyColumnClassName}> <td
key={cell.id}
className={cn(
{ 'first:w-9 first:pr-0': withCheckbox },
tableClassNames.bodyColumnClassName
)}
>
{!isLoading && {!isLoading &&
flexRender(cell.column.columnDef.cell, cell.getContext())} flexRender(cell.column.columnDef.cell, cell.getContext())}
@@ -270,7 +298,7 @@ const Table = <TData extends object>({
emptyContent} emptyContent}
{data.length > 0 && table.getRowModel().rows.length > 0 && !isLoading && ( {data.length > 0 && table.getRowModel().rows.length > 0 && !isLoading && (
<div className={cn('mt-5', className.paginationClassName)}> <div className={cn('mt-5', tableClassNames.paginationClassName)}>
<Pagination <Pagination
totalItems={isServerSideTable ? totalItems : table.getRowCount()} totalItems={isServerSideTable ? totalItems : table.getRowCount()}
itemsPerPage={table.getState().pagination.pageSize} itemsPerPage={table.getState().pagination.pageSize}
@@ -282,6 +310,8 @@ const Table = <TData extends object>({
onPrevPage={prevPageClickHandler} onPrevPage={prevPageClickHandler}
onNextPage={nextPageClickHandler} onNextPage={nextPageClickHandler}
onPageChange={pageChangeHandler} onPageChange={pageChangeHandler}
rowOptions={rowOptions}
onRowChange={onPageSizeChange}
/> />
</div> </div>
)} )}
+13 -2
View File
@@ -2,8 +2,9 @@
import { HTMLProps, useEffect, useRef } from 'react'; import { HTMLProps, useEffect, useRef } from 'react';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { Size } from '@/types/theme';
interface CheckboxInputProps extends HTMLProps<HTMLInputElement> { interface CheckboxInputProps extends Omit<HTMLProps<HTMLInputElement>, 'size'> {
name: string; name: string;
label?: string; label?: string;
indeterminate?: boolean; indeterminate?: boolean;
@@ -16,6 +17,7 @@ interface CheckboxInputProps extends HTMLProps<HTMLInputElement> {
isError?: boolean; isError?: boolean;
isValid?: boolean; isValid?: boolean;
errorMessage?: string; errorMessage?: string;
size?: Size;
} }
const CheckboxInput = ({ const CheckboxInput = ({
@@ -27,10 +29,19 @@ const CheckboxInput = ({
isValid, isValid,
isError, isError,
errorMessage, errorMessage,
size = 'sm',
...rest ...rest
}: CheckboxInputProps) => { }: CheckboxInputProps) => {
const ref = useRef<HTMLInputElement>(null!); const ref = useRef<HTMLInputElement>(null!);
const checkboxBaseClassName = cn('checkbox cursor-pointer rounded-md', {
'checkbox-xs': size === 'xs',
'checkbox-sm': size === 'sm',
'checkbox-md': size === 'md',
'checkbox-lg': size === 'lg',
'checkbox-xl': size === 'xl',
});
useEffect(() => { useEffect(() => {
if (typeof indeterminate === 'boolean') { if (typeof indeterminate === 'boolean') {
ref.current.indeterminate = !rest.checked && indeterminate; ref.current.indeterminate = !rest.checked && indeterminate;
@@ -53,7 +64,7 @@ const CheckboxInput = ({
id={name} id={name}
name={name} name={name}
className={cn( className={cn(
'checkbox rounded-md cursor-pointer', checkboxBaseClassName,
{ {
'border-error': isError, 'border-error': isError,
'border-success': isValid, 'border-success': isValid,
+20 -4
View File
@@ -1,16 +1,32 @@
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { Size } from '@/types/theme';
interface MenuProps { interface MenuProps {
children?: ReactNode; children?: ReactNode;
size?: Size;
direction?: 'vertical' | 'horizontal';
className?: string; className?: string;
} }
const Menu = ({ children, className }: MenuProps) => { const Menu = ({
return ( children,
<ul className={cn('menu w-full p-0 gap-0.5', className)}>{children}</ul> size = 'md',
); direction = 'vertical',
className,
}: MenuProps) => {
const menuBaseClassName = cn('menu w-full', {
'menu-xs': size === 'xs',
'menu-sm': size === 'sm',
'menu-md': size === 'md',
'menu-lg': size === 'lg',
'menu-xl': size === 'xl',
'menu-vertical': direction === 'vertical',
'menu-horizontal': direction === 'horizontal',
});
return <ul className={cn(menuBaseClassName, className)}>{children}</ul>;
}; };
export default Menu; export default Menu;
+92
View File
@@ -0,0 +1,92 @@
import Link from 'next/link';
import Menu from '@/components/menu/Menu';
import { Icon } from '@iconify/react';
import { cn, isPathActive } from '@/lib/helper';
export interface SidebarMenuItem {
type?: 'item' | 'title';
text: string;
link: string;
icon?: string;
submenu?: SidebarMenuItem[];
}
interface SidebarMenuItemProps {
item: SidebarMenuItem;
activeLink: string;
}
interface SidebarMenuProps {
menu: SidebarMenuItem[];
activeLink: string;
}
const SidebarMenuItem = ({ item, activeLink }: SidebarMenuItemProps) => {
const isItemActive = isPathActive(activeLink, item.link);
const menuItemWithoutSubmenu = (
<li>
<Link
href={item.link}
className={cn(
{
'menu-active border-2 border-solid border-base-300': isItemActive,
},
'px-3 py-1.5'
)}
>
{item.icon && <Icon icon={item.icon} width={20} height={20} />}
<span className='text-base'>{item.text}</span>
</Link>
</li>
);
if (!item.submenu || item.submenu.length === 0) {
return menuItemWithoutSubmenu;
}
const menuItemWithSubmenu = (
<li>
<details open={isItemActive}>
<summary
className={cn({
'text-primary': isItemActive,
})}
>
{item.icon && <Icon icon={item.icon} width={20} height={20} />}
<span className='text-base'>{item.text}</span>
</summary>
<ul>
{item.submenu.map((submenuItem, submenuIdx) => (
<SidebarMenuItem
key={`submenu#${submenuIdx}`}
item={submenuItem}
activeLink={activeLink}
/>
))}
</ul>
</details>
</li>
);
return menuItemWithSubmenu;
};
const SidebarMenu = ({ menu, activeLink }: SidebarMenuProps) => {
return (
<Menu>
{menu.map((menuItem, menuIdx) => (
<SidebarMenuItem
key={menuIdx}
item={menuItem}
activeLink={activeLink}
/>
))}
</Menu>
);
};
export default SidebarMenu;
@@ -0,0 +1,95 @@
'use client';
import { useMemo, useState } from 'react';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import Tabs from '@/components/Tabs';
import ClosingGeneralInformationTable from '@/components/pages/closing/ClosingGeneralInformationTable';
import { ClosingGeneralInformation } from '@/types/api/closing';
import ClosingSapronakTabContent from './ClosingSapronakTabContent';
interface ClosingDetailProps {
id: number;
initialValue?: ClosingGeneralInformation;
}
const ClosingDetail: React.FC<ClosingDetailProps> = ({ id, initialValue }) => {
const [activeTab, setActiveTab] = useState<string>('sapronak');
const closingDetailTabs = useMemo(() => {
const validTabs = [
{
id: 'sapronak',
label: 'Sapronak',
content: <ClosingSapronakTabContent projectFlockId={id} />,
},
{
id: 'perhitunganSapronak',
label: 'Perhitungan Sapronak',
content: 'Perhitungan Sapronak',
},
{
id: 'penjualan',
label: 'Penjualan',
content: 'Penjualan',
},
{
id: 'overhead',
label: 'Overhead',
content: 'Overhead',
},
{
id: 'hppEkspedisi',
label: 'HPP Ekspedisi',
content: 'HPP Ekspedisi',
},
{
id: 'dataProduksi',
label: 'Data Produksi',
content: 'Data Produksi',
},
{
id: 'keuangan',
label: 'Keuangan',
content: 'Keuangan',
},
];
return validTabs;
}, [initialValue]);
return (
<>
<section className='w-full max-w-7xl pb-16'>
<header className='flex flex-col gap-4'>
<Button
href='/closing'
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'>Detail Closing</h1>
</header>
<ClosingGeneralInformationTable initialValue={initialValue} />
<Tabs
activeTabId={activeTab}
onTabChange={setActiveTab}
tabs={closingDetailTabs}
variant='lifted'
className={{
wrapper: 'w-full mt-4',
}}
/>
</section>
</>
);
};
export default ClosingDetail;
@@ -0,0 +1,100 @@
import { ClosingGeneralInformation } from '@/types/api/closing';
interface ClosingGeneralInformationProps {
initialValue?: ClosingGeneralInformation;
}
const ClosingGeneralInformationTable = ({
initialValue,
}: ClosingGeneralInformationProps) => {
return (
<div className='w-full my-4 @container'>
<div className='flex flex-col @sm:flex-row gap-4'>
<div className='w-full'>
<div className='overflow-x-auto'>
<table className='table table-zebra table-sm'>
<tbody>
<tr>
<td>Lokasi</td>
<td>:</td>
<td>{initialValue?.location_name}</td>
</tr>
<tr>
<td>Periode</td>
<td>:</td>
<td>{initialValue?.period}</td>
</tr>
<tr>
<td>Kategori</td>
<td>:</td>
<td>{initialValue?.project_category}</td>
</tr>
<tr>
<td>Populasi</td>
<td>:</td>
<td>{initialValue?.population} Ekor</td>
</tr>
<tr>
<td>Jenis Project</td>
<td>:</td>
<td>{initialValue?.project_type}</td>
</tr>
<tr className='table-row @sm:hidden'>
<td>Kandang Aktif</td>
<td>:</td>
<td>{initialValue?.active_house_count} Kandang</td>
</tr>
<tr className='table-row @sm:hidden'>
<td>Status Pembayaran Penjualan</td>
<td>:</td>
<td>{initialValue?.sales_payment_status}</td>
</tr>
<tr className='table-row @sm:hidden'>
<td>Status Project</td>
<td>:</td>
<td>{initialValue?.project_status}</td>
</tr>
<tr className='table-row @sm:hidden'>
<td>Status Closing</td>
<td>:</td>
<td>{initialValue?.closing_status}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div className='w-full hidden @sm:block'>
<div className='overflow-x-auto'>
<table className='table table-zebra table-sm'>
<tbody>
<tr>
<td>Kandang Aktif</td>
<td>:</td>
<td>{initialValue?.active_house_count} Kandang</td>
</tr>
<tr>
<td>Status Pembayaran Penjualan</td>
<td>:</td>
<td>{initialValue?.sales_payment_status}</td>
</tr>
<tr>
<td>Status Project</td>
<td>:</td>
<td>{initialValue?.project_status}</td>
</tr>
<tr>
<td>Status Closing</td>
<td>:</td>
<td>{initialValue?.closing_status}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
);
};
export default ClosingGeneralInformationTable;
@@ -0,0 +1,209 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import useSWR from 'swr';
import { ColumnDef, SortingState } from '@tanstack/react-table';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Card from '@/components/Card';
import Collapse from '@/components/Collapse';
import { cn, formatDate, formatNumber } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ClosingApi } from '@/services/api/closing';
import { ClosingIncomingSapronak } from '@/types/api/closing';
interface ClosingIncomingSapronaksTableProps {
projectFlockId: number;
}
const ClosingIncomingSapronaksTable = ({
projectFlockId,
}: ClosingIncomingSapronaksTableProps) => {
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: '',
nameSort: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
nameSort: 'sort_name',
},
});
const { data: incomingSapronaks, isLoading: isLoadingIncomingSapronaks } =
useSWR(
`${ClosingApi.basePath}/${projectFlockId}/sapronak${getTableFilterQueryString()}&type=incoming`,
ClosingApi.getAllIncomingSapronakFetcher,
{
keepPreviousData: true,
}
);
const [open, setOpen] = useState(true);
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const incomingSapronaksColumns: ColumnDef<ClosingIncomingSapronak>[] = [
{
header: '#',
cell: (props) => props.row.index + 1,
},
{
accessorKey: 'date',
header: 'Tanggal',
cell: (props) => formatDate(props.row.original.date, 'DD MMM YYYY'),
},
{
accessorKey: 'reference_number',
header: 'No. Referensi',
},
{
accessorKey: 'transaction_type',
header: 'Jenis Transaksi',
},
{
accessorKey: 'product_name',
header: 'Produk',
},
{
accessorKey: 'product_category',
header: 'Kategori Produk',
},
{
accessorKey: 'source_warehouse',
header: 'Gudang Asal',
},
{
accessorKey: 'destination_warehouse',
header: 'Gudang Tujuan',
},
{
accessorKey: 'quantity',
header: 'Kuantitas',
cell: (props) =>
`${formatNumber(props.row.original.quantity)} ${props.row.original.unit}`,
},
{
accessorKey: 'notes',
header: 'Keterangan',
},
];
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
// track sorting
useEffect(() => {
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
if (!isNameSorted) {
updateFilter('nameSort', '');
} else {
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
}
}, [sorting, updateFilter]);
useEffect(() => {
if (!open) {
setOpen(
isResponseSuccess(incomingSapronaks)
? incomingSapronaks.data.length > 0
: false
);
}
}, [incomingSapronaks, isResponseSuccess]);
return (
<Card
className={{
wrapper: 'w-full',
body: 'p-4 shadow',
}}
>
<Collapse
open={open}
onOpenChange={setOpen}
title={
<div className='card-actions p-4 justify-between items-center w-full'>
<div className='card-title'>Sapronak Masuk</div>
<Icon
icon='material-symbols:keyboard-arrow-down'
width={24}
height={24}
className={cn('text-primary transition-transform', {
'-rotate-180': open,
})}
/>
</div>
}
className='w-full!'
titleClassName='w-full p-0!'
>
<div className='w-full p-0'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-start items-end sm:items-center gap-4'>
<DebouncedTextInput
name='search'
placeholder='Cari Sapronak Masuk'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div>
</div>
<Table<ClosingIncomingSapronak>
data={
isResponseSuccess(incomingSapronaks)
? incomingSapronaks?.data
: []
}
columns={incomingSapronaksColumns}
pageSize={tableFilterState.pageSize}
onPageSizeChange={setPageSize}
rowOptions={[10, 20, 50, 100]}
page={
isResponseSuccess(incomingSapronaks)
? incomingSapronaks?.meta?.page
: 0
}
totalItems={
isResponseSuccess(incomingSapronaks)
? incomingSapronaks?.meta?.total_results
: 0
}
onPageChange={setPage}
isLoading={isLoadingIncomingSapronaks}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
className={{
containerClassName: cn({
'w-full mb-20':
isResponseSuccess(incomingSapronaks) &&
incomingSapronaks?.data?.length === 0,
}),
}}
/>
</div>
</Collapse>
</Card>
);
};
export default ClosingIncomingSapronaksTable;
@@ -0,0 +1,209 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import useSWR from 'swr';
import { ColumnDef, SortingState } from '@tanstack/react-table';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Card from '@/components/Card';
import Collapse from '@/components/Collapse';
import { cn, formatDate, formatNumber } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ClosingApi } from '@/services/api/closing';
import { ClosingOutgoingSapronak } from '@/types/api/closing';
interface ClosingOutgoingSapronaksTableProps {
projectFlockId: number;
}
const ClosingOutgoingSapronaksTable = ({
projectFlockId,
}: ClosingOutgoingSapronaksTableProps) => {
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: '',
nameSort: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
nameSort: 'sort_name',
},
});
const { data: outgoingSapronaks, isLoading: isLoadingOutgoingSapronaks } =
useSWR(
`${ClosingApi.basePath}/${projectFlockId}/sapronak${getTableFilterQueryString()}&type=outgoing`,
ClosingApi.getAllOutgoingSapronakFetcher,
{
keepPreviousData: true,
}
);
const [open, setOpen] = useState(true);
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const outgoingSapronaksColumns: ColumnDef<ClosingOutgoingSapronak>[] = [
{
header: '#',
cell: (props) => props.row.index + 1,
},
{
accessorKey: 'date',
header: 'Tanggal',
cell: (props) => formatDate(props.row.original.date, 'DD MMM YYYY'),
},
{
accessorKey: 'reference_number',
header: 'No. Referensi',
},
{
accessorKey: 'transaction_type',
header: 'Jenis Transaksi',
},
{
accessorKey: 'product_name',
header: 'Produk',
},
{
accessorKey: 'product_category',
header: 'Kategori Produk',
},
{
accessorKey: 'source_warehouse',
header: 'Gudang Asal',
},
{
accessorKey: 'destination_warehouse',
header: 'Gudang Tujuan',
},
{
accessorKey: 'quantity',
header: 'Kuantitas',
cell: (props) =>
`${formatNumber(props.row.original.quantity)} ${props.row.original.unit}`,
},
{
accessorKey: 'notes',
header: 'Keterangan',
},
];
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
// track sorting
useEffect(() => {
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
if (!isNameSorted) {
updateFilter('nameSort', '');
} else {
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
}
}, [sorting, updateFilter]);
useEffect(() => {
if (!open) {
setOpen(
isResponseSuccess(outgoingSapronaks)
? outgoingSapronaks.data.length > 0
: false
);
}
}, [outgoingSapronaks, isResponseSuccess]);
return (
<Card
className={{
wrapper: 'w-full',
body: 'p-4 shadow',
}}
>
<Collapse
open={open}
onOpenChange={setOpen}
title={
<div className='card-actions p-4 justify-between items-center w-full'>
<div className='card-title'>Sapronak Keluar</div>
<Icon
icon='material-symbols:keyboard-arrow-down'
width={24}
height={24}
className={cn('text-primary transition-transform', {
'-rotate-180': open,
})}
/>
</div>
}
className='w-full!'
titleClassName='w-full p-0!'
>
<div className='w-full p-0'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-start items-end sm:items-center gap-4'>
<DebouncedTextInput
name='search'
placeholder='Cari Sapronak Keluar'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div>
</div>
<Table<ClosingOutgoingSapronak>
data={
isResponseSuccess(outgoingSapronaks)
? outgoingSapronaks?.data
: []
}
columns={outgoingSapronaksColumns}
pageSize={tableFilterState.pageSize}
onPageSizeChange={setPageSize}
rowOptions={[10, 20, 50, 100]}
page={
isResponseSuccess(outgoingSapronaks)
? outgoingSapronaks?.meta?.page
: 0
}
totalItems={
isResponseSuccess(outgoingSapronaks)
? outgoingSapronaks?.meta?.total_results
: 0
}
onPageChange={setPage}
isLoading={isLoadingOutgoingSapronaks}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
className={{
containerClassName: cn({
'w-full mb-20':
isResponseSuccess(outgoingSapronaks) &&
outgoingSapronaks?.data?.length === 0,
}),
}}
/>
</div>
</Collapse>
</Card>
);
};
export default ClosingOutgoingSapronaksTable;
@@ -0,0 +1,26 @@
'use client';
import ClosingIncomingSapronaksTable from '@/components/pages/closing/ClosingIncomingSapronaksTable';
import ClosingOutgoingSapronaksTable from '@/components/pages/closing/ClosingOutgoingSapronaksTable';
interface ClosingSapronakTableProps {
projectFlockId?: number;
}
const ClosingSapronakTabContent = ({
projectFlockId,
}: ClosingSapronakTableProps) => {
return (
<div className='flex flex-col gap-4'>
{projectFlockId && (
<>
<ClosingIncomingSapronaksTable projectFlockId={projectFlockId} />
<ClosingOutgoingSapronaksTable projectFlockId={projectFlockId} />
</>
)}
</div>
);
};
export default ClosingSapronakTabContent;
@@ -0,0 +1,299 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import { cn, formatCurrency, formatDate } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { LocationApi } from '@/services/api/master-data';
import { Location } from '@/types/api/master-data/location';
import { ClosingApi } from '@/services/api/closing';
import { Closing } from '@/types/api/closing';
const PROJECT_STATUS_OPTIONS = [
{
value: 1,
label: 'Pengajuan',
},
{
value: 2,
label: 'Aktif',
},
];
const RowOptionsMenu = ({
type = 'dropdown',
props,
}: {
type: 'dropdown' | 'collapse';
props: CellContext<Closing, unknown>;
}) => {
return (
<RowOptionsMenuWrapper type={type}>
{/* TODO: apply RBAC */}
<div className='w-full max-h-40 overflow-auto flex flex-col gap-1'>
<Button
href={`/closing/detail/?closingId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
</div>
</RowOptionsMenuWrapper>
);
};
const ClosingsTable = () => {
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: '',
nameSort: '',
transactionDate: '',
realizationDate: '',
locationId: '',
projectStatus: '',
userId: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
nameSort: 'sort_name',
transactionDate: 'transaction_date',
realizationDate: 'realization_date',
locationId: 'location_id',
projectStatus: 'project_status',
userId: 'user_id',
},
});
const { data: closings, isLoading: isLoadingClosings } = useSWR(
`${ClosingApi.basePath}${getTableFilterQueryString()}`,
ClosingApi.getAllFetcher
);
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const closingsColumns: ColumnDef<Closing>[] = [
{
header: '#',
cell: (props) => props.row.index + 1,
},
{
accessorKey: 'location_name',
header: 'Lokasi',
},
{
accessorKey: 'project_category',
header: 'Kategori',
},
{
accessorKey: 'period',
header: 'Periode',
},
{
accessorKey: 'closing_date',
header: 'Periode',
cell: (props) =>
formatDate(props.row.original.closing_date, 'DD MMM YYYY'),
},
{
accessorKey: 'shed_label',
header: 'Jumlah Kandang',
},
{
accessorKey: 'sales_paid_amount',
header: 'Jumlah Sudah Bayar',
cell: (props) => (
<span className='text-success'>
{formatCurrency(props.row.original.sales_paid_amount)}
</span>
),
},
{
accessorKey: 'sales_remaining_amount',
header: 'Jumlah Sisa Bayar',
cell: (props) => (
<span className='text-error'>
{formatCurrency(props.row.original.sales_remaining_amount)}
</span>
),
},
{
accessorKey: 'sales_payment_status',
header: 'Status Pembayaran',
},
{
accessorKey: 'project_status',
header: 'Status',
},
{
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 - 3;
return (
<>
{currentPageSize > 3 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu type='dropdown' props={props} />
</RowDropdownOptions>
)}
{currentPageSize <= 3 && (
<RowCollapseOptions>
<RowOptionsMenu type='collapse' props={props} />
</RowCollapseOptions>
)}
</>
);
},
},
];
const {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const [selectedLocation, setSelectedLocation] = useState<OptionType | null>(
null
);
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
setSelectedLocation(val as OptionType);
updateFilter(
'locationId',
val ? ((val as OptionType).value as string) : ''
);
};
const [selectedProjectStatus, setSelectedProjectStatus] =
useState<OptionType | null>(null);
const projectStatusChangeHandler = (
val: OptionType | OptionType[] | null
) => {
setSelectedProjectStatus(val as OptionType);
updateFilter(
'projectStatus',
val ? ((val as OptionType).value as string) : ''
);
};
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
// 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='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-end items-end sm:items-center gap-4'>
<DebouncedTextInput
name='search'
placeholder='Cari Closing'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div>
<div className='grid grid-cols-12 justify-end gap-2'>
<SelectInput
label='Lokasi'
options={locationOptions}
isLoading={isLoadingLocationOptions}
value={selectedLocation}
onChange={locationChangeHandler}
onInputChange={setLocationInputValue}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-6',
}}
/>
<SelectInput
label='Status Project'
placeholder='Pilih Status'
options={PROJECT_STATUS_OPTIONS}
value={selectedProjectStatus}
onChange={projectStatusChangeHandler}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-6',
}}
/>
</div>
</div>
</div>
<Table<Closing>
data={isResponseSuccess(closings) ? closings?.data : []}
columns={closingsColumns}
pageSize={tableFilterState.pageSize}
onPageSizeChange={setPageSize}
rowOptions={[10, 20, 50, 100]}
page={isResponseSuccess(closings) ? closings?.meta?.page : 0}
totalItems={
isResponseSuccess(closings) ? closings?.meta?.total_results : 0
}
onPageChange={setPage}
isLoading={isLoadingClosings}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
className={{
containerClassName: cn({
'w-full mb-20':
isResponseSuccess(closings) && closings?.data?.length === 0,
}),
}}
/>
</div>
</>
);
};
export default ClosingsTable;
+38 -74
View File
@@ -1,161 +1,125 @@
type MAIN_DRAWER_MENU = { import { SidebarMenuItem } from '@/components/molecules/SidebarMenu';
title: string;
link: string;
icon: string;
submenu?: MAIN_DRAWER_MENU[];
};
export const MAIN_DRAWER_LINKS: MAIN_DRAWER_MENU[] = [ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
{ {
title: 'Dashboard', text: 'Dashboard',
link: '/dashboard', link: '/dashboard',
icon: 'gg:chart', icon: 'heroicons-outline:chart-bar-square',
}, },
{ {
title: 'Produksi', text: 'Produksi',
link: '/production', link: '/production',
icon: 'material-symbols:conveyor-belt-outline-rounded', icon: 'heroicons-outline:wrench-screwdriver',
submenu: [ submenu: [
{ {
title: 'List Flock', text: 'Daftar Flock',
link: '/production/project-flock', link: '/production/project-flock',
icon: 'material-symbols:list-alt-add-outline-rounded',
}, },
// { // DI HILANGKAN PADA VERSI REFACTORING
// title: 'Chick In',
// link: '/production/chickin',
// icon: 'mdi:home-import-outline',
// },
{ {
title: 'Recording', text: 'Recording',
link: '/production/recording', link: '/production/recording',
icon: 'mdi:clipboard-text',
}, },
{ {
title: 'Transfer ke Laying', text: 'Transfer to Laying',
link: '/production/transfer-to-laying', link: '/production/transfer-to-laying',
icon: 'streamline:transfer-van',
}, },
], ],
}, },
{ {
title: 'Pembelian', text: 'Pembelian',
link: '/purchase', link: '/purchase',
icon: 'gg:shopping-cart', icon: 'heroicons-outline:shopping-cart',
}, },
{ {
title: 'Penjualan', text: 'Penjualan',
link: '/marketing', link: '/marketing',
icon: 'mdi:attach-money', icon: 'heroicons-outline:currency-dollar',
}, },
{ {
title: 'Biaya Operasional', text: 'Biaya Operasional',
link: '/expense', link: '/expense',
icon: 'uil:wallet', icon: 'heroicons:wallet',
}, },
{ {
title: 'Perhitungan Sapronak', text: 'Closing',
link: '/us-284', link: '/closing',
icon: 'uil:calculator', icon: 'heroicons-outline:presentation-chart-bar',
}, },
{ {
title: 'Persediaan', text: 'Persediaan',
link: '/inventory', link: '/inventory',
icon: 'mdi:warehouse', icon: 'heroicons-outline:folder',
submenu: [ submenu: [
{ {
title: 'Produk', text: 'Produk',
link: '/inventory/product', link: '/inventory/product',
icon: 'mdi:package-variant-closed',
}, },
{ {
title: 'Penyesuaian Stok', text: 'Penyesuaian Stok',
link: '/inventory/adjustment', link: '/inventory/adjustment',
icon: 'mdi:database-edit',
}, },
{ {
title: 'Transfer Stok', text: 'Transfer Stok',
link: '/inventory/movement', link: '/inventory/movement',
icon: 'mdi:swap-horizontal',
}, },
], ],
}, },
{ {
title: 'Master Data', text: 'Master Data',
link: '/master-data', link: '/master-data',
icon: 'majesticons:data-line', icon: 'heroicons-outline:circle-stack',
submenu: [ submenu: [
{ {
title: 'Product', text: 'Produk',
link: '/master-data/product', link: '/master-data/product',
icon: 'fluent-mdl2:product-variant',
}, },
{ {
title: 'Product Category', text: 'Kategori Produk',
link: '/master-data/product-category', link: '/master-data/product-category',
icon: 'carbon:categories',
}, },
{ {
title: 'Bank', text: 'Bank',
link: '/master-data/bank', link: '/master-data/bank',
icon: 'mdi:bank-outline',
}, },
{ {
title: 'Area', text: 'Area',
link: '/master-data/area', link: '/master-data/area',
icon: 'majesticons:map-marker-area-line',
}, },
{ {
title: 'Location', text: 'Lokasi',
link: '/master-data/location', link: '/master-data/location',
icon: 'mingcute:location-line',
}, },
{ {
title: 'Kandang', text: 'Kandang',
link: '/master-data/kandang', link: '/master-data/kandang',
icon: 'mdi:farm-home-outline',
}, },
{ {
title: 'Warehouse', text: 'Warehouse',
link: '/master-data/warehouse', link: '/master-data/warehouse',
icon: 'hugeicons:warehouse',
}, },
{ {
title: 'Customer', text: 'Customer',
link: '/master-data/customer', link: '/master-data/customer',
icon: 'ix:customer',
}, },
{ {
title: 'UOM', text: 'UOM',
link: '/master-data/uom', link: '/master-data/uom',
icon: 'lsicon:measure-outline',
}, },
{ {
title: 'Non-Stock', text: 'Non-Stock',
link: '/master-data/nonstock', link: '/master-data/nonstock',
icon: 'fluent:box-32-regular',
}, },
{ {
title: 'FCR', text: 'FCR',
link: '/master-data/fcr', link: '/master-data/fcr',
icon: 'fluent:food-chicken-leg-16-regular',
}, },
{ {
title: 'Supplier', text: 'Supplier',
link: '/master-data/supplier', link: '/master-data/supplier',
icon: 'material-symbols:add-business-outline-rounded',
}, },
{ {
title: 'Flock', text: 'Flock',
link: '/master-data/flock', link: '/master-data/flock',
icon: 'material-symbols:raven-outline-rounded',
}, },
], ],
}, },
+15
View File
@@ -10,6 +10,8 @@ export const sleep = (ms: number = 1000) =>
new Promise((resolve) => setTimeout(resolve, ms)); new Promise((resolve) => setTimeout(resolve, ms));
export const formatDate = (date: moment.MomentInput, format?: string) => { export const formatDate = (date: moment.MomentInput, format?: string) => {
if (!date) return '-';
return moment(date).format(format); return moment(date).format(format);
}; };
@@ -127,3 +129,16 @@ export const convertRowSelectionObjToArr = (
return result; return result;
}; };
export 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;
};
+54
View File
@@ -0,0 +1,54 @@
import axios from 'axios';
import { BaseApiService } from '@/services/api/base';
import {
Closing,
ClosingGeneralInformation,
ClosingIncomingSapronak,
ClosingOutgoingSapronak,
} from '@/types/api/closing';
import { httpClient, httpClientFetcher } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general';
export class ClosingApiService extends BaseApiService<Closing, null, null> {
constructor(basePath: string) {
super(basePath);
}
async getAllIncomingSapronakFetcher(
endpoint: string
): Promise<BaseApiResponse<ClosingIncomingSapronak[]>> {
return await httpClientFetcher<BaseApiResponse<ClosingIncomingSapronak[]>>(
endpoint
);
}
async getAllOutgoingSapronakFetcher(
endpoint: string
): Promise<BaseApiResponse<ClosingOutgoingSapronak[]>> {
return await httpClientFetcher<BaseApiResponse<ClosingOutgoingSapronak[]>>(
endpoint
);
}
async getGeneralInfo(id: number) {
try {
const getGeneralInfoPath = `${this.basePath}/${id}`;
const getGeneralInfoRes =
await httpClient<BaseApiResponse<ClosingGeneralInformation>>(
getGeneralInfoPath
);
return getGeneralInfoRes;
} catch (error) {
if (
axios.isAxiosError<BaseApiResponse<ClosingGeneralInformation>>(error)
) {
return error.response?.data;
}
return undefined;
}
}
}
export const ClosingApi = new ClosingApiService('/closing');
+5
View File
@@ -1,4 +1,9 @@
@layer utilities { @layer utilities {
.menu {
--menu-active-fg: var(--color-primary);
--menu-active-bg: transparent;
}
.step.step-success::before { .step.step-success::before {
--step-bg: var(--color-success); --step-bg: var(--color-success);
--step-fg: var(--color-success-content); --step-fg: var(--color-success-content);
+55
View File
@@ -0,0 +1,55 @@
import { Area } from '@/types/api/master-data/area';
import { Fcr } from '@/types/api/master-data/fcr';
import { Flock } from '@/types/api/master-data/flock';
import { Kandang } from '@/types/api/master-data/kandang';
import { Location } from '@/types/api/master-data/location';
import { BaseApproval, BaseMetadata } from '@/types/api/api-general';
export type BaseClosing = {
id: number;
location_id: number;
location_name: string;
project_category: 'GROWING' | 'LAYING';
period: number;
closing_date?: string;
shed_label: string;
shed_count: number;
sales_paid_amount: number;
sales_remaining_amount: number;
sales_payment_status: string;
project_status: 'Pengajuan' | 'Aktif' | 'Selesai';
};
export type Closing = BaseMetadata & BaseClosing;
export type BaseClosingGeneralInformation = BaseClosing & {
flock_id: number;
period: number;
project_type: 'GROWING' | 'LAYING';
population: number;
active_house_count: number;
sales_payment_status: string;
project_status: 'Pengajuan' | 'Aktif' | 'Selesai';
closing_status: string;
};
export type ClosingGeneralInformation = BaseMetadata &
BaseClosingGeneralInformation;
export type ClosingIncomingSapronak = {
id: number;
date: string;
reference_number: string;
transaction_type: string;
product_name: string;
product_category: string;
product_sub_category: string;
source_warehouse: string;
destination_warehouse: string;
quantity: number;
unit: string;
formatted_quantity: string;
notes: string;
};
export type ClosingOutgoingSapronak = ClosingIncomingSapronak;
+2 -2
View File
@@ -1,4 +1,4 @@
type Color = export type Color =
| 'primary' | 'primary'
| 'secondary' | 'secondary'
| 'accent' | 'accent'
@@ -9,4 +9,4 @@ type Color =
| 'error' | 'error'
| 'none'; | 'none';
export { Color }; export type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl';