feat(FE-331): implement permission guard in expense

This commit is contained in:
ValdiANS
2025-12-24 11:08:37 +07:00
parent dda29e10d1
commit 6ed7dcfa6d
5 changed files with 362 additions and 294 deletions
@@ -4,6 +4,7 @@ import toast from 'react-hot-toast';
import Link from 'next/link'; import Link from 'next/link';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Button from '@/components/Button'; import Button from '@/components/Button';
import RequirePermission from '@/components/helper/RequirePermission';
import Card from '@/components/Card'; import Card from '@/components/Card';
import DropFileInput from '@/components/input/DropFileInput'; import DropFileInput from '@/components/input/DropFileInput';
@@ -62,16 +63,17 @@ const ExpenseRealizationContent = ({
<div> <div>
<div className='w-full max-w-5xl mx-auto flex flex-col sm:flex-row justify-end gap-2'> <div className='w-full max-w-5xl mx-auto flex flex-col sm:flex-row justify-end gap-2'>
<div className='w-full sm:w-fit sm:ml-2 flex flex-row gap-2 items-center'> <div className='w-full sm:w-fit sm:ml-2 flex flex-row gap-2 items-center'>
{/* TODO: apply RBAC */} <RequirePermission permissions='lti.expense.update.realization'>
<Button <Button
type='button' type='button'
color='warning' color='warning'
href={`/expense/realization/edit/?expenseId=${initialValues?.id}`} href={`/expense/realization/edit/?expenseId=${initialValues?.id}`}
className='px-4 grow sm:grow-0' className='px-4 grow sm:grow-0'
> >
<Icon icon='mdi:pencil-outline' width={24} height={24} /> <Icon icon='mdi:pencil-outline' width={24} height={24} />
Edit Realisasi Edit Realisasi
</Button> </Button>
</RequirePermission>
</div> </div>
</div> </div>
@@ -124,36 +126,38 @@ const ExpenseRealizationContent = ({
)} )}
</div> </div>
<div className='flex flex-col gap-2'> <RequirePermission permissions='lti.expense.document.realization'>
<DropFileInput <div className='flex flex-col gap-2'>
name='documents' <DropFileInput
values={formik.values.documents} name='documents'
onChange={realizationDocumentsChangeHandler} values={formik.values.documents}
onDelete={realizationDocumentsDeleteHandler} onChange={realizationDocumentsChangeHandler}
accept={{ onDelete={realizationDocumentsDeleteHandler}
...ACCEPTED_FILE_TYPE.PDF, accept={{
...ACCEPTED_FILE_TYPE.IMAGE, ...ACCEPTED_FILE_TYPE.PDF,
}} ...ACCEPTED_FILE_TYPE.IMAGE,
maxFiles={10} }}
className={{ maxFiles={10}
wrapper: 'mt-2', className={{
inputWrapper: 'flex items-center', wrapper: 'mt-2',
}} inputWrapper: 'flex items-center',
/> }}
/>
{formik.values.documents && {formik.values.documents &&
formik.values.documents.length > 0 && ( formik.values.documents.length > 0 && (
<Button <Button
onClick={formik.submitForm} onClick={formik.submitForm}
disabled={formik.isSubmitting} disabled={formik.isSubmitting}
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
className='w-fit self-end' className='w-fit self-end'
> >
<Icon icon='ic:round-plus' width={24} height={24} /> <Icon icon='ic:round-plus' width={24} height={24} />
Tambah Tambah
</Button> </Button>
)} )}
</div> </div>
</RequirePermission>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -19,6 +19,7 @@ import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes'; import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import ExpensePDFPreviewButton from '@/components/pages/expense//pdf/ExpensePDFButton'; import ExpensePDFPreviewButton from '@/components/pages/expense//pdf/ExpensePDFButton';
import RequirePermission from '@/components/helper/RequirePermission';
import { Expense } from '@/types/api/expense'; import { Expense } from '@/types/api/expense';
import { formatCurrency, formatDate } from '@/lib/helper'; import { formatCurrency, formatDate } from '@/lib/helper';
@@ -255,100 +256,119 @@ const ExpenseRequestContent = ({
<div className='w-full max-w-5xl mx-auto flex flex-col sm:flex-row justify-end gap-2'> <div className='w-full max-w-5xl mx-auto flex flex-col sm:flex-row justify-end gap-2'>
{isCurrentApprovalOnManager && ( {isCurrentApprovalOnManager && (
<Button <RequirePermission permissions='lti.expense.approve.manager'>
variant='outline' <Button
color='info' variant='outline'
onClick={approveClickHandler} color='info'
className='w-full sm:w-fit' onClick={approveClickHandler}
> className='w-full sm:w-fit'
<Icon icon='lucide-lab:farm' width={24} height={24} /> >
Approve Manager <Icon icon='lucide-lab:farm' width={24} height={24} />
</Button> Approve Manager
</Button>
</RequirePermission>
)} )}
{isCurrentApprovalOnFinance && ( {isCurrentApprovalOnFinance && (
<Button <RequirePermission permissions='lti.expense.approve.finance'>
variant='outline' <Button
color='success' variant='outline'
onClick={approveClickHandler} color='success'
className='w-full sm:w-fit' onClick={approveClickHandler}
> className='w-full sm:w-fit'
<Icon icon='tdesign:money' width={24} height={24} /> >
Approve Finance <Icon icon='tdesign:money' width={24} height={24} />
</Button> Approve Finance
</Button>
</RequirePermission>
)} )}
{isCurrentApprovalOnRealization && ( {isCurrentApprovalOnRealization && (
<Button <RequirePermission permissions='lti.expense.complete.expense'>
variant='outline' <Button
color='success' variant='outline'
onClick={completeExpenseClickHandler} color='success'
className='w-full sm:w-fit' onClick={completeExpenseClickHandler}
> className='w-full sm:w-fit'
<Icon >
icon='material-symbols:done-all-rounded' <Icon
width={24} icon='material-symbols:done-all-rounded'
height={24} width={24}
/> height={24}
Selesai />
</Button> Selesai
</Button>
</RequirePermission>
)} )}
{showRejectButton && ( {showRejectButton && (
<Button <RequirePermission
variant='outline' permissions={[
color='error' 'lti.expense.approve.manager',
onClick={rejectClickHandler} 'lti.expense.approve.finance',
className='w-full:w-fit' ]}
> >
<Icon icon='material-symbols:close' width={24} height={24} /> <Button
Reject variant='outline'
</Button> color='error'
onClick={rejectClickHandler}
className='w-full:w-fit'
>
<Icon icon='material-symbols:close' width={24} height={24} />
Reject
</Button>
</RequirePermission>
)} )}
{isExpenseCanBeRealized && ( {isExpenseCanBeRealized && (
<Button <RequirePermission permissions='lti.expense.create.realization'>
variant='outline' <Button
color='info' variant='outline'
href={`/expense/realization/?expenseId=${initialValues?.id}`} color='info'
className='w-full sm:w-fit' href={`/expense/realization/?expenseId=${initialValues?.id}`}
> className='w-full sm:w-fit'
<Icon >
icon='material-symbols:money-bag-rounded' <Icon
width={24} icon='material-symbols:money-bag-rounded'
height={24} width={24}
/> height={24}
Realisasi />
</Button> Realisasi
</Button>
</RequirePermission>
)} )}
<div className='w-full sm:w-fit sm:ml-2 flex flex-row gap-2 items-center'> <div className='w-full sm:w-fit sm:ml-2 flex flex-row gap-2 items-center'>
{showEditButton && ( {showEditButton && (
<Button <RequirePermission permissions='lti.expense.update'>
type='button' <Button
color='warning' type='button'
href={`/expense/detail/edit/?expenseId=${initialValues?.id}`} color='warning'
className='px-4 grow sm:grow-0' href={`/expense/detail/edit/?expenseId=${initialValues?.id}`}
> className='px-4 grow sm:grow-0'
<Icon icon='mdi:pencil-outline' width={24} height={24} /> >
Edit <Icon icon='mdi:pencil-outline' width={24} height={24} />
</Button> Edit
</Button>
</RequirePermission>
)} )}
<Button <RequirePermission permissions='lti.expense.delete'>
type='button' <Button
color='error' type='button'
onClick={deleteExpenseClickHandler} color='error'
className='px-4 grow sm:grow-0' onClick={deleteExpenseClickHandler}
> className='px-4 grow sm:grow-0'
<Icon >
icon='material-symbols:delete-outline-rounded' <Icon
width={24} icon='material-symbols:delete-outline-rounded'
height={24} width={24}
className='justify-start text-sm' height={24}
/> className='justify-start text-sm'
Delete />
</Button> Delete
</Button>
</RequirePermission>
</div> </div>
</div> </div>
@@ -485,36 +505,42 @@ const ExpenseRequestContent = ({
)} )}
</div> </div>
<div className='flex flex-col gap-2'> <RequirePermission permissions='lti.expense.document'>
<DropFileInput <div className='flex flex-col gap-2'>
name='documents' <DropFileInput
values={formik.values.documents} name='documents'
onChange={requestDocumentsChangeHandler} values={formik.values.documents}
onDelete={requestDocumentsDeleteHandler} onChange={requestDocumentsChangeHandler}
accept={{ onDelete={requestDocumentsDeleteHandler}
...ACCEPTED_FILE_TYPE.PDF, accept={{
...ACCEPTED_FILE_TYPE.IMAGE, ...ACCEPTED_FILE_TYPE.PDF,
}} ...ACCEPTED_FILE_TYPE.IMAGE,
maxFiles={10} }}
className={{ maxFiles={10}
wrapper: 'mt-2', className={{
inputWrapper: 'flex items-center', wrapper: 'mt-2',
}} inputWrapper: 'flex items-center',
/> }}
/>
{formik.values.documents && {formik.values.documents &&
formik.values.documents.length > 0 && ( formik.values.documents.length > 0 && (
<Button <Button
onClick={formik.submitForm} onClick={formik.submitForm}
disabled={formik.isSubmitting} disabled={formik.isSubmitting}
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
className='w-fit self-end' className='w-fit self-end'
> >
<Icon icon='ic:round-plus' width={24} height={24} /> <Icon
Tambah icon='ic:round-plus'
</Button> width={24}
)} height={24}
</div> />
Tambah
</Button>
)}
</div>
</RequirePermission>
</td> </td>
</tr> </tr>
</tbody> </tbody>
+113 -87
View File
@@ -28,6 +28,7 @@ import ExpenseStatusBadge from '@/components/pages/expense/ExpenseStatusBadge';
import CheckboxInput from '@/components/input/CheckboxInput'; import CheckboxInput from '@/components/input/CheckboxInput';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes'; import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import DateInput from '@/components/input/DateInput'; import DateInput from '@/components/input/DateInput';
import RequirePermission from '@/components/helper/RequirePermission';
import { Expense } from '@/types/api/expense'; import { Expense } from '@/types/api/expense';
import { ExpenseApi } from '@/services/api/expense'; import { ExpenseApi } from '@/services/api/expense';
@@ -67,58 +68,70 @@ const RowOptionsMenu = ({
return ( return (
<RowOptionsMenuWrapper type={type}> <RowOptionsMenuWrapper type={type}>
<div className='w-full max-h-40 overflow-auto flex flex-col gap-1'> <div className='w-full max-h-40 overflow-auto flex flex-col gap-1'>
<Button <RequirePermission permissions='lti.expense.detail'>
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 && (
<Button <Button
href={`/expense/detail/edit/?expenseId=${props.row.original.id}`} href={`/expense/detail/?expenseId=${props.row.original.id}`}
variant='ghost' variant='ghost'
color='warning' color='primary'
className='justify-start text-sm' className='justify-start text-sm'
> >
<Icon icon='material-symbols:edit-outline' width={16} height={16} /> <Icon icon='mdi:eye-outline' width={16} height={16} />
Edit Detail
</Button> </Button>
</RequirePermission>
{showEditButton && (
<RequirePermission permissions='lti.expense.update'>
<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>
</RequirePermission>
)} )}
{showRealizationButton && ( {showRealizationButton && (
<Button <RequirePermission permissions='lti.expense.create.realization'>
href={`/expense/realization/?expenseId=${props.row.original.id}`} <Button
variant='ghost' href={`/expense/realization/?expenseId=${props.row.original.id}`}
color='info' variant='ghost'
className='justify-start text-sm text-info focus-visible:text-info-content hover:text-info-content' 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' <Icon
width={16} icon='material-symbols:money-bag-rounded'
height={16} width={16}
/> height={16}
Realisasi />
</Button> Realisasi
</Button>
</RequirePermission>
)} )}
<Button <RequirePermission permissions='lti.expense.delete'>
onClick={deleteClickHandler} <Button
variant='ghost' onClick={deleteClickHandler}
color='error' variant='ghost'
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content' color='error'
> className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content'
<Icon >
icon='material-symbols:delete-outline-rounded' <Icon
width={16} icon='material-symbols:delete-outline-rounded'
height={16} width={16}
className='justify-start text-sm' height={16}
/> className='justify-start text-sm'
Delete />
</Button> Delete
</Button>
</RequirePermission>
</div> </div>
</RowOptionsMenuWrapper> </RowOptionsMenuWrapper>
); );
@@ -559,57 +572,70 @@ const ExpensesTable = () => {
<div className='flex flex-col gap-2 mb-4'> <div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-4'> <div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-4'>
<div className='w-full sm:w-fit flex flex-col sm:flex-row self-start gap-2'> <div className='w-full sm:w-fit flex flex-col sm:flex-row self-start gap-2'>
<Button <RequirePermission permissions='lti.expense.create'>
href='/expense/add' <Button
variant='outline' href='/expense/add'
color='primary' variant='outline'
className='w-full sm:w-fit' color='primary'
> className='w-full sm:w-fit'
<Icon icon='ic:round-plus' width={24} height={24} /> >
Tambah <Icon icon='ic:round-plus' width={24} height={24} />
</Button> Tambah
</Button>
</RequirePermission>
{selectedRowIds.length > 0 && ( {selectedRowIds.length > 0 && (
<> <>
<Button <RequirePermission permissions='lti.expense.approve.manager'>
variant='outline' <Button
color='info' variant='outline'
onClick={bulkApproveClickHandler} color='info'
disabled={!isAllSelectedRowLatestApprovalOnManager} onClick={bulkApproveClickHandler}
className='w-full sm:w-fit' disabled={!isAllSelectedRowLatestApprovalOnManager}
> className='w-full sm:w-fit'
<Icon icon='lucide-lab:farm' width={24} height={24} /> >
Approve Manager <Icon icon='lucide-lab:farm' width={24} height={24} />
</Button> Approve Manager
</Button>
</RequirePermission>
<Button <RequirePermission permissions='lti.expense.approve.finance'>
variant='outline' <Button
color='success' variant='outline'
onClick={bulkApproveClickHandler} color='success'
disabled={!isAllSelectedRowLatestApprovalOnFinance} onClick={bulkApproveClickHandler}
className='w-full sm:w-fit' disabled={!isAllSelectedRowLatestApprovalOnFinance}
> className='w-full sm:w-fit'
<Icon icon='tdesign:money' width={24} height={24} /> >
Approve Finance <Icon icon='tdesign:money' width={24} height={24} />
</Button> Approve Finance
</Button>
</RequirePermission>
<Button <RequirePermission
variant='outline' permissions={[
color='error' 'lti.expense.approve.manager',
onClick={bulkRejectClickHandler} 'lti.expense.approve.finance',
disabled={ ]}
!isAllSelectedRowLatestApprovalOnManager &&
!isAllSelectedRowLatestApprovalOnFinance
}
className='w-full sm:w-fit'
> >
<Icon <Button
icon='material-symbols:close' variant='outline'
width={24} color='error'
height={24} onClick={bulkRejectClickHandler}
/> disabled={
Reject !isAllSelectedRowLatestApprovalOnManager &&
</Button> !isAllSelectedRowLatestApprovalOnFinance
}
className='w-full sm:w-fit'
>
<Icon
icon='material-symbols:close'
width={24}
height={24}
/>
Reject
</Button>
</RequirePermission>
</> </>
)} )}
</div> </div>
@@ -16,6 +16,7 @@ import DateInput from '@/components/input/DateInput';
import DropFileInput from '@/components/input/DropFileInput'; import DropFileInput from '@/components/input/DropFileInput';
import ExpenseKandangsTable from '@/components/pages/expense/form/ExpenseKandangsTable'; import ExpenseKandangsTable from '@/components/pages/expense/form/ExpenseKandangsTable';
import ExpenseRealizationKandangDetailExpense from '@/components/pages/expense/form/ExpenseRealizationKandangDetailExpense'; import ExpenseRealizationKandangDetailExpense from '@/components/pages/expense/form/ExpenseRealizationKandangDetailExpense';
import RequirePermission from '@/components/helper/RequirePermission';
import { import {
CreateExpenseRealizationPayload, CreateExpenseRealizationPayload,
@@ -290,21 +291,23 @@ const ExpenseRealizationForm = ({
className={{ wrapper: 'col-span-12' }} className={{ wrapper: 'col-span-12' }}
/> />
<DropFileInput <RequirePermission permissions='lti.expense.document.realization'>
label='Dokumen Realisasi' <DropFileInput
name='documents' label='Dokumen Realisasi'
values={formik.values.documents} name='documents'
onChange={realizationDocumentsChangeHandler} values={formik.values.documents}
onDelete={realizationDocumentsDeleteHandler} onChange={realizationDocumentsChangeHandler}
accept={{ onDelete={realizationDocumentsDeleteHandler}
...ACCEPTED_FILE_TYPE.PDF, accept={{
...ACCEPTED_FILE_TYPE.IMAGE, ...ACCEPTED_FILE_TYPE.PDF,
}} ...ACCEPTED_FILE_TYPE.IMAGE,
className={{ }}
wrapper: 'col-span-12', className={{
inputWrapper: 'h-12 flex items-center', wrapper: 'col-span-12',
}} inputWrapper: 'h-12 flex items-center',
/> }}
/>
</RequirePermission>
{formik.values.existing_documents && {formik.values.existing_documents &&
formik.values.existing_documents.length > 0 && ( formik.values.existing_documents.length > 0 && (
@@ -357,20 +360,22 @@ const ExpenseRealizationForm = ({
{type !== 'add' && ( {type !== 'add' && (
<div className='flex flex-row justify-start gap-2'> <div className='flex flex-row justify-start gap-2'>
{type !== 'edit' && ( {type !== 'edit' && (
<Button <RequirePermission permissions='lti.expense.update'>
type='button' <Button
color='warning' type='button'
href={`/expense/detail/edit/?expenseId=${initialValues?.id}`} color='warning'
className='px-4' href={`/expense/detail/edit/?expenseId=${initialValues?.id}`}
> className='px-4'
<Icon >
icon='material-symbols:edit-outline' <Icon
width={24} icon='material-symbols:edit-outline'
height={24} width={24}
className='justify-start text-sm' height={24}
/> className='justify-start text-sm'
Edit />
</Button> Edit
</Button>
</RequirePermission>
)} )}
</div> </div>
)} )}
@@ -18,6 +18,7 @@ import DateInput from '@/components/input/DateInput';
import ExpenseKandangsTable from '@/components/pages/expense/form/ExpenseKandangsTable'; import ExpenseKandangsTable from '@/components/pages/expense/form/ExpenseKandangsTable';
import DropFileInput from '@/components/input/DropFileInput'; import DropFileInput from '@/components/input/DropFileInput';
import ExpenseRequestKandangDetailExpense from '@/components/pages/expense/form/ExpenseRequestKandangDetailExpense'; import ExpenseRequestKandangDetailExpense from '@/components/pages/expense/form/ExpenseRequestKandangDetailExpense';
import RequirePermission from '@/components/helper/RequirePermission';
import { import {
ExpenseRequestFormSchema, ExpenseRequestFormSchema,
@@ -385,21 +386,23 @@ const ExpenseRequestForm = ({
className={{ wrapper: 'col-span-12' }} className={{ wrapper: 'col-span-12' }}
/> />
<DropFileInput <RequirePermission permissions='lti.expense.document'>
label='Dokumen Pengajuan' <DropFileInput
name='documents' label='Dokumen Pengajuan'
values={formik.values.documents} name='documents'
onChange={requestDocumentsChangeHandler} values={formik.values.documents}
onDelete={requestDocumentsDeleteHandler} onChange={requestDocumentsChangeHandler}
accept={{ onDelete={requestDocumentsDeleteHandler}
...ACCEPTED_FILE_TYPE.PDF, accept={{
...ACCEPTED_FILE_TYPE.IMAGE, ...ACCEPTED_FILE_TYPE.PDF,
}} ...ACCEPTED_FILE_TYPE.IMAGE,
className={{ }}
wrapper: 'col-span-12', className={{
inputWrapper: 'h-12 flex items-center', wrapper: 'col-span-12',
}} inputWrapper: 'h-12 flex items-center',
/> }}
/>
</RequirePermission>
{formik.values.existing_documents && {formik.values.existing_documents &&
formik.values.existing_documents.length > 0 && ( formik.values.existing_documents.length > 0 && (
@@ -461,36 +464,40 @@ const ExpenseRequestForm = ({
<div className='flex flex-row justify-between gap-2 flex-wrap'> <div className='flex flex-row justify-between gap-2 flex-wrap'>
{type !== 'add' && ( {type !== 'add' && (
<div className='flex flex-row justify-start gap-2'> <div className='flex flex-row justify-start gap-2'>
<Button <RequirePermission permissions='lti.expense.delete'>
type='button'
color='error'
onClick={deleteExpenseClickHandler}
className='px-4'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
className='justify-start text-sm'
/>
Delete
</Button>
{type !== 'edit' && (
<Button <Button
type='button' type='button'
color='warning' color='error'
href={`/expense/detail/edit/?expenseId=${initialValues?.id}`} onClick={deleteExpenseClickHandler}
className='px-4' className='px-4'
> >
<Icon <Icon
icon='material-symbols:edit-outline' icon='material-symbols:delete-outline-rounded'
width={24} width={24}
height={24} height={24}
className='justify-start text-sm' className='justify-start text-sm'
/> />
Edit Delete
</Button> </Button>
</RequirePermission>
{type !== 'edit' && (
<RequirePermission permissions='lti.expense.update'>
<Button
type='button'
color='warning'
href={`/expense/detail/edit/?expenseId=${initialValues?.id}`}
className='px-4'
>
<Icon
icon='material-symbols:edit-outline'
width={24}
height={24}
className='justify-start text-sm'
/>
Edit
</Button>
</RequirePermission>
)} )}
</div> </div>
)} )}