feat(FE-195): add filter and approve/reject functionality

This commit is contained in:
ValdiANS
2025-11-17 13:56:26 +07:00
parent ac227f7780
commit f01e764d9c
+435 -70
View File
@@ -2,7 +2,12 @@
import { ChangeEventHandler, useEffect, useState } from 'react'; import { ChangeEventHandler, useEffect, useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import {
CellContext,
ColumnDef,
Row,
SortingState,
} from '@tanstack/react-table';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
@@ -11,10 +16,18 @@ 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 } from '@/components/input/SelectInput'; 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';
import RealizationStatusBadge from '@/components/pages/expense/RealizationStatusBadge';
import ExpenseStatusBadge from '@/components/pages/expense/ExpenseStatusBadge';
import CheckboxInput from '@/components/input/CheckboxInput';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import DateInput from '@/components/input/DateInput';
import { Expense } from '@/types/api/expense'; import { Expense } from '@/types/api/expense';
import { ExpenseApi } from '@/services/api/expense'; import { ExpenseApi } from '@/services/api/expense';
@@ -22,16 +35,34 @@ import { cn, formatCurrency } 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 { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
import { LocationApi, SupplierApi } from '@/services/api/master-data';
import { Location } from '@/types/api/master-data/location';
import { Supplier } from '@/types/api/master-data/supplier';
const RowOptionsMenu = ({ const RowOptionsMenu = ({
type = 'dropdown', type = 'dropdown',
props, props,
approveClickHandler,
rejectClickHandler,
deleteClickHandler, deleteClickHandler,
}: { }: {
type: 'dropdown' | 'collapse'; type: 'dropdown' | 'collapse';
props: CellContext<Expense, unknown>; props: CellContext<Expense, unknown>;
approveClickHandler: () => void;
rejectClickHandler: () => void;
deleteClickHandler: () => void; deleteClickHandler: () => void;
}) => { }) => {
const showEditButton =
props.row.original.approval.action !== 'REJECTED' &&
props.row.original.approval.step_number !== 5 &&
props.row.original.approval.action !== 'APPROVED';
const showDeleteButton = showEditButton;
// TODO: apply RBAC
const showApproveButton = showEditButton;
const showRejectButton = showEditButton;
return ( return (
<RowOptionsMenuWrapper type={type}> <RowOptionsMenuWrapper type={type}>
<Button <Button
@@ -44,30 +75,59 @@ const RowOptionsMenu = ({
Detail Detail
</Button> </Button>
<Button {showEditButton && (
href={`/expense/detail/edit/?expenseId=${props.row.original.id}`} <Button
variant='ghost' href={`/expense/detail/edit/?expenseId=${props.row.original.id}`}
color='warning' variant='ghost'
className='justify-start text-sm' color='warning'
>
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
Edit
</Button>
<Button
onClick={deleteClickHandler}
variant='ghost'
color='error'
className='text-error hover:text-inherit'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={16}
height={16}
className='justify-start text-sm' className='justify-start text-sm'
/> >
Delete <Icon icon='material-symbols:edit-outline' width={16} height={16} />
</Button> Edit
</Button>
)}
{/* TODO: apply RBAC */}
{showApproveButton && (
<Button
variant='ghost'
color='success'
onClick={approveClickHandler}
className='justify-start text-sm'
>
<Icon icon='material-symbols:check' width={24} height={24} />
Approve
</Button>
)}
{showRejectButton && (
<Button
variant='ghost'
color='error'
onClick={rejectClickHandler}
className='justify-start text-sm'
>
<Icon icon='material-symbols:close' width={24} height={24} />
Reject
</Button>
)}
{showDeleteButton && (
<Button
onClick={deleteClickHandler}
variant='ghost'
color='error'
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={16}
height={16}
className='justify-start text-sm'
/>
Delete
</Button>
)}
</RowOptionsMenuWrapper> </RowOptionsMenuWrapper>
); );
}; };
@@ -80,8 +140,25 @@ const ExpensesTable = () => {
setPageSize, setPageSize,
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter({ } = useTableFilter({
initial: { search: '', nameSort: '' }, initial: {
paramMap: { page: 'page', pageSize: 'limit', nameSort: 'sort_name' }, search: '',
nameSort: '',
transactionDate: '',
realizationDate: '',
locationId: '',
vendorId: '',
userId: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
nameSort: 'sort_name',
transactionDate: 'transaction_date',
realizationDate: 'realization_date',
locationId: 'location_id',
vendorId: 'vendor_id',
userId: 'user_id',
},
}); });
const { const {
@@ -94,21 +171,51 @@ const ExpensesTable = () => {
); );
const deleteModal = useModal(); const deleteModal = useModal();
const approveModal = useModal();
const rejectModal = useModal();
const [selectedExpense, setSelectedExpense] = useState<Expense | undefined>( const [selectedExpense, setSelectedExpense] = useState<Expense | undefined>(
undefined undefined
); );
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isRejectLoading, setIsRejectLoading] = useState(false);
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const selectedRowIds = Object.keys(rowSelection).map((item) =>
parseInt(item)
);
const expensesColumns: ColumnDef<Expense>[] = [ const expensesColumns: ColumnDef<Expense>[] = [
{ {
header: '#', id: 'select',
cell: (props) => header: ({ table }) => (
tableFilterState.pageSize * (tableFilterState.page - 1) + <div className='w-full flex flex-row justify-center'>
props.row.index + <CheckboxInput
1, name='allRow'
checked={table.getIsAllRowsSelected()}
indeterminate={table.getIsSomeRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
</div>
),
cell: ({ row }) => {
const isCheckboxDisabled =
!row.getCanSelect() || row.original.approval.action === 'REJECTED';
return (
<div>
<CheckboxInput
name='row'
checked={row.getIsSelected()}
disabled={isCheckboxDisabled}
indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()}
/>
</div>
);
},
}, },
{ {
accessorKey: 'transaction_date', accessorKey: 'transaction_date',
@@ -158,17 +265,15 @@ const ExpensesTable = () => {
}, },
{ {
header: 'Status Pencairan', header: 'Status Pencairan',
cell: (props) => { cell: (props) => (
// TODO: integrate this to API <RealizationStatusBadge approval={props.row.original.approval} />
return 'test'; ),
},
}, },
{ {
header: 'Status BOP', header: 'Status BOP',
cell: (props) => { cell: (props) => (
// TODO: integrate this to API <ExpenseStatusBadge approval={props.row.original.approval} />
return 'test'; ),
},
}, },
{ {
header: 'Aksi', header: 'Aksi',
@@ -180,6 +285,28 @@ const ExpensesTable = () => {
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2; const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const approveClickHandler = () => {
setSelectedExpense(props.row.original);
// Set row selection
setRowSelection({
[String(props.row.original.id)]: true,
});
approveModal.openModal();
};
const rejectClickHandler = () => {
setSelectedExpense(props.row.original);
// Set row selection
setRowSelection({
[String(props.row.original.id)]: true,
});
rejectModal.openModal();
};
const deleteClickHandler = () => { const deleteClickHandler = () => {
setSelectedExpense(props.row.original); setSelectedExpense(props.row.original);
deleteModal.openModal(); deleteModal.openModal();
@@ -192,6 +319,8 @@ const ExpensesTable = () => {
<RowOptionsMenu <RowOptionsMenu
type='dropdown' type='dropdown'
props={props} props={props}
approveClickHandler={approveClickHandler}
rejectClickHandler={rejectClickHandler}
deleteClickHandler={deleteClickHandler} deleteClickHandler={deleteClickHandler}
/> />
</RowDropdownOptions> </RowDropdownOptions>
@@ -202,6 +331,8 @@ const ExpensesTable = () => {
<RowOptionsMenu <RowOptionsMenu
type='dropdown' type='dropdown'
props={props} props={props}
approveClickHandler={approveClickHandler}
rejectClickHandler={rejectClickHandler}
deleteClickHandler={deleteClickHandler} deleteClickHandler={deleteClickHandler}
/> />
</RowCollapseOptions> </RowCollapseOptions>
@@ -212,6 +343,20 @@ const ExpensesTable = () => {
}, },
]; ];
const tableEnableRowSelectionHandler: (row: Row<Expense>) => boolean = (
row
) => {
return row.original.approval.action !== 'REJECTED';
};
const bulkApproveClickHandler = () => {
approveModal.openModal();
};
const bulkRejectClickHandler = () => {
rejectModal.openModal();
};
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
@@ -223,10 +368,108 @@ const ExpensesTable = () => {
setIsDeleteLoading(false); setIsDeleteLoading(false);
}; };
const confirmationModalApproveClickHandler = async (notes: string) => {
setIsApproveLoading(true);
const bulkApproveResponse = await ExpenseApi.bulkApprove(
selectedRowIds,
notes
);
if (isResponseSuccess(bulkApproveResponse)) {
refreshExpenses();
approveModal.closeModal();
toast.success(
`Berhasil approve ${selectedRowIds.length} data transfer ke laying!`
);
setRowSelection({});
} else {
approveModal.closeModal();
toast.error(
`Gagal approve ${selectedRowIds.length} data transfer ke laying!`
);
}
setIsApproveLoading(false);
};
const confirmationModalRejectClickHandler = async (notes: string) => {
setIsRejectLoading(true);
const bulkRejectResponse = await ExpenseApi.bulkReject(
selectedRowIds,
notes
);
if (isResponseSuccess(bulkRejectResponse)) {
refreshExpenses();
rejectModal.closeModal();
toast.success(
`Berhasil reject ${selectedRowIds.length} data transfer ke laying!`
);
setRowSelection({});
} else {
rejectModal.closeModal();
toast.error(
`Gagal reject ${selectedRowIds.length} data transfer ke laying!`
);
}
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> = (
e
) => {
updateFilter('transactionDate', e.target.value);
};
const realizationDateChangeHandler: ChangeEventHandler<HTMLInputElement> = (
e
) => {
updateFilter('realizationDate', e.target.value);
};
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType; const newVal = val as OptionType;
@@ -248,39 +491,128 @@ const ExpensesTable = () => {
<> <>
<div className='w-full p-0 sm:p-4'> <div className='w-full p-0 sm:p-4'>
<div className='flex flex-col gap-2 mb-4'> <div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'> <div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-row'> <div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-4'>
<Button <div className='w-full sm:w-fit flex flex-col sm:flex-row self-start gap-2'>
href='/expense/add' <Button
variant='outline' href='/expense/add'
color='primary' variant='outline'
className='w-full sm:w-fit' color='primary'
> className='w-full sm:w-fit'
<Icon icon='ic:round-plus' width={24} height={24} /> >
Tambah <Icon icon='ic:round-plus' width={24} height={24} />
</Button> Tambah
</Button>
{selectedRowIds.length > 0 && (
<>
{/* TODO: apply RBAC */}
<Button
variant='outline'
color='success'
onClick={bulkApproveClickHandler}
disabled={selectedRowIds.length === 0}
className='w-full sm:w-fit'
>
<Icon
icon='material-symbols:check'
width={24}
height={24}
/>
Approve
</Button>
<Button
variant='outline'
color='error'
onClick={bulkRejectClickHandler}
disabled={selectedRowIds.length === 0}
className='w-full sm:w-fit'
>
<Icon
icon='material-symbols:close'
width={24}
height={24}
/>
Reject
</Button>
</>
)}
</div>
<DebouncedTextInput
name='search'
placeholder='Cari Biaya Operasional'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div> </div>
<DebouncedTextInput <div className='grid grid-cols-12 justify-end gap-2'>
name='search' <DateInput
placeholder='Cari Biaya Operasional' required
value={tableFilterState.search} label='Tanggal Transaksi'
onChange={searchChangeHandler} name='transaction_date'
className={{ wrapper: 'sm:max-w-3xs' }} placeholder='Masukkan tanggal transaksi'
/> value={tableFilterState.transactionDate}
</div> onChange={transactionDateChangeHandler}
className={{
wrapper: 'col-span-12 sm:col-span-3',
}}
/>
<div className='flex flex-row justify-end'> <DateInput
<SelectInput required
label='Baris' label='Tanggal Realisasi'
options={ROWS_OPTIONS} name='realization_date'
value={{ placeholder='Masukkan tanggal realisasi'
label: String(tableFilterState.pageSize), value={tableFilterState.realizationDate}
value: tableFilterState.pageSize, onChange={realizationDateChangeHandler}
}} className={{
onChange={pageSizeChangeHandler} wrapper: 'col-span-12 sm:col-span-3',
className={{ wrapper: 'max-w-28' }} }}
/> />
<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',
}}
/>
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
value={{
label: String(tableFilterState.pageSize),
value: tableFilterState.pageSize,
}}
onChange={pageSizeChangeHandler}
className={{
wrapper: 'col-span-12 max-w-28 justify-self-end',
}}
/>
</div>
</div> </div>
</div> </div>
@@ -296,6 +628,9 @@ const ExpensesTable = () => {
isLoading={isLoading} isLoading={isLoading}
sorting={sorting} sorting={sorting}
setSorting={setSorting} setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
enableRowSelection={tableEnableRowSelectionHandler}
className={{ className={{
containerClassName: cn({ containerClassName: cn({
'mb-20': 'mb-20':
@@ -327,6 +662,36 @@ const ExpensesTable = () => {
onClick: confirmationModalDeleteClickHandler, onClick: confirmationModalDeleteClickHandler,
}} }}
/> />
<ConfirmationModalWithNotes
ref={approveModal.ref}
type='success'
text={`Apakah anda yakin ingin approve data biaya operasional ini (${selectedRowIds.length} data)?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'success',
isLoading: isApproveLoading,
onClick: confirmationModalApproveClickHandler,
}}
/>
<ConfirmationModalWithNotes
ref={rejectModal.ref}
type='error'
text={`Apakah anda yakin ingin reject data biaya operasional ini (${selectedRowIds.length} data)?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isRejectLoading,
onClick: confirmationModalRejectClickHandler,
}}
/>
</> </>
); );
}; };