refactor: optimize BalanceMonitoringTab with useTableFilter persistence pattern

Replace single-select customerFilter/salesFilter with OptionType[] multi-select
(customers, salesPersons, filterBy), switch SWR to httpClientFetcher with explicit
type, remove PDF export, enableReinitialize, useRef modal hack, useMemo on data/meta,
and useCallback on trivial handlers. Add formikResetHandler using resetFilter().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ValdiANS
2026-05-20 16:10:01 +07:00
parent 7437e2e584
commit c98a51326f
@@ -1,21 +1,22 @@
'use client';
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { useState, useMemo, useEffect } from 'react';
import useSWR from 'swr';
import { Icon } from '@iconify/react';
import { useFormik } from 'formik';
import toast from 'react-hot-toast';
import { ColumnDef, SortingState, Updater } from '@tanstack/react-table';
import { ColumnDef } from '@tanstack/react-table';
import { AxiosError } from 'axios';
import { FinanceApi } from '@/services/api/report/finance-report';
import { CustomerApi } from '@/services/api/master-data';
import { UserApi } from '@/services/api/user';
import SelectInput, {
useSelect,
OptionType,
} from '@/components/input/SelectInput';
import { useSelect, OptionType } from '@/components/input/SelectInput';
import { httpClientFetcher, SWRHttpKey } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import SelectInputRadio from '@/components/input/SelectInputRadio';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
import Dropdown from '@/components/Dropdown';
import ButtonFilter from '@/components/helper/ButtonFilter';
import { formatCurrency, formatNumber } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
@@ -24,7 +25,6 @@ import { CustomerPaymentRow } from '@/types/api/report/customer-payment';
import Modal, { useModal } from '@/components/Modal';
import Button from '@/components/Button';
import DateInput from '@/components/input/DateInput';
import Pagination from '@/components/Pagination';
import Table from '@/components/Table';
import CustomerSupplierSkeleton from '@/components/pages/report/finance/skeleton/CustomerSupplierSkeleton';
@@ -32,12 +32,15 @@ interface BalanceMonitoringTabProps {
tabId: string;
}
const filterByOptions: OptionType<string>[] = [
{ label: 'Tanggal Penjualan (SO Date)', value: 'sold_at' },
{ label: 'Tanggal Realisasi (Delivery Date)', value: 'realized_at' },
];
const BalanceMonitoringTab = ({ tabId }: BalanceMonitoringTabProps) => {
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
const [hasDateError, setHasDateError] = useState(false);
const [dateErrorShown, setDateErrorShown] = useState(false);
const handleFilterModalOpenRef = useRef(() => {});
const filterModal = useModal();
const setTabActions = useTabActionsStore((state) => state.setTabActions);
@@ -48,20 +51,23 @@ const BalanceMonitoringTab = ({ tabId }: BalanceMonitoringTabProps) => {
updateFilter,
setPage,
setPageSize,
toQueryString,
toQueryString: getTableFilterQueryString,
reset: resetFilter,
} = useTableFilter<{
start_date: string;
end_date: string;
customerFilter?: OptionType<number>;
salesFilter?: OptionType<number>;
customers: OptionType<number>[];
salesPersons: OptionType<number>[];
filterBy?: OptionType<string>;
sort_by: string;
order_by: string;
}>({
initial: {
start_date: '',
end_date: '',
customerFilter: undefined,
salesFilter: undefined,
customers: [],
salesPersons: [],
filterBy: undefined,
sort_by: '',
order_by: '',
},
@@ -70,8 +76,9 @@ const BalanceMonitoringTab = ({ tabId }: BalanceMonitoringTabProps) => {
pageSize: 'limit',
start_date: 'start_date',
end_date: 'end_date',
customerFilter: 'customer_id',
salesFilter: 'sales_id',
customers: 'customer_ids',
salesPersons: 'sales_ids',
filterBy: 'filter_by',
sort_by: 'sort_by',
order_by: 'sort_order',
},
@@ -79,31 +86,25 @@ const BalanceMonitoringTab = ({ tabId }: BalanceMonitoringTabProps) => {
storeName: 'balance-monitoring-table',
});
// Keep a stable ref so handleExportPDF doesn't need toQueryString as a dep
const toQueryStringRef = useRef(toQueryString);
useEffect(() => {
toQueryStringRef.current = toQueryString;
});
// const sorting: SortingState = tableFilterState.sort_by
// ? [
// {
// id: tableFilterState.sort_by,
// desc: tableFilterState.order_by === 'desc',
// },
// ]
// : [];
const sorting: SortingState = tableFilterState.sort_by
? [
{
id: tableFilterState.sort_by,
desc: tableFilterState.order_by === 'desc',
},
]
: [];
const handleSortingChange = (updater: Updater<SortingState>) => {
const next = typeof updater === 'function' ? updater(sorting) : updater;
if (next.length > 0) {
updateFilter('sort_by', next[0].id, true);
updateFilter('order_by', next[0].desc ? 'desc' : 'asc', true);
} else {
updateFilter('sort_by', '', true);
updateFilter('order_by', '', true);
}
};
// const handleSortingChange = (updater: Updater<SortingState>) => {
// const next = typeof updater === 'function' ? updater(sorting) : updater;
// if (next.length > 0) {
// updateFilter('sort_by', next[0].id, true);
// updateFilter('order_by', next[0].desc ? 'desc' : 'asc', true);
// } else {
// updateFilter('sort_by', '', true);
// updateFilter('order_by', '', true);
// }
// };
const {
options: customerOptions,
@@ -123,33 +124,40 @@ const BalanceMonitoringTab = ({ tabId }: BalanceMonitoringTabProps) => {
initialValues: {
start_date: tableFilterState.start_date,
end_date: tableFilterState.end_date,
customerFilter: tableFilterState.customerFilter,
salesFilter: tableFilterState.salesFilter,
customers: tableFilterState.customers,
salesPersons: tableFilterState.salesPersons,
filterBy: tableFilterState.filterBy,
},
enableReinitialize: true,
onSubmit: (values) => {
updateFilter('start_date', values.start_date, true);
updateFilter('end_date', values.end_date, true);
updateFilter('customerFilter', values.customerFilter, true);
updateFilter('salesFilter', values.salesFilter, true);
filterModal.closeModal();
},
onReset: () => {
updateFilter('start_date', '', true);
updateFilter('end_date', '', true);
updateFilter('customerFilter', undefined, true);
updateFilter('salesFilter', undefined, true);
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
updateFilter('customers', values.customers, true);
updateFilter('salesPersons', values.salesPersons, true);
updateFilter('filterBy', values.filterBy, true);
filterModal.closeModal();
},
});
handleFilterModalOpenRef.current = () => {
filterModal.openModal();
const formikResetHandler = () => {
resetFilter();
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
formik.resetForm({
values: {
start_date: '',
end_date: '',
customers: [],
salesPersons: [],
filterBy: undefined,
},
});
filterModal.closeModal();
};
const handleStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -201,41 +209,26 @@ const BalanceMonitoringTab = ({ tabId }: BalanceMonitoringTabProps) => {
}
};
const queryString = toQueryString();
const { data: response, isLoading } = useSWR(queryString, (qs) =>
FinanceApi.getBalanceMonitoringReport(
Object.fromEntries(new URLSearchParams(qs)) as Parameters<
typeof FinanceApi.getBalanceMonitoringReport
>[0]
)
const { data: balanceMonitoringsResponse, isLoading } = useSWR<
BaseApiResponse<BalanceMonitoringRow[]>,
AxiosError<BaseApiResponse>,
SWRHttpKey
>(
`${FinanceApi.basePath}/balance-monitoring${getTableFilterQueryString()}`,
httpClientFetcher
);
const data: BalanceMonitoringRow[] = useMemo(
() =>
isResponseSuccess(response)
? ((response.data as BalanceMonitoringRow[]) ?? [])
: [],
[response]
);
const balanceMonitorings: BalanceMonitoringRow[] = isResponseSuccess(
balanceMonitoringsResponse
)
? ((balanceMonitoringsResponse.data as BalanceMonitoringRow[]) ?? [])
: [];
const meta = useMemo(
() => (isResponseSuccess(response) && response.meta ? response.meta : null),
[response]
);
// Stable — uses ref so toQueryString is always current without being a dep
const handleExportPDF = useCallback(async () => {
setIsPdfExportLoading(true);
try {
await FinanceApi.exportBalanceMonitoringToPDF(toQueryStringRef.current());
toast.success('PDF berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat PDF. Silakan coba lagi.');
} finally {
setIsPdfExportLoading(false);
}
}, []);
const meta =
isResponseSuccess(balanceMonitoringsResponse) &&
balanceMonitoringsResponse.meta
? balanceMonitoringsResponse.meta
: null;
// Inject tab actions directly — no nested component, no remount cycle
useEffect(() => {
@@ -246,85 +239,39 @@ const BalanceMonitoringTab = ({ tabId }: BalanceMonitoringTabProps) => {
values={{
start_date: tableFilterState.start_date,
end_date: tableFilterState.end_date,
customerFilter: tableFilterState.customerFilter,
salesFilter: tableFilterState.salesFilter,
customers: tableFilterState.customers,
salesPersons: tableFilterState.salesPersons,
filterBy: tableFilterState.filterBy,
}}
fieldGroups={[['start_date', 'end_date']]}
onClick={() => handleFilterModalOpenRef.current()}
onClick={filterModal.openModal}
variant='outline'
className='px-3 py-2.5'
/>
<Dropdown
align='end'
direction='bottom'
className={{
content:
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
}}
trigger={
<Button
variant='outline'
color='none'
isLoading={isPdfExportLoading}
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
>
<div className='flex flex-row items-center gap-1.5'>
<Icon
icon='heroicons:cloud-arrow-down'
width={20}
height={20}
/>
<span>Export</span>
<div className='w-px self-stretch bg-base-content/10' />
<Icon icon='heroicons:chevron-down' width={14} height={14} />
</div>
</Button>
}
>
<Button
variant='ghost'
color='none'
onClick={handleExportPDF}
isLoading={isPdfExportLoading}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:document' width={20} height={20} />
Export to PDF
</Button>
</Dropdown>
</div>
);
}, [
tabId,
setTabActions,
isPdfExportLoading,
handleExportPDF,
tableFilterState.start_date,
tableFilterState.end_date,
tableFilterState.customerFilter,
tableFilterState.salesFilter,
]);
}, [tabId, setTabActions, tableFilterState, filterModal.openModal]);
useEffect(() => {
return () => clearTabActions(tabId);
}, [tabId, clearTabActions]);
const page = meta?.page || tableFilterState.page;
const pageSize = meta?.limit || tableFilterState.pageSize;
const columns = useMemo(
(): ColumnDef<BalanceMonitoringRow>[] => [
{
header: 'No',
enableSorting: false,
cell: (props) => (page - 1) * pageSize + props.row.index + 1,
cell: (props) =>
(tableFilterState.page - 1) * tableFilterState.pageSize +
props.row.index +
1,
},
{
header: 'Customer',
accessorKey: 'customer_name',
accessorKey: 'customer.name',
enableSorting: true,
id: 'customer_name',
cell: ({ row }) => row.original.customer.name,
},
{
header: 'Saldo Awal',
@@ -342,34 +289,34 @@ const BalanceMonitoringTab = ({ tabId }: BalanceMonitoringTabProps) => {
columns: [
{
header: 'Ekor',
accessorKey: 'penjualan_ayam_ekor',
accessorKey: 'penjualan_ayam.ekor',
id: 'penjualan_ayam_ekor',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatNumber(row.original.penjualan_ayam_ekor)}
{formatNumber(row.original.penjualan_ayam.ekor)}
</div>
),
},
{
header: 'Kg',
accessorKey: 'penjualan_ayam_kg',
accessorKey: 'penjualan_ayam.kg',
id: 'penjualan_ayam_kg',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatNumber(row.original.penjualan_ayam_kg)}
{formatNumber(row.original.penjualan_ayam.kg)}
</div>
),
},
{
header: 'Nominal',
accessorKey: 'penjualan_ayam_nominal',
accessorKey: 'penjualan_ayam.nominal',
id: 'penjualan_ayam_nominal',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatCurrency(row.original.penjualan_ayam_nominal)}
{formatCurrency(row.original.penjualan_ayam.nominal)}
</div>
),
},
@@ -379,35 +326,35 @@ const BalanceMonitoringTab = ({ tabId }: BalanceMonitoringTabProps) => {
header: 'Penjualan Telur',
columns: [
{
header: 'Kuantitas',
accessorKey: 'penjualan_telur_kuantitas',
id: 'penjualan_telur_kuantitas',
header: 'Butir',
accessorKey: 'penjualan_telur.butir',
id: 'penjualan_telur_butir',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatNumber(row.original.penjualan_telur_kuantitas)}
{formatNumber(row.original.penjualan_telur.butir)}
</div>
),
},
{
header: 'Kg',
accessorKey: 'penjualan_telur_kg',
accessorKey: 'penjualan_telur.kg',
id: 'penjualan_telur_kg',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatNumber(row.original.penjualan_telur_kg)}
{formatNumber(row.original.penjualan_telur.kg)}
</div>
),
},
{
header: 'Nominal',
accessorKey: 'penjualan_telur_nominal',
accessorKey: 'penjualan_telur.nominal',
id: 'penjualan_telur_nominal',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatCurrency(row.original.penjualan_telur_nominal)}
{formatCurrency(row.original.penjualan_telur.nominal)}
</div>
),
},
@@ -415,12 +362,12 @@ const BalanceMonitoringTab = ({ tabId }: BalanceMonitoringTabProps) => {
},
{
header: 'Penjualan Trading',
accessorKey: 'penjualan_trading',
accessorKey: 'penjualan_trading.nominal',
id: 'penjualan_trading',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatCurrency(row.original.penjualan_trading)}
{formatCurrency(row.original.penjualan_trading.nominal)}
</div>
),
},
@@ -471,7 +418,7 @@ const BalanceMonitoringTab = ({ tabId }: BalanceMonitoringTabProps) => {
),
},
],
[page, pageSize]
[tableFilterState.page, tableFilterState.pageSize]
);
return (
@@ -483,7 +430,7 @@ const BalanceMonitoringTab = ({ tabId }: BalanceMonitoringTabProps) => {
</div>
)}
{!isLoading && data.length === 0 && (
{!isLoading && balanceMonitorings.length === 0 && (
<CustomerSupplierSkeleton
columns={columns as unknown as ColumnDef<CustomerPaymentRow>[]}
icon={
@@ -499,20 +446,20 @@ const BalanceMonitoringTab = ({ tabId }: BalanceMonitoringTabProps) => {
/>
)}
{!isLoading && data.length > 0 && (
{!isLoading && balanceMonitorings.length > 0 && (
<>
<div className='w-full overflow-x-auto'>
<Table
data={data}
data={balanceMonitorings}
columns={columns}
pageSize={pageSize}
page={meta?.page || 1}
pageSize={tableFilterState.pageSize || 10}
page={tableFilterState.page || 1}
totalItems={meta?.total_results || 0}
onPageChange={setPage}
onPageSizeChange={setPageSize}
sorting={sorting}
setSorting={handleSortingChange}
manualSorting
// sorting={sorting}
// setSorting={handleSortingChange}
// manualSorting
className={{
containerClassName: 'w-full mb-0!',
tableWrapperClassName: 'overflow-x-auto',
@@ -524,31 +471,9 @@ const BalanceMonitoringTab = ({ tabId }: BalanceMonitoringTabProps) => {
'hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200',
bodyColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
paginationClassName: 'hidden',
}}
/>
</div>
{meta && (
<div className='mt-5 px-3'>
<Pagination
totalItems={meta.total_results || 0}
itemsPerPage={meta.limit || 0}
currentPage={meta.page || 0}
onPrevPage={() => setPage(Math.max(1, (meta.page || 1) - 1))}
onNextPage={() =>
setPage(
meta.total_pages && (meta.page || 1) < meta.total_pages
? (meta.page || 1) + 1
: meta.page || 1
)
}
onPageChange={setPage}
rowOptions={[10, 20, 50, 100]}
onRowChange={setPageSize}
/>
</div>
)}
</>
)}
</div>
@@ -576,7 +501,7 @@ const BalanceMonitoringTab = ({ tabId }: BalanceMonitoringTabProps) => {
</Button>
</div>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
<div className='p-4 flex flex-col gap-3'>
<div>
<label className='block text-xs font-semibold text-base-content py-2'>
@@ -602,16 +527,13 @@ const BalanceMonitoringTab = ({ tabId }: BalanceMonitoringTabProps) => {
</div>
</div>
<SelectInput
<SelectInputCheckbox
label='Customer'
placeholder='Pilih Customer'
options={customerOptions}
value={formik.values.customerFilter ?? null}
value={formik.values.customers}
onChange={(val) =>
formik.setFieldValue(
'customerFilter',
val as OptionType<number> | null
)
formik.setFieldValue('customers', Array.isArray(val) ? val : [])
}
onInputChange={setCustomerInput}
isLoading={isLoadingCustomers}
@@ -620,15 +542,15 @@ const BalanceMonitoringTab = ({ tabId }: BalanceMonitoringTabProps) => {
className={{ wrapper: 'w-full' }}
/>
<SelectInput
<SelectInputCheckbox
label='Sales'
placeholder='Pilih Sales'
options={salesOptions}
value={formik.values.salesFilter ?? null}
value={formik.values.salesPersons}
onChange={(val) =>
formik.setFieldValue(
'salesFilter',
val as OptionType<number> | null
'salesPersons',
Array.isArray(val) ? val : []
)
}
onInputChange={setSalesInput}
@@ -637,6 +559,21 @@ const BalanceMonitoringTab = ({ tabId }: BalanceMonitoringTabProps) => {
onMenuScrollToBottom={loadMoreSales}
className={{ wrapper: 'w-full' }}
/>
<SelectInputRadio
label='Filter Berdasarkan'
placeholder='Pilih Filter Berdasarkan'
options={filterByOptions}
value={formik.values.filterBy ?? null}
onChange={(val) =>
formik.setFieldValue(
'filterBy',
!Array.isArray(val) ? (val ?? undefined) : undefined
)
}
isClearable
className={{ wrapper: 'w-full' }}
/>
</div>
{/* Modal Footer */}