feat: implement bulk approval in expense

This commit is contained in:
ValdiANS
2026-04-22 01:14:16 +07:00
parent b77a8ef56f
commit 727ac8ccdb
2 changed files with 289 additions and 101 deletions
+254 -101
View File
@@ -7,8 +7,6 @@ import {
useMemo,
useState,
} from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
import useSWR from 'swr';
import {
CellContext,
@@ -21,8 +19,11 @@ import toast from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import DateInput from '@/components/input/DateInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import TextArea from '@/components/input/TextArea';
import Button from '@/components/Button';
import { useModal } from '@/components/Modal';
import Modal, { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
@@ -37,7 +38,6 @@ import ExpenseTableSkeleton from '@/components/pages/expense/skeleton/ExpenseTab
import { Expense } from '@/types/api/expense';
import { ExpenseApi } from '@/services/api/expense';
import { buildExpenseActionHref } from '@/lib/expense-list-navigation';
import { cn, formatCurrency, formatDate } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
@@ -53,16 +53,34 @@ type ExpenseTableFilters = {
userId: string;
};
const approvalStatusOptions = [
{ value: 'HEAD_AREA', label: 'Approval Head Area' },
{ value: 'UNIT_VICE_PRESIDENT', label: 'Approval Unit Vice President' },
{ value: 'FINANCE', label: 'Approval Finance' },
{ value: 'REALISASI', label: 'Realisasi' },
{ value: 'SELESAI', label: 'Selesai' },
] as const satisfies OptionType<
'HEAD_AREA' | 'UNIT_VICE_PRESIDENT' | 'FINANCE' | 'REALISASI' | 'SELESAI'
>[];
type ApprovalStatusValue =
| 'HEAD_AREA'
| 'UNIT_VICE_PRESIDENT'
| 'FINANCE'
| 'REALISASI'
| 'SELESAI';
const isApprovalDateRequired = (status?: ApprovalStatusValue) =>
status === 'REALISASI' || status === 'SELESAI';
const RowOptionsMenu = ({
popoverPosition = 'bottom',
props,
deleteClickHandler,
returnToSearchParams,
}: {
popoverPosition: 'bottom' | 'top';
props: CellContext<Expense, unknown>;
deleteClickHandler: () => void;
returnToSearchParams: URLSearchParams;
}) => {
const popoverId = `expense#${props.row.original.id}`;
const popoverAnchorName = `--anchor-expense#${props.row.original.id}`;
@@ -105,11 +123,7 @@ const RowOptionsMenu = ({
<div className='flex flex-col bg-base-100 rounded-xl'>
<RequirePermission permissions='lti.expense.detail'>
<Button
href={buildExpenseActionHref(
'/expense/detail/',
props.row.original.id,
returnToSearchParams
)}
href={`/expense/detail/?expenseId=${props.row.original.id}`}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full'
@@ -123,11 +137,7 @@ const RowOptionsMenu = ({
{showEditButton && (
<RequirePermission permissions='lti.expense.update'>
<Button
href={buildExpenseActionHref(
'/expense/detail/edit/',
props.row.original.id,
returnToSearchParams
)}
href={`/expense/detail/edit/?expenseId=${props.row.original.id}`}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full'
@@ -142,11 +152,7 @@ const RowOptionsMenu = ({
{showRealizationButton && (
<RequirePermission permissions='lti.expense.create.realization'>
<Button
href={buildExpenseActionHref(
'/expense/realization/',
props.row.original.id,
returnToSearchParams
)}
href={`/expense/realization/?expenseId=${props.row.original.id}`}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full'
@@ -184,11 +190,6 @@ const RowOptionsMenu = ({
};
const ExpensesTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const {
state: tableFilterState,
updateFilter,
@@ -197,9 +198,9 @@ const ExpensesTable = () => {
toQueryString: getTableFilterQueryString,
} = useTableFilter<ExpenseTableFilters>({
initial: {
page: Number(searchParams.get('page')) || 1,
pageSize: Number(searchParams.get('limit')) || 10,
search: searchValue,
page: 1,
pageSize: 10,
search: '',
nameSort: '',
transactionDate: '',
realizationDate: '',
@@ -217,6 +218,9 @@ const ExpensesTable = () => {
vendorId: 'vendor_id',
userId: 'user_id',
},
persist: true,
storeName: 'expense-table',
});
const {
@@ -228,57 +232,10 @@ const ExpensesTable = () => {
ExpenseApi.getAllFetcher
);
const syncPaginationToUrl = useCallback(
(page: number, pageSize: number) => {
const nextQueryString = new URLSearchParams({
page: String(page),
limit: String(pageSize),
}).toString();
router.replace(
nextQueryString ? `${pathname}?${nextQueryString}` : pathname,
{
scroll: false,
}
);
},
[pathname, router]
);
const pageChangeHandler = useCallback(
(page: number) => {
setPage(page);
syncPaginationToUrl(page, tableFilterState.pageSize);
},
[setPage, syncPaginationToUrl, tableFilterState.pageSize]
);
const pageSizeChangeHandler = useCallback(
(pageSize: number) => {
setPageSize(pageSize);
syncPaginationToUrl(1, pageSize);
},
[setPageSize, syncPaginationToUrl]
);
const returnToSearchParams = useMemo(() => {
const returnToParams = new URLSearchParams();
const queryString = new URLSearchParams({
page: String(tableFilterState.page),
limit: String(tableFilterState.pageSize),
}).toString();
returnToParams.set(
'returnTo',
queryString ? `${pathname}?${queryString}` : pathname
);
return returnToParams;
}, [pathname, tableFilterState.page, tableFilterState.pageSize]);
const deleteModal = useModal();
const approveModal = useModal();
const rejectModal = useModal();
const bulkApproveFormModal = useModal();
// ===== FILTER MODAL STATE =====
const filterModal = useModal();
@@ -290,6 +247,10 @@ const ExpensesTable = () => {
const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isRejectLoading, setIsRejectLoading] = useState(false);
const [, setApprovalNotes] = useState('');
const [bulkApprovalStatus, setBulkApprovalStatus] =
useState<OptionType<ApprovalStatusValue> | null>(null);
const [bulkApprovalDate, setBulkApprovalDate] = useState('');
const [bulkApprovalNotes, setBulkApprovalNotes] = useState('');
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
@@ -456,7 +417,6 @@ const ExpensesTable = () => {
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
props={props}
deleteClickHandler={deleteClickHandler}
returnToSearchParams={returnToSearchParams}
/>
);
},
@@ -474,17 +434,45 @@ const ExpensesTable = () => {
);
};
// const bulkApproveClickHandler = () => {
// approveModal.openModal();
// };
const resetBulkApproveForm = useCallback(() => {
setBulkApprovalStatus(null);
setBulkApprovalDate('');
setBulkApprovalNotes('');
}, []);
// const bulkRejectClickHandler = () => {
// rejectModal.openModal();
// };
const openBulkApproveForm = useCallback(
(presetStatus?: ApprovalStatusValue) => {
resetBulkApproveForm();
if (presetStatus) {
const selectedStatus = approvalStatusOptions.find(
(option) => option.value === presetStatus
);
if (selectedStatus) {
setBulkApprovalStatus(selectedStatus);
}
}
bulkApproveFormModal.openModal();
},
[bulkApproveFormModal, resetBulkApproveForm]
);
const bulkApproveClickHandler = () => {
setApprovalNotes('');
approveModal.openModal();
openBulkApproveForm();
};
const bulkApproveHeadAreaClickHandler = () => {
openBulkApproveForm('HEAD_AREA');
};
const bulkApproveUnitVicePresidentClickHandler = () => {
openBulkApproveForm('UNIT_VICE_PRESIDENT');
};
const bulkApproveFinanceClickHandler = () => {
openBulkApproveForm('FINANCE');
};
const bulkRejectClickHandler = () => {
@@ -492,6 +480,18 @@ const ExpensesTable = () => {
rejectModal.openModal();
};
const bulkApprovalDateChangeHandler: ChangeEventHandler<HTMLInputElement> = (
e
) => {
setBulkApprovalDate(e.target.value);
};
const bulkApprovalNotesChangeHandler: ChangeEventHandler<
HTMLTextAreaElement
> = (e) => {
setBulkApprovalNotes(e.target.value);
};
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
@@ -554,6 +554,48 @@ const ExpensesTable = () => {
setIsApproveLoading(false);
};
const bulkApproveSubmitHandler = async () => {
if (!bulkApprovalStatus) {
return;
}
if (isApprovalDateRequired(bulkApprovalStatus.value) && !bulkApprovalDate) {
toast.error('Tanggal realisasi wajib diisi.');
return;
}
if (!bulkApprovalNotes.trim()) {
toast.error('Catatan wajib diisi.');
return;
}
setIsApproveLoading(true);
const bulkApproveResponse = await ExpenseApi.bulkApprovals(
selectedRowIds,
bulkApprovalStatus.value,
isApprovalDateRequired(bulkApprovalStatus.value) ? bulkApprovalDate : '',
bulkApprovalNotes
);
if (isResponseSuccess(bulkApproveResponse)) {
refreshExpenses();
bulkApproveFormModal.closeModal();
toast.success(
`Berhasil approve ${selectedRowIds.length} data biaya operasional!`
);
resetBulkApproveForm();
setRowSelection({});
} else {
toast.error(
bulkApproveResponse?.message ??
`Gagal approve ${selectedRowIds.length} data biaya operasional!`
);
}
setIsApproveLoading(false);
};
const confirmationModalRejectClickHandler = async (notes: string) => {
setIsRejectLoading(true);
@@ -596,12 +638,7 @@ const ExpensesTable = () => {
setIsRejectLoading(false);
};
useEffect(() => {
setTableState('expense-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
};
@@ -645,7 +682,7 @@ const ExpensesTable = () => {
<div className='w-full'>
<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'>
<div className='w-fit flex flex-col gap-3 flex-wrap'>
<RequirePermission permissions='lti.expense.create'>
<Button
href='/expense/add'
@@ -658,14 +695,36 @@ const ExpensesTable = () => {
</RequirePermission>
{selectedRowIds.length > 0 && (
<>
<hr className='w-px h-full border-none bg-base-content/10 sm:block hidden' />
<div className='flex flex-row gap-3 flex-wrap'>
<RequirePermission
permissions={[
'lti.expense.approve.head_area',
'lti.expense.approve.unit_vice_president',
'lti.expense.approve.finance',
'lti.expense.create.realization',
]}
>
<Button
variant='outline'
color='none'
onClick={bulkApproveClickHandler}
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={20}
height={20}
className='text-success'
/>
Bulk Approve
</Button>
</RequirePermission>
<RequirePermission permissions='lti.expense.approve.head_area'>
<Button
variant='outline'
color='none'
onClick={bulkApproveClickHandler}
onClick={bulkApproveHeadAreaClickHandler}
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'
>
@@ -683,7 +742,7 @@ const ExpensesTable = () => {
<Button
variant='outline'
color='none'
onClick={bulkApproveClickHandler}
onClick={bulkApproveUnitVicePresidentClickHandler}
disabled={
!isAllSelectedRowLatestApprovalOnUnitVicePresident
}
@@ -703,7 +762,7 @@ const ExpensesTable = () => {
<Button
variant='outline'
color='none'
onClick={bulkApproveClickHandler}
onClick={bulkApproveFinanceClickHandler}
disabled={!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'
>
@@ -744,12 +803,12 @@ const ExpensesTable = () => {
Reject
</Button>
</RequirePermission>
</>
</div>
)}
</div>
{/* Search and Filter */}
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
<div className='flex flex-1 flex-row justify-start sm:justify-end items-start gap-3 flex-wrap'>
<DebouncedTextInput
name='search'
placeholder='Search'
@@ -814,8 +873,8 @@ const ExpensesTable = () => {
totalItems={
isResponseSuccess(expenses) ? expenses?.meta?.total_results : 0
}
onPageChange={pageChangeHandler}
onPageSizeChange={pageSizeChangeHandler}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
@@ -884,6 +943,100 @@ const ExpensesTable = () => {
}}
/>
<Modal
ref={bulkApproveFormModal.ref}
className={{
modalBox: 'max-w-lg rounded-lg p-0',
}}
closeOnBackdrop
>
<div className='flex flex-col'>
<div className='flex items-center justify-between border-b border-base-content/10 p-4'>
<h4 className='text-sm font-semibold text-base-content'>
Bulk Approve Expense
</h4>
<Button
variant='ghost'
color='none'
onClick={() => {
bulkApproveFormModal.closeModal();
resetBulkApproveForm();
}}
className='p-1'
>
<Icon icon='mdi:close' width={20} height={20} />
</Button>
</div>
<div className='flex flex-col gap-4 p-4'>
<SelectInput
label='Status Approval'
options={approvalStatusOptions as OptionType[]}
value={bulkApprovalStatus}
onChange={(val) => {
const nextValue = val as OptionType<ApprovalStatusValue> | null;
setBulkApprovalStatus(nextValue);
if (!isApprovalDateRequired(nextValue?.value)) {
setBulkApprovalDate('');
}
}}
placeholder='Pilih status approval'
isClearable
/>
{isApprovalDateRequired(bulkApprovalStatus?.value) && (
<DateInput
name='bulk_approval_date'
label='Tanggal Realisasi'
value={bulkApprovalDate}
onChange={bulkApprovalDateChangeHandler}
isNestedModal
required
/>
)}
<TextArea
name='bulk_approval_notes'
label='Catatan'
value={bulkApprovalNotes}
onChange={bulkApprovalNotesChangeHandler}
placeholder='Masukkan catatan approval...'
rows={4}
required
/>
</div>
<div className='flex justify-end gap-3 border-t border-base-content/10 p-4'>
<Button
variant='outline'
color='none'
onClick={() => {
bulkApproveFormModal.closeModal();
resetBulkApproveForm();
}}
className='px-3 py-2.5'
>
Batal
</Button>
<Button
color='success'
onClick={bulkApproveSubmitHandler}
isLoading={isApproveLoading}
disabled={
!bulkApprovalStatus ||
!bulkApprovalNotes.trim() ||
(isApprovalDateRequired(bulkApprovalStatus.value) &&
!bulkApprovalDate)
}
className='px-3 py-2.5'
>
Submit
</Button>
</div>
</div>
</Modal>
<ExpensesFilterModal
ref={filterModal.ref}
onSubmit={handleFilterSubmit}
+35
View File
@@ -330,6 +330,41 @@ export class ExpenseApiService extends BaseApiService<
}
}
async bulkApprovals(
ids: number[],
status:
| 'HEAD_AREA'
| 'UNIT_VICE_PRESIDENT'
| 'FINANCE'
| 'REALISASI'
| 'SELESAI',
date: string, // YYYY-MM-DD
notes: string
): Promise<BaseApiResponse<Expense> | undefined> {
try {
const bulkApproveRes = await httpClient<BaseApiResponse<Expense>>(
`${this.basePath}/approvals/bulk`,
{
method: 'POST',
body: {
approvable_ids: ids,
status: status,
date: date,
notes: notes,
},
}
);
return bulkApproveRes;
} catch (error) {
if (axios.isAxiosError<BaseApiResponse<Expense>>(error)) {
return error.response?.data;
}
return undefined;
}
}
async rejectHeadArea(
id: number,
notes?: string