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
+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 {
--font-inter: var(--font-inter);
--container-sm: 40rem;
--container-md: 48rem;
--container-lg: 64rem;
--container-xl: 80rem;
--container-2xl: 96rem;
}
html {
+7 -147
View File
@@ -1,161 +1,21 @@
'use client';
import { useCallback, useState } from 'react';
import { useCallback } 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 Button from '@/components/Button';
import SidebarMenu from '@/components/molecules/SidebarMenu';
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>
);
};
import { isPathActive } from '@/lib/helper';
const MainDrawerContent = () => {
const pathname = usePathname();
const { setMainDrawerOpen } = useUiStore();
const closeMainDrawerHandler = () => {
@@ -191,7 +51,7 @@ const MainDrawerContent = () => {
</div>
</div>
<MainDrawerMenu />
<SidebarMenu menu={MAIN_DRAWER_LINKS} activeLink={pathname} />
</div>
);
};
@@ -216,9 +76,9 @@ const MainDrawer = ({
const hasSubmenu = menu?.submenu && menu?.submenu.length > 0;
if (!title) {
title += menu?.title;
title += menu?.text;
} else {
title += ' - ' + menu?.title;
title += ' - ' + menu?.text;
}
if (!hasSubmenu || !menu.submenu) return;
+302 -212
View File
@@ -1,7 +1,9 @@
'use client';
import { ReactNode } from 'react';
import { ChangeEventHandler, ReactNode } from 'react';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import { cn } from '@/lib/helper';
@@ -17,16 +19,18 @@ const PaginationButton = ({
disabled?: boolean;
onClick?: () => void;
}) => (
<button
className={cn(
'join-item btn btn-ghost p-2.5 rounded-lg text-sm font-medium text-gray-500 aspect-square',
'disabled:text-gray-700 disabled:pointer-events-auto! disabled:cursor-not-allowed! disabled:bg-gray-50 disabled:active:translate-y-0'
)}
<Button
variant='ghost'
color='none'
disabled={disabled}
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}
</button>
</Button>
);
const EtcPaginationButton = ({
@@ -48,7 +52,7 @@ const EtcPaginationButton = ({
tabIndex={0}
role='button'
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'>
<ul
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) => (
<li key={pageNumber}>
@@ -76,7 +80,7 @@ const EtcPaginationButton = ({
<button
disabled
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,
totalItems = 0,
itemsPerPage = 10,
rowOptions = [10, 20, 50, 100],
onPageChange,
onPrevPage = () => {},
onNextPage = () => {},
onRowChange,
}: {
currentPage: number;
totalItems: number;
itemsPerPage: number;
rowOptions?: number[];
onPageChange: (pageNumber: number) => void;
onPrevPage: () => void;
onNextPage: () => void;
onRowChange?: (row: number) => void;
}) => {
const totalPages =
Math.ceil(totalItems / itemsPerPage) === 0
@@ -107,30 +115,139 @@ const Pagination = ({
: Math.ceil(totalItems / itemsPerPage);
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 (
<div>
<div className='join w-full justify-between items-center gap-3'>
<button
disabled={currentPage === 1}
onClick={onPrevPage}
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>
<div className='@container'>
<div className='flex flex-row justify-center items-center'>
<div className='hidden @md:block'>
<DisplayedRowCountSelect />
</div>
{totalPages <= 7 && (
<div className='join-item join gap-0.5'>
{range(1, totalPages).map((pageNumber) => (
<div className='join w-full justify-end @md:justify-center items-center gap-0.5'>
<div className='hidden @md:block'>
<GoToFirstPageButton />
</div>
<div className='hidden @md:block'>
<PrevPageButton />
</div>
{totalPages <= 7 &&
range(1, totalPages).map((pageNumber) => (
<PaginationButton
key={pageNumber}
content={pageNumber}
@@ -138,195 +255,168 @@ const Pagination = ({
onClick={() => pageChangeHandler(pageNumber)}
/>
))}
</div>
)}
{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 && (
{totalPages > 7 && (
<>
<PaginationButton
content={totalPages}
disabled={currentPage === totalPages}
onClick={() => pageChangeHandler(totalPages)}
content={1}
disabled={currentPage === 1}
onClick={() => pageChangeHandler(1)}
/>
)}
</div>
)}
<button
disabled={currentPage === totalPages}
onClick={onNextPage}
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'
{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
content={totalPages}
disabled={currentPage === totalPages}
onClick={() => pageChangeHandler(totalPages)}
/>
)}
</>
)}
>
Next{' '}
<Icon
icon='uil:arrow-right'
width={20}
height={20}
className='text-gray-400 group-disabled:text-gray-300'
/>
</button>
<div className='hidden @md:block'>
<NextPageButton />
</div>
<div className='hidden @md:block'>
<GoToLastPageButton />
</div>
</div>
<div className='hidden @md:block'>
<PageInfo />
</div>
</div>
<div className='flex gap-2 mt-2 sm:hidden'>
<button
disabled={currentPage === 1}
onClick={onPrevPage}
className={cn(
'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',
'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>
<div className='flex @md:hidden flex-col justify-center items-end gap-2'>
<div className='flex flex-row items-center gap-0.5'>
<GoToFirstPageButton />
<PrevPageButton />
<NextPageButton />
<GoToLastPageButton />
</div>
<button
disabled={currentPage === totalPages}
onClick={onNextPage}
className={cn(
'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',
'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 className='flex flex-row items-center gap-4'>
<DisplayedRowCountSelect />
<PageInfo />
</div>
</div>
</div>
);
+59 -29
View File
@@ -38,6 +38,7 @@ export interface TableProps<TData extends object> {
data: TData[];
columns: ColumnDef<TData, unknown>[];
pageSize?: number;
onPageSizeChange?: (pageSize: number) => void;
totalItems?: number;
page?: number;
onPageChange?: (page: number) => void;
@@ -52,6 +53,8 @@ export interface TableProps<TData extends object> {
rowSelection?: Record<string, boolean>;
setRowSelection?: OnChangeFn<Record<string, boolean>>;
enableRowSelection?: boolean | ((row: Row<TData>) => boolean);
withCheckbox?: boolean;
rowOptions?: number[];
}
const DUMMY_SKELETON_DATA = [{}, {}, {}, {}, {}];
@@ -64,28 +67,32 @@ const emptyContentDefaultValue = (
</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>({
data = [],
columns = [],
pageSize = 10,
onPageSizeChange,
totalItems,
page,
onPageChange,
isLoading = false,
fuzzySearchValue,
onFuzzySearchValueChange,
className = {
containerClassName: '',
tableWrapperClassName: '',
tableClassName: '',
tableHeaderClassName: '',
headerRowClassName: '',
headerColumnClassName: '',
tableBodyClassName: '',
bodyRowClassName: '',
bodyColumnClassName: '',
paginationClassName: '',
},
className = TABLE_DEFAULT_STYLING,
emptyContent = emptyContentDefaultValue,
sorting,
setSorting,
@@ -93,12 +100,19 @@ const Table = <TData extends object>({
rowSelection,
setRowSelection,
enableRowSelection,
withCheckbox = false,
rowOptions = [10, 20, 50, 100],
}: TableProps<TData>) => {
const isServerSideTable =
totalItems !== undefined &&
page !== undefined &&
onPageChange !== undefined;
const tableClassNames = {
...TABLE_DEFAULT_STYLING,
...className,
};
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: pageSize,
@@ -191,12 +205,15 @@ const Table = <TData extends object>({
}, [pageSize, setPageSize]);
return (
<div className={className.containerClassName}>
<div className={className.tableWrapperClassName}>
<table className={className.tableClassName}>
<thead className={className.tableHeaderClassName}>
<div className={tableClassNames.containerClassName}>
<div className={tableClassNames.tableWrapperClassName}>
<table className={tableClassNames.tableClassName}>
<thead className={tableClassNames.tableHeaderClassName}>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className={className.headerRowClassName}>
<tr
key={headerGroup.id}
className={tableClassNames.headerRowClassName}
>
{headerGroup.headers.map((header) => (
<th
key={header.id}
@@ -206,7 +223,10 @@ const Table = <TData extends object>({
header.column.getCanSort()
? 'cursor-pointer select-none'
: '',
className.headerColumnClassName
{
'first:w-9 first:pr-0': withCheckbox,
},
tableClassNames.headerColumnClassName
)}
>
<div className='flex items-center gap-1'>
@@ -216,12 +236,13 @@ const Table = <TData extends object>({
)}
{header.column.getCanSort() && (
<div className='flex items-center'>
<div className='w-4 h-4 relative flex flex-col items-center'>
<Icon
icon='lucide:arrow-up'
width={12}
height={12}
icon='heroicons:chevron-up-16-solid'
width={18}
height={18}
className={cn(
'absolute -top-1',
'transition-all ease-in-out duration-200',
header.column.getIsSorted() === 'asc'
? 'text-black'
@@ -229,10 +250,11 @@ const Table = <TData extends object>({
)}
/>
<Icon
icon='lucide:arrow-down'
width={12}
height={12}
icon='heroicons:chevron-down-16-solid'
width={18}
height={18}
className={cn(
'absolute -bottom-1.5',
'transition-all ease-in-out duration-200',
header.column.getIsSorted() === 'desc'
? 'text-black'
@@ -248,11 +270,17 @@ const Table = <TData extends object>({
))}
</thead>
<tbody className={className.tableBodyClassName}>
<tbody className={tableClassNames.tableBodyClassName}>
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className={className.bodyRowClassName}>
<tr key={row.id} className={tableClassNames.bodyRowClassName}>
{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 &&
flexRender(cell.column.columnDef.cell, cell.getContext())}
@@ -270,7 +298,7 @@ const Table = <TData extends object>({
emptyContent}
{data.length > 0 && table.getRowModel().rows.length > 0 && !isLoading && (
<div className={cn('mt-5', className.paginationClassName)}>
<div className={cn('mt-5', tableClassNames.paginationClassName)}>
<Pagination
totalItems={isServerSideTable ? totalItems : table.getRowCount()}
itemsPerPage={table.getState().pagination.pageSize}
@@ -282,6 +310,8 @@ const Table = <TData extends object>({
onPrevPage={prevPageClickHandler}
onNextPage={nextPageClickHandler}
onPageChange={pageChangeHandler}
rowOptions={rowOptions}
onRowChange={onPageSizeChange}
/>
</div>
)}
+13 -2
View File
@@ -2,8 +2,9 @@
import { HTMLProps, useEffect, useRef } from 'react';
import { cn } from '@/lib/helper';
import { Size } from '@/types/theme';
interface CheckboxInputProps extends HTMLProps<HTMLInputElement> {
interface CheckboxInputProps extends Omit<HTMLProps<HTMLInputElement>, 'size'> {
name: string;
label?: string;
indeterminate?: boolean;
@@ -16,6 +17,7 @@ interface CheckboxInputProps extends HTMLProps<HTMLInputElement> {
isError?: boolean;
isValid?: boolean;
errorMessage?: string;
size?: Size;
}
const CheckboxInput = ({
@@ -27,10 +29,19 @@ const CheckboxInput = ({
isValid,
isError,
errorMessage,
size = 'sm',
...rest
}: CheckboxInputProps) => {
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(() => {
if (typeof indeterminate === 'boolean') {
ref.current.indeterminate = !rest.checked && indeterminate;
@@ -53,7 +64,7 @@ const CheckboxInput = ({
id={name}
name={name}
className={cn(
'checkbox rounded-md cursor-pointer',
checkboxBaseClassName,
{
'border-error': isError,
'border-success': isValid,
+20 -4
View File
@@ -1,16 +1,32 @@
import { ReactNode } from 'react';
import { cn } from '@/lib/helper';
import { Size } from '@/types/theme';
interface MenuProps {
children?: ReactNode;
size?: Size;
direction?: 'vertical' | 'horizontal';
className?: string;
}
const Menu = ({ children, className }: MenuProps) => {
return (
<ul className={cn('menu w-full p-0 gap-0.5', className)}>{children}</ul>
);
const Menu = ({
children,
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;
+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 = {
title: string;
link: string;
icon: string;
submenu?: MAIN_DRAWER_MENU[];
};
import { SidebarMenuItem } from '@/components/molecules/SidebarMenu';
export const MAIN_DRAWER_LINKS: MAIN_DRAWER_MENU[] = [
export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
{
title: 'Dashboard',
text: 'Dashboard',
link: '/dashboard',
icon: 'gg:chart',
icon: 'heroicons-outline:chart-bar-square',
},
{
title: 'Produksi',
text: 'Produksi',
link: '/production',
icon: 'material-symbols:conveyor-belt-outline-rounded',
icon: 'heroicons-outline:wrench-screwdriver',
submenu: [
{
title: 'List Flock',
text: 'Daftar 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',
icon: 'mdi:clipboard-text',
},
{
title: 'Transfer ke Laying',
text: 'Transfer to Laying',
link: '/production/transfer-to-laying',
icon: 'streamline:transfer-van',
},
],
},
{
title: 'Pembelian',
text: 'Pembelian',
link: '/purchase',
icon: 'gg:shopping-cart',
icon: 'heroicons-outline:shopping-cart',
},
{
title: 'Penjualan',
text: 'Penjualan',
link: '/marketing',
icon: 'mdi:attach-money',
icon: 'heroicons-outline:currency-dollar',
},
{
title: 'Biaya Operasional',
text: 'Biaya Operasional',
link: '/expense',
icon: 'uil:wallet',
icon: 'heroicons:wallet',
},
{
title: 'Perhitungan Sapronak',
link: '/us-284',
icon: 'uil:calculator',
text: 'Closing',
link: '/closing',
icon: 'heroicons-outline:presentation-chart-bar',
},
{
title: 'Persediaan',
text: 'Persediaan',
link: '/inventory',
icon: 'mdi:warehouse',
icon: 'heroicons-outline:folder',
submenu: [
{
title: 'Produk',
text: 'Produk',
link: '/inventory/product',
icon: 'mdi:package-variant-closed',
},
{
title: 'Penyesuaian Stok',
text: 'Penyesuaian Stok',
link: '/inventory/adjustment',
icon: 'mdi:database-edit',
},
{
title: 'Transfer Stok',
text: 'Transfer Stok',
link: '/inventory/movement',
icon: 'mdi:swap-horizontal',
},
],
},
{
title: 'Master Data',
text: 'Master Data',
link: '/master-data',
icon: 'majesticons:data-line',
icon: 'heroicons-outline:circle-stack',
submenu: [
{
title: 'Product',
text: 'Produk',
link: '/master-data/product',
icon: 'fluent-mdl2:product-variant',
},
{
title: 'Product Category',
text: 'Kategori Produk',
link: '/master-data/product-category',
icon: 'carbon:categories',
},
{
title: 'Bank',
text: 'Bank',
link: '/master-data/bank',
icon: 'mdi:bank-outline',
},
{
title: 'Area',
text: 'Area',
link: '/master-data/area',
icon: 'majesticons:map-marker-area-line',
},
{
title: 'Location',
text: 'Lokasi',
link: '/master-data/location',
icon: 'mingcute:location-line',
},
{
title: 'Kandang',
text: 'Kandang',
link: '/master-data/kandang',
icon: 'mdi:farm-home-outline',
},
{
title: 'Warehouse',
text: 'Warehouse',
link: '/master-data/warehouse',
icon: 'hugeicons:warehouse',
},
{
title: 'Customer',
text: 'Customer',
link: '/master-data/customer',
icon: 'ix:customer',
},
{
title: 'UOM',
text: 'UOM',
link: '/master-data/uom',
icon: 'lsicon:measure-outline',
},
{
title: 'Non-Stock',
text: 'Non-Stock',
link: '/master-data/nonstock',
icon: 'fluent:box-32-regular',
},
{
title: 'FCR',
text: 'FCR',
link: '/master-data/fcr',
icon: 'fluent:food-chicken-leg-16-regular',
},
{
title: 'Supplier',
text: 'Supplier',
link: '/master-data/supplier',
icon: 'material-symbols:add-business-outline-rounded',
},
{
title: 'Flock',
text: '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));
export const formatDate = (date: moment.MomentInput, format?: string) => {
if (!date) return '-';
return moment(date).format(format);
};
@@ -127,3 +129,16 @@ export const convertRowSelectionObjToArr = (
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 {
.menu {
--menu-active-fg: var(--color-primary);
--menu-active-bg: transparent;
}
.step.step-success::before {
--step-bg: var(--color-success);
--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'
| 'secondary'
| 'accent'
@@ -9,4 +9,4 @@ type Color =
| 'error'
| 'none';
export { Color };
export type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl';