Merge branch 'feat/bulk-approve-expense' into 'development'

[FEAT/FE] Bulk Approve Expense

See merge request mbugroup/lti-web-client!414
This commit is contained in:
Rivaldi A N S
2026-04-21 18:16:08 +00:00
2 changed files with 289 additions and 101 deletions
+254 -101
View File
@@ -7,8 +7,6 @@ import {
useMemo, useMemo,
useState, useState,
} from 'react'; } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
import useSWR from 'swr'; import useSWR from 'swr';
import { import {
CellContext, CellContext,
@@ -21,8 +19,11 @@ import toast from 'react-hot-toast';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Table from '@/components/Table'; import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput'; 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 Button from '@/components/Button';
import { useModal } from '@/components/Modal'; import Modal, { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import PopoverButton from '@/components/popover/PopoverButton'; import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent'; 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 { Expense } from '@/types/api/expense';
import { ExpenseApi } from '@/services/api/expense'; import { ExpenseApi } from '@/services/api/expense';
import { buildExpenseActionHref } from '@/lib/expense-list-navigation';
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';
@@ -53,16 +53,34 @@ type ExpenseTableFilters = {
userId: string; 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 = ({ const RowOptionsMenu = ({
popoverPosition = 'bottom', popoverPosition = 'bottom',
props, props,
deleteClickHandler, deleteClickHandler,
returnToSearchParams,
}: { }: {
popoverPosition: 'bottom' | 'top'; popoverPosition: 'bottom' | 'top';
props: CellContext<Expense, unknown>; props: CellContext<Expense, unknown>;
deleteClickHandler: () => void; deleteClickHandler: () => void;
returnToSearchParams: URLSearchParams;
}) => { }) => {
const popoverId = `expense#${props.row.original.id}`; const popoverId = `expense#${props.row.original.id}`;
const popoverAnchorName = `--anchor-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'> <div className='flex flex-col bg-base-100 rounded-xl'>
<RequirePermission permissions='lti.expense.detail'> <RequirePermission permissions='lti.expense.detail'>
<Button <Button
href={buildExpenseActionHref( href={`/expense/detail/?expenseId=${props.row.original.id}`}
'/expense/detail/',
props.row.original.id,
returnToSearchParams
)}
variant='ghost' variant='ghost'
color='none' color='none'
className='p-3 justify-start text-sm font-semibold w-full' className='p-3 justify-start text-sm font-semibold w-full'
@@ -123,11 +137,7 @@ const RowOptionsMenu = ({
{showEditButton && ( {showEditButton && (
<RequirePermission permissions='lti.expense.update'> <RequirePermission permissions='lti.expense.update'>
<Button <Button
href={buildExpenseActionHref( href={`/expense/detail/edit/?expenseId=${props.row.original.id}`}
'/expense/detail/edit/',
props.row.original.id,
returnToSearchParams
)}
variant='ghost' variant='ghost'
color='none' color='none'
className='p-3 justify-start text-sm font-semibold w-full' className='p-3 justify-start text-sm font-semibold w-full'
@@ -142,11 +152,7 @@ const RowOptionsMenu = ({
{showRealizationButton && ( {showRealizationButton && (
<RequirePermission permissions='lti.expense.create.realization'> <RequirePermission permissions='lti.expense.create.realization'>
<Button <Button
href={buildExpenseActionHref( href={`/expense/realization/?expenseId=${props.row.original.id}`}
'/expense/realization/',
props.row.original.id,
returnToSearchParams
)}
variant='ghost' variant='ghost'
color='none' color='none'
className='p-3 justify-start text-sm font-semibold w-full' className='p-3 justify-start text-sm font-semibold w-full'
@@ -184,11 +190,6 @@ const RowOptionsMenu = ({
}; };
const ExpensesTable = () => { const ExpensesTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
@@ -197,9 +198,9 @@ const ExpensesTable = () => {
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter<ExpenseTableFilters>({ } = useTableFilter<ExpenseTableFilters>({
initial: { initial: {
page: Number(searchParams.get('page')) || 1, page: 1,
pageSize: Number(searchParams.get('limit')) || 10, pageSize: 10,
search: searchValue, search: '',
nameSort: '', nameSort: '',
transactionDate: '', transactionDate: '',
realizationDate: '', realizationDate: '',
@@ -217,6 +218,9 @@ const ExpensesTable = () => {
vendorId: 'vendor_id', vendorId: 'vendor_id',
userId: 'user_id', userId: 'user_id',
}, },
persist: true,
storeName: 'expense-table',
}); });
const { const {
@@ -228,57 +232,10 @@ const ExpensesTable = () => {
ExpenseApi.getAllFetcher 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 deleteModal = useModal();
const approveModal = useModal(); const approveModal = useModal();
const rejectModal = useModal(); const rejectModal = useModal();
const bulkApproveFormModal = useModal();
// ===== FILTER MODAL STATE ===== // ===== FILTER MODAL STATE =====
const filterModal = useModal(); const filterModal = useModal();
@@ -290,6 +247,10 @@ const ExpensesTable = () => {
const [isApproveLoading, setIsApproveLoading] = useState(false); const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isRejectLoading, setIsRejectLoading] = useState(false); const [isRejectLoading, setIsRejectLoading] = useState(false);
const [, setApprovalNotes] = useState(''); 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 [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({}); const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
@@ -456,7 +417,6 @@ const ExpensesTable = () => {
popoverPosition={isLast2Rows ? 'top' : 'bottom'} popoverPosition={isLast2Rows ? 'top' : 'bottom'}
props={props} props={props}
deleteClickHandler={deleteClickHandler} deleteClickHandler={deleteClickHandler}
returnToSearchParams={returnToSearchParams}
/> />
); );
}, },
@@ -474,17 +434,45 @@ const ExpensesTable = () => {
); );
}; };
// const bulkApproveClickHandler = () => { const resetBulkApproveForm = useCallback(() => {
// approveModal.openModal(); setBulkApprovalStatus(null);
// }; setBulkApprovalDate('');
setBulkApprovalNotes('');
}, []);
// const bulkRejectClickHandler = () => { const openBulkApproveForm = useCallback(
// rejectModal.openModal(); (presetStatus?: ApprovalStatusValue) => {
// }; resetBulkApproveForm();
if (presetStatus) {
const selectedStatus = approvalStatusOptions.find(
(option) => option.value === presetStatus
);
if (selectedStatus) {
setBulkApprovalStatus(selectedStatus);
}
}
bulkApproveFormModal.openModal();
},
[bulkApproveFormModal, resetBulkApproveForm]
);
const bulkApproveClickHandler = () => { const bulkApproveClickHandler = () => {
setApprovalNotes(''); openBulkApproveForm();
approveModal.openModal(); };
const bulkApproveHeadAreaClickHandler = () => {
openBulkApproveForm('HEAD_AREA');
};
const bulkApproveUnitVicePresidentClickHandler = () => {
openBulkApproveForm('UNIT_VICE_PRESIDENT');
};
const bulkApproveFinanceClickHandler = () => {
openBulkApproveForm('FINANCE');
}; };
const bulkRejectClickHandler = () => { const bulkRejectClickHandler = () => {
@@ -492,6 +480,18 @@ const ExpensesTable = () => {
rejectModal.openModal(); rejectModal.openModal();
}; };
const bulkApprovalDateChangeHandler: ChangeEventHandler<HTMLInputElement> = (
e
) => {
setBulkApprovalDate(e.target.value);
};
const bulkApprovalNotesChangeHandler: ChangeEventHandler<
HTMLTextAreaElement
> = (e) => {
setBulkApprovalNotes(e.target.value);
};
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
@@ -554,6 +554,48 @@ const ExpensesTable = () => {
setIsApproveLoading(false); 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) => { const confirmationModalRejectClickHandler = async (notes: string) => {
setIsRejectLoading(true); setIsRejectLoading(true);
@@ -596,12 +638,7 @@ const ExpensesTable = () => {
setIsRejectLoading(false); setIsRejectLoading(false);
}; };
useEffect(() => {
setTableState('expense-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value); updateFilter('search', e.target.value);
}; };
@@ -645,7 +682,7 @@ const ExpensesTable = () => {
<div className='w-full'> <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'> <div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
{/* Action Buttons */} {/* 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'> <RequirePermission permissions='lti.expense.create'>
<Button <Button
href='/expense/add' href='/expense/add'
@@ -658,14 +695,36 @@ const ExpensesTable = () => {
</RequirePermission> </RequirePermission>
{selectedRowIds.length > 0 && ( {selectedRowIds.length > 0 && (
<> <div className='flex flex-row gap-3 flex-wrap'>
<hr className='w-px h-full border-none bg-base-content/10 sm:block hidden' /> <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'> <RequirePermission permissions='lti.expense.approve.head_area'>
<Button <Button
variant='outline' variant='outline'
color='none' color='none'
onClick={bulkApproveClickHandler} onClick={bulkApproveHeadAreaClickHandler}
disabled={!isAllSelectedRowLatestApprovalOnHeadArea} 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' 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 <Button
variant='outline' variant='outline'
color='none' color='none'
onClick={bulkApproveClickHandler} onClick={bulkApproveUnitVicePresidentClickHandler}
disabled={ disabled={
!isAllSelectedRowLatestApprovalOnUnitVicePresident !isAllSelectedRowLatestApprovalOnUnitVicePresident
} }
@@ -703,7 +762,7 @@ const ExpensesTable = () => {
<Button <Button
variant='outline' variant='outline'
color='none' color='none'
onClick={bulkApproveClickHandler} onClick={bulkApproveFinanceClickHandler}
disabled={!isAllSelectedRowLatestApprovalOnFinance} 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' 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 Reject
</Button> </Button>
</RequirePermission> </RequirePermission>
</> </div>
)} )}
</div> </div>
{/* Search and Filter */} {/* 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 <DebouncedTextInput
name='search' name='search'
placeholder='Search' placeholder='Search'
@@ -814,8 +873,8 @@ const ExpensesTable = () => {
totalItems={ totalItems={
isResponseSuccess(expenses) ? expenses?.meta?.total_results : 0 isResponseSuccess(expenses) ? expenses?.meta?.total_results : 0
} }
onPageChange={pageChangeHandler} onPageChange={setPage}
onPageSizeChange={pageSizeChangeHandler} onPageSizeChange={setPageSize}
isLoading={isLoading} isLoading={isLoading}
sorting={sorting} sorting={sorting}
setSorting={setSorting} 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 <ExpensesFilterModal
ref={filterModal.ref} ref={filterModal.ref}
onSubmit={handleFilterSubmit} 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( async rejectHeadArea(
id: number, id: number,
notes?: string notes?: string