feat: create BalanceMonitoringTab component

This commit is contained in:
ValdiANS
2026-05-19 17:30:28 +07:00
parent 9350a6bd3e
commit 3c175d4586
@@ -0,0 +1,665 @@
'use client';
import { useState, useMemo, useCallback, useEffect, useRef } 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 { 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 { 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';
import { BalanceMonitoringRow } from '@/types/api/report/balance-monitoring';
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';
interface BalanceMonitoringTabProps {
tabId: string;
}
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);
const clearTabActions = useTabActionsStore((state) => state.clearTabActions);
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString,
} = useTableFilter<{
start_date: string;
end_date: string;
customerFilter?: OptionType<number>;
salesFilter?: OptionType<number>;
sort_by: string;
order_by: string;
}>({
initial: {
start_date: '',
end_date: '',
customerFilter: undefined,
salesFilter: undefined,
sort_by: '',
order_by: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
start_date: 'start_date',
end_date: 'end_date',
customerFilter: 'customer_id',
salesFilter: 'sales_id',
sort_by: 'sort_by',
order_by: 'sort_order',
},
persist: true,
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 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,
setInputValue: setCustomerInput,
isLoadingOptions: isLoadingCustomers,
loadMore: loadMoreCustomers,
} = useSelect(CustomerApi.basePath, 'id', 'name', 'search');
const {
options: salesOptions,
setInputValue: setSalesInput,
isLoadingOptions: isLoadingSales,
loadMore: loadMoreSales,
} = useSelect(UserApi.basePath, 'id', 'name', 'search');
const formik = useFormik({
initialValues: {
start_date: tableFilterState.start_date,
end_date: tableFilterState.end_date,
customerFilter: tableFilterState.customerFilter,
salesFilter: tableFilterState.salesFilter,
},
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);
}
filterModal.closeModal();
},
});
handleFilterModalOpenRef.current = () => {
filterModal.openModal();
};
const handleStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
formik.setFieldValue('start_date', value);
if (value && formik.values.end_date) {
if (new Date(formik.values.end_date) < new Date(value)) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
} else {
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
}
} else {
setHasDateError(false);
}
};
const handleEndDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
formik.setFieldValue('end_date', value);
if (value && formik.values.start_date) {
if (new Date(value) < new Date(formik.values.start_date)) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
return;
}
}
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
};
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: BalanceMonitoringRow[] = useMemo(
() =>
isResponseSuccess(response)
? ((response.data as BalanceMonitoringRow[]) ?? [])
: [],
[response]
);
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);
}
}, []);
// Inject tab actions directly — no nested component, no remount cycle
useEffect(() => {
setTabActions(
tabId,
<div className='flex flex-row gap-3'>
<ButtonFilter
values={{
start_date: tableFilterState.start_date,
end_date: tableFilterState.end_date,
customerFilter: tableFilterState.customerFilter,
salesFilter: tableFilterState.salesFilter,
}}
fieldGroups={[['start_date', 'end_date']]}
onClick={() => handleFilterModalOpenRef.current()}
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,
]);
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,
},
{
header: 'Customer',
accessorKey: 'customer_name',
enableSorting: true,
id: 'customer_name',
},
{
header: 'Saldo Awal',
accessorKey: 'saldo_awal',
id: 'saldo_awal',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatCurrency(row.original.saldo_awal)}
</div>
),
},
{
header: 'Penjualan Ayam',
columns: [
{
header: 'Ekor',
accessorKey: 'penjualan_ayam_ekor',
id: 'penjualan_ayam_ekor',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatNumber(row.original.penjualan_ayam_ekor)}
</div>
),
},
{
header: 'Kg',
accessorKey: 'penjualan_ayam_kg',
id: 'penjualan_ayam_kg',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatNumber(row.original.penjualan_ayam_kg)}
</div>
),
},
{
header: 'Nominal',
accessorKey: 'penjualan_ayam_nominal',
id: 'penjualan_ayam_nominal',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatCurrency(row.original.penjualan_ayam_nominal)}
</div>
),
},
],
},
{
header: 'Penjualan Telur',
columns: [
{
header: 'Kuantitas',
accessorKey: 'penjualan_telur_kuantitas',
id: 'penjualan_telur_kuantitas',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatNumber(row.original.penjualan_telur_kuantitas)}
</div>
),
},
{
header: 'Kg',
accessorKey: 'penjualan_telur_kg',
id: 'penjualan_telur_kg',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatNumber(row.original.penjualan_telur_kg)}
</div>
),
},
{
header: 'Nominal',
accessorKey: 'penjualan_telur_nominal',
id: 'penjualan_telur_nominal',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatCurrency(row.original.penjualan_telur_nominal)}
</div>
),
},
],
},
{
header: 'Penjualan Trading',
accessorKey: 'penjualan_trading',
id: 'penjualan_trading',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatCurrency(row.original.penjualan_trading)}
</div>
),
},
{
header: 'Pembayaran',
accessorKey: 'pembayaran',
id: 'pembayaran',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatCurrency(row.original.pembayaran)}
</div>
),
},
{
header: 'Aging',
accessorKey: 'aging',
id: 'aging',
enableSorting: true,
cell: ({ row }) => (
<div className='text-center'>
{formatNumber(row.original.aging)} hari
</div>
),
},
{
header: 'Aging Rata-Rata',
accessorKey: 'aging_rata_rata',
id: 'aging_rata_rata',
enableSorting: true,
cell: ({ row }) => (
<div className='text-center'>
{formatNumber(row.original.aging_rata_rata)} hari
</div>
),
},
{
header: 'Saldo Akhir',
accessorKey: 'saldo_akhir',
id: 'saldo_akhir',
enableSorting: true,
cell: ({ row }) => (
<div
className={`text-right font-semibold ${row.original.saldo_akhir < 0 ? 'text-error' : ''}`}
>
{formatCurrency(row.original.saldo_akhir)}
</div>
),
},
],
[page, pageSize]
);
return (
<>
<div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
{isLoading && (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
)}
{!isLoading && data.length === 0 && (
<CustomerSupplierSkeleton
columns={columns as unknown as ColumnDef<CustomerPaymentRow>[]}
icon={
<Icon
icon='heroicons:chart-bar'
className='text-white'
width={20}
height={20}
/>
}
title='Data Not Yet Available'
subtitle='Please change your filters to get the data.'
/>
)}
{!isLoading && data.length > 0 && (
<>
<div className='w-full overflow-x-auto'>
<Table
data={data}
columns={columns}
pageSize={pageSize}
page={meta?.page || 1}
totalItems={meta?.total_results || 0}
onPageChange={setPage}
onPageSizeChange={setPageSize}
sorting={sorting}
setSorting={handleSortingChange}
manualSorting
className={{
containerClassName: 'w-full mb-0!',
tableWrapperClassName: 'overflow-x-auto',
tableClassName: 'w-full table-auto text-sm',
headerRowClassName: 'border-b border-b-gray-200 bg-gray-50',
headerColumnClassName:
'px-4 py-3 text-xs font-semibold text-gray-700 text-left border border-gray-200 text-nowrap',
bodyRowClassName:
'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>
{/* Filter Modal */}
<Modal
ref={filterModal.ref}
className={{
modal: 'p-0',
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
}}
>
{/* Modal Header */}
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
<div className='flex items-center gap-2 text-primary'>
<Icon icon='heroicons:funnel' width={20} height={20} />
<h3 className='font-medium text-sm'>Filter Data</h3>
</div>
<Button
variant='link'
onClick={filterModal.closeModal}
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
>
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<div className='p-4 flex flex-col gap-3'>
<div>
<label className='block text-xs font-semibold text-base-content py-2'>
Tanggal
</label>
<div className='flex flex-row gap-1.5 items-center justify-between'>
<DateInput
name='start_date'
value={formik.values.start_date || ''}
onChange={handleStartDateChange}
className={{ wrapper: 'w-full' }}
isNestedModal
/>
<hr className='w-full max-w-3 h-px border-base-content/10' />
<DateInput
name='end_date'
value={formik.values.end_date || ''}
onChange={handleEndDateChange}
className={{ wrapper: 'w-full' }}
isNestedModal
isError={hasDateError}
/>
</div>
</div>
<SelectInput
label='Customer'
placeholder='Pilih Customer'
options={customerOptions}
value={formik.values.customerFilter ?? null}
onChange={(val) =>
formik.setFieldValue(
'customerFilter',
val as OptionType<number> | null
)
}
onInputChange={setCustomerInput}
isLoading={isLoadingCustomers}
isClearable
onMenuScrollToBottom={loadMoreCustomers}
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Sales'
placeholder='Pilih Sales'
options={salesOptions}
value={formik.values.salesFilter ?? null}
onChange={(val) =>
formik.setFieldValue(
'salesFilter',
val as OptionType<number> | null
)
}
onInputChange={setSalesInput}
isLoading={isLoadingSales}
isClearable
onMenuScrollToBottom={loadMoreSales}
className={{ wrapper: 'w-full' }}
/>
</div>
{/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button
type='reset'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
>
Reset Filter
</Button>
<Button
type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
disabled={hasDateError}
>
Apply Filter
</Button>
</div>
</form>
</Modal>
</>
);
};
export default BalanceMonitoringTab;