refactor(FE): Refactor FinanceTable and simplify Finance page structure

This commit is contained in:
rstubryan
2026-02-27 11:14:13 +07:00
parent b14bc00af4
commit d3ce60d3ba
2 changed files with 446 additions and 265 deletions
+1 -6
View File
@@ -3,12 +3,7 @@
import FinanceTable from '@/components/pages/finance/FinanceTable'; import FinanceTable from '@/components/pages/finance/FinanceTable';
const Finance = () => { const Finance = () => {
return ( return <FinanceTable />;
<section className='size-full p-6'>
<div className='flex flex-row gap-4'></div>
<FinanceTable />
</section>
);
}; };
export default Finance; export default Finance;
+294 -108
View File
@@ -1,10 +1,19 @@
import { useEffect, useMemo, useRef, useState } from 'react'; 'use client';
import { CellContext } from '@tanstack/react-table';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { CellContext, ColumnDef } from '@tanstack/react-table';
import useSWR from 'swr'; import useSWR from 'swr';
import { Icon } from '@iconify/react';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import { cn, formatCurrency, formatDate, formatTitleCase } from '@/lib/helper';
import Button from '@/components/Button'; import Button from '@/components/Button';
import Card from '@/components/Card';
import DateInput from '@/components/input/DateInput'; import DateInput from '@/components/input/DateInput';
import DebouncedTextInput from '@/components/input/DebouncedTextInput'; import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput, { import SelectInput, {
@@ -12,7 +21,6 @@ import SelectInput, {
useSelect, useSelect,
} from '@/components/input/SelectInput'; } from '@/components/input/SelectInput';
import Table from '@/components/Table'; import Table from '@/components/Table';
import { formatCurrency, formatDate, formatTitleCase } from '@/lib/helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { Finance } from '@/types/api/finance/finance'; import { Finance } from '@/types/api/finance/finance';
import { import {
@@ -25,31 +33,61 @@ import { FinanceApi } from '@/services/api/finance';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { BankApi, CustomerApi, SupplierApi } from '@/services/api/master-data'; import { BankApi, CustomerApi, SupplierApi } from '@/services/api/master-data';
import { Bank } from '@/types/api/master-data/bank'; import { Bank } from '@/types/api/master-data/bank';
import { useModal } from '@/components/Modal'; import Modal, { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import { Icon } from '@iconify/react';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import { useUiStore } from '@/stores/ui/ui.store'; import { useUiStore } from '@/stores/ui/ui.store';
import { import {
FinanceTableFilterSchema, FinanceTableFilterSchema,
FinanceTableFilterValues, FinanceTableFilterValues,
} from './FinanceTableFilter.schema'; } from './FinanceTableFilter.schema';
import SelectInputRadio from '@/components/input/SelectInputRadio';
const RowOptionsMenu = ({ const RowOptionsMenu = ({
type = 'dropdown', popoverPosition = 'bottom',
props, props,
deleteClickHandler, deleteClickHandler,
}: { }: {
type: 'dropdown' | 'collapse'; popoverPosition: 'bottom' | 'top';
props: CellContext<Finance, unknown>; props: CellContext<Finance, unknown>;
deleteClickHandler: () => void; deleteClickHandler: () => void;
}) => { }) => {
const popoverId = `finance#${props.row.original.id}`;
const popoverAnchorName = `--anchor-finance#${props.row.original.id}`;
const closePopover = () => {
const popover = document.getElementById(popoverId) as
| HTMLDivElement
| undefined;
popover?.hidePopover?.();
};
return ( return (
<RowOptionsMenuWrapper type={type}> <div className='relative'>
<Button
tabIndex={0}
variant='ghost'
color='none'
className='p-2'
popoverTarget={popoverId}
style={{ anchorName: popoverAnchorName } as React.CSSProperties}
>
<Icon icon='material-symbols:more-vert' width={20} height={20} />
</Button>
<div
id={popoverId}
popover='auto'
style={{ positionAnchor: popoverAnchorName } as React.CSSProperties}
className={cn(
'fixed inset-auto bg-base-100 rounded-xl border border-base-content/5 shadow-sm w-full max-w-40 z-[100]',
popoverPosition === 'bottom'
? 'top-[anchor(bottom)] left-[anchor(left)] mt-1'
: 'bottom-[anchor(top)] left-[anchor(left)] mb-1'
)}
>
<div className='flex flex-col bg-base-100 rounded-xl'>
<RequirePermission <RequirePermission
permissions={[ permissions={[
'lti.finance.transactions.detail', 'lti.finance.transactions.detail',
@@ -61,10 +99,11 @@ const RowOptionsMenu = ({
<Button <Button
href={`/finance/detail?financeId=${props.row.original.id}`} href={`/finance/detail?financeId=${props.row.original.id}`}
variant='ghost' variant='ghost'
color='primary' color='none'
className='justify-start text-sm' className='p-3 justify-start text-sm font-semibold w-full'
onClick={closePopover}
> >
<Icon icon='mdi:eye-outline' width={16} height={16} /> <Icon icon='heroicons:eye' width={20} height={20} />
Detail Detail
</Button> </Button>
</RequirePermission> </RequirePermission>
@@ -76,10 +115,11 @@ const RowOptionsMenu = ({
<Button <Button
href={`/finance/detail/edit?financeId=${props.row.original.id}`} href={`/finance/detail/edit?financeId=${props.row.original.id}`}
variant='ghost' variant='ghost'
color='warning' color='none'
className='justify-start text-sm' className='p-3 justify-start text-sm font-semibold w-full'
onClick={closePopover}
> >
<Icon icon='material-symbols:edit-outline' width={16} height={16} /> <Icon icon='mdi:pencil-outline' width={20} height={20} />
Edit Edit
</Button> </Button>
</RequirePermission> </RequirePermission>
@@ -92,10 +132,11 @@ const RowOptionsMenu = ({
<Button <Button
href={`/finance/detail/edit/initial-balance?financeId=${props.row.original.id}`} href={`/finance/detail/edit/initial-balance?financeId=${props.row.original.id}`}
variant='ghost' variant='ghost'
color='warning' color='none'
className='justify-start text-sm' className='p-3 justify-start text-sm font-semibold w-full'
onClick={closePopover}
> >
<Icon icon='material-symbols:edit-outline' width={16} height={16} /> <Icon icon='mdi:pencil-outline' width={20} height={20} />
Edit Edit
</Button> </Button>
</RequirePermission> </RequirePermission>
@@ -108,10 +149,11 @@ const RowOptionsMenu = ({
<Button <Button
href={`/finance/detail/edit/injection?financeId=${props.row.original.id}`} href={`/finance/detail/edit/injection?financeId=${props.row.original.id}`}
variant='ghost' variant='ghost'
color='warning' color='none'
className='justify-start text-sm' className='p-3 justify-start text-sm font-semibold w-full'
onClick={closePopover}
> >
<Icon icon='material-symbols:edit-outline' width={16} height={16} /> <Icon icon='mdi:pencil-outline' width={20} height={20} />
Edit Edit
</Button> </Button>
</RequirePermission> </RequirePermission>
@@ -119,21 +161,21 @@ const RowOptionsMenu = ({
<RequirePermission permissions='lti.finance.transactions.delete'> <RequirePermission permissions='lti.finance.transactions.delete'>
<Button <Button
onClick={deleteClickHandler} onClick={() => {
deleteClickHandler();
closePopover();
}}
variant='ghost' variant='ghost'
color='error' color='error'
className='text-error hover:text-inherit' className='p-3 justify-start text-sm font-semibold w-full focus-visible:text-error-content hover:text-error-content'
> >
<Icon <Icon icon='mdi:delete-outline' width={20} height={20} />
icon='material-symbols:delete-outline-rounded'
width={16}
height={16}
className='justify-start text-sm'
/>
Delete Delete
</Button> </Button>
</RequirePermission> </RequirePermission>
</RowOptionsMenuWrapper> </div>
</div>
</div>
); );
}; };
@@ -171,6 +213,9 @@ const FinanceTable = () => {
}, },
}); });
// ===== FILTER MODAL STATE =====
const filterModal = useModal();
// ===== State ===== // ===== State =====
const deleteModal = useModal(); const deleteModal = useModal();
const [selectedTransactionType, setSelectedTransactionType] = useState< const [selectedTransactionType, setSelectedTransactionType] = useState<
@@ -214,6 +259,18 @@ const FinanceTable = () => {
updateFilter('sortBy', values.sort_by); updateFilter('sortBy', values.sort_by);
updateFilter('startDate', values.start_date); updateFilter('startDate', values.start_date);
updateFilter('endDate', values.end_date); updateFilter('endDate', values.end_date);
filterModal.closeModal();
},
onReset: () => {
updateFilter('search', '');
resetSearchValue();
updateFilter('transactionTypes', '');
updateFilter('bankIds', '');
updateFilter('customerIds', '');
updateFilter('supplierIds', '');
updateFilter('sortBy', '');
updateFilter('startDate', '');
updateFilter('endDate', '');
}, },
}); });
@@ -266,10 +323,41 @@ const FinanceTable = () => {
}); });
}, [bankOptions, bankRawData]); }, [bankOptions, bankRawData]);
// ===== ACTIVE FILTERS COUNT =====
const activeFiltersCount = useMemo(() => {
let count = 0;
if (tableFilterState.transactionTypes) count += 1;
if (tableFilterState.bankIds) count += 1;
if (tableFilterState.customerIds) count += 1;
if (tableFilterState.supplierIds) count += 1;
if (tableFilterState.sortBy) count += 1;
if (tableFilterState.startDate) count += 1;
if (tableFilterState.endDate) count += 1;
return count;
}, [
tableFilterState.transactionTypes,
tableFilterState.bankIds,
tableFilterState.customerIds,
tableFilterState.supplierIds,
tableFilterState.sortBy,
tableFilterState.startDate,
tableFilterState.endDate,
]);
const hasFilters = activeFiltersCount > 0;
// ===== Handler ===== // ===== Handler =====
const searchChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => { const searchChangeHandler = useCallback(
filterFormik.setFieldValue('search', e.target.value); (e: React.ChangeEvent<HTMLInputElement>) => {
}; updateFilter('search', e.target.value);
setSearchValue(e.target.value);
setPage(1);
},
[updateFilter, setSearchValue, setPage]
);
const transactionTypeChangeHandler = ( const transactionTypeChangeHandler = (
val: OptionType | OptionType[] | null val: OptionType | OptionType[] | null
) => { ) => {
@@ -387,6 +475,11 @@ const FinanceTable = () => {
} }
}; };
const handleFilterModalOpen = () => {
filterModal.openModal();
filterFormik.validateForm();
};
const resetFilterHandler = () => { const resetFilterHandler = () => {
setSelectedTransactionType(null); setSelectedTransactionType(null);
setSelectedBank(null); setSelectedBank(null);
@@ -406,6 +499,7 @@ const FinanceTable = () => {
updateFilter('startDate', ''); updateFilter('startDate', '');
updateFilter('endDate', ''); updateFilter('endDate', '');
}; };
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
@@ -417,8 +511,8 @@ const FinanceTable = () => {
setIsDeleteLoading(false); setIsDeleteLoading(false);
}; };
const columns = useMemo(() => { const columns: ColumnDef<Finance>[] = useMemo(
return [ () => [
{ {
header: 'ID', header: 'ID',
accessorKey: 'payment_code', accessorKey: 'payment_code',
@@ -498,32 +592,17 @@ const FinanceTable = () => {
}; };
return ( return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu <RowOptionsMenu
type='dropdown'
props={props} props={props}
deleteClickHandler={deleteClickHandler} deleteClickHandler={deleteClickHandler}
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
/> />
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu
type='collapse'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
); );
}, },
}, },
]; ],
}, []); []
);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -555,69 +634,158 @@ const FinanceTable = () => {
}, [resetSearchValue, dateErrorShown]); }, [resetSearchValue, dateErrorShown]);
return ( return (
<section className='size-full flex flex-col gap-6'> <>
<div className='flex justify-end gap-2'> <div className='w-full'>
{/* Header Section */}
<div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
{/* Action Buttons */}
<div className='w-fit flex flex-row gap-3 flex-wrap'>
<RequirePermission permissions='lti.finance.injections.create'> <RequirePermission permissions='lti.finance.injections.create'>
<Button <Button
color='warning'
className='min-w-24'
href='/finance/add/injection' href='/finance/add/injection'
color='warning'
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
> >
Injection Saldo Bank <Icon icon='mdi:bank-transfer-in' width={20} height={20} />
Add Injection (Saldo Bank)
</Button> </Button>
</RequirePermission> </RequirePermission>
<RequirePermission permissions='lti.finance.initial_balances.create'> <RequirePermission permissions='lti.finance.initial_balances.create'>
<Button <Button
color='info'
className='text-white min-w-24'
href='/finance/add/initial-balance' href='/finance/add/initial-balance'
color='info'
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
> >
Saldo Awal <Icon icon='mdi:cash-register' width={20} height={20} />
Add Initial Balance
</Button> </Button>
</RequirePermission> </RequirePermission>
<RequirePermission permissions='lti.finance.payments.create'> <RequirePermission permissions='lti.finance.payments.create'>
<Button color='primary' className='min-w-24' href='/finance/add'> <Button
Tambah href='/finance/add'
color='primary'
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
>
<Icon icon='heroicons:plus' width={20} height={20} />
Add Finance
</Button> </Button>
</RequirePermission> </RequirePermission>
</div> </div>
<Card
variant='bordered' {/* Search and Filter */}
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
<DebouncedTextInput
name='search'
placeholder='Search'
value={tableFilterState.search ?? ''}
onChange={searchChangeHandler}
startAdornment={
<Icon
icon='heroicons:magnifying-glass'
width={20}
height={20}
/>
}
className={{ className={{
wrapper: 'w-full', wrapper: 'w-full min-w-24 max-w-3xs',
inputWrapper: 'rounded-xl! shadow-button-soft',
input:
'placeholder:font-semibold placeholder:text-base-content/50',
}} }}
footer={ />
<div className='flex justify-end gap-2'>
<Button <Button
color='warning' variant='outline'
className='min-w-24' color='none'
onClick={resetFilterHandler} onClick={handleFilterModalOpen}
className={cn(
'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all',
{
'border-primary-gradient text-primary': hasFilters,
}
)}
> >
Reset <Icon icon='heroicons:funnel' width={20} height={20} />
</Button> Filter
<Button {hasFilters && (
color='primary' <span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
className='min-w-24' {activeFiltersCount}
onClick={() => filterFormik.handleSubmit()} </span>
> )}
Cari
</Button> </Button>
</div> </div>
</div>
{/* Table Section */}
<div className='flex flex-col mb-4 -mx-4 px-4'>
{isLoading ? (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
) : (
<Table<Finance>
data={isResponseSuccess(finances) ? finances.data : []}
columns={columns}
pageSize={tableFilterState.pageSize}
page={tableFilterState.page}
totalItems={
isResponseSuccess(finances) ? finances.meta?.total_results : 0
} }
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={isLoading}
className={{
containerClassName: 'p-3 mb-0',
headerColumnClassName: 'text-nowrap',
}}
/>
)}
</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',
}}
> >
<div className='grid grid-cols-4 gap-6'> {/* 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={filterFormik.handleSubmit}
onReset={filterFormik.handleReset}
>
<div className='p-4 flex flex-col gap-1.5'>
<SelectInput <SelectInput
options={FINANCE_TRANSACTION_TYPE_OPTIONS} options={FINANCE_TRANSACTION_TYPE_OPTIONS}
label='Jenis Transaksi' label='Jenis Transaksi'
placeholder='Pilih Jenis Transaksi'
value={selectedTransactionType} value={selectedTransactionType}
onChange={transactionTypeChangeHandler} onChange={transactionTypeChangeHandler}
onInputChange={() => {}}
closeMenuOnSelect={false} closeMenuOnSelect={false}
isClearable isClearable
isMulti isMulti
className={{ wrapper: 'w-full' }}
/> />
<SelectInput <SelectInput
options={customerOptions} options={customerOptions}
label={'Customer'} label='Customer'
placeholder='Pilih Customer'
value={selectedCustomerId} value={selectedCustomerId}
onChange={customerIdChangeHandler} onChange={customerIdChangeHandler}
onInputChange={customerInputValue} onInputChange={customerInputValue}
@@ -626,10 +794,12 @@ const FinanceTable = () => {
closeMenuOnSelect={false} closeMenuOnSelect={false}
isClearable isClearable
isMulti isMulti
className={{ wrapper: 'w-full' }}
/> />
<SelectInput <SelectInput
options={supplierOptions} options={supplierOptions}
label={'Supplier'} label='Supplier'
placeholder='Pilih Supplier'
value={selectedSupplierId} value={selectedSupplierId}
onChange={supplierIdChangeHandler} onChange={supplierIdChangeHandler}
onInputChange={supplierInputValue} onInputChange={supplierInputValue}
@@ -638,10 +808,12 @@ const FinanceTable = () => {
closeMenuOnSelect={false} closeMenuOnSelect={false}
isClearable isClearable
isMulti isMulti
className={{ wrapper: 'w-full' }}
/> />
<SelectInput <SelectInput
options={bankSelectOptions} options={bankSelectOptions}
label='Bank' label='Bank'
placeholder='Pilih Bank'
value={selectedBank} value={selectedBank}
onChange={bankChangeHandler} onChange={bankChangeHandler}
onInputChange={bankInputValue} onInputChange={bankInputValue}
@@ -649,13 +821,16 @@ const FinanceTable = () => {
closeMenuOnSelect={false} closeMenuOnSelect={false}
isClearable isClearable
isMulti isMulti
className={{ wrapper: 'w-full' }}
/> />
<SelectInput <SelectInputRadio
options={sortByOptions} options={sortByOptions}
label='Urutkan Berdasarkan' label='Urutkan Berdasarkan'
placeholder='Pilih Urutan'
value={selectedSortBy} value={selectedSortBy}
onChange={sortByChangeHandler} onChange={sortByChangeHandler}
isClearable isClearable
className={{ wrapper: 'w-full' }}
/> />
<DateInput <DateInput
name='start_date' name='start_date'
@@ -679,27 +854,38 @@ const FinanceTable = () => {
: undefined : undefined
} }
/> />
<DebouncedTextInput
name='search'
label='Cari'
placeholder='Cari'
value={filterFormik.values.search}
onChange={searchChangeHandler}
/>
</div> </div>
</Card>
<Table<Finance> {/* Modal Footer */}
data={isResponseSuccess(finances) ? finances.data : []} <div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
columns={columns} <Button
pageSize={tableFilterState.pageSize} type='button'
page={tableFilterState.page} variant='soft'
onPageChange={setPage} 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'
onPageSizeChange={setPageSize} onClick={() => {
totalItems={ filterFormik.resetForm();
isResponseSuccess(finances) ? finances.meta?.total_results : 0 setSelectedTransactionType(null);
} setSelectedBank(null);
isLoading={isLoading} setSelectedCustomerId(null);
/> setSelectedSupplierId(null);
setSelectedSortBy(null);
resetFilterHandler();
filterModal.closeModal();
}}
>
Reset Filter
</Button>
<Button
type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
disabled={!filterFormik.isValid || filterFormik.isSubmitting}
>
Apply Filter
</Button>
</div>
</form>
</Modal>
<ConfirmationModal <ConfirmationModal
ref={deleteModal.ref} ref={deleteModal.ref}
type='error' type='error'
@@ -714,7 +900,7 @@ const FinanceTable = () => {
onClick: confirmationModalDeleteClickHandler, onClick: confirmationModalDeleteClickHandler,
}} }}
/> />
</section> </>
); );
}; };