diff --git a/src/components/pages/report/finance/tab/BalanceMonitoringTab.tsx b/src/components/pages/report/finance/tab/BalanceMonitoringTab.tsx new file mode 100644 index 00000000..134f27df --- /dev/null +++ b/src/components/pages/report/finance/tab/BalanceMonitoringTab.tsx @@ -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; + salesFilter?: OptionType; + 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) => { + 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) => { + 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) => { + 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, +
+ handleFilterModalOpenRef.current()} + variant='outline' + className='px-3 py-2.5' + /> + + +
+ + Export +
+ +
+ + } + > + + +
+ ); + }, [ + 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[] => [ + { + 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 }) => ( +
+ {formatCurrency(row.original.saldo_awal)} +
+ ), + }, + { + header: 'Penjualan Ayam', + columns: [ + { + header: 'Ekor', + accessorKey: 'penjualan_ayam_ekor', + id: 'penjualan_ayam_ekor', + enableSorting: true, + cell: ({ row }) => ( +
+ {formatNumber(row.original.penjualan_ayam_ekor)} +
+ ), + }, + { + header: 'Kg', + accessorKey: 'penjualan_ayam_kg', + id: 'penjualan_ayam_kg', + enableSorting: true, + cell: ({ row }) => ( +
+ {formatNumber(row.original.penjualan_ayam_kg)} +
+ ), + }, + { + header: 'Nominal', + accessorKey: 'penjualan_ayam_nominal', + id: 'penjualan_ayam_nominal', + enableSorting: true, + cell: ({ row }) => ( +
+ {formatCurrency(row.original.penjualan_ayam_nominal)} +
+ ), + }, + ], + }, + { + header: 'Penjualan Telur', + columns: [ + { + header: 'Kuantitas', + accessorKey: 'penjualan_telur_kuantitas', + id: 'penjualan_telur_kuantitas', + enableSorting: true, + cell: ({ row }) => ( +
+ {formatNumber(row.original.penjualan_telur_kuantitas)} +
+ ), + }, + { + header: 'Kg', + accessorKey: 'penjualan_telur_kg', + id: 'penjualan_telur_kg', + enableSorting: true, + cell: ({ row }) => ( +
+ {formatNumber(row.original.penjualan_telur_kg)} +
+ ), + }, + { + header: 'Nominal', + accessorKey: 'penjualan_telur_nominal', + id: 'penjualan_telur_nominal', + enableSorting: true, + cell: ({ row }) => ( +
+ {formatCurrency(row.original.penjualan_telur_nominal)} +
+ ), + }, + ], + }, + { + header: 'Penjualan Trading', + accessorKey: 'penjualan_trading', + id: 'penjualan_trading', + enableSorting: true, + cell: ({ row }) => ( +
+ {formatCurrency(row.original.penjualan_trading)} +
+ ), + }, + { + header: 'Pembayaran', + accessorKey: 'pembayaran', + id: 'pembayaran', + enableSorting: true, + cell: ({ row }) => ( +
+ {formatCurrency(row.original.pembayaran)} +
+ ), + }, + { + header: 'Aging', + accessorKey: 'aging', + id: 'aging', + enableSorting: true, + cell: ({ row }) => ( +
+ {formatNumber(row.original.aging)} hari +
+ ), + }, + { + header: 'Aging Rata-Rata', + accessorKey: 'aging_rata_rata', + id: 'aging_rata_rata', + enableSorting: true, + cell: ({ row }) => ( +
+ {formatNumber(row.original.aging_rata_rata)} hari +
+ ), + }, + { + header: 'Saldo Akhir', + accessorKey: 'saldo_akhir', + id: 'saldo_akhir', + enableSorting: true, + cell: ({ row }) => ( +
+ {formatCurrency(row.original.saldo_akhir)} +
+ ), + }, + ], + [page, pageSize] + ); + + return ( + <> +
+ {isLoading && ( +
+ +
+ )} + + {!isLoading && data.length === 0 && ( + []} + icon={ + + } + title='Data Not Yet Available' + subtitle='Please change your filters to get the data.' + /> + )} + + {!isLoading && data.length > 0 && ( + <> +
+ + + + {meta && ( +
+ 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} + /> +
+ )} + + )} + + + {/* Filter Modal */} + + {/* Modal Header */} +
+
+ +

Filter Data

+
+ +
+ +
+
+
+ +
+ +
+ +
+
+ + + formik.setFieldValue( + 'customerFilter', + val as OptionType | null + ) + } + onInputChange={setCustomerInput} + isLoading={isLoadingCustomers} + isClearable + onMenuScrollToBottom={loadMoreCustomers} + className={{ wrapper: 'w-full' }} + /> + + + formik.setFieldValue( + 'salesFilter', + val as OptionType | null + ) + } + onInputChange={setSalesInput} + isLoading={isLoadingSales} + isClearable + onMenuScrollToBottom={loadMoreSales} + className={{ wrapper: 'w-full' }} + /> +
+ + {/* Modal Footer */} +
+ + +
+ +
+ + ); +}; + +export default BalanceMonitoringTab;