feat(FE-195): implement bulk approve/reject in Expense list page

This commit is contained in:
ValdiANS
2025-11-24 09:42:14 +07:00
parent b083b9cb1a
commit 510d10270e
+151 -77
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { ChangeEventHandler, useEffect, useState } from 'react'; import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { import {
CellContext, CellContext,
@@ -31,13 +31,14 @@ 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';
import { cn, formatCurrency } 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 { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
import { LocationApi, SupplierApi } from '@/services/api/master-data'; import { LocationApi, SupplierApi } from '@/services/api/master-data';
import { Location } from '@/types/api/master-data/location'; import { Location } from '@/types/api/master-data/location';
import { Supplier } from '@/types/api/master-data/supplier'; import { Supplier } from '@/types/api/master-data/supplier';
import { BaseApiResponse } from '@/types/api/api-general';
const RowOptionsMenu = ({ const RowOptionsMenu = ({
type = 'dropdown', type = 'dropdown',
@@ -53,18 +54,19 @@ const RowOptionsMenu = ({
deleteClickHandler: () => void; deleteClickHandler: () => void;
}) => { }) => {
const showEditButton = const showEditButton =
props.row.original.approval.action !== 'REJECTED' && props.row.original.latest_approval.step_number !== 5 &&
props.row.original.approval.step_number !== 5 && (props.row.original.latest_approval.step_number === 1 ||
props.row.original.approval.action !== 'APPROVED'; props.row.original.latest_approval.step_number === 2 ||
props.row.original.latest_approval.step_number === 3);
const showDeleteButton = showEditButton;
// TODO: apply RBAC // TODO: apply RBAC
const showApproveButton = showEditButton; const showRealizationButton =
const showRejectButton = showEditButton; props.row.original.latest_approval.action !== 'REJECTED' &&
props.row.original.latest_approval.step_number === 3;
return ( return (
<RowOptionsMenuWrapper type={type}> <RowOptionsMenuWrapper type={type}>
<div className='w-full max-h-40 overflow-auto flex flex-col gap-1'>
<Button <Button
href={`/expense/detail/?expenseId=${props.row.original.id}`} href={`/expense/detail/?expenseId=${props.row.original.id}`}
variant='ghost' variant='ghost'
@@ -87,32 +89,22 @@ const RowOptionsMenu = ({
</Button> </Button>
)} )}
{/* TODO: apply RBAC */} {showRealizationButton && (
{showApproveButton && (
<Button <Button
href={`/expense/realization/?expenseId=${props.row.original.id}`}
variant='ghost' variant='ghost'
color='success' color='info'
onClick={approveClickHandler} className='justify-start text-sm text-info focus-visible:text-info-content hover:text-info-content'
className='justify-start text-sm'
> >
<Icon icon='material-symbols:check' width={24} height={24} /> <Icon
Approve icon='material-symbols:money-bag-rounded'
width={16}
height={16}
/>
Realisasi
</Button> </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 <Button
onClick={deleteClickHandler} onClick={deleteClickHandler}
variant='ghost' variant='ghost'
@@ -127,7 +119,7 @@ const RowOptionsMenu = ({
/> />
Delete Delete
</Button> </Button>
)} </div>
</RowOptionsMenuWrapper> </RowOptionsMenuWrapper>
); );
}; };
@@ -178,6 +170,7 @@ const ExpensesTable = () => {
undefined undefined
); );
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isCompleteLoading, setIsCompleteLoading] = useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false); const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isRejectLoading, setIsRejectLoading] = useState(false); const [isRejectLoading, setIsRejectLoading] = useState(false);
@@ -187,6 +180,57 @@ const ExpensesTable = () => {
parseInt(item) parseInt(item)
); );
const isAllSelectedRowLatestApprovalOnManager = useMemo(() => {
return selectedRowIds.every((rowId) => {
if (!isResponseSuccess(expenses)) return false;
const expenseItem = expenses.data.find((item) => item.id === rowId);
const isLatestApprovalRejected =
expenseItem?.latest_approval.action === 'REJECTED';
const isCurrentApprovalOnManager =
!isLatestApprovalRejected &&
expenseItem?.latest_approval.step_number === 1;
return isCurrentApprovalOnManager;
});
}, [expenses, selectedRowIds]);
const isAllSelectedRowLatestApprovalOnFinance = useMemo(() => {
return selectedRowIds.every((rowId) => {
if (!isResponseSuccess(expenses)) return false;
const expenseItem = expenses.data.find((item) => item.id === rowId);
const isLatestApprovalRejected =
expenseItem?.latest_approval.action === 'REJECTED';
const isCurrentApprovalOnFinance =
!isLatestApprovalRejected &&
expenseItem?.latest_approval.step_number === 2;
return isCurrentApprovalOnFinance;
});
}, [expenses, selectedRowIds]);
const isAllSelectedRowLatestApprovalOnRealization = useMemo(() => {
return selectedRowIds.every((rowId) => {
if (!isResponseSuccess(expenses)) return false;
const expenseItem = expenses.data.find((item) => item.id === rowId);
const isLatestApprovalRejected =
expenseItem?.latest_approval.action === 'REJECTED';
const isCurrentApprovalOnRealization =
!isLatestApprovalRejected &&
expenseItem?.latest_approval.step_number === 4;
return isCurrentApprovalOnRealization;
});
}, [expenses, selectedRowIds]);
const expensesColumns: ColumnDef<Expense>[] = [ const expensesColumns: ColumnDef<Expense>[] = [
{ {
id: 'select', id: 'select',
@@ -202,7 +246,8 @@ const ExpensesTable = () => {
), ),
cell: ({ row }) => { cell: ({ row }) => {
const isCheckboxDisabled = const isCheckboxDisabled =
!row.getCanSelect() || row.original.approval.action === 'REJECTED'; !row.getCanSelect() ||
row.original.latest_approval.action === 'REJECTED';
return ( return (
<div> <div>
@@ -218,61 +263,52 @@ const ExpensesTable = () => {
}, },
}, },
{ {
accessorKey: 'transaction_date', accessorKey: 'expense_date',
header: 'Tanggal Pengajuan', header: 'Tanggal Pengajuan',
cell: (props) =>
props.row.original.expense_date
? formatDate(props.row.original.expense_date, 'DD MMM YYYY')
: '-',
}, },
{ {
accessorKey: 'realization_date', accessorKey: 'realization_date',
header: 'Tanggal Realisasi', header: 'Tanggal Realisasi',
cell: (props) => props.getValue() ?? '-', cell: (props) =>
props.row.original.realization_date
? formatDate(props.row.original.realization_date, 'DD MMM YYYY')
: '-',
}, },
{ {
accessorKey: 'location', accessorKey: 'location',
header: 'Lokasi', header: 'Lokasi',
cell: (props) => props.row.original.location.name ?? '-', cell: (props) => props.row.original.location?.name ?? '-',
}, },
{ {
accessorFn: (row) => row.created_user.name ?? '-', accessorFn: (row) => row.created_user.name ?? '-',
header: 'Nama Pengaju', header: 'Nama Pengaju',
}, },
{ {
accessorFn: (row) => row.vendor.name ?? '-', accessorFn: (row) => row.supplier.name ?? '-',
header: 'Vendor', header: 'Vendor',
}, },
{ {
accessorKey: 'nominal', accessorKey: 'grand_total',
header: 'Nominal', header: 'Nominal',
cell: (props) => cell: (props) =>
props.row.original.nominal props.row.original.grand_total
? `Rp${formatCurrency(props.row.original.nominal)}` ? formatCurrency(props.row.original.grand_total)
: '-',
},
{
accessorKey: 'paid',
header: 'Sudah Bayar',
cell: (props) =>
props.row.original.paid
? `Rp${formatCurrency(props.row.original.paid)}`
: '-',
},
{
accessorKey: 'remaining_cost',
header: 'Sisa Bayar',
cell: (props) =>
props.row.original.remaining_cost
? `Rp${formatCurrency(props.row.original.remaining_cost)}`
: '-', : '-',
}, },
{ {
header: 'Status Pencairan', header: 'Status Pencairan',
cell: (props) => ( cell: (props) => (
<RealizationStatusBadge approval={props.row.original.approval} /> <RealizationStatusBadge approval={props.row.original.latest_approval} />
), ),
}, },
{ {
header: 'Status BOP', header: 'Status BOP',
cell: (props) => ( cell: (props) => (
<ExpenseStatusBadge approval={props.row.original.approval} /> <ExpenseStatusBadge approval={props.row.original.latest_approval} />
), ),
}, },
{ {
@@ -283,7 +319,7 @@ const ExpensesTable = () => {
const currentRowRelativeIndex = const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1; currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2; const isLast2Rows = currentRowRelativeIndex > currentPageSize - 3;
const approveClickHandler = () => { const approveClickHandler = () => {
setSelectedExpense(props.row.original); setSelectedExpense(props.row.original);
@@ -314,7 +350,7 @@ const ExpensesTable = () => {
return ( return (
<> <>
{currentPageSize > 2 && ( {currentPageSize > 3 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}> <RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu <RowOptionsMenu
type='dropdown' type='dropdown'
@@ -326,7 +362,7 @@ const ExpensesTable = () => {
</RowDropdownOptions> </RowDropdownOptions>
)} )}
{currentPageSize <= 2 && ( {currentPageSize <= 3 && (
<RowCollapseOptions> <RowCollapseOptions>
<RowOptionsMenu <RowOptionsMenu
type='dropdown' type='dropdown'
@@ -346,9 +382,20 @@ const ExpensesTable = () => {
const tableEnableRowSelectionHandler: (row: Row<Expense>) => boolean = ( const tableEnableRowSelectionHandler: (row: Row<Expense>) => boolean = (
row row
) => { ) => {
return row.original.approval.action !== 'REJECTED'; return (
row.original.latest_approval.action !== 'REJECTED' &&
row.original.latest_approval.step_number !== 5
);
}; };
// const bulkApproveClickHandler = () => {
// approveModal.openModal();
// };
// const bulkRejectClickHandler = () => {
// rejectModal.openModal();
// };
const bulkApproveClickHandler = () => { const bulkApproveClickHandler = () => {
approveModal.openModal(); approveModal.openModal();
}; };
@@ -371,17 +418,26 @@ const ExpensesTable = () => {
const confirmationModalApproveClickHandler = async (notes: string) => { const confirmationModalApproveClickHandler = async (notes: string) => {
setIsApproveLoading(true); setIsApproveLoading(true);
const bulkApproveResponse = await ExpenseApi.bulkApprove( let bulkApproveResponse: BaseApiResponse<Expense> | undefined = undefined;
if (isAllSelectedRowLatestApprovalOnManager) {
bulkApproveResponse = await ExpenseApi.bulkApproveManager(
selectedRowIds, selectedRowIds,
notes notes
); );
} else if (isAllSelectedRowLatestApprovalOnFinance) {
bulkApproveResponse = await ExpenseApi.bulkApproveFinance(
selectedRowIds,
notes
);
}
if (isResponseSuccess(bulkApproveResponse)) { if (isResponseSuccess(bulkApproveResponse)) {
refreshExpenses(); refreshExpenses();
approveModal.closeModal(); approveModal.closeModal();
toast.success( toast.success(
`Berhasil approve ${selectedRowIds.length} data transfer ke laying!` `Berhasil approve ${selectedRowIds.length} data biaya operasional!`
); );
setRowSelection({}); setRowSelection({});
@@ -389,7 +445,7 @@ const ExpensesTable = () => {
approveModal.closeModal(); approveModal.closeModal();
toast.error( toast.error(
`Gagal approve ${selectedRowIds.length} data transfer ke laying!` `Gagal approve ${selectedRowIds.length} data biaya operasional!`
); );
} }
@@ -399,24 +455,33 @@ const ExpensesTable = () => {
const confirmationModalRejectClickHandler = async (notes: string) => { const confirmationModalRejectClickHandler = async (notes: string) => {
setIsRejectLoading(true); setIsRejectLoading(true);
const bulkRejectResponse = await ExpenseApi.bulkReject( let bulkRejectResponse: BaseApiResponse<Expense> | undefined = undefined;
if (isAllSelectedRowLatestApprovalOnManager) {
bulkRejectResponse = await ExpenseApi.bulkRejectManager(
selectedRowIds, selectedRowIds,
notes notes
); );
} else if (isAllSelectedRowLatestApprovalOnFinance) {
bulkRejectResponse = await ExpenseApi.bulkRejectFinance(
selectedRowIds,
notes
);
}
if (isResponseSuccess(bulkRejectResponse)) { if (isResponseSuccess(bulkRejectResponse)) {
refreshExpenses(); refreshExpenses();
rejectModal.closeModal(); rejectModal.closeModal();
toast.success( toast.success(
`Berhasil reject ${selectedRowIds.length} data transfer ke laying!` `Berhasil reject ${selectedRowIds.length} data biaya operasional!`
); );
setRowSelection({}); setRowSelection({});
} else { } else {
rejectModal.closeModal(); rejectModal.closeModal();
toast.error( toast.error(
`Gagal reject ${selectedRowIds.length} data transfer ke laying!` `Gagal reject ${selectedRowIds.length} data biaya operasional!`
); );
} }
@@ -506,27 +571,36 @@ const ExpensesTable = () => {
{selectedRowIds.length > 0 && ( {selectedRowIds.length > 0 && (
<> <>
{/* TODO: apply RBAC */} <Button
variant='outline'
color='info'
onClick={bulkApproveClickHandler}
disabled={!isAllSelectedRowLatestApprovalOnManager}
className='w-full sm:w-fit'
>
<Icon icon='lucide-lab:farm' width={24} height={24} />
Approve Manager
</Button>
<Button <Button
variant='outline' variant='outline'
color='success' color='success'
onClick={bulkApproveClickHandler} onClick={bulkApproveClickHandler}
disabled={selectedRowIds.length === 0} disabled={!isAllSelectedRowLatestApprovalOnFinance}
className='w-full sm:w-fit' className='w-full sm:w-fit'
> >
<Icon <Icon icon='tdesign:money' width={24} height={24} />
icon='material-symbols:check' Approve Finance
width={24}
height={24}
/>
Approve
</Button> </Button>
<Button <Button
variant='outline' variant='outline'
color='error' color='error'
onClick={bulkRejectClickHandler} onClick={bulkRejectClickHandler}
disabled={selectedRowIds.length === 0} disabled={
!isAllSelectedRowLatestApprovalOnManager &&
!isAllSelectedRowLatestApprovalOnFinance
}
className='w-full sm:w-fit' className='w-full sm:w-fit'
> >
<Icon <Icon
@@ -666,7 +740,7 @@ const ExpensesTable = () => {
<ConfirmationModalWithNotes <ConfirmationModalWithNotes
ref={approveModal.ref} ref={approveModal.ref}
type='success' type='success'
text={`Apakah anda yakin ingin approve data biaya operasional ini (${selectedRowIds.length} data)?`} text='Apakah anda yakin ingin approve data biaya operasional ini?'
secondaryButton={{ secondaryButton={{
text: 'Tidak', text: 'Tidak',
}} }}
@@ -681,7 +755,7 @@ const ExpensesTable = () => {
<ConfirmationModalWithNotes <ConfirmationModalWithNotes
ref={rejectModal.ref} ref={rejectModal.ref}
type='error' type='error'
text={`Apakah anda yakin ingin reject data biaya operasional ini (${selectedRowIds.length} data)?`} text='Apakah anda yakin ingin reject data biaya operasional ini?'
secondaryButton={{ secondaryButton={{
text: 'Tidak', text: 'Tidak',
}} }}