mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 13:32:00 +00:00
feat(FE-196): create Expense Request Detail's content component
This commit is contained in:
@@ -0,0 +1,650 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useFormik } from 'formik';
|
||||
import useSWR from 'swr';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import { Icon } from '@iconify/react';
|
||||
import Link from 'next/link';
|
||||
import Button from '@/components/Button';
|
||||
import RealizationStatusBadge from '@/components/pages/expense/RealizationStatusBadge';
|
||||
import ExpenseStatusBadge from '@/components/pages/expense/ExpenseStatusBadge';
|
||||
import DropFileInput from '@/components/input/DropFileInput';
|
||||
import ApprovalSteps, {
|
||||
formatGroupedApprovalsToApprovalSteps,
|
||||
} from '@/components/pages/ApprovalSteps';
|
||||
import { useModal } from '@/components/Modal';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
|
||||
|
||||
import { Expense } from '@/types/api/expense';
|
||||
import { formatCurrency, formatDate } from '@/lib/helper';
|
||||
import {
|
||||
UploadRequestDocumentsFormSchema,
|
||||
UploadRequestDocumentsFormValues,
|
||||
} from '@/components/pages/expense/form/ExpenseRequestForm.schema';
|
||||
import { ACCEPTED_FILE_TYPE } from '@/config/constant';
|
||||
import { ExpenseApi } from '@/services/api/expense';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { EXPENSE_REQUEST_APPROVAL_LINE } from '@/config/approval-line';
|
||||
import { BaseApiResponse } from '@/types/api/api-general';
|
||||
|
||||
interface ExpenseRequestContentProps {
|
||||
initialValues?: Expense;
|
||||
}
|
||||
|
||||
const ExpenseRequestContent = ({
|
||||
initialValues,
|
||||
}: ExpenseRequestContentProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { data: approvalHistory, isLoading: isLoadingApprovalHistory } = useSWR(
|
||||
initialValues ? [String(initialValues.id)] : null,
|
||||
([id]: string[]) => ExpenseApi.getApprovalHistory(Number(id))
|
||||
);
|
||||
|
||||
const isLatestApprovalRejected =
|
||||
initialValues?.latest_approval.action === 'REJECTED';
|
||||
|
||||
const isLatestApprovalRejectedOrDone =
|
||||
isLatestApprovalRejected ||
|
||||
initialValues?.latest_approval.step_number === 5;
|
||||
|
||||
const isCurrentApprovalOnManager =
|
||||
!isLatestApprovalRejected &&
|
||||
initialValues?.latest_approval.step_number === 1;
|
||||
|
||||
const isCurrentApprovalOnFinance =
|
||||
!isLatestApprovalRejected &&
|
||||
initialValues?.latest_approval.step_number === 2;
|
||||
|
||||
const isCurrentApprovalOnRealization =
|
||||
!isLatestApprovalRejected &&
|
||||
initialValues?.latest_approval.step_number === 4;
|
||||
|
||||
const showEditButton =
|
||||
initialValues?.latest_approval.step_number !== 5 &&
|
||||
(initialValues?.latest_approval.step_number === 1 ||
|
||||
initialValues?.latest_approval.step_number === 2 ||
|
||||
initialValues?.latest_approval.step_number === 3);
|
||||
|
||||
const showRejectButton =
|
||||
!isLatestApprovalRejected &&
|
||||
(initialValues?.latest_approval.step_number === 1 ||
|
||||
initialValues?.latest_approval.step_number === 2);
|
||||
|
||||
const isExpenseCanBeRealized =
|
||||
!isLatestApprovalRejected &&
|
||||
initialValues?.latest_approval.step_number === 3;
|
||||
|
||||
// Modal hooks
|
||||
const deleteModal = useModal();
|
||||
const completeModal = useModal();
|
||||
const approveModal = useModal();
|
||||
const rejectModal = useModal();
|
||||
|
||||
// Modal loading state
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
const [isCompleteLoading, setIsCompleteLoading] = useState(false);
|
||||
const [isApproveLoading, setIsApproveLoading] = useState(false);
|
||||
const [isRejectLoading, setIsRejectLoading] = useState(false);
|
||||
|
||||
const formik = useFormik<UploadRequestDocumentsFormValues>({
|
||||
initialValues: {
|
||||
documents: [],
|
||||
},
|
||||
validationSchema: UploadRequestDocumentsFormSchema,
|
||||
onSubmit: async (values) => {
|
||||
const addRequestDocumentsRes = await ExpenseApi.uploadRequestDocuments(
|
||||
initialValues?.id as number,
|
||||
values.documents
|
||||
);
|
||||
|
||||
if (isResponseSuccess(addRequestDocumentsRes)) {
|
||||
toast.success(addRequestDocumentsRes.message);
|
||||
window.location.reload();
|
||||
} else {
|
||||
toast.error(String(addRequestDocumentsRes?.message));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const deleteExpenseClickHandler = () => {
|
||||
deleteModal.openModal();
|
||||
};
|
||||
|
||||
const completeExpenseClickHandler = () => {
|
||||
completeModal.openModal();
|
||||
};
|
||||
|
||||
const approveClickHandler = () => {
|
||||
approveModal.openModal();
|
||||
};
|
||||
|
||||
const rejectClickHandler = () => {
|
||||
rejectModal.openModal();
|
||||
};
|
||||
|
||||
// Modal confirm click handler
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
|
||||
try {
|
||||
await ExpenseApi.delete(initialValues?.id as number);
|
||||
|
||||
toast.success('Berhasil menghapus data biaya operasional!');
|
||||
router.push('/expense');
|
||||
} catch (error) {
|
||||
toast.error('Gagal menghapus data biaya operasional!');
|
||||
} finally {
|
||||
deleteModal.closeModal();
|
||||
setIsDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmationModalCompleteClickHandler = async () => {
|
||||
setIsCompleteLoading(true);
|
||||
|
||||
const completeRes = await ExpenseApi.complete(initialValues?.id as number);
|
||||
|
||||
if (isResponseSuccess(completeRes)) {
|
||||
toast.success(completeRes.message);
|
||||
router.push('/expense');
|
||||
} else {
|
||||
toast.error(completeRes?.message as string);
|
||||
}
|
||||
|
||||
completeModal.closeModal();
|
||||
setIsCompleteLoading(false);
|
||||
};
|
||||
|
||||
const confirmationModalApproveClickHandler = async (notes: string) => {
|
||||
setIsApproveLoading(true);
|
||||
|
||||
let approveResponse: BaseApiResponse<Expense> | undefined = undefined;
|
||||
|
||||
if (isCurrentApprovalOnManager) {
|
||||
approveResponse = await ExpenseApi.approveManager(
|
||||
initialValues.id,
|
||||
notes
|
||||
);
|
||||
}
|
||||
|
||||
if (isCurrentApprovalOnFinance) {
|
||||
approveResponse = await ExpenseApi.approveFinance(
|
||||
initialValues.id,
|
||||
notes
|
||||
);
|
||||
}
|
||||
|
||||
if (isResponseSuccess(approveResponse)) {
|
||||
approveModal.closeModal();
|
||||
|
||||
toast.success(approveResponse?.message);
|
||||
router.push('/expense');
|
||||
} else {
|
||||
approveModal.closeModal();
|
||||
|
||||
toast.error(approveResponse?.message as string);
|
||||
}
|
||||
|
||||
setIsApproveLoading(false);
|
||||
};
|
||||
|
||||
const confirmationModalRejectClickHandler = async (notes: string) => {
|
||||
setIsRejectLoading(true);
|
||||
|
||||
let rejectResponse: BaseApiResponse<Expense> | undefined = undefined;
|
||||
|
||||
if (isCurrentApprovalOnManager) {
|
||||
rejectResponse = await ExpenseApi.rejectManager(initialValues.id, notes);
|
||||
}
|
||||
|
||||
if (isCurrentApprovalOnFinance) {
|
||||
rejectResponse = await ExpenseApi.rejectFinance(initialValues.id, notes);
|
||||
}
|
||||
|
||||
if (isResponseSuccess(rejectResponse)) {
|
||||
rejectModal.closeModal();
|
||||
|
||||
toast.success(rejectResponse.message);
|
||||
router.push('/expense');
|
||||
} else {
|
||||
rejectModal.closeModal();
|
||||
|
||||
toast.error(rejectResponse?.message as string);
|
||||
}
|
||||
|
||||
setIsRejectLoading(false);
|
||||
};
|
||||
|
||||
const requestDocumentsChangeHandler = (val: File[]) => {
|
||||
formik.setFieldTouched('documents', true);
|
||||
formik.setFieldValue('documents', val);
|
||||
};
|
||||
|
||||
const requestDocumentsDeleteHandler = (deletedFileIdx: number) => {
|
||||
const newRequestDocuments = formik.values.documents;
|
||||
|
||||
newRequestDocuments?.splice(deletedFileIdx, 1);
|
||||
|
||||
formik.setFieldValue('documents', newRequestDocuments);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
{initialValues &&
|
||||
!isLoadingApprovalHistory &&
|
||||
isResponseSuccess(approvalHistory) && (
|
||||
<div className='w-full max-w-5xl my-4 mx-auto'>
|
||||
<ApprovalSteps
|
||||
approvals={formatGroupedApprovalsToApprovalSteps(
|
||||
EXPENSE_REQUEST_APPROVAL_LINE,
|
||||
approvalHistory.data,
|
||||
initialValues.latest_approval
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='w-full mt-4 flex flex-col gap-4'>
|
||||
{/* TODO: apply RBAC */}
|
||||
|
||||
<div className='w-full max-w-5xl mx-auto flex flex-col sm:flex-row justify-end gap-2'>
|
||||
{isCurrentApprovalOnManager && (
|
||||
<Button
|
||||
variant='outline'
|
||||
color='info'
|
||||
onClick={approveClickHandler}
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon icon='lucide-lab:farm' width={24} height={24} />
|
||||
Approve Manager
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isCurrentApprovalOnFinance && (
|
||||
<Button
|
||||
variant='outline'
|
||||
color='success'
|
||||
onClick={approveClickHandler}
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon icon='tdesign:money' width={24} height={24} />
|
||||
Approve Finance
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isCurrentApprovalOnRealization && (
|
||||
<Button
|
||||
variant='outline'
|
||||
color='success'
|
||||
onClick={completeExpenseClickHandler}
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:done-all-rounded'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
Selesai
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showRejectButton && (
|
||||
<Button
|
||||
variant='outline'
|
||||
color='error'
|
||||
onClick={rejectClickHandler}
|
||||
className='w-full:w-fit'
|
||||
>
|
||||
<Icon icon='material-symbols:close' width={24} height={24} />
|
||||
Reject
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isExpenseCanBeRealized && (
|
||||
<Button
|
||||
variant='outline'
|
||||
color='info'
|
||||
href={`/expense/realization/?expenseId=${initialValues?.id}`}
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:money-bag-rounded'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
Realisasi
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className='w-full sm:w-fit sm:ml-2 flex flex-row gap-2 items-center'>
|
||||
{showEditButton && (
|
||||
<Button
|
||||
type='button'
|
||||
color='warning'
|
||||
href={`/expense/detail/edit/?expenseId=${initialValues?.id}`}
|
||||
className='px-4 grow sm:grow-0'
|
||||
>
|
||||
<Icon icon='mdi:pencil-outline' width={24} height={24} />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type='button'
|
||||
color='error'
|
||||
onClick={deleteExpenseClickHandler}
|
||||
className='px-4 grow sm:grow-0'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:delete-outline-rounded'
|
||||
width={24}
|
||||
height={24}
|
||||
className='justify-start text-sm'
|
||||
/>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='overflow-x-auto w-full max-w-5xl mx-auto'>
|
||||
<table className='table table-sm table-zebra'>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Nomor PO</th>
|
||||
<th>:</th>
|
||||
<td>{initialValues?.po_number ?? '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Nomor Referensi</th>
|
||||
<th>:</th>
|
||||
<td>{initialValues?.reference_number}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Kategori</th>
|
||||
<th>:</th>
|
||||
<td>
|
||||
{initialValues?.category === 'BOP'
|
||||
? 'Biaya Operasional'
|
||||
: 'Non Biaya Operasional'}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Lokasi</th>
|
||||
<th>:</th>
|
||||
<td>{initialValues?.location.name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Kandang</th>
|
||||
<th>:</th>
|
||||
<td>
|
||||
{initialValues?.kandangs
|
||||
.map((item) => item.name)
|
||||
.join(', ')}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Vendor</th>
|
||||
<th>:</th>
|
||||
<td>{initialValues?.supplier.name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Tanggal Transaksi</th>
|
||||
<th>:</th>
|
||||
<td>
|
||||
{formatDate(initialValues?.expense_date, 'DD MMMM YYYY')}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Tanggal Realisasi</th>
|
||||
<th>:</th>
|
||||
<td>
|
||||
{initialValues?.realization_date
|
||||
? formatDate(
|
||||
initialValues?.realization_date,
|
||||
'DD MMMM YYYY'
|
||||
)
|
||||
: '-'}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Nama Pengaju</th>
|
||||
<th>:</th>
|
||||
<td>{initialValues?.created_user.name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Nominal Biaya</th>
|
||||
<th>:</th>
|
||||
<td>{formatCurrency(initialValues?.grand_total ?? 0)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Status Pencairan</th>
|
||||
<th>:</th>
|
||||
<td>
|
||||
<RealizationStatusBadge
|
||||
approval={initialValues?.latest_approval}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Status Biaya</th>
|
||||
<th>:</th>
|
||||
<td>
|
||||
<ExpenseStatusBadge
|
||||
approval={initialValues?.latest_approval}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Dokumen Pengajuan</th>
|
||||
<th>:</th>
|
||||
<td>
|
||||
<div>
|
||||
{!initialValues?.documents ||
|
||||
(initialValues?.documents &&
|
||||
initialValues?.documents.length === 0 &&
|
||||
'-')}
|
||||
|
||||
{initialValues?.documents &&
|
||||
initialValues?.documents.length > 0 && (
|
||||
<ul className='list-disc'>
|
||||
{initialValues?.documents.map(
|
||||
(requestDocument, requestDocumentIdx) => (
|
||||
<li key={requestDocumentIdx}>
|
||||
<Link
|
||||
href={requestDocument.path}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-500 underline'
|
||||
>
|
||||
{requestDocument.path}{' '}
|
||||
<Icon
|
||||
icon='cuida:open-in-new-tab-outline'
|
||||
width={12}
|
||||
height={12}
|
||||
className='inline'
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-2'>
|
||||
<DropFileInput
|
||||
name='documents'
|
||||
values={formik.values.documents}
|
||||
onChange={requestDocumentsChangeHandler}
|
||||
onDelete={requestDocumentsDeleteHandler}
|
||||
accept={{
|
||||
...ACCEPTED_FILE_TYPE.PDF,
|
||||
...ACCEPTED_FILE_TYPE.IMAGE,
|
||||
}}
|
||||
maxFiles={10}
|
||||
className={{
|
||||
wrapper: 'mt-2',
|
||||
inputWrapper: 'flex items-center',
|
||||
}}
|
||||
/>
|
||||
|
||||
{formik.values.documents &&
|
||||
formik.values.documents.length > 0 && (
|
||||
<Button
|
||||
onClick={formik.submitForm}
|
||||
disabled={formik.isSubmitting}
|
||||
isLoading={formik.isSubmitting}
|
||||
className='w-fit self-end'
|
||||
>
|
||||
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||
Tambah
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div className='w-full max-w-5xl mt-8 mx-auto'>
|
||||
<h2 className='font-bold text-xl text-center'>
|
||||
Rincian Pengajuan Biaya Operasional
|
||||
</h2>
|
||||
|
||||
<div className='w-full mt-2 flex flex-col gap-4'>
|
||||
{initialValues?.kandangs.map(
|
||||
(kandangExpense, kandangExpenseIdx) => {
|
||||
let expenseGrandTotal = 0;
|
||||
|
||||
kandangExpense.pengajuans?.forEach(
|
||||
(item) => (expenseGrandTotal += item.total_price)
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={kandangExpenseIdx}
|
||||
className='overflow-x-auto w-full mx-auto'
|
||||
>
|
||||
<table className='table table-sm table-zebra'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
colSpan={5}
|
||||
className='font-bold text-center text-base-content text-lg'
|
||||
>
|
||||
Biaya {kandangExpense.name}
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Nonstock</th>
|
||||
<th>Total Kuantitas</th>
|
||||
<th>Total Biaya</th>
|
||||
<th>Catatan</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{kandangExpense.pengajuans?.map(
|
||||
(pengajuanItem, pengajuanIdx) => (
|
||||
<tr key={pengajuanIdx}>
|
||||
<td>{pengajuanItem.nonstock.name}</td>
|
||||
<td>{pengajuanItem.qty}</td>
|
||||
<td>
|
||||
{formatCurrency(pengajuanItem.total_price)}
|
||||
</td>
|
||||
<td className='w-xs'>
|
||||
{pengajuanItem.note ?? '-'}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
)}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className='border-y'>
|
||||
<th colSpan={2} className='text-right'>
|
||||
Total Biaya Keseluruhan:
|
||||
</th>
|
||||
<th colSpan={2}>
|
||||
{formatCurrency(expenseGrandTotal)}
|
||||
</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</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,
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmationModal
|
||||
ref={completeModal.ref}
|
||||
type='success'
|
||||
text='Apakah anda yakin ingin menyelesaikan biaya operasional ini?'
|
||||
secondaryButton={{
|
||||
text: 'Tidak',
|
||||
}}
|
||||
primaryButton={{
|
||||
text: 'Ya',
|
||||
color: 'success',
|
||||
isLoading: isCompleteLoading,
|
||||
onClick: confirmationModalCompleteClickHandler,
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmationModalWithNotes
|
||||
ref={approveModal.ref}
|
||||
type='success'
|
||||
text='Apakah anda yakin ingin approve data biaya operasional ini?'
|
||||
secondaryButton={{
|
||||
text: 'Tidak',
|
||||
}}
|
||||
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',
|
||||
}}
|
||||
primaryButton={{
|
||||
text: 'Ya',
|
||||
color: 'error',
|
||||
isLoading: isRejectLoading,
|
||||
onClick: confirmationModalRejectClickHandler,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExpenseRequestContent;
|
||||
Reference in New Issue
Block a user