feat(FE-196): create Expense Request Detail's content component

This commit is contained in:
ValdiANS
2025-11-24 09:38:31 +07:00
parent 93d14cb98b
commit b083b9cb1a
@@ -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;