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
+177 -103
View File
@@ -1,6 +1,6 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import useSWR from 'swr';
import {
CellContext,
@@ -31,13 +31,14 @@ import DateInput from '@/components/input/DateInput';
import { Expense } from '@/types/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 { useTableFilter } from '@/services/hooks/useTableFilter';
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';
import { BaseApiResponse } from '@/types/api/api-general';
const RowOptionsMenu = ({
type = 'dropdown',
@@ -53,66 +54,57 @@ const RowOptionsMenu = ({
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;
props.row.original.latest_approval.step_number !== 5 &&
(props.row.original.latest_approval.step_number === 1 ||
props.row.original.latest_approval.step_number === 2 ||
props.row.original.latest_approval.step_number === 3);
// TODO: apply RBAC
const showApproveButton = showEditButton;
const showRejectButton = showEditButton;
const showRealizationButton =
props.row.original.latest_approval.action !== 'REJECTED' &&
props.row.original.latest_approval.step_number === 3;
return (
<RowOptionsMenuWrapper type={type}>
<Button
href={`/expense/detail/?expenseId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
{showEditButton && (
<div className='w-full max-h-40 overflow-auto flex flex-col gap-1'>
<Button
href={`/expense/detail/edit/?expenseId=${props.row.original.id}`}
href={`/expense/detail/?expenseId=${props.row.original.id}`}
variant='ghost'
color='warning'
color='primary'
className='justify-start text-sm'
>
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
Edit
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</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>
)}
{showEditButton && (
<Button
href={`/expense/detail/edit/?expenseId=${props.row.original.id}`}
variant='ghost'
color='warning'
className='justify-start text-sm'
>
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
Edit
</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>
)}
{showRealizationButton && (
<Button
href={`/expense/realization/?expenseId=${props.row.original.id}`}
variant='ghost'
color='info'
className='justify-start text-sm text-info focus-visible:text-info-content hover:text-info-content'
>
<Icon
icon='material-symbols:money-bag-rounded'
width={16}
height={16}
/>
Realisasi
</Button>
)}
{showDeleteButton && (
<Button
onClick={deleteClickHandler}
variant='ghost'
@@ -127,7 +119,7 @@ const RowOptionsMenu = ({
/>
Delete
</Button>
)}
</div>
</RowOptionsMenuWrapper>
);
};
@@ -178,6 +170,7 @@ const ExpensesTable = () => {
undefined
);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isCompleteLoading, setIsCompleteLoading] = useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isRejectLoading, setIsRejectLoading] = useState(false);
@@ -187,6 +180,57 @@ const ExpensesTable = () => {
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>[] = [
{
id: 'select',
@@ -202,7 +246,8 @@ const ExpensesTable = () => {
),
cell: ({ row }) => {
const isCheckboxDisabled =
!row.getCanSelect() || row.original.approval.action === 'REJECTED';
!row.getCanSelect() ||
row.original.latest_approval.action === 'REJECTED';
return (
<div>
@@ -218,61 +263,52 @@ const ExpensesTable = () => {
},
},
{
accessorKey: 'transaction_date',
accessorKey: 'expense_date',
header: 'Tanggal Pengajuan',
cell: (props) =>
props.row.original.expense_date
? formatDate(props.row.original.expense_date, 'DD MMM YYYY')
: '-',
},
{
accessorKey: 'realization_date',
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',
header: 'Lokasi',
cell: (props) => props.row.original.location.name ?? '-',
cell: (props) => props.row.original.location?.name ?? '-',
},
{
accessorFn: (row) => row.created_user.name ?? '-',
header: 'Nama Pengaju',
},
{
accessorFn: (row) => row.vendor.name ?? '-',
accessorFn: (row) => row.supplier.name ?? '-',
header: 'Vendor',
},
{
accessorKey: 'nominal',
accessorKey: 'grand_total',
header: 'Nominal',
cell: (props) =>
props.row.original.nominal
? `Rp${formatCurrency(props.row.original.nominal)}`
: '-',
},
{
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)}`
props.row.original.grand_total
? formatCurrency(props.row.original.grand_total)
: '-',
},
{
header: 'Status Pencairan',
cell: (props) => (
<RealizationStatusBadge approval={props.row.original.approval} />
<RealizationStatusBadge approval={props.row.original.latest_approval} />
),
},
{
header: 'Status BOP',
cell: (props) => (
<ExpenseStatusBadge approval={props.row.original.approval} />
<ExpenseStatusBadge approval={props.row.original.latest_approval} />
),
},
{
@@ -283,7 +319,7 @@ const ExpensesTable = () => {
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 3;
const approveClickHandler = () => {
setSelectedExpense(props.row.original);
@@ -314,7 +350,7 @@ const ExpensesTable = () => {
return (
<>
{currentPageSize > 2 && (
{currentPageSize > 3 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
@@ -326,7 +362,7 @@ const ExpensesTable = () => {
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
{currentPageSize <= 3 && (
<RowCollapseOptions>
<RowOptionsMenu
type='dropdown'
@@ -346,9 +382,20 @@ const ExpensesTable = () => {
const tableEnableRowSelectionHandler: (row: Row<Expense>) => boolean = (
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 = () => {
approveModal.openModal();
};
@@ -371,17 +418,26 @@ const ExpensesTable = () => {
const confirmationModalApproveClickHandler = async (notes: string) => {
setIsApproveLoading(true);
const bulkApproveResponse = await ExpenseApi.bulkApprove(
selectedRowIds,
notes
);
let bulkApproveResponse: BaseApiResponse<Expense> | undefined = undefined;
if (isAllSelectedRowLatestApprovalOnManager) {
bulkApproveResponse = await ExpenseApi.bulkApproveManager(
selectedRowIds,
notes
);
} else if (isAllSelectedRowLatestApprovalOnFinance) {
bulkApproveResponse = await ExpenseApi.bulkApproveFinance(
selectedRowIds,
notes
);
}
if (isResponseSuccess(bulkApproveResponse)) {
refreshExpenses();
approveModal.closeModal();
toast.success(
`Berhasil approve ${selectedRowIds.length} data transfer ke laying!`
`Berhasil approve ${selectedRowIds.length} data biaya operasional!`
);
setRowSelection({});
@@ -389,7 +445,7 @@ const ExpensesTable = () => {
approveModal.closeModal();
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) => {
setIsRejectLoading(true);
const bulkRejectResponse = await ExpenseApi.bulkReject(
selectedRowIds,
notes
);
let bulkRejectResponse: BaseApiResponse<Expense> | undefined = undefined;
if (isAllSelectedRowLatestApprovalOnManager) {
bulkRejectResponse = await ExpenseApi.bulkRejectManager(
selectedRowIds,
notes
);
} else if (isAllSelectedRowLatestApprovalOnFinance) {
bulkRejectResponse = await ExpenseApi.bulkRejectFinance(
selectedRowIds,
notes
);
}
if (isResponseSuccess(bulkRejectResponse)) {
refreshExpenses();
rejectModal.closeModal();
toast.success(
`Berhasil reject ${selectedRowIds.length} data transfer ke laying!`
`Berhasil reject ${selectedRowIds.length} data biaya operasional!`
);
setRowSelection({});
} else {
rejectModal.closeModal();
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 && (
<>
{/* 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
variant='outline'
color='success'
onClick={bulkApproveClickHandler}
disabled={selectedRowIds.length === 0}
disabled={!isAllSelectedRowLatestApprovalOnFinance}
className='w-full sm:w-fit'
>
<Icon
icon='material-symbols:check'
width={24}
height={24}
/>
Approve
<Icon icon='tdesign:money' width={24} height={24} />
Approve Finance
</Button>
<Button
variant='outline'
color='error'
onClick={bulkRejectClickHandler}
disabled={selectedRowIds.length === 0}
disabled={
!isAllSelectedRowLatestApprovalOnManager &&
!isAllSelectedRowLatestApprovalOnFinance
}
className='w-full sm:w-fit'
>
<Icon
@@ -666,7 +740,7 @@ const ExpensesTable = () => {
<ConfirmationModalWithNotes
ref={approveModal.ref}
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={{
text: 'Tidak',
}}
@@ -681,7 +755,7 @@ const ExpensesTable = () => {
<ConfirmationModalWithNotes
ref={rejectModal.ref}
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={{
text: 'Tidak',
}}