diff --git a/CLAUDE.md b/CLAUDE.md index 711d5a1c..a0e24f9b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,76 +80,124 @@ Data tables across all modules (master-data, inventory, finance, purchase, etc.) - Apply to: search handlers, filter form submissions, reset handlers 3. **Create custom formikResetHandler function** - - Clear each filter with `updateFilter(fieldName, defaultValue, true)` - - Call `formik.resetForm({ values: { ...defaults } })` - - Close the modal at the end - - Attach to both button `onClick` and form `onReset` handler + - Call `resetFilter()` (single call — resets all `useTableFilter` state to defaults) + - Reset any local error state (e.g. `setHasDateError(false)`, dismiss toasts) + - Call `formik.resetForm({ values: { ...defaults } })` to sync formik to defaults + - Call `filterModal.closeModal()` at the end + - Attach to form `onReset` handler (not `formik.handleReset`) -**Optimization: Avoid useCallback for simple handlers** + ```tsx + const formikResetHandler = () => { + resetFilter(); + setHasDateError(false); + if (dateErrorShown) { toast.dismiss(); setDateErrorShown(false); } + formik.resetForm({ values: { start_date: '', end_date: '', customers: [], filterBy: undefined } }); + filterModal.closeModal(); + }; + // ... +
+ ``` -- `useCallback` adds overhead and is only useful for complex logic or memoized child components -- Simple pass-through handlers don't need it: +**Optimization: Avoid useCallback and useMemo for trivial operations** + +- `useCallback` and `useMemo` add overhead; only use them when the computation is expensive or the result is passed to a memoized child +- Simple derivations and pass-through handlers don't need them: ```tsx - // ✅ Good: Simple handler without useCallback - const handleFilterChange = (val) => setFieldValue('location', val); + // ✅ Good: plain derivation + const data = isResponseSuccess(response) ? (response.data ?? []) : []; + const meta = + isResponseSuccess(response) && response.meta ? response.meta : null; - // ❌ Avoid: Unnecessary useCallback overhead - const handleFilterChange = useCallback( + // ❌ Avoid: useMemo for trivial conditional access + const data = useMemo( + () => (isResponseSuccess(response) ? (response.data ?? []) : []), + [response] + ); + + // ✅ Good: simple handler + const handleChange = (val) => setFieldValue('location', val); + + // ❌ Avoid: unnecessary useCallback + const handleChange = useCallback( (val) => setFieldValue('location', val), [setFieldValue] ); ``` +- `useMemo` IS justified for large column definition arrays (TanStack Table re-processes on every render) + **Best practice: Store OptionType objects directly, not IDs** -For select inputs, store the complete `OptionType` object in both formik state and tableFilterState. This eliminates the need for computed helper values (like searching options arrays to find the matching object). +For select inputs, store the complete `OptionType` object (or `OptionType[]` for multi-select) in both formik state and tableFilterState. `useTableFilter`'s `serializeValue` handles serialization automatically: + +- `OptionType` → serialized as `String(value)` in the query string +- `OptionType[]` → serialized as comma-separated values (CSV) — ideal for multi-select API params like `customer_ids`, `sales_ids` ```tsx -// Type the useTableFilter with the filter state structure const { state: tableFilterState, updateFilter, ... } = useTableFilter<{ search: string; - locationFilter?: OptionType; - picFilter?: OptionType; + customers: OptionType[]; // multi-select → serializes as CSV + location?: OptionType; // single-select → serializes as value string + filterBy?: OptionType; // single-select radio }>({ initial: { search: '', - locationFilter: undefined, - picFilter: undefined + customers: [], + location: undefined, + filterBy: undefined, }, paramMap: { page: 'page', pageSize: 'limit', - locationFilter: 'location_id', - picFilter: 'pic_id', + customers: 'customer_ids', // serializes OptionType[] → "1,2,3" + location: 'location_id', // serializes OptionType → "abc" + filterBy: 'filter_by', }, persist: true, - storeName: 'kandangs-table', + storeName: 'my-table', }); -// Initialize formik with tableFilterState values (now typed OptionType objects) -const formik = useFormik({ +// Initialize formik directly from tableFilterState (no hardcoded defaults) +const formik = useFormik({ initialValues: { - location: tableFilterState.locationFilter, - pic: tableFilterState.picFilter, + customers: tableFilterState.customers, + location: tableFilterState.location, + filterBy: tableFilterState.filterBy, }, ... }); -// Handlers store the complete OptionType, not just the ID -const handleFilterLocationChange = useCallback( - (val) => setFieldValue('location', val), - [setFieldValue] -); - -// Use formik values directly in select inputs (no computed helpers needed) - +// Use formik values directly — no computed helpers needed + formik.setFieldValue('customers', Array.isArray(val) ? val : [])} /> + formik.setFieldValue('location', val)} /> + formik.setFieldValue('filterBy', !Array.isArray(val) ? (val ?? undefined) : undefined)} /> ``` +**Filter field naming convention** + +- Multi-select fields: use plural entity name — `customers`, `salesPersons`, `locations` +- Single-select fields: use descriptive camelCase — `filterBy`, `status`, `category` +- No `Filter` suffix (e.g. avoid `customerFilter`, `locationFilter`) + +**Filter modal: pass `openModal` directly, never use `enableReinitialize`** + +`enableReinitialize: true` resets formik mid-interaction whenever `tableFilterState` changes, breaking the modal UX. Pass `filterModal.openModal` directly to the button — no ref wrapper needed. Formik retains its last state across open/close, which is acceptable UX (values sync with `tableFilterState` on submit and reset anyway). + +```tsx +// ❌ Avoid: enableReinitialize breaks modal mid-interaction +const formik = useFormik({ initialValues: { ... }, enableReinitialize: true }); + +// ❌ Avoid: unnecessary ref indirection +const handleFilterModalOpenRef = useRef(() => {}); +handleFilterModalOpenRef.current = () => { formik.setValues({...}); filterModal.openModal(); }; + +// ✅ Correct: pass openModal directly + +``` + +Include `filterModal.openModal` in the `useEffect` deps array when it's used inside the effect. + **Apply this pattern to:** - Any data table component across any module that needs persistent filters @@ -159,7 +207,31 @@ const handleFilterLocationChange = useCallback( **Reference implementations:** - `SupplierTable`, `KandangsTable`, `LocationsTable`, `CustomersTable` in `src/components/pages/master-data/` -- Use same pattern for data tables in other modules (inventory, finance, purchase, etc.) +- `BalanceMonitoringTab` in `src/components/pages/report/finance/tab/` — multi-select + radio + date range + +## SWR fetch pattern + +Use `FinanceApi.getAllFetcher` (or the relevant service's `getAllFetcher`) when the result type matches the service generic `T`. When it differs, use `httpClientFetcher` with an explicit type: + +```tsx +// ✅ Same type as service generic — use getAllFetcher +const { data } = useSWR( + `${Api.basePath}${getTableFilterQueryString()}`, + Api.getAllFetcher +); + +// ✅ Different type — use httpClientFetcher with explicit useSWR type +const { data } = useSWR< + BaseApiResponse, + AxiosError, + SWRHttpKey +>( + `${FinanceApi.basePath}/balance-monitoring${getTableFilterQueryString()}`, + httpClientFetcher +); +``` + +Always name the `toQueryString` alias `getTableFilterQueryString` when destructuring from `useTableFilter`. ## Server-side sorting pattern diff --git a/src/app/report/finance/layout.tsx b/src/app/report/finance/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/report/finance/layout.tsx @@ -0,0 +1,11 @@ +import SuspenseHelper from '@/components/helper/SuspenseHelper'; + +const Layout = ({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) => { + return {children}; +}; + +export default Layout; diff --git a/src/components/Tabs.tsx b/src/components/Tabs.tsx index 52047d8b..6e6956d6 100644 --- a/src/components/Tabs.tsx +++ b/src/components/Tabs.tsx @@ -6,6 +6,7 @@ export interface TabItem { label: ReactNode; content?: ReactNode; disabled?: boolean; + hide?: boolean; } export interface TabsProps @@ -122,17 +123,19 @@ const Tabs = ({ >
- {tabs.map(({ id, label, disabled }) => ( - - ))} + {tabs.map(({ id, label, disabled, hide }) => + hide ? null : ( + + ) + )}
{sideContent && sideContent}
diff --git a/src/components/pages/report/finance/FinanceTabs.tsx b/src/components/pages/report/finance/FinanceTabs.tsx index 5c49ed3c..0604cb48 100644 --- a/src/components/pages/report/finance/FinanceTabs.tsx +++ b/src/components/pages/report/finance/FinanceTabs.tsx @@ -1,25 +1,47 @@ 'use client'; -import { useState } from 'react'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import Tabs from '@/components/Tabs'; import CustomerPaymentTab from '@/components/pages/report/finance/tab/CustomerPaymentTab'; import DebtSupplierTab from '@/components/pages/report/finance/tab/DebtSupplierTab'; +import BalanceMonitoringTab from '@/components/pages/report/finance/tab/BalanceMonitoringTab'; import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store'; +const VALID_TAB_IDS = [ + 'debt-supplier', + 'customer-payment', + 'balance-monitoring', +]; + const FinanceTabs = () => { - const [activeTabId, setActiveTabId] = useState('1'); + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const tabParam = searchParams.get('tab') ?? 'debt-supplier'; + const activeTabId = VALID_TAB_IDS.includes(tabParam) + ? tabParam + : 'debt-supplier'; const tabActions = useTabActionsStore((state) => state.tabActions); + const handleTabChange = (tabId: string) => { + router.push(`${pathname}?tab=${tabId}`); + }; + const tabs = [ { - id: '1', + id: 'debt-supplier', label: 'Rekapitulasi Hutang Ke Supplier', - content: , + content: , }, { - id: '2', + id: 'customer-payment', label: 'Kontrol Pembayaran Customer', - content: , + content: , + }, + { + id: 'balance-monitoring', + label: 'Monitoring Saldo', + content: , }, ]; @@ -29,7 +51,7 @@ const FinanceTabs = () => { tabs={tabs} variant='boxed' activeTabId={activeTabId} - onTabChange={setActiveTabId} + onTabChange={handleTabChange} className={{ tabHeaderWrapper: 'justify-between items-center p-3 border-b border-base-content/10', 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..b2920008 --- /dev/null +++ b/src/components/pages/report/finance/tab/BalanceMonitoringTab.tsx @@ -0,0 +1,602 @@ +'use client'; + +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 } 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 { 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 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 Table from '@/components/Table'; +import CustomerSupplierSkeleton from '@/components/pages/report/finance/skeleton/CustomerSupplierSkeleton'; + +interface BalanceMonitoringTabProps { + tabId: string; +} + +const filterByOptions: OptionType[] = [ + { label: 'Tanggal Penjualan (SO Date)', value: 'sold_at' }, + { label: 'Tanggal Realisasi (Delivery Date)', value: 'realized_at' }, +]; + +const BalanceMonitoringTab = ({ tabId }: BalanceMonitoringTabProps) => { + const [hasDateError, setHasDateError] = useState(false); + const [dateErrorShown, setDateErrorShown] = useState(false); + + const filterModal = useModal(); + + const setTabActions = useTabActionsStore((state) => state.setTabActions); + const clearTabActions = useTabActionsStore((state) => state.clearTabActions); + + const { + state: tableFilterState, + updateFilter, + setPage, + setPageSize, + toQueryString: getTableFilterQueryString, + reset: resetFilter, + } = useTableFilter<{ + start_date: string; + end_date: string; + customers: OptionType[]; + salesPersons: OptionType[]; + filterBy?: OptionType; + sort_by: string; + order_by: string; + }>({ + initial: { + start_date: '', + end_date: '', + customers: [], + salesPersons: [], + filterBy: undefined, + sort_by: '', + order_by: '', + }, + paramMap: { + page: 'page', + pageSize: 'limit', + start_date: 'start_date', + end_date: 'end_date', + customers: 'customer_ids', + salesPersons: 'sales_ids', + filterBy: 'filter_by', + sort_by: 'sort_by', + order_by: 'sort_order', + }, + persist: true, + storeName: 'balance-monitoring-table', + }); + + // 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, + customers: tableFilterState.customers, + salesPersons: tableFilterState.salesPersons, + filterBy: tableFilterState.filterBy, + }, + onSubmit: (values) => { + updateFilter('start_date', values.start_date, true); + updateFilter('end_date', values.end_date, true); + updateFilter('customers', values.customers, true); + updateFilter('salesPersons', values.salesPersons, true); + updateFilter('filterBy', values.filterBy, true); + filterModal.closeModal(); + }, + }); + + 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) => { + 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 { data: balanceMonitoringsResponse, isLoading } = useSWR< + BaseApiResponse, + AxiosError, + SWRHttpKey + >( + `${FinanceApi.basePath}/balance-monitoring${getTableFilterQueryString()}`, + httpClientFetcher + ); + + const balanceMonitorings: BalanceMonitoringRow[] = isResponseSuccess( + balanceMonitoringsResponse + ) + ? ((balanceMonitoringsResponse.data as BalanceMonitoringRow[]) ?? []) + : []; + + const meta = + isResponseSuccess(balanceMonitoringsResponse) && + balanceMonitoringsResponse.meta + ? balanceMonitoringsResponse.meta + : null; + + // Inject tab actions directly — no nested component, no remount cycle + useEffect(() => { + setTabActions( + tabId, +
+ +
+ ); + }, [tabId, setTabActions, tableFilterState, filterModal.openModal]); + + useEffect(() => { + return () => clearTabActions(tabId); + }, [tabId, clearTabActions]); + + const columns = useMemo( + (): ColumnDef[] => [ + { + header: 'No', + enableSorting: false, + cell: (props) => + (tableFilterState.page - 1) * tableFilterState.pageSize + + props.row.index + + 1, + }, + { + header: 'Customer', + accessorKey: 'customer.name', + enableSorting: true, + id: 'customer_name', + cell: ({ row }) => row.original.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: 'Butir', + accessorKey: 'penjualan_telur.butir', + id: 'penjualan_telur_butir', + enableSorting: true, + cell: ({ row }) => ( +
+ {formatNumber(row.original.penjualan_telur.butir)} +
+ ), + }, + { + 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.nominal', + id: 'penjualan_trading', + enableSorting: true, + cell: ({ row }) => ( +
+ {formatCurrency(row.original.penjualan_trading.nominal)} +
+ ), + }, + { + 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)} +
+ ), + }, + ], + [tableFilterState.page, tableFilterState.pageSize] + ); + + return ( + <> +
+ {isLoading && ( +
+ +
+ )} + + {!isLoading && balanceMonitorings.length === 0 && ( + []} + icon={ + + } + title='Data Not Yet Available' + subtitle='Please change your filters to get the data.' + /> + )} + + {!isLoading && balanceMonitorings.length > 0 && ( + <> +
+ + + + )} + + + {/* Filter Modal */} + + {/* Modal Header */} +
+
+ +

Filter Data

+
+ +
+ + +
+
+ +
+ +
+ +
+
+ + + formik.setFieldValue('customers', Array.isArray(val) ? val : []) + } + onInputChange={setCustomerInput} + isLoading={isLoadingCustomers} + isClearable + onMenuScrollToBottom={loadMoreCustomers} + className={{ wrapper: 'w-full' }} + /> + + + formik.setFieldValue( + 'salesPersons', + Array.isArray(val) ? val : [] + ) + } + onInputChange={setSalesInput} + isLoading={isLoadingSales} + isClearable + onMenuScrollToBottom={loadMoreSales} + className={{ wrapper: 'w-full' }} + /> + + + formik.setFieldValue( + 'filterBy', + !Array.isArray(val) ? (val ?? undefined) : undefined + ) + } + isClearable + className={{ wrapper: 'w-full' }} + /> +
+ + {/* Modal Footer */} +
+ + +
+ +
+ + ); +}; + +export default BalanceMonitoringTab; diff --git a/src/components/pages/report/finance/tab/CustomerPaymentTab.tsx b/src/components/pages/report/finance/tab/CustomerPaymentTab.tsx index e9c20053..e786c9a5 100644 --- a/src/components/pages/report/finance/tab/CustomerPaymentTab.tsx +++ b/src/components/pages/report/finance/tab/CustomerPaymentTab.tsx @@ -1,14 +1,17 @@ -import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import useSWR from 'swr'; import { Icon } from '@iconify/react'; +import { AxiosError } from 'axios'; import Card from '@/components/Card'; import StatusBadge from '@/components/helper/StatusBadge'; -import { useSelect } from '@/components/input/SelectInput'; +import { useSelect, OptionType } from '@/components/input/SelectInput'; import SelectInputCheckbox from '@/components/input/SelectInputCheckbox'; import SelectInputRadio from '@/components/input/SelectInputRadio'; import DateInput from '@/components/input/DateInput'; import { CustomerApi } from '@/services/api/master-data'; import { FinanceApi } from '@/services/api/report/finance-report'; +import { httpClientFetcher, SWRHttpKey } from '@/services/http/client'; +import { BaseApiResponse } from '@/types/api/api-general'; import Table from '@/components/Table'; import { ColumnDef } from '@tanstack/react-table'; import { @@ -27,28 +30,22 @@ import Dropdown from '@/components/Dropdown'; import Modal, { useModal } from '@/components/Modal'; import toast from 'react-hot-toast'; import { useFormik } from 'formik'; -import { - CustomerPaymentFilterSchema, - CustomerPaymentFilterType, -} from '@/components/pages/report/finance/filter/CustomerPaymentFilter'; import { generateCustomerPaymentPDF } from '@/components/pages/report/finance/export/CustomerPaymentExportPDF'; import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store'; import CustomerSupplierSkeleton from '@/components/pages/report/finance/skeleton/CustomerSupplierSkeleton'; -import { OptionType } from '@/components/table/TableRowSizeSelector'; import { Color } from '@/types/theme'; import ButtonFilter from '@/components/helper/ButtonFilter'; import Pagination from '@/components/Pagination'; +import { useTableFilter } from '@/services/hooks/useTableFilter'; interface CustomerPaymentTabProps { tabId: string; } -interface FilterParams { - customer_ids?: string; - start_date?: string; - end_date?: string; - filter_by?: string; -} +const dataTypeOptions: OptionType[] = [ + { value: 'trans_date', label: 'Tanggal Jual/Bayar' }, + { value: 'realization_date', label: 'Tanggal Realisasi' }, +]; const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { // ===== STATE MANAGEMENT ===== @@ -59,26 +56,44 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading || isExcelGeneralExportLoading; - // ===== PAGINATION STATE ===== - const [currentPage, setCurrentPage] = useState(1); - const [pageSize, setPageSize] = useState(10); - - // ===== SUBMISSION STATE ===== - const [filterParams, setFilterParams] = useState({}); const [dateErrorShown, setDateErrorShown] = useState(false); const [hasDateError, setHasDateError] = useState(false); - const handleFilterModalOpenRef = useRef(() => {}); - const filterModal = useModal(); - const dataTypeOptions = useMemo( - () => [ - { value: 'trans_date', label: 'Tanggal Jual/Bayar' }, - { value: 'realization_date', label: 'Tanggal Realisasi' }, - ], - [] - ); + const setTabActions = useTabActionsStore((state) => state.setTabActions); + const clearTabActions = useTabActionsStore((state) => state.clearTabActions); + + const { + state: tableFilterState, + updateFilter, + setPage, + setPageSize, + toQueryString: getTableFilterQueryString, + reset: resetFilter, + } = useTableFilter<{ + start_date: string; + end_date: string; + customers: OptionType[]; + filterBy?: OptionType; + }>({ + initial: { + start_date: '', + end_date: '', + customers: [], + filterBy: undefined, + }, + paramMap: { + page: 'page', + pageSize: 'limit', + start_date: 'start_date', + end_date: 'end_date', + customers: 'customer_ids', + filterBy: 'filter_by', + }, + persist: true, + storeName: 'customer-payment-report-table', + }); const { options: customerOptions, @@ -88,222 +103,159 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { } = useSelect(CustomerApi.basePath, 'id', 'name', 'search'); // ===== FORMIK SETUP ===== - const formik = useFormik({ + const formik = useFormik({ initialValues: { - start_date: null, - end_date: null, - customer_ids: null, - filter_by: null, + start_date: tableFilterState.start_date, + end_date: tableFilterState.end_date, + customers: tableFilterState.customers, + filterBy: tableFilterState.filterBy, }, - validationSchema: CustomerPaymentFilterSchema, - onSubmit: (values, { setSubmitting }) => { - setFilterParams({ - start_date: values.start_date || undefined, - end_date: values.end_date || undefined, - customer_ids: values.customer_ids || undefined, - filter_by: values.filter_by || undefined, - }); - filterModal.closeModal(); - setCurrentPage(1); - setSubmitting(false); - }, - onReset: () => { - setFilterParams({}); - setCurrentPage(1); - setHasDateError(false); - if (dateErrorShown) { - toast.dismiss(); - setDateErrorShown(false); - } + onSubmit: (values) => { + updateFilter('start_date', values.start_date, true); + updateFilter('end_date', values.end_date, true); + updateFilter('customers', values.customers, true); + updateFilter('filterBy', values.filterBy, true); filterModal.closeModal(); }, }); - handleFilterModalOpenRef.current = () => { - formik.setValues({ - start_date: filterParams.start_date || null, - end_date: filterParams.end_date || null, - customer_ids: filterParams.customer_ids || null, - filter_by: filterParams.filter_by || null, + const formikResetHandler = () => { + resetFilter(); + + setHasDateError(false); + if (dateErrorShown) { + toast.dismiss(); + setDateErrorShown(false); + } + + formik.resetForm({ + values: { + start_date: '', + end_date: '', + customers: [], + filterBy: undefined, + }, }); - filterModal.openModal(); + + filterModal.closeModal(); }; const getPaymentStatusBadgeColor = (notes: string): Color => { const normalizedValue = notes.toLowerCase(); - - if (normalizedValue === 'lunas') { - return 'primary'; - } - - if (normalizedValue.includes('belum')) { - return 'warning'; - } - + if (normalizedValue === 'lunas') return 'primary'; + if (normalizedValue.includes('belum')) return 'warning'; return 'neutral'; }; // ===== DATE CHANGE HANDLERS ===== - const handleStartDateChange = useCallback( - (e: React.ChangeEvent) => { - const value = e.target.value; - formik.setFieldValue('start_date', value || null); + const handleStartDateChange = (e: React.ChangeEvent) => { + const value = e.target.value; + formik.setFieldValue('start_date', value); - if (value && formik.values.end_date) { - const startDate = new Date(value); - const endDateObj = new Date(formik.values.end_date); - - if (endDateObj < startDate) { - 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); - } + 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); - } - }, - [formik, dateErrorShown] - ); - - const handleEndDateChange = useCallback( - (e: React.ChangeEvent) => { - const value = e.target.value; - formik.setFieldValue('end_date', value || null); - - if (value && formik.values.start_date) { - const startDateObj = new Date(formik.values.start_date); - const endDate = new Date(value); - - if (endDate < startDateObj) { - setHasDateError(true); - if (!dateErrorShown) { - toast.error('Tanggal akhir tidak boleh masa lampau', { - duration: Infinity, - }); - setDateErrorShown(true); - } - return; + if (dateErrorShown) { + toast.dismiss(); + setDateErrorShown(false); } } - + } else { setHasDateError(false); - if (dateErrorShown) { - toast.dismiss(); - setDateErrorShown(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; } - }, - [formik, dateErrorShown] - ); + } - // ===== FILTER HELPERS ===== - const customerIdsValue = useMemo(() => { - if (!formik.values.customer_ids) return []; - return customerOptions.filter((opt) => - formik.values.customer_ids?.split(',').includes(String(opt.value)) - ); - }, [formik.values.customer_ids, customerOptions]); - - const filterByValue = useMemo(() => { - if (!formik.values.filter_by) return null; - return ( - dataTypeOptions.find((opt) => opt.value === formik.values.filter_by) || - null - ); - }, [formik.values.filter_by, dataTypeOptions]); + setHasDateError(false); + if (dateErrorShown) { + toast.dismiss(); + setDateErrorShown(false); + } + }; // ===== DATA FETCHING ===== - const { data: customerPayment, isLoading } = useSWR( - () => { - const params = { - customer_ids: filterParams.customer_ids, - filter_by: filterParams.filter_by as - | 'trans_date' - | 'realization_date' - | undefined, - start_date: filterParams.start_date, - end_date: filterParams.end_date, - page: currentPage, - limit: pageSize, - }; - - return ['customer-payment-report', params]; - }, - ([, params]) => - FinanceApi.getCustomerPaymentReport( - params.customer_ids, - params.filter_by, - params.start_date, - params.end_date, - params.page, - params.limit - ) + const { data: customerPayment, isLoading } = useSWR< + BaseApiResponse, + AxiosError, + SWRHttpKey + >( + `${FinanceApi.basePath}/customer-payment${getTableFilterQueryString()}`, + httpClientFetcher ); - const data: CustomerPaymentReport[] = useMemo( - () => - isResponseSuccess(customerPayment) - ? (customerPayment?.data as unknown as CustomerPaymentReport[]) || [] - : [], - [customerPayment] - ); + const data: CustomerPaymentReport[] = isResponseSuccess(customerPayment) + ? (customerPayment?.data as unknown as CustomerPaymentReport[]) || [] + : []; - const meta = useMemo( - () => - isResponseSuccess(customerPayment) && customerPayment.meta - ? customerPayment.meta - : null, - [customerPayment] - ); + const meta = + isResponseSuccess(customerPayment) && customerPayment.meta + ? customerPayment.meta + : null; // ===== EXPORT DATA FETCHER ===== const customerPaymentExport = useCallback(async (): Promise< CustomerPaymentReport[] | null > => { - const params = { - customer_ids: filterParams.customer_ids, - filter_by: filterParams.filter_by as - | 'trans_date' - | 'realization_date' - | undefined, - start_date: filterParams.start_date, - end_date: filterParams.end_date, - limit: 100, - page: 1, - }; + const customer_ids = + tableFilterState.customers.length > 0 + ? tableFilterState.customers.map((o) => String(o.value)).join(',') + : undefined; + const filter_by = tableFilterState.filterBy?.value as + | 'trans_date' + | 'realization_date' + | undefined; const response = await FinanceApi.getCustomerPaymentReport( - params.customer_ids, - params.filter_by, - params.start_date, - params.end_date, - params.page, - params.limit + customer_ids, + filter_by, + tableFilterState.start_date || undefined, + tableFilterState.end_date || undefined, + 1, + 100 ); return isResponseSuccess(response) ? (response.data as unknown as CustomerPaymentReport[]) : null; - }, [filterParams]); + }, [tableFilterState]); // ===== EXPORT HANDLERS ===== const handleExportExcelGeneral = useCallback(async () => { setIsExcelGeneralExportLoading(true); try { + const customer_ids = + tableFilterState.customers.length > 0 + ? tableFilterState.customers.map((o) => String(o.value)).join(',') + : undefined; await FinanceApi.exportCustomerPaymentToExcelGeneral( - filterParams.customer_ids, - filterParams.filter_by, - filterParams.start_date, - filterParams.end_date + customer_ids, + tableFilterState.filterBy?.value, + tableFilterState.start_date || undefined, + tableFilterState.end_date || undefined ); toast.success('Excel General berhasil dibuat dan diunduh.'); } catch { @@ -311,16 +263,20 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { } finally { setIsExcelGeneralExportLoading(false); } - }, [filterParams]); + }, [tableFilterState]); const handleExportExcel = useCallback(async () => { setIsExcelExportLoading(true); try { + const customer_ids = + tableFilterState.customers.length > 0 + ? tableFilterState.customers.map((o) => String(o.value)).join(',') + : undefined; await FinanceApi.exportCustomerPaymentToExcelCustomerPerSheet( - filterParams.customer_ids, - filterParams.filter_by, - filterParams.start_date, - filterParams.end_date + customer_ids, + tableFilterState.filterBy?.value, + tableFilterState.start_date || undefined, + tableFilterState.end_date || undefined ); toast.success('Excel berhasil dibuat dan diunduh.'); } catch { @@ -328,7 +284,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { } finally { setIsExcelExportLoading(false); } - }, [filterParams]); + }, [tableFilterState]); const handleExportPdf = useCallback(async () => { setIsPdfExportLoading(true); @@ -344,22 +300,18 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { return; } - const customerName = filterParams.customer_ids - ? customerOptions - .filter((opt) => - filterParams.customer_ids?.split(',').includes(String(opt.value)) - ) - .map((opt) => opt.label) - .join(', ') || 'Semua Customer' - : 'Semua Customer'; + const customerName = + tableFilterState.customers.length > 0 + ? tableFilterState.customers.map((o) => o.label).join(', ') + : 'Semua Customer'; await generateCustomerPaymentPDF({ data: allDataForExport, params: { customer_name: customerName, - start_date: filterParams.start_date, - end_date: filterParams.end_date, - filter_by: filterParams.filter_by as + start_date: tableFilterState.start_date || undefined, + end_date: tableFilterState.end_date || undefined, + filter_by: tableFilterState.filterBy?.value as | 'trans_date' | 'realization_date' | undefined, @@ -371,119 +323,103 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { } finally { setIsPdfExportLoading(false); } - }, [customerPaymentExport, filterParams, customerOptions]); + }, [customerPaymentExport, tableFilterState]); - // ===== TAB ACTIONS COMPONENT ===== - const TabActions = useMemo(() => { - return function TabActionsComponent() { - const setTabActions = useTabActionsStore((state) => state.setTabActions); - const clearTabActions = useTabActionsStore( - (state) => state.clearTabActions - ); + // ===== TAB ACTIONS ===== + useEffect(() => { + setTabActions( + tabId, +
+ - useEffect(() => { - setTabActions( - tabId, -
- handleFilterModalOpenRef.current()} + - - -
- - - Export - -
- - -
- - } + color='none' + isLoading={isAnyExportLoading} + className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft' > - - - - - -
- ); - }, [setTabActions]); - - useEffect(() => { - return () => { - clearTabActions(tabId); - }; - }, [clearTabActions]); - - return null; - }; +
+ + Export +
+ +
+ + } + > + + + + +
+ ); }, [ tabId, + setTabActions, + tableFilterState, + filterModal.openModal, isAnyExportLoading, - handleExportExcelGeneral, handleExportExcel, + handleExportExcelGeneral, handleExportPdf, - isExcelGeneralExportLoading, isExcelExportLoading, + isExcelGeneralExportLoading, isPdfExportLoading, - filterParams, ]); - const TabActionsElement = useMemo(() => , [TabActions]); + useEffect(() => { + return () => clearTabActions(tabId); + }, [tabId, clearTabActions]); const getTableColumns = ( summary: CustomerPaymentSummary @@ -690,11 +626,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { enableSorting: false, cell: (props) => { const value = props.row.original.status; - - if (!value) { - return '-'; - } - + if (!value) return '-'; return ( { return ( <> - {TabActionsElement}
{isLoading && (
@@ -762,16 +693,16 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { - setCurrentPage((curr) => (curr > 1 ? curr - 1 : curr)) - } + currentPage={tableFilterState.page} + onPrevPage={() => setPage(Math.max(1, tableFilterState.page - 1))} onNextPage={() => - setCurrentPage((curr) => - meta.total_pages && curr < meta.total_pages ? curr + 1 : curr + setPage( + meta.total_pages && tableFilterState.page < meta.total_pages + ? tableFilterState.page + 1 + : tableFilterState.page ) } - onPageChange={(pageNumber) => setCurrentPage(pageNumber)} + onPageChange={setPage} rowOptions={[10, 20, 50, 100]} onRowChange={setPageSize} /> @@ -878,16 +809,16 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { - setCurrentPage((curr) => (curr > 1 ? curr - 1 : curr)) - } + currentPage={tableFilterState.page} + onPrevPage={() => setPage(Math.max(1, tableFilterState.page - 1))} onNextPage={() => - setCurrentPage((curr) => - meta.total_pages && curr < meta.total_pages ? curr + 1 : curr + setPage( + meta.total_pages && tableFilterState.page < meta.total_pages + ? tableFilterState.page + 1 + : tableFilterState.page ) } - onPageChange={(pageNumber) => setCurrentPage(pageNumber)} + onPageChange={setPage} rowOptions={[10, 20, 50, 100]} onRowChange={setPageSize} /> @@ -917,7 +848,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
-
+
@@ -958,15 +878,10 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { label='Customer' placeholder='Pilih Customer' options={customerOptions} - value={customerIdsValue} - onChange={(val) => { - formik.setFieldValue( - 'customer_ids', - Array.isArray(val) && val.length > 0 - ? val.map((v: OptionType) => String(v.value)).join(',') - : null - ); - }} + value={formik.values.customers} + onChange={(val) => + formik.setFieldValue('customers', Array.isArray(val) ? val : []) + } onInputChange={setCustomerInputValue} isLoading={isLoadingCustomers} isClearable @@ -978,14 +893,15 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { label='Filter Berdasarkan' placeholder='Pilih Filter Berdasarkan' options={dataTypeOptions} - value={filterByValue} - onChange={(val) => { - if (!Array.isArray(val)) { - formik.setFieldValue('filter_by', val?.value || null); - } - }} + value={formik.values.filterBy ?? null} + onChange={(val) => + formik.setFieldValue( + 'filterBy', + !Array.isArray(val) ? (val ?? undefined) : undefined + ) + } className={{ wrapper: 'w-full' }} - isClearable={true} + isClearable />
@@ -1001,7 +917,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { diff --git a/src/components/pages/report/finance/tab/DebtSupplierTab.tsx b/src/components/pages/report/finance/tab/DebtSupplierTab.tsx index 95bed1f2..78d27f2a 100644 --- a/src/components/pages/report/finance/tab/DebtSupplierTab.tsx +++ b/src/components/pages/report/finance/tab/DebtSupplierTab.tsx @@ -9,23 +9,15 @@ import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table'; import { isResponseSuccess } from '@/lib/api-helper'; import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/helper'; import { SupplierApi } from '@/services/api/master-data'; -import { - DebtRow, - DebtSupplier, - DebtSupplierFilter, -} from '@/types/api/report/debt-supplier'; +import { DebtRow, DebtSupplier } from '@/types/api/report/debt-supplier'; import { generateDebtSupplierPDF } from '@/components/pages/report/finance/export/DebtSupllierExportPDF'; import { Icon } from '@iconify/react'; import { ColumnDef } from '@tanstack/react-table'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import toast from 'react-hot-toast'; import useSWR from 'swr'; import { DebtSupplierApi } from '@/services/api/report/debt-supplier'; import { useFormik } from 'formik'; -import { - DebtSupplierFilterSchema, - DebtSupplierFilterType, -} from '@/components/pages/report/finance/filter/DebtSupplierFilter'; import ButtonFilter from '@/components/helper/ButtonFilter'; import { Color } from '@/types/theme'; import { Supplier } from '@/types/api/master-data/supplier'; @@ -34,6 +26,10 @@ import SelectInputRadio from '@/components/input/SelectInputRadio'; import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store'; import StatusBadge from '@/components/helper/StatusBadge'; import DebtSupplierSkeleton from '@/components/pages/report/finance/skeleton/DebtSupplierSkeleton'; +import { useTableFilter } from '@/services/hooks/useTableFilter'; +import { httpClientFetcher, SWRHttpKey } from '@/services/http/client'; +import { BaseApiResponse } from '@/types/api/api-general'; +import { AxiosError } from 'axios'; const dueStatus: Record = { 'Sudah Jatuh Tempo': 'error', @@ -51,7 +47,6 @@ const getPillBadge = ( statusText: string, type: 'due' | 'payment' = 'payment' ) => { - // Get color based on type const color = type === 'due' ? dueStatus[statusText] || 'neutral' @@ -68,6 +63,11 @@ const getPillBadge = ( ); }; +const dataTypeOptions: OptionType[] = [ + { value: 'received_date', label: 'Tanggal Terima' }, + { value: 'po_date', label: 'Tanggal PO' }, +]; + interface DebtSupplierTabProps { tabId: string; } @@ -81,26 +81,45 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => { const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading || isExcelGeneralExportLoading; - // ===== PAGINATION STATE ===== - const [currentPage, setCurrentPage] = useState(1); - const [pageSize, setPageSize] = useState(10); - - // ===== SUBMISSION STATE ===== - const [filterParams, setFilterParams] = useState({ - start_date: undefined, - end_date: undefined, - supplier_ids: undefined, - filter_by: undefined, - }); - - // ===== DATE ERROR STATE ===== const [dateErrorShown, setDateErrorShown] = useState(false); const [hasDateError, setHasDateError] = 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: getTableFilterQueryString, + reset: resetFilter, + } = useTableFilter<{ + start_date: string; + end_date: string; + suppliers: OptionType[]; + filterBy?: OptionType; + }>({ + initial: { + start_date: '', + end_date: '', + suppliers: [], + filterBy: undefined, + }, + paramMap: { + page: 'page', + pageSize: 'limit', + start_date: 'start_date', + end_date: 'end_date', + suppliers: 'supplier_ids', + filterBy: 'filter_by', + }, + persist: true, + storeName: 'debt-supplier-report-table', + }); + const { setInputValue: setSupplierInputValue, options: supplierOptions, @@ -108,154 +127,149 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => { loadMore: loadMoreSuppliers, } = useSelect(SupplierApi.basePath, 'id', 'name'); - const dataTypeOptions = useMemo( - () => [ - { value: 'received_date', label: 'Tanggal Terima' }, - { value: 'po_date', label: 'Tanggal PO' }, - ], - [] - ); - // ===== FORMIK SETUP ===== - const formik = useFormik({ + const formik = useFormik({ initialValues: { - startDate: null, - endDate: null, - supplierIds: null, - filterBy: null, + start_date: tableFilterState.start_date, + end_date: tableFilterState.end_date, + suppliers: tableFilterState.suppliers, + filterBy: tableFilterState.filterBy, }, - validationSchema: DebtSupplierFilterSchema, onSubmit: (values) => { - setFilterParams({ - start_date: values.startDate?.toString() || undefined, - end_date: values.endDate?.toString() || undefined, - supplier_ids: - values.supplierIds?.map((v) => String(v.value)).join(',') || - undefined, - filter_by: values.filterBy?.value?.toString() || undefined, - }); - filterModal.closeModal(); - setCurrentPage(1); - }, - onReset: () => { - setFilterParams({ - start_date: undefined, - end_date: undefined, - supplier_ids: undefined, - filter_by: undefined, - }); - setCurrentPage(1); + updateFilter('start_date', values.start_date, true); + updateFilter('end_date', values.end_date, true); + updateFilter('suppliers', values.suppliers, true); + updateFilter('filterBy', values.filterBy, true); filterModal.closeModal(); }, }); - handleFilterModalOpenRef.current = () => { - const restoredFilterBy = - dataTypeOptions.find((opt) => opt.value === filterParams.filter_by) || - null; + const formikResetHandler = () => { + resetFilter(); - const supplierIdList = filterParams.supplier_ids - ? filterParams.supplier_ids.split(',') - : []; - const restoredSupplierIds = supplierOptions.filter((opt) => - supplierIdList.includes(String(opt.value)) - ); + setHasDateError(false); + if (dateErrorShown) { + toast.dismiss(); + setDateErrorShown(false); + } - formik.setValues({ - startDate: filterParams.start_date || null, - endDate: filterParams.end_date || null, - supplierIds: restoredSupplierIds.length > 0 ? restoredSupplierIds : null, - filterBy: restoredFilterBy, + formik.resetForm({ + values: { + start_date: '', + end_date: '', + suppliers: [], + filterBy: undefined, + }, }); - filterModal.openModal(); + + filterModal.closeModal(); + }; + + // ===== DATE CHANGE HANDLERS ===== + 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); + } }; // ===== DATA FETCHING ===== - const { data: debtSupplier, isLoading } = useSWR( - () => { - const params = { - supplier_ids: filterParams.supplier_ids, - filter_by: filterParams.filter_by, - start_date: filterParams.start_date, - end_date: filterParams.end_date, - page: currentPage, - limit: pageSize, - }; - - return ['debt-supplier-report', params]; - }, - ([, params]) => - DebtSupplierApi.getDebtSupplierReport( - params.supplier_ids, - params.filter_by, - params.start_date, - params.end_date, - params.page, - params.limit - ) + const { data: debtSupplierResponse, isLoading } = useSWR< + BaseApiResponse, + AxiosError, + SWRHttpKey + >( + `${DebtSupplierApi.basePath}/debt-supplier${getTableFilterQueryString()}`, + httpClientFetcher ); - const data: DebtSupplier[] = useMemo( - () => - isResponseSuccess(debtSupplier) - ? (debtSupplier?.data as unknown as DebtSupplier[]) || [] - : [], - [debtSupplier] - ); + const data: DebtSupplier[] = isResponseSuccess(debtSupplierResponse) + ? ((debtSupplierResponse?.data as unknown as DebtSupplier[]) ?? []) + : []; - const meta = useMemo( - () => - isResponseSuccess(debtSupplier) && debtSupplier.meta - ? debtSupplier.meta - : null, - [debtSupplier] - ); + const meta = + isResponseSuccess(debtSupplierResponse) && debtSupplierResponse.meta + ? debtSupplierResponse.meta + : null; // ===== EXPORT DATA FETCHER ===== const debtSupplierExport = useCallback(async (): Promise< DebtSupplier[] | null > => { - const params = { - supplier_ids: - formik.values.supplierIds && formik.values.supplierIds.length > 0 - ? formik.values.supplierIds.map((v) => String(v.value)).join(',') - : undefined, - filter_by: formik.values.filterBy?.value?.toString() || undefined, - start_date: formik.values.startDate || undefined, - end_date: formik.values.endDate || undefined, - date_type: formik.values.filterBy - ? formik.values.filterBy.value - : undefined, - limit: 100, - page: 1, - }; + const supplier_ids = + tableFilterState.suppliers.length > 0 + ? tableFilterState.suppliers.map((o) => String(o.value)).join(',') + : undefined; const response = await DebtSupplierApi.getDebtSupplierReport( - params.supplier_ids, - params.filter_by, - params.start_date, - params.end_date + supplier_ids, + tableFilterState.filterBy?.value, + tableFilterState.start_date || undefined, + tableFilterState.end_date || undefined, + 1, + 100 ); return isResponseSuccess(response) ? (response.data as unknown as DebtSupplier[]) : null; - }, [ - formik.values.supplierIds, - formik.values.startDate, - formik.values.endDate, - formik.values.filterBy, - ]); + }, [tableFilterState]); // ===== EXPORT HANDLERS ===== const handleExportExcel = useCallback(async () => { setIsExcelExportLoading(true); try { + const supplier_ids = + tableFilterState.suppliers.length > 0 + ? tableFilterState.suppliers.map((o) => String(o.value)).join(',') + : undefined; await DebtSupplierApi.exportToExcelSupplierPerSheet( - filterParams.supplier_ids, - filterParams.filter_by, - filterParams.start_date, - filterParams.end_date + supplier_ids, + tableFilterState.filterBy?.value, + tableFilterState.start_date || undefined, + tableFilterState.end_date || undefined ); toast.success('Excel berhasil dibuat dan diunduh.'); } catch { @@ -263,7 +277,30 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => { } finally { setIsExcelExportLoading(false); } - }, [filterParams]); + }, [tableFilterState]); + + const handleExportExcelGeneral = useCallback(async () => { + setIsExcelGeneralExportLoading(true); + try { + const supplier_ids = + tableFilterState.suppliers.length > 0 + ? tableFilterState.suppliers.map((o) => String(o.value)).join(',') + : undefined; + + await DebtSupplierApi.exportToExcelGeneral( + supplier_ids, + tableFilterState.filterBy?.value, + tableFilterState.start_date || undefined, + tableFilterState.end_date || undefined + ); + + toast.success('Excel General berhasil dibuat dan diunduh.'); + } catch { + toast.error('Gagal membuat Excel General. Silakan coba lagi.'); + } finally { + setIsExcelGeneralExportLoading(false); + } + }, [tableFilterState]); const handleExportPdf = useCallback(async () => { setIsPdfExportLoading(true); @@ -279,15 +316,18 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => { return; } + const supplierName = + tableFilterState.suppliers.length > 0 + ? tableFilterState.suppliers.map((o) => o.label).join(', ') + : undefined; + await generateDebtSupplierPDF({ data: allDataForExport, params: { - supplier_name: formik.values.supplierIds - ?.map((v) => v.label) - .join(', '), - filter_by: formik.values.filterBy?.label, - start_date: formik.values.startDate || undefined, - end_date: formik.values.endDate || undefined, + supplier_name: supplierName, + filter_by: tableFilterState.filterBy?.label, + start_date: tableFilterState.start_date || undefined, + end_date: tableFilterState.end_date || undefined, }, }); toast.success('PDF berhasil dibuat dan diunduh.'); @@ -296,131 +336,91 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => { } finally { setIsPdfExportLoading(false); } - }, [ - debtSupplierExport, - formik.values.supplierIds, - formik.values.filterBy, - formik.values.startDate, - formik.values.endDate, - ]); + }, [debtSupplierExport, tableFilterState]); - const handleExportExcelGeneral = useCallback(async () => { - setIsExcelGeneralExportLoading(true); - try { - await DebtSupplierApi.exportToExcelGeneral( - filterParams.supplier_ids, - filterParams.filter_by, - filterParams.start_date, - filterParams.end_date - ); - toast.success('Excel General berhasil dibuat dan diunduh.'); - } catch { - toast.error('Gagal membuat Excel General. Silakan coba lagi.'); - } finally { - setIsExcelGeneralExportLoading(false); - } - }, [filterParams]); + // ===== TAB ACTIONS ===== + useEffect(() => { + setTabActions( + tabId, +
+ - // ===== TAB ACTIONS COMPONENT ===== - const TabActions = useMemo(() => { - return function TabActionsComponent() { - const setTabActions = useTabActionsStore((state) => state.setTabActions); - const clearTabActions = useTabActionsStore( - (state) => state.clearTabActions - ); - - useEffect(() => { - setTabActions( - tabId, -
- handleFilterModalOpenRef.current()} + - - -
- - - Export - -
- - -
- - } + color='none' + isLoading={isAnyExportLoading} + className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft' > - - - - -
- ); - }, [setTabActions]); - - useEffect(() => { - return () => { - clearTabActions(tabId); - }; - }, [clearTabActions]); - - return null; - }; +
+ + Export +
+ +
+ + } + > + + + + +
+ ); }, [ tabId, - filterParams, + setTabActions, + tableFilterState, + filterModal.openModal, isAnyExportLoading, handleExportExcel, handleExportExcelGeneral, @@ -430,24 +430,9 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => { isPdfExportLoading, ]); - const TabActionsElement = useMemo(() => , [TabActions]); - useEffect(() => { - return () => { - if (dateErrorShown) { - toast.dismiss(); - } - }; - }, [dateErrorShown]); - - useEffect(() => { - return () => { - if (dateErrorShown) { - toast.dismiss(); - setDateErrorShown(false); - } - }; - }, [filterModal.open, dateErrorShown]); + return () => clearTabActions(tabId); + }, [tabId, clearTabActions]); const getTableColumns = (supplier?: DebtSupplier): ColumnDef[] => [ { @@ -662,9 +647,9 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => { }, }, ]; + return ( <> - {TabActionsElement}
{isLoading && (
@@ -693,16 +678,16 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => { - setCurrentPage((curr) => (curr > 1 ? curr - 1 : curr)) - } + currentPage={tableFilterState.page} + onPrevPage={() => setPage(Math.max(1, tableFilterState.page - 1))} onNextPage={() => - setCurrentPage((curr) => - meta.total_pages && curr < meta.total_pages ? curr + 1 : curr + setPage( + meta.total_pages && tableFilterState.page < meta.total_pages + ? tableFilterState.page + 1 + : tableFilterState.page ) } - onPageChange={(pageNumber) => setCurrentPage(pageNumber)} + onPageChange={setPage} rowOptions={[10, 20, 50, 100]} onRowChange={setPageSize} /> @@ -802,16 +787,16 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => { - setCurrentPage((curr) => (curr > 1 ? curr - 1 : curr)) - } + currentPage={tableFilterState.page} + onPrevPage={() => setPage(Math.max(1, tableFilterState.page - 1))} onNextPage={() => - setCurrentPage((curr) => - meta.total_pages && curr < meta.total_pages ? curr + 1 : curr + setPage( + meta.total_pages && tableFilterState.page < meta.total_pages + ? tableFilterState.page + 1 + : tableFilterState.page ) } - onPageChange={(pageNumber) => setCurrentPage(pageNumber)} + onPageChange={setPage} rowOptions={[10, 20, 50, 100]} onRowChange={setPageSize} /> @@ -827,23 +812,23 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => { modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm', }} > - - {/* Modal Header */} -
-
- -

Filter Data

-
- + {/* Modal Header */} +
+
+ +

Filter Data

+ +
+ {/* Modal Body */}
@@ -852,153 +837,68 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
{ - const value = e.target.value; - formik.setFieldValue('startDate', value || null); - - if (value && formik.values.endDate) { - const startDate = new Date(value); - const endDateObj = new Date(formik.values.endDate); - - if (endDateObj < startDate) { - 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); - } - }} + name='start_date' + value={formik.values.start_date || ''} + onChange={handleStartDateChange} className={{ wrapper: 'w-full' }} - isError={ - formik.touched.startDate && !!formik.errors.startDate - } - errorMessage={formik.errors.startDate} isNestedModal /> -
+
{ - const value = e.target.value; - formik.setFieldValue('endDate', value || null); - - if (value && formik.values.startDate) { - const startDateObj = new Date(formik.values.startDate); - const endDate = new Date(value); - - if (endDate < startDateObj) { - 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); - } - }} + name='end_date' + value={formik.values.end_date || ''} + onChange={handleEndDateChange} className={{ wrapper: 'w-full' }} - isError={ - (formik.touched.endDate && !!formik.errors.endDate) || - hasDateError - } - errorMessage={formik.errors.endDate} isNestedModal + isError={hasDateError} />
-
- { - formik.setFieldValue( - 'supplierIds', - Array.isArray(val) ? val : val ? [val] : null - ); - }} - onInputChange={setSupplierInputValue} - onMenuScrollToBottom={loadMoreSuppliers} - isLoading={isLoadingSupplierOptions} - isClearable - className={{ wrapper: 'w-full' }} - isError={ - formik.touched.supplierIds && !!formik.errors.supplierIds - } - errorMessage={formik.errors.supplierIds as string} - /> -
+ + formik.setFieldValue('suppliers', Array.isArray(val) ? val : []) + } + onInputChange={setSupplierInputValue} + onMenuScrollToBottom={loadMoreSuppliers} + isLoading={isLoadingSupplierOptions} + isClearable + className={{ wrapper: 'w-full' }} + /> -
- { - formik.setFieldValue( - 'filterBy', - val ? (val as OptionType) : null - ); - }} - className={{ wrapper: 'w-full' }} - isClearable - isError={formik.touched.filterBy && !!formik.errors.filterBy} - errorMessage={formik.errors.filterBy as string} - /> -
+ + formik.setFieldValue( + 'filterBy', + !Array.isArray(val) ? (val ?? undefined) : undefined + ) + } + className={{ wrapper: 'w-full' }} + isClearable + />
- {/* Action Buttons */} + {/* Modal Footer */}
diff --git a/src/services/api/report/finance-report.ts b/src/services/api/report/finance-report.ts index d113d0ed..132e3063 100644 --- a/src/services/api/report/finance-report.ts +++ b/src/services/api/report/finance-report.ts @@ -1,8 +1,9 @@ import { BaseApiService } from '@/services/api/base'; -import { httpClient } from '@/services/http/client'; -import { formatDate } from '@/lib/helper'; import { BaseApiResponse } from '@/types/api/api-general'; import { CustomerPaymentReport } from '@/types/api/report/customer-payment'; +import { httpClient } from '@/services/http/client'; +import { formatDate } from '@/lib/helper'; +import { BalanceMonitoringRow } from '@/types/api/report/balance-monitoring'; export class FinanceApiService extends BaseApiService< CustomerPaymentReport, @@ -85,6 +86,23 @@ export class FinanceApiService extends BaseApiService< link.remove(); } + async getBalanceMonitoringReport(params: { + start_date?: string; + end_date?: string; + customer_ids?: string; + sales_ids?: string; + filter_by?: string; + sort_by?: string; + sort_order?: string; + page?: number; + limit?: number; + }): Promise | undefined> { + return await this.customRequest>( + 'balance-monitoring', + { method: 'GET', params } + ); + } + async getCustomerPaymentReport( customer_ids?: string, // TODO: Uncomment when BE is ready diff --git a/src/types/api/report/balance-monitoring.d.ts b/src/types/api/report/balance-monitoring.d.ts new file mode 100644 index 00000000..a2309314 --- /dev/null +++ b/src/types/api/report/balance-monitoring.d.ts @@ -0,0 +1,25 @@ +import { Customer } from '@/services/api/master-data'; + +export type BalanceMonitoringRow = { + customer: Customer; + saldo_awal: number; + penjualan_ayam: { + ekor: number; + kg: number; + nominal: number; + }; + penjualan_telur: { + butir: number; + kg: number; + nominal: number; + }; + penjualan_trading: { + qty: number; + kg: number; + nominal: number; + }; + pembayaran: number; + aging: number; + aging_rata_rata: number; + saldo_akhir: number; +};