From ac227f778080c3cbf04d67b870b24538df3dd4a2 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 17 Nov 2025 13:48:07 +0700 Subject: [PATCH] feat(FE-196): create ExpenseDetail component for expense detail page --- .../pages/expense/ExpenseDetail.tsx | 507 ++++++++++++++++++ 1 file changed, 507 insertions(+) create mode 100644 src/components/pages/expense/ExpenseDetail.tsx diff --git a/src/components/pages/expense/ExpenseDetail.tsx b/src/components/pages/expense/ExpenseDetail.tsx new file mode 100644 index 00000000..af7ec7c7 --- /dev/null +++ b/src/components/pages/expense/ExpenseDetail.tsx @@ -0,0 +1,507 @@ +'use client'; + +import { useState } from 'react'; +import toast from 'react-hot-toast'; +import { useRouter } from 'next/navigation'; +import { useFormik } from 'formik'; + +import Link from 'next/link'; +import { Icon } from '@iconify/react'; +import Button from '@/components/Button'; +import { useModal } from '@/components/Modal'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; +import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes'; +import RealizationStatusBadge from '@/components/pages/expense/RealizationStatusBadge'; +import ExpenseStatusBadge from '@/components/pages/expense/ExpenseStatusBadge'; +import DropFileInput from '@/components/input/DropFileInput'; + +import { Expense } from '@/types/api/expense'; +import { formatCurrency, formatDate } from '@/lib/helper'; +import { ExpenseApi } from '@/services/api/expense'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { ACCEPTED_FILE_TYPE } from '@/config/constant'; +import { + UploadRequestDocumentsFormSchema, + UploadRequestDocumentsFormValues, +} from '@/components/pages/expense/form/ExpenseRequestForm.schema'; + +interface ExpenseDetailProps { + initialValues?: Expense; +} + +const ExpenseDetail: React.FC = ({ initialValues }) => { + const router = useRouter(); + + // Modal hooks + const deleteModal = useModal(); + const approveModal = useModal(); + const rejectModal = useModal(); + + // Modal loading state + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + const [isApproveLoading, setIsApproveLoading] = useState(false); + const [isRejectLoading, setIsRejectLoading] = useState(false); + + const isLatestApprovalRejectedOrDone = + initialValues?.approval && + (initialValues.approval.action === 'REJECTED' || + initialValues.approval.step_number === 5); + + const formik = useFormik({ + initialValues: { + request_documents: [], + }, + validationSchema: UploadRequestDocumentsFormSchema, + onSubmit: async (values) => { + const addRequestDocumentsRes = await ExpenseApi.uploadRequestDocuments( + initialValues?.id as number, + values.request_documents + ); + + if (isResponseSuccess(addRequestDocumentsRes)) { + toast.success(addRequestDocumentsRes.message); + window.location.reload(); + } else { + toast.error(String(addRequestDocumentsRes?.message)); + } + }, + }); + + const deleteExpenseClickHandler = () => { + deleteModal.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 confirmationModalApproveClickHandler = async (notes: string) => { + setIsApproveLoading(true); + + const approveResponse = await ExpenseApi.approve( + initialValues?.id as number, + notes + ); + + if (isResponseSuccess(approveResponse)) { + approveModal.closeModal(); + + toast.success('Berhasil approve pengajuan biaya operasional!'); + router.push('/expense'); + } else { + approveModal.closeModal(); + + toast.error('Gagal approve pengajuan biaya operasional!'); + } + + setIsApproveLoading(false); + }; + + const confirmationModalRejectClickHandler = async (notes: string) => { + setIsRejectLoading(true); + + const rejectResponse = await ExpenseApi.reject( + initialValues?.id as number, + notes + ); + + if (isResponseSuccess(rejectResponse)) { + rejectModal.closeModal(); + + toast.success('Berhasil reject pengajuan biaya operasional!'); + router.push('/expense'); + } else { + rejectModal.closeModal(); + + toast.error('Gagal reject pengajuan biaya operasional!'); + } + + setIsRejectLoading(false); + }; + + const requestDocumentsChangeHandler = (val: File[]) => { + formik.setFieldTouched('request_documents', true); + formik.setFieldValue('request_documents', val); + }; + + const requestDocumentsDeleteHandler = (deletedFileIdx: number) => { + const newRequestDocuments = formik.values.request_documents; + + newRequestDocuments?.splice(deletedFileIdx, 1); + + formik.setFieldValue('request_documents', newRequestDocuments); + }; + + return ( + <> +
+
+ + +

+ Detail Biaya Operasional +

+
+ +
+ {/* TODO: apply RBAC */} + {!isLatestApprovalRejectedOrDone && ( +
+ + + + + + + +
+ )} + + {/* TODO: add and integrate ApprovalSteps component with API */} + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Nomor PO:{initialValues?.po_number ?? '-'}
Nomor Referensi:{initialValues?.reference_number}
Lokasi:{initialValues?.location.name}
Kandang: + {initialValues?.kandangs + .map((item) => item.name) + .join(', ')} +
Vendor:{initialValues?.vendor.name}
Tanggal Transaksi: + {formatDate( + initialValues?.transaction_date, + 'DD MMMM YYYY' + )} +
Tanggal Realisasi: + {initialValues?.realization_date + ? formatDate( + initialValues?.realization_date, + 'DD MMMM YYYY' + ) + : '-'} +
Nama Pengaju:{initialValues?.created_user.name}
Nominal Biaya:{formatCurrency(initialValues?.nominal ?? 0)}
Nominal Sudah Bayar:{formatCurrency(initialValues?.paid ?? 0)}
Nominal Sisa Bayar:{formatCurrency(initialValues?.remaining_cost ?? 0)}
Status Pencairan: + +
Status Biaya: + +
Dokumen Pengajuan: +
+ {initialValues?.request_documents.length === 0 && '-'} + + {initialValues?.request_documents && + initialValues?.request_documents.length > 0 && ( +
    + {initialValues?.request_documents.map( + (requestDocument, requestDocumentIdx) => ( +
  • + + {requestDocument.name}{' '} + + +
  • + ) + )} +
+ )} +
+ +
+ + + {formik.values.request_documents && + formik.values.request_documents.length > 0 && ( + + )} +
+
+
+
+ +
+

+ Rincian Pengajuan Biaya Operasional +

+ +
+ {initialValues?.kandang_expenses.map( + (kandangExpense, kandangExpenseIdx) => { + let expenseGrandTotal = 0; + + kandangExpense.expenses.forEach( + (item) => (expenseGrandTotal += item.total_expense) + ); + + return ( +
+ + + + + + + + + + + + + + {kandangExpense.expenses.map( + (expenseItem, expenseIdx) => ( + + + + + + + ) + )} + + + + + + + +
+ Biaya {kandangExpense.kandang.name} +
NonstockTotal KuantitasTotal BiayaCatatan
{expenseItem.nonstock.name}{expenseItem.total_quantity} + {formatCurrency(expenseItem.total_expense)} + + {expenseItem.notes ?? '-'} +
+ Total Biaya Keseluruhan: + + {formatCurrency(expenseGrandTotal)} +
+
+ ); + } + )} +
+
+
+ + + + + + + + ); +}; + +export default ExpenseDetail;