refactor(FE): Refactor ExpensesTable to use ExpensesFilterModal

This commit is contained in:
rstubryan
2026-02-25 11:40:42 +07:00
parent e2e64f093f
commit 22b1102454
3 changed files with 423 additions and 217 deletions
+189 -217
View File
@@ -16,10 +16,6 @@ import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button'; import Button from '@/components/Button';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions'; import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions'; import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper'; import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
@@ -27,17 +23,15 @@ import RealizationStatusBadge from '@/components/pages/expense/RealizationStatus
import ExpenseStatusBadge from '@/components/pages/expense/ExpenseStatusBadge'; import ExpenseStatusBadge from '@/components/pages/expense/ExpenseStatusBadge';
import CheckboxInput from '@/components/input/CheckboxInput'; import CheckboxInput from '@/components/input/CheckboxInput';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes'; import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import DateInput from '@/components/input/DateInput';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import ButtonFilter from '@/components/helper/ButtonFilter';
import ExpensesFilterModal from '@/components/pages/expense/filter/ExpensesFilterModal';
import { Expense } from '@/types/api/expense'; import { Expense } from '@/types/api/expense';
import { ExpenseApi } from '@/services/api/expense'; import { ExpenseApi } from '@/services/api/expense';
import { cn, formatCurrency, formatDate } from '@/lib/helper'; import { cn, formatCurrency, formatDate } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { LocationApi, SupplierApi } from '@/services/api/master-data';
import { Location } from '@/types/api/master-data/location';
import { Supplier } from '@/types/api/master-data/supplier';
import { BaseApiResponse } from '@/types/api/api-general'; import { BaseApiResponse } from '@/types/api/api-general';
const RowOptionsMenu = ({ const RowOptionsMenu = ({
@@ -179,6 +173,9 @@ const ExpensesTable = () => {
const approveModal = useModal(); const approveModal = useModal();
const rejectModal = useModal(); const rejectModal = useModal();
// ===== FILTER MODAL STATE =====
const filterModal = useModal();
const [selectedExpense, setSelectedExpense] = useState<Expense | undefined>( const [selectedExpense, setSelectedExpense] = useState<Expense | undefined>(
undefined undefined
); );
@@ -535,51 +532,32 @@ const ExpensesTable = () => {
setIsRejectLoading(false); setIsRejectLoading(false);
}; };
const {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const [selectedLocation, setSelectedLocation] = useState<OptionType | null>(
null
);
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
setSelectedLocation(val as OptionType);
updateFilter(
'locationId',
val ? ((val as OptionType).value as string) : ''
);
};
const {
setInputValue: setVendorInputValue,
options: vendorOptions,
isLoadingOptions: isLoadingVendorOptions,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
const [selectedVendor, setSelectedVendor] = useState<OptionType | null>(null);
const vendorChangeHandler = (val: OptionType | OptionType[] | null) => {
setSelectedVendor(val as OptionType);
updateFilter('vendorId', val ? ((val as OptionType).value as string) : '');
};
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value); updateFilter('search', e.target.value);
}; };
const transactionDateChangeHandler: ChangeEventHandler<HTMLInputElement> = ( // ===== FILTER MODAL HANDLERS =====
e const handleFilterModalOpen = () => {
) => { filterModal.openModal();
updateFilter('transactionDate', e.target.value);
}; };
const realizationDateChangeHandler: ChangeEventHandler<HTMLInputElement> = ( const handleFilterSubmit = (values: {
e transaction_date?: string | null;
) => { realization_date?: string | null;
updateFilter('realizationDate', e.target.value); location_id?: string | null;
vendor_id?: string | null;
}) => {
updateFilter('transactionDate', values.transaction_date || '');
updateFilter('realizationDate', values.realization_date || '');
updateFilter('locationId', values.location_id || '');
updateFilter('vendorId', values.vendor_id || '');
};
const handleFilterReset = () => {
updateFilter('transactionDate', '');
updateFilter('realizationDate', '');
updateFilter('locationId', '');
updateFilter('vendorId', '');
}; };
// track sorting // track sorting
@@ -595,188 +573,176 @@ const ExpensesTable = () => {
return ( return (
<> <>
<div className='w-full p-0 sm:p-4'> <div className='w-full'>
<div className='flex flex-col gap-2 mb-4'> <div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
<div className='flex flex-col gap-2 mb-4'> {/* Action Buttons */}
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-4'> <div className='w-fit flex flex-row gap-3 flex-wrap'>
<div className='w-full sm:w-fit flex flex-col sm:flex-row self-start gap-2'> <RequirePermission permissions='lti.expense.create'>
<RequirePermission permissions='lti.expense.create'> <Button
href='/expense/add'
color='primary'
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-xl shadow-button-soft'
>
<Icon icon='heroicons:plus' width={20} height={20} />
Add Expense
</Button>
</RequirePermission>
{selectedRowIds.length > 0 && (
<>
<hr className='w-px h-full border-none bg-base-content/10 sm:block hidden' />
<RequirePermission permissions='lti.expense.approve.head_area'>
<Button <Button
href='/expense/add' variant='outline'
color='primary' color='none'
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm' onClick={bulkApproveClickHandler}
disabled={!isAllSelectedRowLatestApprovalOnHeadArea}
className='px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
> >
<Icon icon='heroicons:plus' width={20} height={20} /> <Icon
Add Expense icon='lucide-lab:farm'
width={20}
height={20}
className='text-success'
/>
Approve Head Area
</Button> </Button>
</RequirePermission> </RequirePermission>
{selectedRowIds.length > 0 && ( <RequirePermission permissions='lti.expense.approve.unit_vice_president'>
<> <Button
<RequirePermission permissions='lti.expense.approve.head_area'> variant='outline'
<Button color='none'
variant='outline' onClick={bulkApproveClickHandler}
color='info' disabled={
onClick={bulkApproveClickHandler} !isAllSelectedRowLatestApprovalOnUnitVicePresident
disabled={!isAllSelectedRowLatestApprovalOnHeadArea} }
className='w-full sm:w-fit' className='px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
> >
<Icon icon='lucide-lab:farm' width={24} height={24} /> <Icon
Approve Head Area icon='tdesign:money'
</Button> width={20}
</RequirePermission> height={20}
className='text-success'
/>
Approve Unit Vice President
</Button>
</RequirePermission>
<RequirePermission permissions='lti.expense.approve.unit_vice_president'> <RequirePermission permissions='lti.expense.approve.finance'>
<Button <Button
variant='outline' variant='outline'
color='success' color='none'
onClick={bulkApproveClickHandler} onClick={bulkApproveClickHandler}
disabled={ disabled={!isAllSelectedRowLatestApprovalOnFinance}
!isAllSelectedRowLatestApprovalOnUnitVicePresident className='px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
} >
className='w-full sm:w-fit' <Icon
> icon='tdesign:money'
<Icon icon='tdesign:money' width={24} height={24} /> width={20}
Approve Unit Vice President height={20}
</Button> className='text-success'
</RequirePermission> />
Approve Finance
</Button>
</RequirePermission>
<RequirePermission permissions='lti.expense.approve.finance'> <RequirePermission
<Button permissions={[
variant='outline' 'lti.expense.approve.head_area',
color='success' 'lti.expense.approve.unit_vice_president',
onClick={bulkApproveClickHandler} 'lti.expense.approve.finance',
disabled={!isAllSelectedRowLatestApprovalOnFinance} ]}
className='w-full sm:w-fit' >
> <Button
<Icon icon='tdesign:money' width={24} height={24} /> variant='outline'
Approve Finance color='none'
</Button> onClick={bulkRejectClickHandler}
</RequirePermission> disabled={
!isAllSelectedRowLatestApprovalOnHeadArea &&
!isAllSelectedRowLatestApprovalOnUnitVicePresident &&
!isAllSelectedRowLatestApprovalOnFinance
}
className='px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
>
<Icon
icon='material-symbols:close'
width={20}
height={20}
className='text-error'
/>
Reject
</Button>
</RequirePermission>
</>
)}
</div>
<RequirePermission {/* Search and Filter */}
permissions={[ <div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
'lti.expense.approve.head_area', <DebouncedTextInput
'lti.expense.approve.unit_vice_president', name='search'
'lti.expense.approve.finance', placeholder='Search'
]} value={tableFilterState.search ?? ''}
> onChange={searchChangeHandler}
<Button startAdornment={
variant='outline' <Icon
color='error' icon='heroicons:magnifying-glass'
onClick={bulkRejectClickHandler} width={20}
disabled={ height={20}
!isAllSelectedRowLatestApprovalOnHeadArea && />
!isAllSelectedRowLatestApprovalOnUnitVicePresident && }
!isAllSelectedRowLatestApprovalOnFinance className={{
} wrapper: 'w-full min-w-24 max-w-3xs',
className='w-full sm:w-fit' inputWrapper: 'rounded-xl! shadow-button-soft',
> input:
<Icon 'placeholder:font-semibold placeholder:text-base-content/50',
icon='material-symbols:close' }}
width={24} />
height={24}
/>
Reject
</Button>
</RequirePermission>
</>
)}
</div>
</div>
<div className='grid grid-cols-12 justify-end gap-2'> <ButtonFilter
<DateInput values={tableFilterState}
required excludeFields={[
label='Tanggal Transaksi' 'page',
name='transaction_date' 'pageSize',
placeholder='Masukkan tanggal transaksi' 'search',
value={tableFilterState.transactionDate} 'nameSort',
onChange={transactionDateChangeHandler} 'userId',
className={{ ]}
wrapper: 'col-span-12 sm:col-span-3', onClick={handleFilterModalOpen}
}} className='px-3 py-2.5'
/> />
<DateInput
required
label='Tanggal Realisasi'
name='realization_date'
placeholder='Masukkan tanggal realisasi'
value={tableFilterState.realizationDate}
onChange={realizationDateChangeHandler}
className={{
wrapper: 'col-span-12 sm:col-span-3',
}}
/>
<SelectInput
label='Lokasi'
options={locationOptions}
isLoading={isLoadingLocationOptions}
value={selectedLocation}
onChange={locationChangeHandler}
onInputChange={setLocationInputValue}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-3',
}}
/>
<SelectInput
label='Vendor'
options={vendorOptions}
isLoading={isLoadingVendorOptions}
value={selectedVendor}
onChange={vendorChangeHandler}
onInputChange={setVendorInputValue}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-3',
}}
/>
<DebouncedTextInput
name='search'
placeholder='Cari Biaya Operasional'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'col-span-12 max-w-52 justify-self-end' }}
/>
</div>
</div> </div>
</div> </div>
<Table<Expense> {/* Table Section */}
data={isResponseSuccess(expenses) ? expenses?.data : []} <div className='flex flex-col mb-4 -mx-4 px-4'>
columns={expensesColumns} <Table<Expense>
pageSize={tableFilterState.pageSize} data={isResponseSuccess(expenses) ? expenses?.data : []}
page={isResponseSuccess(expenses) ? expenses?.meta?.page : 0} columns={expensesColumns}
totalItems={ pageSize={tableFilterState.pageSize}
isResponseSuccess(expenses) ? expenses?.meta?.total_results : 0 page={isResponseSuccess(expenses) ? expenses?.meta?.page : 0}
} totalItems={
onPageChange={setPage} isResponseSuccess(expenses) ? expenses?.meta?.total_results : 0
isLoading={isLoading} }
sorting={sorting} onPageChange={setPage}
setSorting={setSorting} onPageSizeChange={setPageSize}
rowSelection={rowSelection} isLoading={isLoading}
setRowSelection={setRowSelection} sorting={sorting}
enableRowSelection={tableEnableRowSelectionHandler} setSorting={setSorting}
className={{ rowSelection={rowSelection}
containerClassName: cn({ setRowSelection={setRowSelection}
'mb-20': enableRowSelection={tableEnableRowSelectionHandler}
isResponseSuccess(expenses) && expenses?.data?.length === 0, className={{
}), containerClassName: cn('p-3 mb-0', {
tableWrapperClassName: 'overflow-x-auto min-h-full!', 'w-full':
tableClassName: 'font-inter w-full table-auto min-h-full!', isResponseSuccess(expenses) && expenses?.data?.length === 0,
headerRowClassName: 'border-b border-b-gray-200', }),
headerColumnClassName: headerColumnClassName: 'text-nowrap',
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', }}
bodyRowClassName: 'border-b border-b-gray-200', />
bodyColumnClassName: </div>
'px-6 py-3 last:flex last:flex-row last:justify-end',
}}
/>
</div> </div>
<ConfirmationModal <ConfirmationModal
@@ -831,6 +797,12 @@ const ExpensesTable = () => {
onClick: confirmationModalRejectClickHandler, onClick: confirmationModalRejectClickHandler,
}} }}
/> />
<ExpensesFilterModal
ref={filterModal.ref}
onSubmit={handleFilterSubmit}
onReset={handleFilterReset}
/>
</> </>
); );
}; };
@@ -0,0 +1,28 @@
import * as yup from 'yup';
export type ExpensesFilterType = {
transaction_date: string | null;
realization_date: string | null;
location_id: string | null;
vendor_id: string | null;
};
export const ExpensesFilterSchema = yup.object({
transaction_date: yup.string().nullable(),
realization_date: yup
.string()
.nullable()
.test(
'is-greater-or-equal-transaction',
'Tanggal realisasi tidak boleh sebelum tanggal transaksi',
function (value) {
const { transaction_date } = this.parent;
if (!transaction_date || !value) return true;
return new Date(value) >= new Date(transaction_date);
}
),
location_id: yup.string().nullable(),
vendor_id: yup.string().nullable(),
});
export type ExpensesFilterValues = yup.InferType<typeof ExpensesFilterSchema>;
@@ -0,0 +1,206 @@
'use client';
import { RefObject } from 'react';
import { useFormik } from 'formik';
import { Icon } from '@iconify/react';
import Modal from '@/components/Modal';
import Button from '@/components/Button';
import DateInput from '@/components/input/DateInput';
import SelectInput from '@/components/input/SelectInput';
import { OptionType, useSelect } from '@/components/input/SelectInput';
import { LocationApi, SupplierApi } from '@/services/api/master-data';
import { Location } from '@/types/api/master-data/location';
import { Supplier } from '@/types/api/master-data/supplier';
import {
ExpensesFilterSchema,
ExpensesFilterValues,
} from '@/components/pages/expense/filter/ExpensesFilter';
interface ExpensesFilterModalProps {
ref: RefObject<HTMLDialogElement | null>;
initialValues?: ExpensesFilterValues;
onSubmit?: (values: Partial<ExpensesFilterValues>) => void;
onReset?: () => void;
}
const ExpensesFilterModal = ({
ref,
initialValues,
onSubmit,
onReset,
}: ExpensesFilterModalProps) => {
const closeModalHandler = () => {
ref.current?.close();
};
const {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const {
setInputValue: setVendorInputValue,
options: vendorOptions,
isLoadingOptions: isLoadingVendorOptions,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
const formik = useFormik<ExpensesFilterValues>({
initialValues: initialValues || {
transaction_date: null,
realization_date: null,
location_id: null,
vendor_id: null,
},
validationSchema: ExpensesFilterSchema,
onSubmit: async (values) => {
onSubmit?.(values);
closeModalHandler();
},
onReset: () => {
onReset?.();
closeModalHandler();
},
});
const locationValue = formik.values.location_id
? locationOptions.find(
(opt) => String(opt.value) === formik.values.location_id
) || null
: null;
const vendorValue = formik.values.vendor_id
? vendorOptions.find(
(opt) => String(opt.value) === formik.values.vendor_id
) || null
: null;
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
const locationId =
val && !Array.isArray(val) ? (String(val.value) as string) : null;
formik.setFieldValue('location_id', locationId);
};
const vendorChangeHandler = (val: OptionType | OptionType[] | null) => {
const vendorId =
val && !Array.isArray(val) ? (String(val.value) as string) : null;
formik.setFieldValue('vendor_id', vendorId);
};
return (
<Modal
ref={ref}
className={{
modalBox: 'p-0 rounded-xl xl:max-w-4/12 max-w-sm',
}}
>
<form
onSubmit={formik.handleSubmit}
onReset={formik.handleReset}
className='w-full flex flex-col'
>
{/* Modal Header */}
<div className='p-4 flex items-center justify-between gap-2 border-b border-base-content/10'>
<div className='flex items-center gap-2 text-primary'>
<Icon icon='heroicons:funnel' width={20} height={20} />
<h3 className='text-sm font-medium'>Filter Data</h3>
</div>
<Button
type='button'
variant='ghost'
color='none'
onClick={closeModalHandler}
className='p-0 text-base-content/50 hover:text-base-content'
>
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
{/* Modal Body */}
<div className='p-4 flex flex-col gap-1.5'>
<DateInput
label='Tanggal Transaksi'
name='transaction_date'
placeholder='Masukkan tanggal transaksi'
value={formik.values.transaction_date || ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={
formik.touched.transaction_date &&
!!formik.errors.transaction_date
}
/>
<DateInput
label='Tanggal Realisasi'
name='realization_date'
placeholder='Masukkan tanggal realisasi'
value={formik.values.realization_date || ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={
formik.touched.realization_date &&
!!formik.errors.realization_date
}
/>
{formik.touched.realization_date &&
formik.errors.realization_date && (
<span className='text-xs text-error'>
{formik.errors.realization_date}
</span>
)}
<SelectInput
label='Lokasi'
placeholder='Pilih Lokasi'
options={locationOptions}
value={locationValue}
onChange={locationChangeHandler}
onInputChange={setLocationInputValue}
isLoading={isLoadingLocationOptions}
isClearable
isSearchable={true}
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Vendor'
placeholder='Pilih Vendor'
options={vendorOptions}
value={vendorValue}
onChange={vendorChangeHandler}
onInputChange={setVendorInputValue}
isLoading={isLoadingVendorOptions}
isClearable
isSearchable={true}
className={{ wrapper: 'w-full' }}
/>
</div>
{/* Modal Footer */}
<div className='p-4 flex justify-between gap-4 border-t border-base-content/10 bg-gray-50'>
<Button
type='reset'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
>
Reset Filter
</Button>
<Button
type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
disabled={!formik.isValid || formik.isSubmitting}
>
Apply Filter
</Button>
</div>
</form>
</Modal>
);
};
export default ExpensesFilterModal;