Files
lti-web-client/src/components/pages/expense/ExpensesTable.tsx
T
2026-04-22 11:05:54 +07:00

1254 lines
39 KiB
TypeScript

'use client';
import axios from 'axios';
import {
ChangeEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import useSWR from 'swr';
import {
CellContext,
ColumnDef,
Row,
SortingState,
} from '@tanstack/react-table';
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 Modal, { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
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 RequirePermission from '@/components/helper/RequirePermission';
import ButtonFilter from '@/components/helper/ButtonFilter';
import ExpensesFilterModal from '@/components/pages/expense/filter/ExpensesFilterModal';
import ExpenseTableSkeleton from '@/components/pages/expense/skeleton/ExpenseTableSkeleton';
import Dropdown from '@/components/dropdown/Dropdown';
import { Expense } from '@/types/api/expense';
import { ExpenseApi } from '@/services/api/expense';
import { cn, formatCurrency, formatDate } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { BaseApiResponse } from '@/types/api/api-general';
type ExpenseTableFilters = {
search: string;
nameSort: string;
transactionDate: string;
realizationDate: string;
locationId: string;
vendorId: 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 getExportErrorMessage = async (
error: unknown,
fallbackMessage: string
) => {
if (axios.isAxiosError(error)) {
const responseData = error.response?.data;
if (responseData instanceof Blob) {
try {
const parsed = JSON.parse(await responseData.text()) as {
message?: string;
};
return parsed.message || fallbackMessage;
} catch {
return fallbackMessage;
}
}
if (
responseData &&
typeof responseData === 'object' &&
'message' in responseData &&
typeof responseData.message === 'string'
) {
return responseData.message;
}
return error.message || fallbackMessage;
}
if (error instanceof Error) {
return error.message;
}
return fallbackMessage;
};
const RowOptionsMenu = ({
popoverPosition = 'bottom',
props,
deleteClickHandler,
}: {
popoverPosition: 'bottom' | 'top';
props: CellContext<Expense, unknown>;
deleteClickHandler: () => void;
}) => {
const popoverId = `expense#${props.row.original.id}`;
const popoverAnchorName = `--anchor-expense#${props.row.original.id}`;
const closePopover = () => {
document.getElementById(popoverId)?.hidePopover();
};
const showEditButton = props.row.original.latest_approval
? props.row.original.latest_approval.step_number !== 6 &&
(props.row.original.latest_approval.step_number === 1 ||
props.row.original.latest_approval.step_number === 2 ||
props.row.original.latest_approval.step_number === 3 ||
props.row.original.latest_approval.step_number === 4)
: false;
const showRealizationButton = props.row.original.latest_approval
? props.row.original.latest_approval.action !== 'REJECTED' &&
props.row.original.latest_approval.step_number === 4
: false;
return (
<div className='relative'>
<PopoverButton
tabIndex={0}
variant='ghost'
color='none'
popoverTarget={popoverId}
anchorName={popoverAnchorName}
>
<Icon icon='material-symbols:more-vert' width={16} height={16} />
</PopoverButton>
<PopoverContent
id={popoverId}
anchorName={popoverAnchorName}
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
>
<div className='flex flex-col bg-base-100 rounded-xl'>
<RequirePermission permissions='lti.expense.detail'>
<Button
href={`/expense/detail/?expenseId=${props.row.original.id}`}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full'
onClick={closePopover}
>
<Icon icon='heroicons:eye' width={20} height={20} />
Detail
</Button>
</RequirePermission>
{showEditButton && (
<RequirePermission permissions='lti.expense.update'>
<Button
href={`/expense/detail/edit/?expenseId=${props.row.original.id}`}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full'
onClick={closePopover}
>
<Icon icon='mdi:pencil-outline' width={20} height={20} />
Edit
</Button>
</RequirePermission>
)}
{showRealizationButton && (
<RequirePermission permissions='lti.expense.create.realization'>
<Button
href={`/expense/realization/?expenseId=${props.row.original.id}`}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full'
onClick={closePopover}
>
<Icon
icon='material-symbols:money-bag-rounded'
width={20}
height={20}
className='text-info'
/>
Realisasi
</Button>
</RequirePermission>
)}
<RequirePermission permissions='lti.expense.delete'>
<Button
onClick={() => {
deleteClickHandler();
closePopover();
}}
variant='ghost'
color='error'
className='p-3 justify-start text-sm font-semibold w-full focus-visible:text-error-content hover:text-error-content'
>
<Icon icon='mdi:delete-outline' width={20} height={20} />
Delete
</Button>
</RequirePermission>
</div>
</PopoverContent>
</div>
);
};
const ExpensesTable = () => {
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter<ExpenseTableFilters>({
initial: {
page: 1,
pageSize: 10,
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',
},
persist: true,
storeName: 'expense-table',
});
const {
data: expenses,
isLoading,
mutate: refreshExpenses,
} = useSWR(
`${ExpenseApi.basePath}${getTableFilterQueryString()}`,
ExpenseApi.getAllFetcher
);
const deleteModal = useModal();
const approveModal = useModal();
const rejectModal = useModal();
const bulkApproveFormModal = useModal();
const exportProgressInputModal = useModal();
// ===== FILTER MODAL STATE =====
const filterModal = useModal();
const [selectedExpense, setSelectedExpense] = useState<Expense | undefined>(
undefined
);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isRejectLoading, setIsRejectLoading] = useState(false);
const [isExportProgressLoading, setIsExportProgressLoading] = useState(false);
const [, setApprovalNotes] = useState('');
const [bulkApprovalStatus, setBulkApprovalStatus] =
useState<OptionType<ApprovalStatusValue> | null>(null);
const [bulkApprovalDate, setBulkApprovalDate] = useState('');
const [bulkApprovalNotes, setBulkApprovalNotes] = useState('');
const [exportProgressStartDate, setExportProgressStartDate] = useState('');
const [exportProgressEndDate, setExportProgressEndDate] = useState('');
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const selectedRowIds = Object.keys(rowSelection).map((item) =>
parseInt(item)
);
const isAllSelectedRowLatestApprovalOnHeadArea = 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 isCurrentApprovalOnHeadArea =
!isLatestApprovalRejected &&
expenseItem?.latest_approval.step_number === 1;
return isCurrentApprovalOnHeadArea;
});
}, [expenses, selectedRowIds]);
const isAllSelectedRowLatestApprovalOnUnitVicePresident = 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 isCurrentApprovalOnUnitVicePresident =
!isLatestApprovalRejected &&
expenseItem?.latest_approval.step_number === 2;
return isCurrentApprovalOnUnitVicePresident;
});
}, [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 === 3;
return isCurrentApprovalOnFinance;
});
}, [expenses, selectedRowIds]);
const expensesColumns: ColumnDef<Expense>[] = [
{
id: 'select',
header: ({ table }) => (
<div className='w-full flex flex-row justify-center'>
<CheckboxInput
name='allRow'
checked={table.getIsAllRowsSelected()}
indeterminate={table.getIsSomeRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
</div>
),
cell: ({ row }) => {
const isCheckboxDisabled =
!row.getCanSelect() ||
!row.original.latest_approval ||
row.original.latest_approval.action === 'REJECTED';
return (
<div>
<CheckboxInput
name='row'
checked={row.getIsSelected()}
disabled={isCheckboxDisabled}
indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()}
/>
</div>
);
},
},
{
accessorKey: 'reference_number',
header: 'Nomor Referensi',
cell: (props) => {
return props.row.original.reference_number ?? '-';
},
},
{
accessorKey: 'transaction_date',
header: 'Tanggal Pengajuan',
cell: (props) =>
props.row.original.transaction_date
? formatDate(props.row.original.transaction_date, 'DD MMM YYYY')
: '-',
},
{
accessorKey: 'realization_date',
header: 'Tanggal Realisasi',
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 ?? '-',
},
{
accessorFn: (row) => row.created_user.name ?? '-',
header: 'Nama Pengaju',
},
{
accessorFn: (row) => row.supplier.name ?? '-',
header: 'Vendor',
},
{
accessorKey: 'grand_total',
header: 'Nominal',
cell: (props) =>
props.row.original.grand_total
? formatCurrency(props.row.original.grand_total)
: '-',
},
{
header: 'Status Pencairan',
cell: (props) => (
<RealizationStatusBadge approval={props.row.original.latest_approval} />
),
},
{
header: 'Status BOP',
cell: (props) => (
<ExpenseStatusBadge approval={props.row.original.latest_approval} />
),
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize = props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedExpense(props.row.original);
deleteModal.openModal();
};
return (
<RowOptionsMenu
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
props={props}
deleteClickHandler={deleteClickHandler}
/>
);
},
},
];
const tableEnableRowSelectionHandler: (row: Row<Expense>) => boolean = (
row
) => {
if (!row.original.latest_approval) return false;
return (
row.original.latest_approval.action !== 'REJECTED' &&
row.original.latest_approval.step_number !== 6
);
};
const resetBulkApproveForm = useCallback(() => {
setBulkApprovalStatus(null);
setBulkApprovalDate('');
setBulkApprovalNotes('');
}, []);
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 = () => {
openBulkApproveForm();
};
const bulkApproveHeadAreaClickHandler = () => {
openBulkApproveForm('HEAD_AREA');
};
const bulkApproveUnitVicePresidentClickHandler = () => {
openBulkApproveForm('UNIT_VICE_PRESIDENT');
};
const bulkApproveFinanceClickHandler = () => {
openBulkApproveForm('FINANCE');
};
const bulkRejectClickHandler = () => {
setApprovalNotes('');
rejectModal.openModal();
};
const bulkApprovalDateChangeHandler: ChangeEventHandler<HTMLInputElement> = (
e
) => {
setBulkApprovalDate(e.target.value);
};
const bulkApprovalNotesChangeHandler: ChangeEventHandler<
HTMLTextAreaElement
> = (e) => {
setBulkApprovalNotes(e.target.value);
};
const resetExportProgressForm = useCallback(() => {
setExportProgressStartDate('');
setExportProgressEndDate('');
}, []);
const exportProgressStartDateChangeHandler: ChangeEventHandler<
HTMLInputElement
> = (e) => {
setExportProgressStartDate(e.target.value);
};
const exportProgressEndDateChangeHandler: ChangeEventHandler<
HTMLInputElement
> = (e) => {
setExportProgressEndDate(e.target.value);
};
const exportProgressInputToExcelClickHandler = () => {
resetExportProgressForm();
exportProgressInputModal.openModal();
};
const submitExportProgressInputHandler = async () => {
if (!exportProgressStartDate || !exportProgressEndDate) {
return;
}
setIsExportProgressLoading(true);
try {
await ExpenseApi.exportInputProgressToExcel(
exportProgressStartDate,
exportProgressEndDate
);
exportProgressInputModal.closeModal();
resetExportProgressForm();
toast.success('Ekspor berhasil');
} catch (error) {
toast.error(
await getExportErrorMessage(error, 'Gagal mengekspor input progress')
);
} finally {
setIsExportProgressLoading(false);
}
};
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
const deleteResponse = await ExpenseApi.delete(
selectedExpense?.id as number
);
if (isResponseSuccess(deleteResponse)) {
refreshExpenses();
deleteModal.closeModal();
toast.success('Berhasil menghapus biaya operasional!');
} else {
deleteModal.closeModal();
toast.error('Gagal menghapus biaya operasional!');
}
setIsDeleteLoading(false);
};
const confirmationModalApproveClickHandler = async (notes: string) => {
setIsApproveLoading(true);
let bulkApproveResponse: BaseApiResponse<Expense> | undefined = undefined;
if (isAllSelectedRowLatestApprovalOnHeadArea) {
bulkApproveResponse = await ExpenseApi.bulkApproveHeadArea(
selectedRowIds,
notes
);
} else if (isAllSelectedRowLatestApprovalOnUnitVicePresident) {
bulkApproveResponse = await ExpenseApi.bulkApproveUnitVicePresident(
selectedRowIds,
notes
);
} else if (isAllSelectedRowLatestApprovalOnFinance) {
bulkApproveResponse = await ExpenseApi.bulkApproveFinance(
selectedRowIds,
notes
);
}
if (isResponseSuccess(bulkApproveResponse)) {
refreshExpenses();
approveModal.closeModal();
toast.success(
`Berhasil approve ${selectedRowIds.length} data biaya operasional!`
);
setApprovalNotes('');
setRowSelection({});
} else {
approveModal.closeModal();
toast.error(
`Gagal approve ${selectedRowIds.length} data biaya operasional!`
);
}
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);
let bulkRejectResponse: BaseApiResponse<Expense> | undefined = undefined;
if (isAllSelectedRowLatestApprovalOnHeadArea) {
bulkRejectResponse = await ExpenseApi.bulkRejectHeadArea(
selectedRowIds,
notes
);
} else if (isAllSelectedRowLatestApprovalOnUnitVicePresident) {
bulkRejectResponse = await ExpenseApi.bulkRejectUnitVicePresident(
selectedRowIds,
notes
);
} else if (isAllSelectedRowLatestApprovalOnFinance) {
bulkRejectResponse = await ExpenseApi.bulkRejectFinance(
selectedRowIds,
notes
);
}
if (isResponseSuccess(bulkRejectResponse)) {
refreshExpenses();
rejectModal.closeModal();
toast.success(
`Berhasil reject ${selectedRowIds.length} data biaya operasional!`
);
setApprovalNotes('');
setRowSelection({});
} else {
rejectModal.closeModal();
toast.error(
`Gagal reject ${selectedRowIds.length} data biaya operasional!`
);
}
setIsRejectLoading(false);
};
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
// ===== FILTER MODAL HANDLERS =====
const handleFilterModalOpen = () => {
filterModal.openModal();
};
const handleFilterSubmit = (values: {
transaction_date?: string | null;
realization_date?: string | null;
location_id?: string | null;
vendor_id?: string | null;
}) => {
updateFilter('transactionDate', values.transaction_date || '');
updateFilter('realizationDate', values.realization_date || '');
updateFilter('locationId', values.location_id || '');
updateFilter('vendorId', values.vendor_id || '');
};
const handleFilterReset = () => {
updateFilter('transactionDate', '');
updateFilter('realizationDate', '');
updateFilter('locationId', '');
updateFilter('vendorId', '');
};
// track sorting
useEffect(() => {
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
if (!isNameSorted) {
updateFilter('nameSort', '', false);
} else {
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
}
}, [sorting, updateFilter]);
return (
<>
<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-col gap-3 flex-wrap'>
<RequirePermission permissions='lti.expense.create'>
<Button
href='/expense/add'
color='primary'
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-xl shadow-button-soft'
>
<Icon icon='heroicons:plus' width={20} height={20} />
Add Expense
</Button>
</RequirePermission>
{selectedRowIds.length > 0 && (
<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={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'
>
<Icon
icon='lucide-lab:farm'
width={20}
height={20}
className='text-success'
/>
Approve Head Area
</Button>
</RequirePermission>
<RequirePermission permissions='lti.expense.approve.unit_vice_president'>
<Button
variant='outline'
color='none'
onClick={bulkApproveUnitVicePresidentClickHandler}
disabled={
!isAllSelectedRowLatestApprovalOnUnitVicePresident
}
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='tdesign:money'
width={20}
height={20}
className='text-success'
/>
Approve Unit Vice President
</Button>
</RequirePermission>
<RequirePermission permissions='lti.expense.approve.finance'>
<Button
variant='outline'
color='none'
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'
>
<Icon
icon='tdesign:money'
width={20}
height={20}
className='text-success'
/>
Approve Finance
</Button>
</RequirePermission>
<RequirePermission
permissions={[
'lti.expense.approve.head_area',
'lti.expense.approve.unit_vice_president',
'lti.expense.approve.finance',
]}
>
<Button
variant='outline'
color='none'
onClick={bulkRejectClickHandler}
disabled={
!isAllSelectedRowLatestApprovalOnHeadArea &&
!isAllSelectedRowLatestApprovalOnUnitVicePresident &&
!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'
>
<Icon
icon='material-symbols:close'
width={20}
height={20}
className='text-error'
/>
Reject
</Button>
</RequirePermission>
</div>
)}
</div>
{/* Search and Filter */}
<div className='flex flex-1 flex-row justify-start sm:justify-end items-start gap-3 flex-wrap'>
<DebouncedTextInput
name='search'
placeholder='Search'
value={tableFilterState.search ?? ''}
onChange={searchChangeHandler}
startAdornment={
<Icon
icon='heroicons:magnifying-glass'
width={20}
height={20}
/>
}
className={{
wrapper: 'w-full min-w-24 max-w-3xs',
inputWrapper: 'rounded-xl! shadow-button-soft',
input:
'placeholder:font-semibold placeholder:text-base-content/50',
}}
/>
<ButtonFilter
values={tableFilterState}
excludeFields={[
'page',
'pageSize',
'search',
'nameSort',
'userId',
]}
onClick={handleFilterModalOpen}
className='px-3 py-2.5'
/>
<Dropdown
align='end'
direction='bottom'
className={{
content:
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
}}
trigger={
<Button
variant='outline'
color='none'
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
>
<div className='flex flex-row items-center gap-1.5'>
<Icon
icon='heroicons:cloud-arrow-down'
width={20}
height={20}
/>
<span>Export</span>
<div className='w-px self-stretch bg-base-content/10' />
<Icon
icon='heroicons:chevron-down'
width={14}
height={14}
/>
</div>
</Button>
}
>
<Button
variant='ghost'
color='none'
onClick={exportProgressInputToExcelClickHandler}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:table-cells' width={20} height={20} />
Ekspor Input Progress (Excel)
</Button>
</Dropdown>
</div>
</div>
{/* Table Section */}
<div className='flex flex-col mb-4'>
{isLoading ? (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
) : !isResponseSuccess(expenses) || expenses.data?.length === 0 ? (
<div className='p-3'>
<ExpenseTableSkeleton
columns={expensesColumns}
icon={
<Icon
icon='heroicons:document-text'
className='text-white'
width={20}
height={20}
/>
}
/>
</div>
) : (
<Table<Expense>
data={isResponseSuccess(expenses) ? expenses?.data : []}
columns={expensesColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(expenses) ? expenses?.meta?.page : 0}
totalItems={
isResponseSuccess(expenses) ? expenses?.meta?.total_results : 0
}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
enableRowSelection={tableEnableRowSelectionHandler}
className={{
containerClassName: cn('p-3 mb-0'),
headerColumnClassName: 'text-nowrap',
}}
/>
)}
</div>
</div>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text='Apakah anda yakin ingin menghapus data biaya operasional ini?'
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
<ConfirmationModalWithNotes
ref={approveModal.ref}
type='success'
text='Apakah anda yakin ingin approve data biaya operasional ini?'
secondaryButton={{
text: 'Tidak',
onClick: () => {
setApprovalNotes('');
approveModal.closeModal();
},
}}
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?'
secondaryButton={{
text: 'Tidak',
onClick: () => {
setApprovalNotes('');
rejectModal.closeModal();
},
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isRejectLoading,
onClick: confirmationModalRejectClickHandler,
}}
/>
<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>
<Modal
ref={exportProgressInputModal.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'>
Ekspor Input Progress
</h4>
<Button
variant='ghost'
color='none'
onClick={() => {
exportProgressInputModal.closeModal();
resetExportProgressForm();
}}
className='p-1'
>
<Icon icon='mdi:close' width={20} height={20} />
</Button>
</div>
<div className='flex flex-col gap-4 p-4'>
<DateInput
name='export_progress_start_date'
label='Tanggal Mulai'
value={exportProgressStartDate}
onChange={exportProgressStartDateChangeHandler}
isNestedModal
required
/>
<DateInput
name='export_progress_end_date'
label='Tanggal Selesai'
value={exportProgressEndDate}
onChange={exportProgressEndDateChangeHandler}
isNestedModal
required
/>
</div>
<div className='flex justify-end gap-3 border-t border-base-content/10 p-4'>
<Button
variant='outline'
color='none'
onClick={() => {
exportProgressInputModal.closeModal();
resetExportProgressForm();
}}
className='px-3 py-2.5'
>
Batal
</Button>
<Button
color='success'
onClick={submitExportProgressInputHandler}
isLoading={isExportProgressLoading}
disabled={!exportProgressStartDate || !exportProgressEndDate}
className='px-3 py-2.5'
>
Submit
</Button>
</div>
</div>
</Modal>
<ExpensesFilterModal
ref={filterModal.ref}
onSubmit={handleFilterSubmit}
onReset={handleFilterReset}
/>
</>
);
};
export default ExpensesTable;