From 6a08854603ece7ccd01d005ab3a2a471efd72a44 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 24 Nov 2025 09:21:32 +0700 Subject: [PATCH 01/31] chore(FE-204,207): add validation to check if expense can be edited --- src/app/expense/detail/edit/page.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/app/expense/detail/edit/page.tsx b/src/app/expense/detail/edit/page.tsx index b37fdb8f..e254f01d 100644 --- a/src/app/expense/detail/edit/page.tsx +++ b/src/app/expense/detail/edit/page.tsx @@ -34,13 +34,15 @@ const ExpenseEditPage = () => { return; } - const isExpenseRejectedOrApproved = + const isExpenseCanBeEdited = !isLoadingExpense && isResponseSuccess(expense) && - (expense.data.approval.action === 'REJECTED' || - expense.data.approval.step_number === 5); + expense.data.latest_approval.step_number !== 5 && + (expense.data.latest_approval.step_number === 1 || + expense.data.latest_approval.step_number === 2 || + expense.data.latest_approval.step_number === 3); - if (isExpenseRejectedOrApproved) { + if (!isLoadingExpense && !isExpenseCanBeEdited) { router.back(); return; } From d34c113be3e39d1ab71e1499146bd5f598cbff29 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 24 Nov 2025 09:23:54 +0700 Subject: [PATCH 02/31] chore(FE-205): create layout file for expense realization detail page --- src/app/expense/realization/layout.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/app/expense/realization/layout.tsx diff --git a/src/app/expense/realization/layout.tsx b/src/app/expense/realization/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/expense/realization/layout.tsx @@ -0,0 +1,11 @@ +import SuspenseHelper from '@/components/helper/SuspenseHelper'; + +const Layout = ({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) => { + return {children}; +}; + +export default Layout; From 70b1ba3f6b08047761cd3e71549fe092cdd70288 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 24 Nov 2025 09:26:20 +0700 Subject: [PATCH 03/31] chore(FE-200): create Add Expense Realization page --- src/app/expense/realization/page.tsx | 67 ++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/app/expense/realization/page.tsx diff --git a/src/app/expense/realization/page.tsx b/src/app/expense/realization/page.tsx new file mode 100644 index 00000000..027e8d65 --- /dev/null +++ b/src/app/expense/realization/page.tsx @@ -0,0 +1,67 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +import ExpenseRealizationForm from '@/components/pages/expense/form/ExpenseRealizationForm'; + +import { ExpenseApi } from '@/services/api/expense'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; + +const ExpenseRealization = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const expenseId = searchParams.get('expenseId'); + + const { data: expense, isLoading: isLoadingExpense } = useSWR( + expenseId, + (id: number) => ExpenseApi.getSingle(id) + ); + + if (!expenseId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingExpense && (!expense || isResponseError(expense))) { + router.replace('/404'); + return; + } + + const isExpenseCanBeRealized = + isResponseSuccess(expense) && + expense.data.latest_approval.action !== 'REJECTED' && + expense.data.latest_approval.step_number === 3; + + if (isResponseSuccess(expense) && !isExpenseCanBeRealized) { + if (typeof window !== 'undefined') { + router.back(); + } + + return ( +
+ +
+ ); + } + + return ( +
+ {isLoadingExpense && ( + + )} + + {!isLoadingExpense && isResponseSuccess(expense) && ( + + )} +
+ ); +}; + +export default ExpenseRealization; From 4027b25598b7fea94f410e298df36c1e9f6ade05 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 24 Nov 2025 09:26:37 +0700 Subject: [PATCH 04/31] chore(FE-204): create Edit Expense Realization page --- src/app/expense/realization/edit/page.tsx | 62 +++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 src/app/expense/realization/edit/page.tsx diff --git a/src/app/expense/realization/edit/page.tsx b/src/app/expense/realization/edit/page.tsx new file mode 100644 index 00000000..95e27eef --- /dev/null +++ b/src/app/expense/realization/edit/page.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +import ExpenseRealizationForm from '@/components/pages/expense/form/ExpenseRealizationForm'; + +import { ExpenseApi } from '@/services/api/expense'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; + +const ExpenseRealizationEditPage = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const expenseId = searchParams.get('expenseId'); + + const { data: expense, isLoading: isLoadingExpense } = useSWR( + expenseId, + (id: number) => ExpenseApi.getSingle(id) + ); + + if (!expenseId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingExpense && (!expense || isResponseError(expense))) { + router.replace('/404'); + return; + } + + const isExpenseRealizationCanBeEdited = + !isLoadingExpense && + isResponseSuccess(expense) && + expense.data.latest_approval.action !== 'REJECTED' && + (expense.data.latest_approval.step_number === 4 || + expense.data.latest_approval.step_number === 5); + + if (!isLoadingExpense && !isExpenseRealizationCanBeEdited) { + router.back(); + return; + } + + return ( +
+ {isLoadingExpense && ( + + )} + + {!isLoadingExpense && isResponseSuccess(expense) && ( + + )} +
+ ); +}; + +export default ExpenseRealizationEditPage; From 032e9d45b3461fbc4e3e473d6569a2794287e8a9 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 24 Nov 2025 09:30:12 +0700 Subject: [PATCH 05/31] feat: add logout functionality --- src/components/Navbar.tsx | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index f7cfbb6a..973bf031 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -1,16 +1,38 @@ 'use client'; +import toast from 'react-hot-toast'; +import { useRouter } from 'next/navigation'; + import { Icon } from '@iconify/react'; import Menu from '@/components/menu/Menu'; import MenuItem from '@/components/menu/MenuItem'; import Button from '@/components/Button'; +import { useAuth } from '@/services/hooks/useAuth'; +import { AuthApi } from '@/services/api/auth'; +import { isResponseError } from '@/lib/api-helper'; + interface NavbarProps { title: string; toggleSidebar?: () => void; } const Navbar = ({ title, toggleSidebar }: NavbarProps) => { + const { setUser } = useAuth(); + const router = useRouter(); + + const logoutClickHandler = async () => { + const logoutRes = await AuthApi.logout(); + + if (isResponseError(logoutRes)) { + toast.error('Gagal logout! Coba lagi!'); + return; + } + + setUser(undefined); + router.replace(process.env.NEXT_PUBLIC_SSO_LOGIN_URL as string); + }; + return (
@@ -42,8 +64,7 @@ const Navbar = ({ title, toggleSidebar }: NavbarProps) => {
- - +
From c0bba827a09fecfd2d76bee84e2312c60b7dbf53 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 24 Nov 2025 09:31:27 +0700 Subject: [PATCH 06/31] chore: remove dummy data --- src/components/helper/RequireAuth.tsx | 201 +++++--------------------- 1 file changed, 34 insertions(+), 167 deletions(-) diff --git a/src/components/helper/RequireAuth.tsx b/src/components/helper/RequireAuth.tsx index dbd4b6bc..33540b41 100644 --- a/src/components/helper/RequireAuth.tsx +++ b/src/components/helper/RequireAuth.tsx @@ -1,152 +1,14 @@ 'use client'; import { ReactNode, useEffect } from 'react'; -import { useRouter } from 'next/navigation'; +import { usePathname, useRouter } from 'next/navigation'; import useSWRImmutable from 'swr/immutable'; import { useAuth } from '@/services/hooks/useAuth'; import { httpClientFetcher, SWRHttpKey } from '@/services/http/client'; -import { isResponseSuccess } from '@/lib/api-helper'; -import { GetMeResponse } from '@/types/api/api-general'; - -// TODO: delete this later, DONT HARDCODE USER DATA -const DUMMY_USER = { - id: 1, - email: 'admin@mbugroup.id', - npk: '0001', - name: 'Super Admin', - image: null, - created_at: '2025-09-30T03:24:20.899229Z', - updated_at: '2025-09-30T03:24:20.899229Z', - roles: [ - { - id: 1, - key: 'mbu.super_admin', - name: 'MBU Administrator', - client: { - id: 1, - name: 'PT Mitra Berlian Unggas', - alias: 'MBU', - }, - permissions: [ - { - id: 1, - name: 'mbu:purchase:read', - action: 'read', - client: { - id: 1, - name: 'PT Mitra Berlian Unggas', - alias: 'MBU', - }, - }, - { - id: 2, - name: 'mbu:purchase:create', - action: 'create', - client: { - id: 1, - name: 'PT Mitra Berlian Unggas', - alias: 'MBU', - }, - }, - { - id: 3, - name: 'mbu:purchase:approve', - action: 'approve', - client: { - id: 1, - name: 'PT Mitra Berlian Unggas', - alias: 'MBU', - }, - }, - ], - }, - { - id: 2, - key: 'lti.super_admin', - name: 'LTI Administrator', - client: { - id: 2, - name: 'PT Lumbung Telur Indonesia', - alias: 'LTI', - }, - permissions: [ - { - id: 4, - name: 'lti:purchase:read', - action: 'read', - client: { - id: 2, - name: 'PT Lumbung Telur Indonesia', - alias: 'LTI', - }, - }, - { - id: 5, - name: 'lti:purchase:create', - action: 'create', - client: { - id: 2, - name: 'PT Lumbung Telur Indonesia', - alias: 'LTI', - }, - }, - { - id: 6, - name: 'lti:purchase:approve', - action: 'approve', - client: { - id: 2, - name: 'PT Lumbung Telur Indonesia', - alias: 'LTI', - }, - }, - ], - }, - { - id: 3, - key: 'manbu.super_admin', - name: 'MANBU Administrator', - client: { - id: 3, - name: 'PT Mandiri Berlian Unggas', - alias: 'MANBU', - }, - permissions: [ - { - id: 7, - name: 'manbu:purchase:read', - action: 'read', - client: { - id: 3, - name: 'PT Mandiri Berlian Unggas', - alias: 'MANBU', - }, - }, - { - id: 8, - name: 'manbu:purchase:create', - action: 'create', - client: { - id: 3, - name: 'PT Mandiri Berlian Unggas', - alias: 'MANBU', - }, - }, - { - id: 9, - name: 'manbu:purchase:approve', - action: 'approve', - client: { - id: 3, - name: 'PT Mandiri Berlian Unggas', - alias: 'MANBU', - }, - }, - ], - }, - ], -}; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import { BaseApiResponse, GetMeResponse } from '@/types/api/api-general'; +import { AxiosError } from 'axios'; interface RequireAuthProps { children?: ReactNode; @@ -156,17 +18,20 @@ const RequireAuth = ({ children }: RequireAuthProps) => { const router = useRouter(); const { setUser, setIsLoadingUser } = useAuth(); - const { data: userResponse, isLoading: isLoadingUserResponse } = - useSWRImmutable( - '/auth/sso/userinfo', - httpClientFetcher, - { - shouldRetryOnError: false, - revalidateOnFocus: false, - revalidateOnReconnect: false, - refreshInterval: 0, - } - ); + const { + data: userResponse, + isLoading: isLoadingUserResponse, + error: userErrorResponse, + } = useSWRImmutable< + GetMeResponse & { ok?: boolean }, + AxiosError, + SWRHttpKey + >('/sso/userinfo', httpClientFetcher, { + shouldRetryOnError: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshInterval: 0, + }); useEffect(() => { setIsLoadingUser(isLoadingUserResponse); @@ -175,23 +40,25 @@ const RequireAuth = ({ children }: RequireAuthProps) => { useEffect(() => { if (isResponseSuccess(userResponse)) { setUser(userResponse.data); - } else { - // router.replace(process.env.NEXT_PUBLIC_SSO_LOGIN_URL as string); - // TODO: remove this later, DONT HARDCODE USER DATA - setUser(DUMMY_USER); + } else if ( + isResponseError(userErrorResponse?.response?.data) && + typeof window !== 'undefined' + ) { + router.replace( + `${process.env.NEXT_PUBLIC_SSO_LOGIN_URL as string}?redirect_url=${window.location.href}` + ); } - }, [userResponse, setIsLoadingUser, setUser]); + }, [userResponse, userErrorResponse, setIsLoadingUser, setUser]); - // TODO: uncomment this later - // if (isLoadingUserResponse && !userResponse) { - // return ( - //
- // - //
- // ); - // } + if (isLoadingUserResponse && !userResponse && !userErrorResponse) { + return ( +
+ +
+ ); + } - return <>{children}; + return <>{isResponseSuccess(userResponse) && children}; }; export default RequireAuth; From b0bd2bd8a515a5316266b623a559c13b35e3f9d3 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 24 Nov 2025 09:35:30 +0700 Subject: [PATCH 07/31] chore(FE-196,205): refactor ExpenseDetail component --- .../pages/expense/ExpenseDetail.tsx | 499 ++---------------- 1 file changed, 34 insertions(+), 465 deletions(-) diff --git a/src/components/pages/expense/ExpenseDetail.tsx b/src/components/pages/expense/ExpenseDetail.tsx index af7ec7c7..859b19ce 100644 --- a/src/components/pages/expense/ExpenseDetail.tsx +++ b/src/components/pages/expense/ExpenseDetail.tsx @@ -1,157 +1,45 @@ 'use client'; -import { useState } from 'react'; -import toast from 'react-hot-toast'; -import { useRouter } from 'next/navigation'; -import { useFormik } from 'formik'; +import { useMemo, useState } from 'react'; -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 Tabs from '@/components/Tabs'; +import ExpenseRequestContent from '@/components/pages/expense/ExpenseRequestContent'; +import ExpenseRealizationContent from '@/components/pages/expense/ExpenseRealizationContent'; 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(); + const [activeTab, setActiveTab] = useState('request'); - // Modal hooks - const deleteModal = useModal(); - const approveModal = useModal(); - const rejectModal = useModal(); + const expenseDetailTabs = useMemo(() => { + const validTabs = [ + { + id: 'request', + label: 'Pengajuan', + content: , + }, + ]; - // 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!'); + if ( + initialValues?.latest_approval && + initialValues?.latest_approval.step_number >= 4 && + initialValues.latest_approval.action !== 'REJECTED' + ) { + validTabs.push({ + id: 'realization', + label: 'Realisasi', + content: , + }); } - 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 validTabs; + }, [initialValues]); return ( <> @@ -171,335 +59,16 @@ const ExpenseDetail: React.FC = ({ initialValues }) => { -
- {/* 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)} -
-
- ); - } - )} -
-
+ - - - - - - ); }; From 93d14cb98bd5d046bef254eb1d4a2462c04fe4db Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 24 Nov 2025 09:38:13 +0700 Subject: [PATCH 08/31] feat(FE-205): create Expense Realization Detail's content component --- .../expense/ExpenseRealizationContent.tsx | 323 ++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 src/components/pages/expense/ExpenseRealizationContent.tsx diff --git a/src/components/pages/expense/ExpenseRealizationContent.tsx b/src/components/pages/expense/ExpenseRealizationContent.tsx new file mode 100644 index 00000000..b9199102 --- /dev/null +++ b/src/components/pages/expense/ExpenseRealizationContent.tsx @@ -0,0 +1,323 @@ +import { useFormik } from 'formik'; +import toast from 'react-hot-toast'; + +import Link from 'next/link'; +import { Icon } from '@iconify/react'; +import Button from '@/components/Button'; +import Card from '@/components/Card'; +import DropFileInput from '@/components/input/DropFileInput'; + +import { Expense } from '@/types/api/expense'; +import { formatCurrency, formatDate } from '@/lib/helper'; +import { + UploadRequestDocumentsFormSchema, + UploadRequestDocumentsFormValues, +} from '@/components/pages/expense/form/ExpenseRequestForm.schema'; +import { ExpenseApi } from '@/services/api/expense'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { ACCEPTED_FILE_TYPE } from '@/config/constant'; + +interface ExpenseRealizationContentProps { + initialValues?: Expense; +} + +const ExpenseRealizationContent = ({ + initialValues, +}: ExpenseRealizationContentProps) => { + const formik = useFormik({ + initialValues: { + documents: [], + }, + validationSchema: UploadRequestDocumentsFormSchema, + onSubmit: async (values) => { + const addRealizationDocumentsRes = + await ExpenseApi.uploadRealizationDocuments( + initialValues?.id as number, + values.documents + ); + + if (isResponseSuccess(addRealizationDocumentsRes)) { + toast.success(addRealizationDocumentsRes.message); + window.location.reload(); + } else { + toast.error(String(addRealizationDocumentsRes?.message)); + } + }, + }); + + const realizationDocumentsChangeHandler = (val: File[]) => { + formik.setFieldTouched('documents', true); + formik.setFieldValue('documents', val); + }; + + const realizationDocumentsDeleteHandler = (deletedFileIdx: number) => { + const newRealizationDocuments = formik.values.documents; + + newRealizationDocuments?.splice(deletedFileIdx, 1); + + formik.setFieldValue('documents', newRealizationDocuments); + }; + + return ( +
+
+
+ {/* TODO: apply RBAC */} + +
+
+ +
+ + + + + + + + + + + + + +
Tanggal Realisasi: + {initialValues?.realization_date + ? formatDate(initialValues?.realization_date, 'DD MMMM YYYY') + : '-'} +
Dokumen Realisasi: +
+ {!initialValues?.realization_docs || + (initialValues?.realization_docs && + initialValues?.realization_docs.length === 0 && + '-')} + + {initialValues?.realization_docs && + initialValues?.realization_docs.length > 0 && ( +
    + {initialValues?.realization_docs.map( + (realizationDocument, realizationDocumentIdx) => ( +
  • + + {realizationDocument.path}{' '} + + +
  • + ) + )} +
+ )} +
+ +
+ + + {formik.values.documents && + formik.values.documents.length > 0 && ( + + )} +
+
+
+ +
+
+ +

Nominal Pengajuan

+ + + {formatCurrency(initialValues?.total_pengajuan as number)} + + + + Terbayar{' '} + {formatCurrency(initialValues?.total_realisasi as number)} + +
+ + +

Nominal Realisasi

+ + + {formatCurrency(initialValues?.total_realisasi as number)} + + + + Selisih{' '} + {formatCurrency( + (initialValues?.total_realisasi as number) - + (initialValues?.total_pengajuan as number) + )} + +
+
+
+ +
+

+ Rincian Pengajuan Biaya Operasional +

+ +
+ {initialValues?.kandangs.map((kandangExpense, kandangExpenseIdx) => { + let expenseGrandTotal = 0; + + kandangExpense.pengajuans?.forEach( + (item) => (expenseGrandTotal += item.total_price) + ); + + return ( +
+ + + + + + + + + + + + + + {kandangExpense.pengajuans?.map( + (pengajuanItem, pengajuanIdx) => ( + + + + + + + ) + )} + + + + + + + +
+ Biaya {kandangExpense.name} +
NonstockTotal KuantitasTotal BiayaCatatan
{pengajuanItem.nonstock.name}{pengajuanItem.qty}{formatCurrency(pengajuanItem.total_price)}{pengajuanItem.note ?? '-'}
+ Total Biaya Keseluruhan: + {formatCurrency(expenseGrandTotal)}
+
+ ); + })} +
+
+ +
+

+ Rincian Realisasi Biaya Operasional +

+ +
+ {initialValues?.kandangs.map((kandangExpense, kandangExpenseIdx) => { + let expenseGrandTotal = 0; + + kandangExpense.realisasi?.forEach( + (item) => (expenseGrandTotal += item.total_price) + ); + + return ( +
+ + + + + + + + + + + + + + {kandangExpense.realisasi?.map( + (realisasiItem, realisasiIdx) => ( + + + + + + + ) + )} + + + + + + + +
+ Biaya {kandangExpense.name} +
NonstockTotal KuantitasTotal BiayaCatatan
{realisasiItem.nonstock.name}{realisasiItem.qty}{formatCurrency(realisasiItem.total_price)}{realisasiItem.note ?? '-'}
+ Total Biaya Keseluruhan: + {formatCurrency(expenseGrandTotal)}
+
+ ); + })} +
+
+
+ ); +}; + +export default ExpenseRealizationContent; From b083b9cb1aec19b32de72eb3cf210e7f6937004a Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 24 Nov 2025 09:38:31 +0700 Subject: [PATCH 09/31] feat(FE-196): create Expense Request Detail's content component --- .../pages/expense/ExpenseRequestContent.tsx | 650 ++++++++++++++++++ 1 file changed, 650 insertions(+) create mode 100644 src/components/pages/expense/ExpenseRequestContent.tsx diff --git a/src/components/pages/expense/ExpenseRequestContent.tsx b/src/components/pages/expense/ExpenseRequestContent.tsx new file mode 100644 index 00000000..fa3f7d6d --- /dev/null +++ b/src/components/pages/expense/ExpenseRequestContent.tsx @@ -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({ + 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 | 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 | 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 ( + <> +
+ {initialValues && + !isLoadingApprovalHistory && + isResponseSuccess(approvalHistory) && ( +
+ +
+ )} + +
+ {/* TODO: apply RBAC */} + +
+ {isCurrentApprovalOnManager && ( + + )} + + {isCurrentApprovalOnFinance && ( + + )} + + {isCurrentApprovalOnRealization && ( + + )} + + {showRejectButton && ( + + )} + + {isExpenseCanBeRealized && ( + + )} + +
+ {showEditButton && ( + + )} + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Nomor PO:{initialValues?.po_number ?? '-'}
Nomor Referensi:{initialValues?.reference_number}
Kategori: + {initialValues?.category === 'BOP' + ? 'Biaya Operasional' + : 'Non Biaya Operasional'} +
Lokasi:{initialValues?.location.name}
Kandang: + {initialValues?.kandangs + .map((item) => item.name) + .join(', ')} +
Vendor:{initialValues?.supplier.name}
Tanggal Transaksi: + {formatDate(initialValues?.expense_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?.grand_total ?? 0)}
Status Pencairan: + +
Status Biaya: + +
Dokumen Pengajuan: +
+ {!initialValues?.documents || + (initialValues?.documents && + initialValues?.documents.length === 0 && + '-')} + + {initialValues?.documents && + initialValues?.documents.length > 0 && ( +
    + {initialValues?.documents.map( + (requestDocument, requestDocumentIdx) => ( +
  • + + {requestDocument.path}{' '} + + +
  • + ) + )} +
+ )} +
+ +
+ + + {formik.values.documents && + formik.values.documents.length > 0 && ( + + )} +
+
+
+
+
+

+ Rincian Pengajuan Biaya Operasional +

+ +
+ {initialValues?.kandangs.map( + (kandangExpense, kandangExpenseIdx) => { + let expenseGrandTotal = 0; + + kandangExpense.pengajuans?.forEach( + (item) => (expenseGrandTotal += item.total_price) + ); + + return ( +
+ + + + + + + + + + + + + + {kandangExpense.pengajuans?.map( + (pengajuanItem, pengajuanIdx) => ( + + + + + + + ) + )} + + + + + + + +
+ Biaya {kandangExpense.name} +
NonstockTotal KuantitasTotal BiayaCatatan
{pengajuanItem.nonstock.name}{pengajuanItem.qty} + {formatCurrency(pengajuanItem.total_price)} + + {pengajuanItem.note ?? '-'} +
+ Total Biaya Keseluruhan: + + {formatCurrency(expenseGrandTotal)} +
+
+ ); + } + )} +
+
+
+ + + + + + + + + + ); +}; + +export default ExpenseRequestContent; From 510d10270e601466c8789bfe24738e74efd6e767 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 24 Nov 2025 09:42:14 +0700 Subject: [PATCH 10/31] feat(FE-195): implement bulk approve/reject in Expense list page --- .../pages/expense/ExpensesTable.tsx | 280 +++++++++++------- 1 file changed, 177 insertions(+), 103 deletions(-) diff --git a/src/components/pages/expense/ExpensesTable.tsx b/src/components/pages/expense/ExpensesTable.tsx index fc6f6d13..01573a31 100644 --- a/src/components/pages/expense/ExpensesTable.tsx +++ b/src/components/pages/expense/ExpensesTable.tsx @@ -1,6 +1,6 @@ 'use client'; -import { ChangeEventHandler, useEffect, useState } from 'react'; +import { ChangeEventHandler, useEffect, useMemo, useState } from 'react'; import useSWR from 'swr'; import { CellContext, @@ -31,13 +31,14 @@ import DateInput from '@/components/input/DateInput'; import { Expense } from '@/types/api/expense'; import { ExpenseApi } from '@/services/api/expense'; -import { cn, formatCurrency } from '@/lib/helper'; +import { cn, formatCurrency, formatDate } from '@/lib/helper'; import { isResponseSuccess } from '@/lib/api-helper'; import { useTableFilter } from '@/services/hooks/useTableFilter'; import { ROWS_OPTIONS } from '@/config/constant'; import { LocationApi, SupplierApi } from '@/services/api/master-data'; import { Location } from '@/types/api/master-data/location'; import { Supplier } from '@/types/api/master-data/supplier'; +import { BaseApiResponse } from '@/types/api/api-general'; const RowOptionsMenu = ({ type = 'dropdown', @@ -53,66 +54,57 @@ const RowOptionsMenu = ({ deleteClickHandler: () => void; }) => { const showEditButton = - props.row.original.approval.action !== 'REJECTED' && - props.row.original.approval.step_number !== 5 && - props.row.original.approval.action !== 'APPROVED'; - - const showDeleteButton = showEditButton; + props.row.original.latest_approval.step_number !== 5 && + (props.row.original.latest_approval.step_number === 1 || + props.row.original.latest_approval.step_number === 2 || + props.row.original.latest_approval.step_number === 3); // TODO: apply RBAC - const showApproveButton = showEditButton; - const showRejectButton = showEditButton; + const showRealizationButton = + props.row.original.latest_approval.action !== 'REJECTED' && + props.row.original.latest_approval.step_number === 3; return ( - - - {showEditButton && ( +
- )} - {/* TODO: apply RBAC */} - {showApproveButton && ( - - )} + {showEditButton && ( + + )} - {showRejectButton && ( - - )} + {showRealizationButton && ( + + )} - {showDeleteButton && ( - )} +
); }; @@ -178,6 +170,7 @@ const ExpensesTable = () => { undefined ); const [isDeleteLoading, setIsDeleteLoading] = useState(false); + const [isCompleteLoading, setIsCompleteLoading] = useState(false); const [isApproveLoading, setIsApproveLoading] = useState(false); const [isRejectLoading, setIsRejectLoading] = useState(false); @@ -187,6 +180,57 @@ const ExpensesTable = () => { parseInt(item) ); + const isAllSelectedRowLatestApprovalOnManager = 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 isCurrentApprovalOnManager = + !isLatestApprovalRejected && + expenseItem?.latest_approval.step_number === 1; + + return isCurrentApprovalOnManager; + }); + }, [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 === 2; + + return isCurrentApprovalOnFinance; + }); + }, [expenses, selectedRowIds]); + + const isAllSelectedRowLatestApprovalOnRealization = 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 isCurrentApprovalOnRealization = + !isLatestApprovalRejected && + expenseItem?.latest_approval.step_number === 4; + + return isCurrentApprovalOnRealization; + }); + }, [expenses, selectedRowIds]); + const expensesColumns: ColumnDef[] = [ { id: 'select', @@ -202,7 +246,8 @@ const ExpensesTable = () => { ), cell: ({ row }) => { const isCheckboxDisabled = - !row.getCanSelect() || row.original.approval.action === 'REJECTED'; + !row.getCanSelect() || + row.original.latest_approval.action === 'REJECTED'; return (
@@ -218,61 +263,52 @@ const ExpensesTable = () => { }, }, { - accessorKey: 'transaction_date', + accessorKey: 'expense_date', header: 'Tanggal Pengajuan', + cell: (props) => + props.row.original.expense_date + ? formatDate(props.row.original.expense_date, 'DD MMM YYYY') + : '-', }, { accessorKey: 'realization_date', header: 'Tanggal Realisasi', - cell: (props) => props.getValue() ?? '-', + 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 ?? '-', + cell: (props) => props.row.original.location?.name ?? '-', }, { accessorFn: (row) => row.created_user.name ?? '-', header: 'Nama Pengaju', }, { - accessorFn: (row) => row.vendor.name ?? '-', + accessorFn: (row) => row.supplier.name ?? '-', header: 'Vendor', }, { - accessorKey: 'nominal', + accessorKey: 'grand_total', header: 'Nominal', cell: (props) => - props.row.original.nominal - ? `Rp${formatCurrency(props.row.original.nominal)}` - : '-', - }, - { - accessorKey: 'paid', - header: 'Sudah Bayar', - cell: (props) => - props.row.original.paid - ? `Rp${formatCurrency(props.row.original.paid)}` - : '-', - }, - { - accessorKey: 'remaining_cost', - header: 'Sisa Bayar', - cell: (props) => - props.row.original.remaining_cost - ? `Rp${formatCurrency(props.row.original.remaining_cost)}` + props.row.original.grand_total + ? formatCurrency(props.row.original.grand_total) : '-', }, { header: 'Status Pencairan', cell: (props) => ( - + ), }, { header: 'Status BOP', cell: (props) => ( - + ), }, { @@ -283,7 +319,7 @@ const ExpensesTable = () => { const currentRowRelativeIndex = currentPageRows.findIndex((r) => r.id === props.row.id) + 1; - const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2; + const isLast2Rows = currentRowRelativeIndex > currentPageSize - 3; const approveClickHandler = () => { setSelectedExpense(props.row.original); @@ -314,7 +350,7 @@ const ExpensesTable = () => { return ( <> - {currentPageSize > 2 && ( + {currentPageSize > 3 && ( { )} - {currentPageSize <= 2 && ( + {currentPageSize <= 3 && ( { const tableEnableRowSelectionHandler: (row: Row) => boolean = ( row ) => { - return row.original.approval.action !== 'REJECTED'; + return ( + row.original.latest_approval.action !== 'REJECTED' && + row.original.latest_approval.step_number !== 5 + ); }; + // const bulkApproveClickHandler = () => { + // approveModal.openModal(); + // }; + + // const bulkRejectClickHandler = () => { + // rejectModal.openModal(); + // }; + const bulkApproveClickHandler = () => { approveModal.openModal(); }; @@ -371,17 +418,26 @@ const ExpensesTable = () => { const confirmationModalApproveClickHandler = async (notes: string) => { setIsApproveLoading(true); - const bulkApproveResponse = await ExpenseApi.bulkApprove( - selectedRowIds, - notes - ); + let bulkApproveResponse: BaseApiResponse | undefined = undefined; + + if (isAllSelectedRowLatestApprovalOnManager) { + bulkApproveResponse = await ExpenseApi.bulkApproveManager( + selectedRowIds, + notes + ); + } else if (isAllSelectedRowLatestApprovalOnFinance) { + bulkApproveResponse = await ExpenseApi.bulkApproveFinance( + selectedRowIds, + notes + ); + } if (isResponseSuccess(bulkApproveResponse)) { refreshExpenses(); approveModal.closeModal(); toast.success( - `Berhasil approve ${selectedRowIds.length} data transfer ke laying!` + `Berhasil approve ${selectedRowIds.length} data biaya operasional!` ); setRowSelection({}); @@ -389,7 +445,7 @@ const ExpensesTable = () => { approveModal.closeModal(); toast.error( - `Gagal approve ${selectedRowIds.length} data transfer ke laying!` + `Gagal approve ${selectedRowIds.length} data biaya operasional!` ); } @@ -399,24 +455,33 @@ const ExpensesTable = () => { const confirmationModalRejectClickHandler = async (notes: string) => { setIsRejectLoading(true); - const bulkRejectResponse = await ExpenseApi.bulkReject( - selectedRowIds, - notes - ); + let bulkRejectResponse: BaseApiResponse | undefined = undefined; + + if (isAllSelectedRowLatestApprovalOnManager) { + bulkRejectResponse = await ExpenseApi.bulkRejectManager( + selectedRowIds, + notes + ); + } else if (isAllSelectedRowLatestApprovalOnFinance) { + bulkRejectResponse = await ExpenseApi.bulkRejectFinance( + selectedRowIds, + notes + ); + } if (isResponseSuccess(bulkRejectResponse)) { refreshExpenses(); rejectModal.closeModal(); toast.success( - `Berhasil reject ${selectedRowIds.length} data transfer ke laying!` + `Berhasil reject ${selectedRowIds.length} data biaya operasional!` ); setRowSelection({}); } else { rejectModal.closeModal(); toast.error( - `Gagal reject ${selectedRowIds.length} data transfer ke laying!` + `Gagal reject ${selectedRowIds.length} data biaya operasional!` ); } @@ -506,27 +571,36 @@ const ExpensesTable = () => { {selectedRowIds.length > 0 && ( <> - {/* TODO: apply RBAC */} + + + +

+ Realisasi Biaya Operasional +

+ + +
+
+ + + + + + + + + + + {formik.values.existing_documents && + formik.values.existing_documents.length > 0 && ( +
+
    + {formik.values.existing_documents.map( + (existingDocument, existingDocumentIdx) => ( +
  • + + {existingDocument.name}{' '} + + +
  • + ) + )} +
+
+ )} + + +
+ + {expenseFormErrorMessage && ( +
+ + {expenseFormErrorMessage} +
+ )} + +
+ {type !== 'add' && ( +
+ {type !== 'edit' && ( + + )} +
+ )} + + {type !== 'detail' && ( +
+ + + +
+ )} +
+
+ + ); +}; + +export default ExpenseRealizationForm; From f24ae992e622fedfe15c1d097c080ff4f7a1b8d3 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 24 Nov 2025 09:43:58 +0700 Subject: [PATCH 12/31] feat(FE-206): create Expense Realization Form validation --- .../form/ExpenseRealizationForm.schema.ts | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 src/components/pages/expense/form/ExpenseRealizationForm.schema.ts diff --git a/src/components/pages/expense/form/ExpenseRealizationForm.schema.ts b/src/components/pages/expense/form/ExpenseRealizationForm.schema.ts new file mode 100644 index 00000000..863238b9 --- /dev/null +++ b/src/components/pages/expense/form/ExpenseRealizationForm.schema.ts @@ -0,0 +1,181 @@ +import * as Yup from 'yup'; +import { Expense } from '@/types/api/expense'; +import { formatDate } from '@/lib/helper'; + +type ExpenseRealizationFormSchemaType = { + category?: { + value: 'BOP' | 'NON-BOP'; + label: 'BOP' | 'NON-BOP'; + }; + location?: { + value: number; + label: string; + }; + realization_date?: string; + kandangs?: { id: number; name: string }[]; + supplier?: { + value: number; + label: string; + }; + existing_documents?: { name: string; url: string }[]; + documents?: File[]; + realizations: { + kandang_id: number; + cost_items: { + nonstock?: { + value: number; + label: string; + }; + quantity?: number; + total_cost?: number; + notes?: string; + }[]; + }[]; +}; + +export const ExpenseRealizationFormSchema: Yup.ObjectSchema = + Yup.object({ + category: Yup.object({ + value: Yup.string().oneOf(['BOP', 'NON-BOP']).required(), + label: Yup.string().oneOf(['BOP', 'NON-BOP']).required(), + }).required('Kategori wajib diisi!'), + + location: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).required('Lokasi wajib diisi!'), + + realization_date: Yup.string().required('Tanggal transaksi wajib diisi!'), + kandangs: Yup.array() + .of( + Yup.object({ + id: Yup.number().required('Kandang wajib dipilih!'), + name: Yup.string().required('Kandang wajib dipilih!'), + }) + ) + .min(1, 'Kandang wajib dipilih!') + .required('Kandang wajib dipilih!'), + + supplier: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).required('Vendor wajib diisi!'), + + existing_documents: Yup.array().of( + Yup.object({ + name: Yup.string().required(), + url: Yup.string().required(), + }) + ), + + documents: Yup.array().of(Yup.mixed().required()).optional(), + + realizations: Yup.array() + .of( + Yup.object({ + kandang_id: Yup.number().min(1, 'Wajib memilih kandang!').required(), + cost_items: Yup.array() + .of( + Yup.object({ + nonstock: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).required('Nonstock wajib diisi!'), + quantity: Yup.number().required('Total kuantitas wajib diisi!'), + total_cost: Yup.number().required('Total biaya wajib diisi!'), + notes: Yup.string(), + }) + ) + .min(1, 'Kandang harus memiliki setidaknya 1 biaya!') + .required('Biaya kandang wajib diisi!'), + }) + ) + .min(1, 'Biaya kandang wajib diisi!') + .required('Biaya kandang wajib diisi!'), + }); + +export const UpdateExpenseRealizationFormSchema = ExpenseRealizationFormSchema; + +export const UploadRealizationDocumentsFormSchema = Yup.object({ + realization_documents: Yup.array() + .of(Yup.mixed().required()) + .required(), +}); + +export type ExpenseRealizationFormValues = Yup.InferType< + typeof ExpenseRealizationFormSchema +>; + +export type UploadRealizationDocumentsFormValues = Yup.InferType< + typeof UploadRealizationDocumentsFormSchema +>; + +export const getExpenseRealizationFormInitialValues = ( + initialValues?: Expense +): ExpenseRealizationFormValues => { + return { + category: initialValues?.category + ? { + value: initialValues.category, + label: initialValues.category, + } + : undefined, + location: initialValues?.location + ? { + value: initialValues.location.id, + label: initialValues.location.name, + } + : undefined, + realization_date: initialValues?.realization_date + ? formatDate(initialValues?.realization_date, 'YYYY-MM-DD') + : undefined, + kandangs: initialValues?.kandangs.map((kandang) => ({ + id: kandang.kandang_id, + name: kandang.name, + })), + supplier: initialValues?.supplier + ? { + value: initialValues.supplier.id, + label: initialValues.supplier.name, + } + : undefined, + existing_documents: initialValues?.realization_docs?.map((doc) => ({ + name: doc.path, + url: doc.path, + })), + documents: [], + realizations: initialValues?.kandangs + ? initialValues.kandangs.map((kandangExpense) => { + const costItemsInitialValue = kandangExpense.realisasi + ? kandangExpense.realisasi.map((realisasiItem, realisasiIdx) => { + return { + nonstock: { + value: kandangExpense.pengajuans?.[realisasiIdx] + .id as number, + label: realisasiItem.nonstock.name, + }, + quantity: realisasiItem.qty, + total_cost: realisasiItem.total_price, + notes: realisasiItem.note, + }; + }) + : kandangExpense.pengajuans + ? kandangExpense.pengajuans.map((expenseItem) => ({ + nonstock: { + value: expenseItem.id, + label: expenseItem.nonstock.name, + }, + quantity: expenseItem.qty, + total_cost: expenseItem.total_price, + notes: expenseItem.note, + })) + : []; + + return { + kandang_id: kandangExpense.kandang_id, + cost_items: costItemsInitialValue, + }; + }) + : [], + }; +}; From 20c3e2d6b4547c048e53ef69cdd1c63676dc7a72 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 24 Nov 2025 09:44:48 +0700 Subject: [PATCH 13/31] feat(FE-200,204): create ExpenseRealizationKandangDetailExpense component --- ...ExpenseRealizationKandangDetailExpense.tsx | 224 ++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 src/components/pages/expense/form/ExpenseRealizationKandangDetailExpense.tsx diff --git a/src/components/pages/expense/form/ExpenseRealizationKandangDetailExpense.tsx b/src/components/pages/expense/form/ExpenseRealizationKandangDetailExpense.tsx new file mode 100644 index 00000000..8b889c5b --- /dev/null +++ b/src/components/pages/expense/form/ExpenseRealizationKandangDetailExpense.tsx @@ -0,0 +1,224 @@ +'use client'; + +import { FormikContextType } from 'formik'; + +import Card from '@/components/Card'; +import SelectInput, { + OptionType, + useSelect, +} from '@/components/input/SelectInput'; +import NumberInput from '@/components/input/NumberInput'; +import TextInput from '@/components/input/TextInput'; + +import { ExpenseRealizationFormValues } from '@/components/pages/expense/form/ExpenseRealizationForm.schema'; +import { cn } from '@/lib/helper'; +import { NonstockApi } from '@/services/api/master-data'; +import { Nonstock } from '@/types/api/master-data/nonstock'; + +interface ExpenseRealizationKandangDetailExpenseProps { + type?: 'add' | 'edit' | 'detail'; + formik: FormikContextType; + className?: { + wrapper?: string; + }; +} + +const ExpenseRealizationKandangDetailExpense: React.FC< + ExpenseRealizationKandangDetailExpenseProps +> = ({ type, formik, className }) => { + const { + setInputValue: setNonstockInputValue, + options: nonstockOptions, + isLoadingOptions: isLoadingNonstockOptions, + } = useSelect(NonstockApi.basePath, 'id', 'name'); + + const nonstockChangeHandler = ( + kandangExpenseIdx: number, + costItemIdx: number, + val: OptionType | OptionType[] | null + ) => { + formik.setFieldTouched( + `realizations[${kandangExpenseIdx}].cost_items[${costItemIdx}].nonstock`, + true + ); + formik.setFieldValue( + `realizations[${kandangExpenseIdx}].cost_items[${costItemIdx}].nonstock`, + val + ); + }; + + const isExpenseRepeaterInputError = ( + column: 'nonstock' | 'quantity' | 'total_cost' | 'notes', + kandangExpenseIdx: number, + expenseIdx: number + ) => { + return ( + formik.touched.realizations?.[kandangExpenseIdx]?.cost_items?.[ + expenseIdx + ]?.[column] && + Boolean( + formik.errors.realizations?.[kandangExpenseIdx] instanceof Object && + formik.errors.realizations?.[kandangExpenseIdx].cost_items?.[ + expenseIdx + ] instanceof Object && + formik.errors.realizations?.[kandangExpenseIdx].cost_items?.[ + expenseIdx + ]?.[column] + ) + ); + }; + + return ( + +
+

+ Rincian Realisasi Biaya Operasional +

+
+ +
+ {formik.values.realizations.length === 0 && ( +
+

+ Pilih kandang terlebih dahulu! +

+
+ )} + + {formik.values.realizations.map((kandangExpense, kandangExpenseIdx) => { + const kandangName = formik.values.kandangs?.find( + (kandang) => kandang.id === kandangExpense.kandang_id + ); + + return ( + kandangName?.name && ( +
+
+
+ Biaya {kandangName?.name} +
+ +
+ + + + + + + + + + + + {kandangExpense.cost_items.map( + (expenseItem, expenseIdx) => ( + + + + + + + + + + ) + )} + +
NonstockTotal KuantitasTotal BiayaCatatan
+ { + nonstockChangeHandler( + kandangExpenseIdx, + expenseIdx, + val + ); + }} + options={nonstockOptions} + isLoading={isLoadingNonstockOptions} + onInputChange={setNonstockInputValue} + className={{ wrapper: 'min-w-48' }} + isDisabled + /> + + + + + Rp + + } + className={{ wrapper: 'min-w-24' }} + /> + + +
+
+
+
+ ) + ); + })} +
+
+ ); +}; + +export default ExpenseRealizationKandangDetailExpense; From 82eac4a96567c56afad083a3f4d86625a4bbba6a Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 24 Nov 2025 09:47:09 +0700 Subject: [PATCH 14/31] chore(FE-198): adjust Expense Request Form validation --- .../expense/form/ExpenseRequestForm.schema.ts | 100 +++++++++++------- 1 file changed, 62 insertions(+), 38 deletions(-) diff --git a/src/components/pages/expense/form/ExpenseRequestForm.schema.ts b/src/components/pages/expense/form/ExpenseRequestForm.schema.ts index 4c2ae600..66ca9c78 100644 --- a/src/components/pages/expense/form/ExpenseRequestForm.schema.ts +++ b/src/components/pages/expense/form/ExpenseRequestForm.schema.ts @@ -3,27 +3,32 @@ import { Expense } from '@/types/api/expense'; import { formatDate } from '@/lib/helper'; type ExpenseFormSchemaType = { + category?: { + value: 'BOP' | 'NON-BOP'; + label: 'BOP' | 'NON-BOP'; + }; location?: { value: number; label: string; }; transaction_date?: string; kandangs?: { id: number; name: string }[]; - vendor?: { + supplier?: { value: number; label: string; }; - existing_documents?: { name: string; url: string }[]; - request_documents?: File[]; - kandangExpenses: { - kandangId: number; - expenses: { + existing_documents?: { id: number; name: string; url: string }[]; + deleted_documents?: number[]; + documents?: File[]; + cost_per_kandangs: { + kandang_id: number; + cost_items: { nonstock?: { value: number; label: string; }; - totalQuantity?: number; - totalExpense?: number; + quantity?: number; + total_cost?: number; notes?: string; }[]; }[]; @@ -31,6 +36,11 @@ type ExpenseFormSchemaType = { export const ExpenseRequestFormSchema: Yup.ObjectSchema = Yup.object({ + category: Yup.object({ + value: Yup.string().oneOf(['BOP', 'NON-BOP']).required(), + label: Yup.string().oneOf(['BOP', 'NON-BOP']).required(), + }).required('Kategori wajib diisi!'), + location: Yup.object({ value: Yup.number().min(1).required(), label: Yup.string().required(), @@ -47,35 +57,36 @@ export const ExpenseRequestFormSchema: Yup.ObjectSchema = .min(1, 'Kandang wajib dipilih!') .required('Kandang wajib dipilih!'), - vendor: Yup.object({ + supplier: Yup.object({ value: Yup.number().min(1).required(), label: Yup.string().required(), }).required('Vendor wajib diisi!'), existing_documents: Yup.array().of( Yup.object({ + id: Yup.number().required(), name: Yup.string().required(), url: Yup.string().required(), }) ), - request_documents: Yup.array().of(Yup.mixed().required()).optional(), + deleted_documents: Yup.array().of(Yup.number().required()).optional(), - kandangExpenses: Yup.array() + documents: Yup.array().of(Yup.mixed().required()).optional(), + + cost_per_kandangs: Yup.array() .of( Yup.object({ - kandangId: Yup.number().min(1, 'Wajib memilih kandang!').required(), - expenses: Yup.array() + kandang_id: Yup.number().min(1, 'Wajib memilih kandang!').required(), + cost_items: Yup.array() .of( Yup.object({ nonstock: Yup.object({ value: Yup.number().min(1).required(), label: Yup.string().required(), }).required('Nonstock wajib diisi!'), - totalQuantity: Yup.number().required( - 'Total kuantitas wajib diisi!' - ), - totalExpense: Yup.number().required('Total biaya wajib diisi!'), + quantity: Yup.number().required('Total kuantitas wajib diisi!'), + total_cost: Yup.number().required('Total biaya wajib diisi!'), notes: Yup.string(), }) ) @@ -90,7 +101,7 @@ export const ExpenseRequestFormSchema: Yup.ObjectSchema = export const UpdateExpenseRequestFormSchema = ExpenseRequestFormSchema; export const UploadRequestDocumentsFormSchema = Yup.object({ - request_documents: Yup.array().of(Yup.mixed().required()).required(), + documents: Yup.array().of(Yup.mixed().required()).required(), }); export type ExpenseRequestFormValues = Yup.InferType< @@ -105,39 +116,52 @@ export const getExpenseFormInitialValues = ( initialValues?: Expense ): ExpenseRequestFormValues => { return { + category: initialValues?.category + ? { + value: initialValues.category, + label: initialValues.category, + } + : undefined, location: initialValues?.location ? { value: initialValues.location.id, label: initialValues.location.name, } : undefined, - transaction_date: initialValues?.transaction_date - ? formatDate(initialValues.transaction_date, 'YYYY-MM-DD') + transaction_date: initialValues?.expense_date + ? formatDate(initialValues.expense_date, 'YYYY-MM-DD') : undefined, kandangs: initialValues?.kandangs.map((kandang) => ({ - id: kandang.id, + id: kandang.kandang_id, name: kandang.name, })), - vendor: initialValues?.vendor + supplier: initialValues?.supplier ? { - value: initialValues.vendor.id, - label: initialValues.vendor.name, + value: initialValues.supplier.id, + label: initialValues.supplier.name, } : undefined, - existing_documents: initialValues?.request_documents, - request_documents: [], - kandangExpenses: initialValues?.kandang_expenses - ? initialValues.kandang_expenses.map((kandangExpense) => ({ - kandangId: kandangExpense.kandang.id, - expenses: kandangExpense.expenses.map((expenseItem) => ({ - nonstock: { - value: expenseItem.nonstock.id, - label: expenseItem.nonstock.name, - }, - totalQuantity: expenseItem.total_quantity, - totalExpense: expenseItem.total_expense, - notes: expenseItem.notes, - })), + existing_documents: initialValues?.documents?.map((doc) => ({ + id: doc.id, + name: doc.path, + url: doc.path, + })), + deleted_documents: [], + documents: [], + cost_per_kandangs: initialValues?.kandangs + ? initialValues.kandangs.map((kandangExpense) => ({ + kandang_id: kandangExpense.kandang_id, + cost_items: kandangExpense.pengajuans + ? kandangExpense.pengajuans.map((expenseItem) => ({ + nonstock: { + value: expenseItem.nonstock.id, + label: expenseItem.nonstock.name, + }, + quantity: expenseItem.qty, + total_cost: expenseItem.total_price, + notes: expenseItem.note, + })) + : [], })) : [], }; From e4a6b223574ca30d06d077b2c353a958c11bf2eb Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 24 Nov 2025 09:54:28 +0700 Subject: [PATCH 15/31] chore(FE-188,193,199): adjust Expense Request Form and integrate to API --- .../pages/expense/form/ExpenseRequestForm.tsx | 246 ++++++++++++------ 1 file changed, 169 insertions(+), 77 deletions(-) diff --git a/src/components/pages/expense/form/ExpenseRequestForm.tsx b/src/components/pages/expense/form/ExpenseRequestForm.tsx index 0fd68c88..e47f2f76 100644 --- a/src/components/pages/expense/form/ExpenseRequestForm.tsx +++ b/src/components/pages/expense/form/ExpenseRequestForm.tsx @@ -42,7 +42,6 @@ interface ExpenseFormProps { initialValues?: Expense; } -// TODO: integrate this with real API const ExpenseRequestForm = ({ type = 'add', initialValues, @@ -59,7 +58,7 @@ const ExpenseRequestForm = ({ const createExpenseHandler = useCallback( async (payload: CreateExpensePayload) => { const createExpenseRes = await ExpenseApi.create( - ExpenseApi.convertPayloadToFormData(payload) + ExpenseApi.convertExpenseRequestPayloadToFormData(payload) ); if (isResponseError(createExpenseRes)) { @@ -74,10 +73,15 @@ const ExpenseRequestForm = ({ ); const updateExpenseHandler = useCallback( - async (expenseId: number, payload: UpdateExpensePayload) => { + async ( + expenseId: number, + payload: UpdateExpensePayload, + deletedDocumentIds: number[] + ) => { const updateExpenseRes = await ExpenseApi.update( expenseId, - ExpenseApi.convertPayloadToFormData(payload) + ExpenseApi.convertExpenseRequestUpdatePayloadToFormData(payload), + deletedDocumentIds ); if (updateExpenseRes?.status === 'error') { @@ -102,20 +106,17 @@ const ExpenseRequestForm = ({ setExpenseFormErrorMessage(''); const expensePayload: CreateExpensePayload = { - locationId: values.location?.value as number, - kandangIds: values.kandangs - ? values.kandangs.map((item) => item.id) - : [], - transaction_date: values.transaction_date as string, - vendorId: values.vendor?.value as number, - request_documents: values.request_documents as File[], - kandang_expenses: values.kandangExpenses.map((kandangExpense) => ({ - kandangId: kandangExpense.kandangId, - expenses: kandangExpense.expenses.map((expenseItem) => ({ - nonstockId: expenseItem.nonstock?.value as number, - total_quantity: expenseItem.totalQuantity as number, - total_expense: expenseItem.totalExpense as number, - notes: expenseItem.notes, + category: formik.values.category?.value as 'BOP' | 'NON-BOP', + transaction_date: values?.transaction_date as string, + supplier_id: values.supplier?.value as number, + documents: values.documents as File[], + cost_per_kandangs: values.cost_per_kandangs.map((costPerKandang) => ({ + kandang_id: costPerKandang.kandang_id, + cost_items: costPerKandang.cost_items.map((costItem) => ({ + nonstock_id: costItem.nonstock?.value as number, + quantity: parseFloat(String(costItem.quantity)) as number, + total_cost: parseFloat(String(costItem.total_cost)) as number, + notes: costItem.notes ?? '', })), })), }; @@ -126,9 +127,28 @@ const ExpenseRequestForm = ({ break; case 'edit': + const expenseUpdatePayload: UpdateExpensePayload = { + category: formik.values.category?.value as 'BOP' | 'NON-BOP', + transaction_date: values?.transaction_date as string, + supplier_id: values.supplier?.value as number, + documents: values.documents as File[], + cost_per_kandang: values.cost_per_kandangs.map( + (costPerKandang) => ({ + kandang_id: costPerKandang.kandang_id, + cost_items: costPerKandang.cost_items.map((costItem) => ({ + nonstock_id: costItem.nonstock?.value as number, + quantity: parseFloat(String(costItem.quantity)) as number, + total_cost: parseFloat(String(costItem.total_cost)) as number, + notes: costItem.notes ?? '', + })), + }) + ), + }; + await updateExpenseHandler( initialValues?.id as number, - expensePayload + expenseUpdatePayload, + formik.values.deleted_documents ?? [] ); break; } @@ -145,72 +165,103 @@ const ExpenseRequestForm = ({ const { setInputValue: setVendorInputValue, - options: vendorOptions, + options: supplierOptions, isLoadingOptions: isLoadingVendorOptions, } = useSelect(SupplierApi.basePath, 'id', 'name'); + const categoryChangeHandler = (val: OptionType | OptionType[] | null) => { + formik.setFieldTouched('category', true); + formik.setFieldValue('category', val); + }; + const locationChangeHandler = (val: OptionType | OptionType[] | null) => { formik.setFieldTouched('location', true); formik.setFieldValue('location', val); formik.setFieldValue('kandangs', []); - formik.setFieldValue('kandangExpenses', []); + formik.setFieldValue('cost_per_kandangs', []); }; const kandangsChangeHandler = (kandangs: { id: number; name: string }[]) => { formik.setFieldTouched('kandangs', true); formik.setFieldValue('kandangs', kandangs); - const newKandangExpenses = [...(formik.values.kandangExpenses ?? [])]; + const newCostPerKandangs = [...(formik.values.cost_per_kandangs ?? [])]; - // add new kandangExpenses + // add new cost_per_kandangs kandangs.forEach((kandangItem) => { - const isKandangExistInKandangExpense = newKandangExpenses.find( - (kandangExpenseItem) => kandangExpenseItem.kandangId === kandangItem.id + const isKandangExistInCostPerKandangs = newCostPerKandangs.find( + (costPerKandangItem) => costPerKandangItem.kandang_id === kandangItem.id ); - if (isKandangExistInKandangExpense) return; + if (isKandangExistInCostPerKandangs) return; - newKandangExpenses.push({ - kandangId: kandangItem.id, - expenses: [ + newCostPerKandangs.push({ + kandang_id: kandangItem.id, + cost_items: [ { nonstock: undefined, - totalExpense: undefined, - totalQuantity: undefined, + quantity: undefined, + total_cost: undefined, notes: '', }, ], }); }); - // prune kandangExpenses + // prune cost_per_kandangs const kandangIds = new Set(kandangs.map((kandang) => kandang.id)); - const deletedKandangExpensesIdx: number[] = []; + const deletedCostPerKandangsIdx: number[] = []; - newKandangExpenses.forEach((kandangExpense, idx) => { - const isKandangExpenseValid = kandangIds.has(kandangExpense.kandangId); + newCostPerKandangs.forEach((costPerKandang, idx) => { + const isCostPerKandangValid = kandangIds.has(costPerKandang.kandang_id); - if (!isKandangExpenseValid) { - deletedKandangExpensesIdx.push(idx); + if (!isCostPerKandangValid) { + deletedCostPerKandangsIdx.push(idx); } }); - deletedKandangExpensesIdx.forEach((deletedKandangExpenseIdx) => { - newKandangExpenses.splice(deletedKandangExpenseIdx, 1); + deletedCostPerKandangsIdx.forEach((deletedCostPerKandangIdx) => { + newCostPerKandangs.splice(deletedCostPerKandangIdx, 1); }); - formik.setFieldValue('kandangExpenses', newKandangExpenses); + formik.setFieldValue('cost_per_kandangs', newCostPerKandangs); }; - const vendorChangeHandler = (val: OptionType | OptionType[] | null) => { - formik.setFieldTouched('vendor', true); - formik.setFieldValue('vendor', val); + const supplierChangeHandler = (val: OptionType | OptionType[] | null) => { + formik.setFieldTouched('supplier', true); + formik.setFieldValue('supplier', val); }; const requestDocumentsChangeHandler = (val: File[]) => { - formik.setFieldTouched('request_documents', true); - formik.setFieldValue('request_documents', val); + 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); + }; + + const deleteDocumentClickHandler = ( + deletedDocumentIdx: number, + deletedDocumentId: number + ) => { + const newDeletedDocumentIds = [...(formik.values.deleted_documents ?? [])]; + const newExistingDocuments = [ + ...(formik.values.existing_documents ?? []), + ].filter((_, idx) => idx !== deletedDocumentIdx); + + newDeletedDocumentIds.push(deletedDocumentId); + + formik.setFieldTouched('deleted_documents', true); + formik.setFieldValue('deleted_documents', newDeletedDocumentIds); + + formik.setFieldTouched('existing_documents', true); + formik.setFieldValue('existing_documents', newExistingDocuments); }; const deleteExpenseClickHandler = () => { @@ -269,6 +320,25 @@ const ExpenseRequestForm = ({ className='w-full mt-8 flex flex-col gap-6' >
+ + @@ -306,9 +376,9 @@ const ExpenseRequestForm = ({ label='Vendor' required placeholder='Pilih Vendor' - value={formik.values.vendor} - onChange={vendorChangeHandler} - options={vendorOptions} + value={formik.values.supplier} + onChange={supplierChangeHandler} + options={supplierOptions} isLoading={isLoadingVendorOptions} onInputChange={setVendorInputValue} className={{ wrapper: 'col-span-12' }} @@ -316,9 +386,10 @@ const ExpenseRequestForm = ({ (
  • - - {existingDocument.name}{' '} - - +
    + + {existingDocument.name}{' '} + + + + +
  • ) )} @@ -402,6 +494,17 @@ const ExpenseRequestForm = ({
    )} + {expenseFormErrorMessage && ( +
    + + {expenseFormErrorMessage} +
    + )} + {type !== 'detail' && (
    )}
    - - {expenseFormErrorMessage && ( -
    - - {expenseFormErrorMessage} -
    - )} From b868a37485e71ad1ddbbe86f696d93a14f339160 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Tue, 25 Nov 2025 09:17:02 +0700 Subject: [PATCH 16/31] chore(FE-188,193): adjust ExpenseRequestKandangDetailExpense component --- .../ExpenseRequestKandangDetailExpense.tsx | 343 +++++++++--------- 1 file changed, 174 insertions(+), 169 deletions(-) diff --git a/src/components/pages/expense/form/ExpenseRequestKandangDetailExpense.tsx b/src/components/pages/expense/form/ExpenseRequestKandangDetailExpense.tsx index ca9edf37..73e6c9b7 100644 --- a/src/components/pages/expense/form/ExpenseRequestKandangDetailExpense.tsx +++ b/src/components/pages/expense/form/ExpenseRequestKandangDetailExpense.tsx @@ -41,28 +41,28 @@ const ExpenseRequestKandangDetailExpense: React.FC< val: OptionType | OptionType[] | null ) => { formik.setFieldTouched( - `kandangExpenses[${kandangExpenseIdx}].expenses[${expenseIdx}].nonstock`, + `cost_per_kandangs[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`, true ); formik.setFieldValue( - `kandangExpenses[${kandangExpenseIdx}].expenses[${expenseIdx}].nonstock`, + `cost_per_kandangs[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`, val ); }; const addExpenseItemHandler = (kandangExpenseIdx: number) => { const newExpensesValue = [ - ...formik.values.kandangExpenses[kandangExpenseIdx].expenses, + ...formik.values.cost_per_kandangs[kandangExpenseIdx].cost_items, { nonstock: undefined, - totalExpense: undefined, - totalQuantity: undefined, + total_cost: undefined, + quantity: undefined, notes: '', }, ]; formik.setFieldValue( - `kandangExpenses[${kandangExpenseIdx}].expenses`, + `cost_per_kandangs[${kandangExpenseIdx}].cost_items`, newExpensesValue ); }; @@ -71,27 +71,28 @@ const ExpenseRequestKandangDetailExpense: React.FC< kandangExpenseIdx: number, expenseIdx: number ) => { - const path = `kandangExpenses[${kandangExpenseIdx}].expenses`; + const path = `cost_per_kandangs[${kandangExpenseIdx}].cost_items`; // trims values, errors, and touched at expenseIdx removeArrayItemAndSync(formik, path, expenseIdx); }; const isExpenseRepeaterInputError = ( - column: 'nonstock' | 'totalQuantity' | 'totalExpense' | 'notes', + column: 'nonstock' | 'quantity' | 'total_cost' | 'notes', kandangExpenseIdx: number, expenseIdx: number ) => { return ( - formik.touched.kandangExpenses?.[kandangExpenseIdx]?.expenses?.[ + formik.touched.cost_per_kandangs?.[kandangExpenseIdx]?.cost_items?.[ expenseIdx ]?.[column] && Boolean( - formik.errors.kandangExpenses?.[kandangExpenseIdx] instanceof Object && - formik.errors.kandangExpenses?.[kandangExpenseIdx].expenses?.[ + formik.errors.cost_per_kandangs?.[kandangExpenseIdx] instanceof + Object && + formik.errors.cost_per_kandangs?.[kandangExpenseIdx].cost_items?.[ expenseIdx ] instanceof Object && - formik.errors.kandangExpenses?.[kandangExpenseIdx].expenses?.[ + formik.errors.cost_per_kandangs?.[kandangExpenseIdx].cost_items?.[ expenseIdx ]?.[column] ) @@ -112,7 +113,8 @@ const ExpenseRequestKandangDetailExpense: React.FC<
    - {formik.values.kandangExpenses.length === 0 && ( + {(formik.values.cost_per_kandangs.length === 0 || + !formik.values.supplier?.value) && (

    Pilih kandang terlebih dahulu! @@ -120,168 +122,171 @@ const ExpenseRequestKandangDetailExpense: React.FC<

    )} - {formik.values.kandangExpenses.map( - (kandangExpense, kandangExpenseIdx) => { - const kandangName = formik.values.kandangs?.find( - (kandang) => kandang.id === kandangExpense.kandangId - ); + {formik.values.cost_per_kandangs.length > 0 && + formik.values.supplier?.value && + formik.values.cost_per_kandangs.map( + (kandangExpense, kandangExpenseIdx) => { + const kandangName = formik.values.kandangs?.find( + (kandang) => kandang.id === kandangExpense.kandang_id + ); - return ( - kandangName?.name && ( -
    -
    -
    - Biaya {kandangName?.name} -
    + return ( + kandangName?.name && ( +
    +
    +
    + Biaya {kandangName?.name} +
    -
    - - - - - - - - {type !== 'detail' && } - - +
    +
    NonstockTotal KuantitasTotal BiayaCatatanAksi
    + + + + + + + {type !== 'detail' && } + + - - {kandangExpense.expenses.map( - (expenseItem, expenseIdx) => ( - - - - - - - - - - {type !== 'detail' && ( - + {kandangExpense.cost_items.map( + (expenseItem, expenseIdx) => ( + + - )} - - ) - )} - -
    NonstockTotal KuantitasTotal BiayaCatatanAksi
    - { - nonstockChangeHandler( - kandangExpenseIdx, - expenseIdx, - val - ); - }} - options={nonstockOptions} - isLoading={isLoadingNonstockOptions} - onInputChange={setNonstockInputValue} - className={{ wrapper: 'min-w-48' }} - /> - - - - - Rp - - } - className={{ wrapper: 'min-w-24' }} - /> - - - -
    + { + nonstockChangeHandler( kandangExpenseIdx, - expenseIdx - ) - } - > - - + expenseIdx, + val + ); + }} + options={nonstockOptions} + isLoading={isLoadingNonstockOptions} + onInputChange={setNonstockInputValue} + className={{ wrapper: 'min-w-48' }} + />
    -
    -
    - {type !== 'detail' && ( - - )} -
    - ) - ); - } - )} + + + + + + + Rp + + } + className={{ wrapper: 'min-w-24' }} + /> + + + + + + + {type !== 'detail' && ( + + + + )} + + ) + )} + + +
    +
    + + {type !== 'detail' && ( + + )} +
    + ) + ); + } + )} ); From 47690f82ac4b88be436e22ffccc9bc6f3807707f Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Tue, 25 Nov 2025 09:18:04 +0700 Subject: [PATCH 17/31] chore(FE-199): update Expense Request approval line step name --- src/config/approval-line.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/approval-line.ts b/src/config/approval-line.ts index 8174057d..df330a6e 100644 --- a/src/config/approval-line.ts +++ b/src/config/approval-line.ts @@ -40,7 +40,7 @@ export const EXPENSE_REQUEST_APPROVAL_LINE: ApprovalLine = [ }, { step_number: 2, - step_name: 'Approval Manager Area', + step_name: 'Approval Manager', }, { step_number: 3, From 1a1fefc237e2150519fc3864ca3769b120587f99 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Tue, 25 Nov 2025 09:19:58 +0700 Subject: [PATCH 18/31] chore: create AuthApiService --- src/services/api/auth.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/services/api/auth.ts b/src/services/api/auth.ts index e69de29b..f5319435 100644 --- a/src/services/api/auth.ts +++ b/src/services/api/auth.ts @@ -0,0 +1,23 @@ +import axios from 'axios'; +import { httpClient } from '@/services/http/client'; +import { BaseApiResponse, LogoutResponse } from '@/types/api/api-general'; + +export class AuthApiService { + async logout() { + try { + const logoutRes = await httpClient(`/sso/logout`, { + method: 'POST', + }); + + return logoutRes; + } catch (error) { + if (axios.isAxiosError(error)) { + return error.response?.data; + } + + return undefined; + } + } +} + +export const AuthApi = new AuthApiService(); From 334202569c4005bfca55176d5f90fffc9b53ba8e Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Tue, 25 Nov 2025 09:24:12 +0700 Subject: [PATCH 19/31] chore(FE-199,207): integrate ExpenseApiService to API --- src/services/api/expense.ts | 1514 +++++++++++------------------------ 1 file changed, 477 insertions(+), 1037 deletions(-) diff --git a/src/services/api/expense.ts b/src/services/api/expense.ts index 24931316..337730e6 100644 --- a/src/services/api/expense.ts +++ b/src/services/api/expense.ts @@ -1,738 +1,15 @@ import axios from 'axios'; import { sleep } from '@/lib/helper'; import { BaseApiService } from '@/services/api/base'; -import { BaseApiResponse, CreatedUser } from '@/types/api/api-general'; -import { CreateExpensePayload, Expense } from '@/types/api/expense'; +import { BaseApiResponse, GroupedApprovals } from '@/types/api/api-general'; +import { + CreateExpensePayload, + CreateExpenseRealizationPayload, + Expense, + UpdateExpensePayload, +} from '@/types/api/expense'; import { httpClient } from '@/services/http/client'; -import { BaseArea } from '@/types/api/master-data/area'; -import { BaseLocation, Location } from '@/types/api/master-data/location'; -import { Kandang } from '@/types/api/master-data/kandang'; -import { BaseSupplier, Supplier } from '@/types/api/master-data/supplier'; -import { Nonstock } from '@/types/api/master-data/nonstock'; -import { resourceUsage } from 'process'; - -// Shared base objects -const adminUser: CreatedUser = { - id: 1, - id_user: 1, - email: 'admin@example.com', - name: 'Admin User', -}; - -const managerAreaUser: CreatedUser = { - id: 200, - id_user: 200, - email: 'manager.area@example.com', - name: 'Manager Area', -}; - -const headFinanceUser: CreatedUser = { - id: 300, - id_user: 300, - email: 'head.finance@example.com', - name: 'Head Finance', -}; - -const financeStaffUser: CreatedUser = { - id: 400, - id_user: 400, - email: 'finance.staff@example.com', - name: 'Finance Staff', -}; - -const auditUser: CreatedUser = { - id: 500, - id_user: 500, - email: 'internal.audit@example.com', - name: 'Internal Audit', -}; - -const areaUtara: BaseArea = { - id: 1, - name: 'Area Utara', -}; - -const areaSelatan: BaseArea = { - id: 2, - name: 'Area Selatan', -}; - -const baseLocationA: BaseLocation = { - id: 1, - name: 'Singaparna', - address: 'Jl. Kebun Raya 12', - area: areaUtara, -}; - -const baseLocationB: BaseLocation = { - id: 2, - name: 'Cikaum', - address: 'Jl. Melati 20', - area: areaSelatan, -}; - -const locationA: Location = { - ...baseLocationA, - created_user: adminUser, - created_at: '2025-01-01T00:00:00Z', - updated_at: '2025-01-01T00:00:00Z', -}; - -const locationB: Location = { - ...baseLocationB, - created_user: adminUser, - created_at: '2025-01-05T00:00:00Z', - updated_at: '2025-01-05T00:00:00Z', -}; - -const kandangA1: Kandang = { - id: 1, - name: 'Singaparna 1', - status: 'ACTIVE', - capacity: 5000, - location: baseLocationA, - pic: { - id: 101, - id_user: 101, - email: 'abkA1@example.com', - name: 'ABK A1', - }, - created_user: adminUser, - created_at: '2024-12-10T00:00:00Z', - updated_at: '2024-12-10T00:00:00Z', -}; - -const kandangA2: Kandang = { - id: 2, - name: 'Singaparna 2', - status: 'ACTIVE', - capacity: 4500, - location: baseLocationA, - pic: { - id: 102, - id_user: 102, - email: 'abkA2@example.com', - name: 'ABK A2', - }, - created_user: adminUser, - created_at: '2024-12-12T00:00:00Z', - updated_at: '2024-12-12T00:00:00Z', -}; - -const kandangB1: Kandang = { - id: 21, - name: 'Kandang B1', - status: 'ACTIVE', - capacity: 3800, - location: baseLocationB, - pic: { - id: 201, - id_user: 201, - email: 'abkB1@example.com', - name: 'ABK B1', - }, - created_user: adminUser, - created_at: '2024-12-15T00:00:00Z', - updated_at: '2024-12-15T00:00:00Z', -}; - -const baseSupplierPakan: BaseSupplier = { - id: 1, - name: 'PT CHAROEN POKPHAND INDONESIA Tbk', - alias: 'PPJ', - pic: 'Budi', - type: 'Pakan', - category: 'PAKAN', - hatchery: '-', - phone: '08121234567', - email: 'pakan@example.com', - address: 'Jl. Raya Pakan 88', - npwp: '1234567890', - account_number: '111-222-333', - due_date: 30, - balance: 5000000, -}; - -const baseSupplierObat: BaseSupplier = { - id: 502, - name: 'CV Obat Sehat', - alias: 'COS', - pic: 'Susi', - type: 'Obat', - category: 'OBAT', - hatchery: '-', - phone: '085612345678', - email: 'cos@example.com', - address: 'Jl. Obat Raya 10', - npwp: '987654321', - account_number: '222-333-444', - due_date: 14, -}; - -const supplierPakan: Supplier = { - ...baseSupplierPakan, - created_user: adminUser, - created_at: '2025-01-01T00:00:00Z', - updated_at: '2025-01-01T00:00:00Z', -}; - -const supplierObat: Supplier = { - ...baseSupplierObat, - created_user: adminUser, - created_at: '2025-01-02T00:00:00Z', - updated_at: '2025-01-02T00:00:00Z', -}; - -const nonstockPakanStarter: Nonstock = { - id: 3001, - name: 'Pakan Ayam Starter', - uom_id: 1, - uom: { id: 1, name: 'KG' }, - suppliers: [baseSupplierPakan], - flags: ['PAKAN', 'STARTER'], - created_user: adminUser, - created_at: '2025-01-10T00:00:00Z', - updated_at: '2025-01-10T00:00:00Z', -}; - -const nonstockPakanFinisher: Nonstock = { - id: 3002, - name: 'Pakan Ayam Finisher', - uom_id: 1, - uom: { id: 1, name: 'KG' }, - suppliers: [baseSupplierPakan], - flags: ['PAKAN', 'FINISHER'], - created_user: adminUser, - created_at: '2025-01-11T00:00:00Z', - updated_at: '2025-01-11T00:00:00Z', -}; - -const nonstockObat: Nonstock = { - id: 3101, - name: 'Obat Antibiotik A', - uom_id: 2, - uom: { id: 2, name: 'BOTOL' }, - suppliers: [baseSupplierObat], - flags: ['OBAT'], - created_user: adminUser, - created_at: '2025-01-12T00:00:00Z', - updated_at: '2025-01-12T00:00:00Z', -}; - -const nonstockVitamin: Nonstock = { - id: 3102, - name: 'Vitamin Ayam B-Complex', - uom_id: 2, - uom: { id: 2, name: 'BOTOL' }, - suppliers: [baseSupplierObat], - flags: ['VITAMIN'], - created_user: adminUser, - created_at: '2025-01-13T00:00:00Z', - updated_at: '2025-01-13T00:00:00Z', -}; - -export const DUMMY_EXPENSE: Expense[] = [ - // STEP 1 - Pengajuan (OK) - { - id: 1, - reference_number: 'REF-EXP-0001', - po_number: 'PO-STEP1-OK', - created_user: adminUser, - created_at: '2025-02-10T08:00:00Z', - updated_at: '2025-02-10T08:00:00Z', - - location: locationA, - transaction_date: '2025-02-10', - - kandangs: [kandangA1, kandangA2], - vendor: supplierPakan, - request_documents: [ - { - name: 'pengajuan_step1_ok.pdf', - url: 'https://example.com/pengajuan_step1_ok.pdf', - }, - ], - kandang_expenses: [ - { - kandang: kandangA1, - expenses: [ - { - nonstock: nonstockPakanStarter, - total_quantity: 100, - total_expense: 1500000, - }, - ], - }, - { - kandang: kandangA2, - expenses: [ - { - nonstock: nonstockPakanFinisher, - total_quantity: 80, - total_expense: 1200000, - }, - ], - }, - ], - nominal: 2700000, - paid: 0, - remaining_cost: 2700000, - - approval: { - step_number: 1, - step_name: 'Pengajuan', - action: 'SUBMITTED', - notes: 'Pengajuan pakan untuk kandang A1 dan A2.', - action_by: adminUser, - action_at: '2025-02-10T08:05:00Z', - }, - }, - - // STEP 1 - Pengajuan (REJECTED) - { - id: 2, - reference_number: 'REF-EXP-0002', - po_number: 'PO-STEP1-REJECT', - created_user: adminUser, - created_at: '2025-02-11T09:00:00Z', - updated_at: '2025-02-11T09:15:00Z', - - location: locationA, - transaction_date: '2025-02-11', - - kandangs: [kandangA1], - vendor: supplierPakan, - request_documents: [], - kandang_expenses: [ - { - kandang: kandangA1, - expenses: [ - { - nonstock: nonstockPakanFinisher, - total_quantity: 300, - total_expense: 4500000, - }, - ], - }, - ], - nominal: 4500000, - paid: 0, - remaining_cost: 4500000, - - approval: { - step_number: 1, - step_name: 'Pengajuan', - action: 'REJECTED', - notes: 'Jumlah terlalu besar untuk pengajuan awal.', - action_by: managerAreaUser, - action_at: '2025-02-11T09:15:00Z', - }, - }, - - // STEP 2 - Approval Manager Area (APPROVED) - { - id: 3, - reference_number: 'REF-EXP-0003', - po_number: 'PO-STEP2-OK', - created_user: adminUser, - created_at: '2025-02-12T07:30:00Z', - updated_at: '2025-02-12T08:30:00Z', - - location: locationA, - transaction_date: '2025-02-12', - - kandangs: [kandangA1, kandangA2], - vendor: supplierPakan, - request_documents: [ - { - name: 'pengajuan_step2_ok.pdf', - url: 'https://example.com/pengajuan_step2_ok.pdf', - }, - ], - kandang_expenses: [ - { - kandang: kandangA1, - expenses: [ - { - nonstock: nonstockPakanStarter, - total_quantity: 120, - total_expense: 1800000, - notes: - 'Lorem ipsum dolor sit amet consectetur adipisicing elit. Non eveniet quos aspernatur magnam mollitia consequatur dolore natus amet libero enim?', - }, - { - nonstock: nonstockPakanFinisher, - total_quantity: 50, - total_expense: 750000, - }, - ], - }, - { - kandang: kandangA2, - expenses: [ - { - nonstock: nonstockPakanStarter, - total_quantity: 60, - total_expense: 900000, - }, - ], - }, - ], - nominal: 3450000, - paid: 0, - remaining_cost: 3450000, - - approval: { - step_number: 2, - step_name: 'Approval Manager Area', - action: 'APPROVED', - notes: 'Disetujui, kebutuhan pakan sesuai rencana.', - action_by: managerAreaUser, - action_at: '2025-02-12T08:30:00Z', - }, - }, - - // STEP 2 - Approval Manager Area (REJECTED) - { - id: 4, - reference_number: 'REF-EXP-0004', - created_user: adminUser, - created_at: '2025-02-13T10:00:00Z', - updated_at: '2025-02-13T10:20:00Z', - - location: locationB, - transaction_date: '2025-02-13', - - kandangs: [kandangB1], - vendor: supplierPakan, - request_documents: [], - kandang_expenses: [ - { - kandang: kandangB1, - expenses: [ - { - nonstock: nonstockPakanFinisher, - total_quantity: 400, - total_expense: 6000000, - }, - ], - }, - ], - nominal: 6000000, - paid: 0, - remaining_cost: 6000000, - - approval: { - step_number: 2, - step_name: 'Approval Manager Area', - action: 'REJECTED', - notes: 'Tidak sesuai rencana kebutuhan area Selatan.', - action_by: managerAreaUser, - action_at: '2025-02-13T10:20:00Z', - }, - }, - - // STEP 3 - Approval Finance (APPROVED) - { - id: 5, - reference_number: 'REF-EXP-0005', - po_number: 'PO-STEP3-OK', - created_user: adminUser, - created_at: '2025-02-14T09:00:00Z', - updated_at: '2025-02-14T09:30:00Z', - - location: locationB, - transaction_date: '2025-02-14', - - kandangs: [kandangB1], - vendor: supplierObat, - request_documents: [ - { - name: 'pengajuan_step3_ok.pdf', - url: 'https://example.com/pengajuan_step3_ok.pdf', - }, - ], - kandang_expenses: [ - { - kandang: kandangB1, - expenses: [ - { - nonstock: nonstockObat, - total_quantity: 10, - total_expense: 700000, - }, - { - nonstock: nonstockVitamin, - total_quantity: 5, - total_expense: 250000, - }, - ], - }, - ], - nominal: 950000, - paid: 0, - remaining_cost: 950000, - - approval: { - step_number: 3, - step_name: 'Approval Finance', - action: 'APPROVED', - notes: 'Budget tersedia untuk obat & vitamin.', - action_by: headFinanceUser, - action_at: '2025-02-14T09:30:00Z', - }, - }, - - // STEP 3 - Approval Finance (REJECTED) - { - id: 6, - reference_number: 'REF-EXP-0006', - created_user: adminUser, - created_at: '2025-02-15T11:00:00Z', - updated_at: '2025-02-15T11:30:00Z', - - location: locationB, - transaction_date: '2025-02-15', - - kandangs: [kandangB1], - vendor: supplierObat, - request_documents: [], - kandang_expenses: [ - { - kandang: kandangB1, - expenses: [ - { - nonstock: nonstockObat, - total_quantity: 60, - total_expense: 4200000, - }, - ], - }, - ], - nominal: 4200000, - paid: 0, - remaining_cost: 4200000, - - approval: { - step_number: 3, - step_name: 'Approval Finance', - action: 'REJECTED', - notes: 'Melebihi plafon budget obat bulan ini.', - action_by: headFinanceUser, - action_at: '2025-02-15T11:30:00Z', - }, - }, - - // STEP 4 - Realisasi (IN_PROGRESS) - { - id: 7, - reference_number: 'REF-EXP-0007', - po_number: 'PO-STEP4-OK', - created_user: adminUser, - created_at: '2025-02-16T08:00:00Z', - updated_at: '2025-02-16T12:00:00Z', - - location: locationA, - transaction_date: '2025-02-16', - realization_date: '2025-02-17', - - kandangs: [kandangA1, kandangA2], - vendor: supplierPakan, - request_documents: [ - { - name: 'do_step4_ok.pdf', - url: 'https://example.com/do_step4_ok.pdf', - }, - ], - kandang_expenses: [ - { - kandang: kandangA1, - expenses: [ - { - nonstock: nonstockPakanStarter, - total_quantity: 70, - total_expense: 1050000, - }, - ], - }, - { - kandang: kandangA2, - expenses: [ - { - nonstock: nonstockPakanFinisher, - total_quantity: 40, - total_expense: 600000, - }, - ], - }, - ], - nominal: 1650000, - paid: 500000, - remaining_cost: 1150000, - - approval: { - step_number: 4, - step_name: 'Realisasi', - action: 'IN_PROGRESS', - notes: 'Barang diterima, pembayaran sebagian.', - action_by: financeStaffUser, - action_at: '2025-02-16T12:00:00Z', - }, - }, - - // STEP 4 - Realisasi (REJECTED) - { - id: 8, - reference_number: 'REF-EXP-0008', - created_user: adminUser, - created_at: '2025-02-17T09:00:00Z', - updated_at: '2025-02-17T09:45:00Z', - - location: locationA, - transaction_date: '2025-02-17', - - kandangs: [kandangA1], - vendor: supplierPakan, - request_documents: [ - { - name: 'invoice_step4_reject.pdf', - url: 'https://example.com/invoice_step4_reject.pdf', - }, - ], - kandang_expenses: [ - { - kandang: kandangA1, - expenses: [ - { - nonstock: nonstockPakanStarter, - total_quantity: 50, - total_expense: 750000, - }, - ], - }, - ], - nominal: 750000, - paid: 0, - remaining_cost: 750000, - - approval: { - step_number: 4, - step_name: 'Realisasi', - action: 'REJECTED', - notes: 'Dokumen realisasi tidak sesuai PO.', - action_by: financeStaffUser, - action_at: '2025-02-17T09:45:00Z', - }, - }, - - // STEP 5 - Selesai (DONE) - { - id: 9, - reference_number: 'REF-EXP-0009', - po_number: 'PO-STEP5-OK', - created_user: adminUser, - created_at: '2025-02-18T08:00:00Z', - updated_at: '2025-02-20T15:00:00Z', - - location: locationB, - transaction_date: '2025-02-18', - realization_date: '2025-02-19', - - kandangs: [kandangB1], - vendor: supplierObat, - request_documents: [ - { - name: 'invoice_step5_ok.pdf', - url: 'https://example.com/invoice_step5_ok.pdf', - }, - { - name: 'bukti_transfer_step5_ok.pdf', - url: 'https://example.com/bukti_transfer_step5_ok.pdf', - }, - ], - kandang_expenses: [ - { - kandang: kandangB1, - expenses: [ - { - nonstock: nonstockObat, - total_quantity: 20, - total_expense: 1400000, - }, - ], - }, - ], - nominal: 1400000, - paid: 1400000, - remaining_cost: 0, - - approval: { - step_number: 5, - step_name: 'Selesai', - action: 'DONE', - notes: 'Proses selesai, sudah lunas.', - action_by: financeStaffUser, - action_at: '2025-02-20T15:00:00Z', - }, - }, - - // STEP 5 - Selesai (REJECTED by audit) - { - id: 10, - reference_number: 'REF-EXP-0010', - created_user: adminUser, - created_at: '2025-02-19T09:00:00Z', - updated_at: '2025-02-21T10:30:00Z', - - location: locationA, - transaction_date: '2025-02-19', - - kandangs: [kandangA1, kandangA2], - vendor: supplierPakan, - request_documents: [ - { - name: 'invoice_step5_recheck.pdf', - url: 'https://example.com/invoice_step5_recheck.pdf', - }, - ], - kandang_expenses: [ - { - kandang: kandangA1, - expenses: [ - { - nonstock: nonstockPakanStarter, - total_quantity: 60, - total_expense: 900000, - }, - ], - }, - { - kandang: kandangA2, - expenses: [ - { - nonstock: nonstockPakanFinisher, - total_quantity: 40, - total_expense: 600000, - }, - ], - }, - ], - nominal: 1500000, - paid: 1500000, - remaining_cost: 0, - - approval: { - step_number: 5, - step_name: 'Selesai', - action: 'REJECTED', - notes: 'Dibatalkan saat audit akhir, terdapat perbedaan kuantitas.', - action_by: auditUser, - action_at: '2025-02-21T10:30:00Z', - }, - }, -]; - export class ExpenseApiService extends BaseApiService< Expense, FormData, @@ -742,134 +19,404 @@ export class ExpenseApiService extends BaseApiService< super(basePath); } - // TODO: remove dummy data and integrate to real API - override async getAllFetcher( - endpoint: string - ): Promise> { - // return await httpClientFetcher>(endpoint); - - await sleep(750); - - return { - code: 200, - status: 'success', - message: 'Successfully get all expense data!', - meta: { - page: 1, - limit: 10, - total_pages: 1, - total_results: 8, - }, - data: DUMMY_EXPENSE, - }; - } - - // TODO: remove this and integrate to real API - async getSingle(id: number): Promise | undefined> { - await sleep(1000); - - return { - code: 200, - status: 'success', - message: 'Successfully get expense data!', - meta: { - page: 1, - limit: 10, - total_pages: 1, - total_results: 8, - }, - data: DUMMY_EXPENSE[id - 1], - }; - } - - // TODO: remove this and integrate to real API async create( payload: FormData ): Promise | undefined> { - await sleep(750); + try { + const createExpenseRequestRes = await httpClient< + BaseApiResponse + >(this.basePath, { + method: 'POST', + body: payload, + }); - const sentPayload = new Map(); - for (const pair of payload.entries()) { - sentPayload.set(pair[0], pair[1]); + return createExpenseRequestRes; + } catch (error) { + if (axios.isAxiosError>(error)) { + return error.response?.data; + } + + return undefined; } - - console.log({ sentPayload }); - - return { - code: 200, - status: 'success', - message: 'Berhasil membuat pengajuan biaya operasional!', - data: DUMMY_EXPENSE[0], - }; } - // TODO: remove this and integrate to real API - async update( + async createRealization( id: number, payload: FormData ): Promise | undefined> { - await sleep(750); + try { + const createExpenseRealizationRes = await httpClient< + BaseApiResponse + >(`${this.basePath}/${id}/realizations`, { + method: 'POST', + body: payload, + }); - const sentPayload = new Map(); - for (const pair of payload.entries()) { - sentPayload.set(pair[0], pair[1]); + return createExpenseRealizationRes; + } catch (error) { + if (axios.isAxiosError>(error)) { + return error.response?.data; + } + + return undefined; } - - console.log({ sentPayload }); - - return { - code: 200, - status: 'success', - message: 'Berhasil mengubah pengajuan biaya operasional!', - data: DUMMY_EXPENSE[0], - }; } - // TODO: remove this and integrate to real API - async delete(id: number): Promise { - await sleep(1000); + async update( + id: number, + payload: FormData, + deletedDocumentIds?: number[] + ): Promise | undefined> { + try { + for (const deletedDocumentId of deletedDocumentIds ?? []) { + await this.deleteExpenseRequestDocument(id, deletedDocumentId); + } - return { - code: 200, - status: 'success', - message: 'Successfully delete expense data!', - data: { - id, - }, - }; + const updateExpenseRequestRes = await httpClient< + BaseApiResponse + >(`${this.basePath}/${id}`, { + method: 'PATCH', + body: payload, + }); + + return updateExpenseRequestRes; + } catch (error) { + if (axios.isAxiosError>(error)) { + return error.response?.data; + } + + return undefined; + } + } + + async updateRealization( + id: number, + payload: FormData + ): Promise | undefined> { + try { + const updateExpenseRealizationRes = await httpClient< + BaseApiResponse + >(`${this.basePath}/${id}/realizations`, { + method: 'PATCH', + body: payload, + }); + + return updateExpenseRealizationRes; + } catch (error) { + if (axios.isAxiosError>(error)) { + return error.response?.data; + } + + return undefined; + } } - // TODO: remove this and integrate to real API async uploadRequestDocuments( id: number, files: File[] + ): Promise | undefined> { + try { + const updateExpenseRequestDocumentsFormData = new FormData(); + + // files (multiple "documents" keys) + files.forEach((file) => { + updateExpenseRequestDocumentsFormData.append('documents', file); + }); + + const updateExpenseRealizationRes = await httpClient< + BaseApiResponse + >(`${this.basePath}/${id}`, { + method: 'PATCH', + body: updateExpenseRequestDocumentsFormData, + }); + + return updateExpenseRealizationRes; + } catch (error) { + if (axios.isAxiosError>(error)) { + return error.response?.data; + } + + return undefined; + } + } + + async uploadRealizationDocuments( + id: number, + files: File[] + ): Promise | undefined> { + try { + const updateExpenseRealizationDocumentsFormData = new FormData(); + + // files (multiple "documents" keys) + files.forEach((file) => { + updateExpenseRealizationDocumentsFormData.append('documents', file); + }); + + const updateExpenseRealizationRes = await httpClient< + BaseApiResponse + >(`${this.basePath}/${id}/realizations`, { + method: 'PATCH', + body: updateExpenseRealizationDocumentsFormData, + }); + + return updateExpenseRealizationRes; + } catch (error) { + if (axios.isAxiosError>(error)) { + return error.response?.data; + } + + return undefined; + } + } + + async approveManager( + id: number, + notes?: string + ): Promise | undefined> { + try { + const approveRes = await httpClient>( + `${this.basePath}/approvals/manager`, + { + method: 'POST', + body: { + action: 'APPROVED', + approvable_ids: [id], + notes: notes, + }, + } + ); + + return approveRes; + } catch (error) { + if (axios.isAxiosError>(error)) { + return error.response?.data; + } + + return undefined; + } + } + + async bulkApproveManager( + ids: number[], + notes?: string + ): Promise | undefined> { + try { + const bulkApproveRes = await httpClient>( + `${this.basePath}/approvals/manager`, + { + method: 'POST', + body: { + action: 'APPROVED', + approvable_ids: ids, + notes: notes, + }, + } + ); + + return bulkApproveRes; + } catch (error) { + if (axios.isAxiosError>(error)) { + return error.response?.data; + } + + return undefined; + } + } + + async approveFinance( + id: number, + notes?: string + ): Promise | undefined> { + try { + const approveRes = await httpClient>( + `${this.basePath}/approvals/finance`, + { + method: 'POST', + body: { + action: 'APPROVED', + approvable_ids: [id], + notes: notes, + }, + } + ); + + return approveRes; + } catch (error) { + if (axios.isAxiosError>(error)) { + return error.response?.data; + } + + return undefined; + } + } + + async bulkApproveFinance( + ids: number[], + notes?: string + ): Promise | undefined> { + try { + const bulkApproveRes = await httpClient>( + `${this.basePath}/approvals/finance`, + { + method: 'POST', + body: { + action: 'APPROVED', + approvable_ids: ids, + notes: notes, + }, + } + ); + + return bulkApproveRes; + } catch (error) { + if (axios.isAxiosError>(error)) { + return error.response?.data; + } + + return undefined; + } + } + + async rejectManager( + id: number, + notes?: string + ): Promise | undefined> { + try { + const rejectRes = await httpClient>( + `${this.basePath}/approvals/manager`, + { + method: 'POST', + body: { + action: 'REJECTED', + approvable_ids: [id], + notes: notes, + }, + } + ); + + return rejectRes; + } catch (error) { + if (axios.isAxiosError>(error)) { + return error.response?.data; + } + + return undefined; + } + } + + async bulkRejectManager( + ids: number[], + notes?: string + ): Promise | undefined> { + try { + const bulkRejectRes = await httpClient>( + `${this.basePath}/approvals/manager`, + { + method: 'POST', + body: { + action: 'REJECTED', + approvable_ids: ids, + notes: notes, + }, + } + ); + + return bulkRejectRes; + } catch (error) { + if (axios.isAxiosError>(error)) { + return error.response?.data; + } + + return undefined; + } + } + + async rejectFinance( + id: number, + notes?: string + ): Promise | undefined> { + try { + const rejectRes = await httpClient>( + `${this.basePath}/approvals/finance`, + { + method: 'POST', + body: { + action: 'REJECTED', + approvable_ids: [id], + notes: notes, + }, + } + ); + + return rejectRes; + } catch (error) { + if (axios.isAxiosError>(error)) { + return error.response?.data; + } + + return undefined; + } + } + + async bulkRejectFinance( + ids: number[], + notes?: string + ): Promise | undefined> { + try { + const bulkRejectRes = await httpClient>( + `${this.basePath}/approvals/finance`, + { + method: 'POST', + body: { + action: 'REJECTED', + approvable_ids: ids, + notes: notes, + }, + } + ); + + return bulkRejectRes; + } catch (error) { + if (axios.isAxiosError>(error)) { + return error.response?.data; + } + + return undefined; + } + } + + async complete(id: number): Promise | undefined> { + try { + const completeRes = await httpClient>( + `${this.basePath}/${id}/complete`, + { + method: 'POST', + } + ); + + return completeRes; + } catch (error) { + if (axios.isAxiosError>(error)) { + return error.response?.data; + } + + return undefined; + } + } + + async deleteExpenseRequestDocument( + expenseId: number, + documentId: number ): Promise { try { - // const requestDocumentsFormData = new FormData(); + const deleteExpenseRequestDocument = await httpClient( + `${this.basePath}/${expenseId}/documents/${documentId}`, + { + method: 'DELETE', + } + ); - // // request_documents (array of File) - // files.forEach((file, index) => { - // requestDocumentsFormData.append(`request_documents[${index}]`, file); - // }); - - // const uploadRequestDocumentsRes = await httpClient( - // this.basePath, - // { - // method: 'POST', - // body: requestDocumentsFormData, - // } - // ); - - // return uploadRequestDocumentsRes; - - await sleep(1000); - - return { - code: 200, - status: 'success', - message: 'Berhasil menambahkan dokumen pengajuan!', - data: null, - }; + return deleteExpenseRequestDocument; } catch (error) { if (axios.isAxiosError(error)) { return error.response?.data; @@ -879,42 +426,22 @@ export class ExpenseApiService extends BaseApiService< } } - // TODO: integrate to real API - async approve( - id: number, - notes?: string - ): Promise | undefined> { + async deleteExpenseRealizationDocument( + expenseId: number, + documentId: number + ): Promise { try { - // const approveRes = await httpClient>( - // `${this.basePath}/approvals`, - // { - // method: 'POST', - // body: { - // action: 'APPROVED', - // approvable_ids: [id], - // notes: notes, - // }, - // } - // ); - // - // return approveRes; + const deleteExpenseRealizationDocument = + await httpClient( + `${this.basePath}/${expenseId}/realization-documents/${documentId}`, + { + method: 'DELETE', + } + ); - await sleep(1000); - - return { - code: 200, - status: 'success', - message: 'Successfully approve expense data!', - meta: { - page: 1, - limit: 10, - total_pages: 1, - total_results: 1, - }, - data: DUMMY_EXPENSE[id - 1], - }; + return deleteExpenseRealizationDocument; } catch (error) { - if (axios.isAxiosError>(error)) { + if (axios.isAxiosError(error)) { return error.response?.data; } @@ -922,181 +449,94 @@ export class ExpenseApiService extends BaseApiService< } } - // TODO: integrate to real API - async bulkApprove( - ids: number[], - notes?: string - ): Promise | undefined> { + async getApprovalHistory( + expenseId: number, + group: boolean = true, + page: number = 1, + limit: number = 10 + ) { try { - // const approveRes = await httpClient>( - // `${this.basePath}/approvals`, - // { - // method: 'POST', - // body: { - // action: 'APPROVED', - // approvable_ids: ids, - // notes: notes, - // }, - // } - // ); - // - // return approveRes; - - await sleep(1000); - - return { - code: 200, - status: 'success', - message: 'Successfully bulk approve expense data!', - meta: { - page: 1, - limit: 10, - total_pages: 1, - total_results: 1, - }, - data: DUMMY_EXPENSE[ids[0] - 1], - }; - } catch (error) { - if (axios.isAxiosError>(error)) { - return error.response?.data; - } - - return undefined; - } - } - - // TODO: integrate to real API - async reject( - id: number, - notes?: string - ): Promise | undefined> { - try { - // const rejectRes = await httpClient>( - // `${this.basePath}/approvals`, - // { - // method: 'POST', - // body: { - // action: 'REJECTED', - // approvable_ids: [id], - // notes: notes, - // }, - // } - // ); - // - // return rejectRes; - - await sleep(1000); - - return { - code: 200, - status: 'success', - message: 'Successfully reject expense data!', - meta: { - page: 1, - limit: 10, - total_pages: 1, - total_results: 1, - }, - data: DUMMY_EXPENSE[id - 1], - }; - } catch (error) { - if (axios.isAxiosError>(error)) { - return error.response?.data; - } - - return undefined; - } - } - - // TODO: integrate to real API - async bulkReject( - ids: number[], - notes?: string - ): Promise | undefined> { - try { - // const rejectRes = await httpClient>( - // `${this.basePath}/approvals`, - // { - // method: 'POST', - // body: { - // action: 'REJECTED', - // approvable_ids: ids, - // notes: notes, - // }, - // } - // ); - // - // return rejectRes; - - await sleep(1000); - - return { - code: 200, - status: 'success', - message: 'Successfully bulk reject expense data!', - meta: { - page: 1, - limit: 10, - total_pages: 1, - total_results: 1, - }, - data: DUMMY_EXPENSE[ids[0] - 1], - }; - } catch (error) { - if (axios.isAxiosError>(error)) { - return error.response?.data; - } - - return undefined; - } - } - - convertPayloadToFormData = (payload: CreateExpensePayload) => { - const formData = new FormData(); - - formData.append('locationId', String(payload.locationId)); - formData.append('transaction_date', payload.transaction_date); - formData.append('vendorId', String(payload.vendorId)); - - // kandangIds (array) - payload.kandangIds.forEach((id, index) => { - formData.append(`kandangIds[${index}]`, String(id)); - }); - - // request_documents (array of File) - payload.request_documents.forEach((file, index) => { - formData.append(`request_documents[${index}]`, file); - }); - - // kandang_expenses (nested array) - payload.kandang_expenses.forEach((kandangExpense, kandangIndex) => { - formData.append( - `kandang_expenses[${kandangIndex}][kandangId]`, - String(kandangExpense.kandangId) + const approvalHistoryRes = await httpClient( + '/approvals', + { + query: { + module_name: 'EXPENSES', + module_id: expenseId, + group_step_number: group ? 'true' : 'false', + page, + limit, + }, + } ); - kandangExpense.expenses.forEach((expenseItem, expenseIndex) => { - formData.append( - `kandang_expenses[${kandangIndex}][expenses][${expenseIndex}][nonstockId]`, - String(expenseItem.nonstockId) - ); - formData.append( - `kandang_expenses[${kandangIndex}][expenses][${expenseIndex}][total_quantity]`, - String(expenseItem.total_quantity) - ); - formData.append( - `kandang_expenses[${kandangIndex}][expenses][${expenseIndex}][total_expense]`, - String(expenseItem.total_expense) - ); - formData.append( - `kandang_expenses[${kandangIndex}][expenses][${expenseIndex}][notes]`, - expenseItem.notes ?? '' - ); - }); + return approvalHistoryRes; + } catch (error) { + if (axios.isAxiosError(error)) { + return error.response?.data; + } + + return undefined; + } + } + + convertExpenseRequestPayloadToFormData = (payload: CreateExpensePayload) => { + const formData = new FormData(); + + formData.append('category', payload.category); + formData.append('transaction_date', payload.transaction_date); + formData.append('supplier_id', String(payload.supplier_id)); + + // files (multiple "documents" keys) + payload.documents.forEach((file) => { + formData.append('documents', file); }); + formData.append( + 'cost_per_kandangs', + JSON.stringify(payload.cost_per_kandangs) + ); + + return formData; + }; + + convertExpenseRequestUpdatePayloadToFormData = ( + payload: UpdateExpensePayload + ) => { + const formData = new FormData(); + + formData.append('category', payload.category); + formData.append('transaction_date', payload.transaction_date); + formData.append('supplier_id', String(payload.supplier_id)); + + // files (multiple "documents" keys) + payload.documents.forEach((file) => { + formData.append('documents', file); + }); + + formData.append( + 'cost_per_kandang', + JSON.stringify(payload.cost_per_kandang) + ); + + return formData; + }; + + convertExpenseRealizationPayloadToFormData = ( + payload: CreateExpenseRealizationPayload + ) => { + const formData = new FormData(); + + formData.append('realization_date', payload.realization_date); + + // files (multiple "documents" keys) + payload.documents.forEach((file) => { + formData.append('documents', file); + }); + + formData.append('realizations', JSON.stringify(payload.realizations)); + return formData; }; } -export const ExpenseApi = new ExpenseApiService('/expense'); +export const ExpenseApi = new ExpenseApiService('/expenses'); From b616f28c952d3195b57f4fadd4fb1691abcf7e63 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Tue, 25 Nov 2025 09:24:46 +0700 Subject: [PATCH 20/31] feat: add interceptor to axiosClient to redirect to login page if the user is logged out --- src/services/http/client.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/services/http/client.ts b/src/services/http/client.ts index 9dd382ca..f9389a16 100644 --- a/src/services/http/client.ts +++ b/src/services/http/client.ts @@ -1,10 +1,22 @@ import axios from 'axios'; -import type { AxiosRequestConfig } from 'axios'; +import type { AxiosError, AxiosRequestConfig } from 'axios'; import { RequestOptions } from '@/services/http/base'; const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? ''; const axiosClient = axios.create({ baseURL: BASE_URL, timeout: 10_000 }); +axiosClient.interceptors.response.use( + (response) => response, + (error: AxiosError) => { + if (error.response?.status === 401) { + const ssoLoginUrl = `${process.env.NEXT_PUBLIC_SSO_LOGIN_URL as string}?redirect_url=${window.location.href}`; + window.location.href = ssoLoginUrl; + } + + return Promise.reject(error); + } +); + export async function httpClient( path: string, opts: RequestOptions = {} From c3e4d4c630e3c0bf32419b9aa09c12ce0a05fee6 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Tue, 25 Nov 2025 09:25:40 +0700 Subject: [PATCH 21/31] chore(FE-199,207): adjust expense type according to the API need --- src/types/api/expense.d.ts | 125 ++++++++++++++++++++++++++----------- 1 file changed, 89 insertions(+), 36 deletions(-) diff --git a/src/types/api/expense.d.ts b/src/types/api/expense.d.ts index 78a8ac76..71863503 100644 --- a/src/types/api/expense.d.ts +++ b/src/types/api/expense.d.ts @@ -1,54 +1,107 @@ import { BaseApproval, BaseMetadata } from '@/types/api/api-general'; -import { Location } from '@/types/api/master-data/location'; -import { Kandang } from '@/types/api/master-data/kandang'; -import { Supplier } from '@/types/api/master-data/supplier'; -import { Nonstock } from '@/types/api/master-data/nonstock'; +import { BaseLocation, Location } from '@/types/api/master-data/location'; +import { BaseKandang, Kandang } from '@/types/api/master-data/kandang'; +import { BaseSupplier, Supplier } from '@/types/api/master-data/supplier'; +import { BaseNonstock, Nonstock } from '@/types/api/master-data/nonstock'; +import { BaseUser } from '@/types/api/user'; export type BaseExpense = { id: number; reference_number: string; - po_number?: string; - location: Location; - transaction_date: string; - realization_date?: string; - kandangs: Kandang[]; - vendor: Supplier; - request_documents: { - name: string; - url: string; + po_number: string | null; + category: 'BOP' | 'NON-BOP'; + documents?: { + id: number; + path: string; }[]; - kandang_expenses: { - kandang: Kandang; - expenses: { - nonstock: Nonstock; - total_quantity: number; - total_expense: number; - notes?: string; + realization_docs?: { + id: number; + path: string; + }[]; + expense_date: string; + realization_date?: string; + grand_total: number; + location: BaseLocation; + supplier: BaseSupplier; + kandangs: { + id: number; + kandang_id: number; + name: string; + pengajuans?: { + id: number; + qty: number; + unit_price: number; + total_price: number; + note?: string; + nonstock: Pick; + project_flock_kandang: { + id: number; + kandang_id: number; + }; + }[]; + realisasi?: { + id: number; + qty: number; + unit_price: number; + total_price: number; + date: string; + note?: string; + nonstock: Pick; + project_flock_kandang: { + id: number; + kandang_id: number; + }; }[]; }[]; - nominal: number; - paid?: number; - remaining_cost?: number; - approval: BaseApproval; + total_pengajuan: number; + total_realisasi: number; + latest_approval: BaseApproval; }; export type Expense = BaseMetadata & BaseExpense; export type CreateExpensePayload = { - locationId: number; + category: 'BOP' | 'NON-BOP'; transaction_date: string; - kandangIds: number[]; - vendorId: number; - request_documents: File[]; - kandang_expenses: { - kandangId: number; - expenses: { - nonstockId: number; - total_quantity: number; - total_expense: number; - notes?: string; + supplier_id: number; + documents: File[]; + cost_per_kandangs: { + kandang_id: number; + cost_items: { + nonstock_id: number; + quantity: number; + total_cost: number; + notes: string; }[]; }[]; }; -export type UpdateExpensePayload = CreateExpensePayload; +export type UpdateExpensePayload = { + category: 'BOP' | 'NON-BOP'; + transaction_date: string; + supplier_id: number; + documents: File[]; + cost_per_kandang: { + kandang_id: number; + cost_items: { + nonstock_id: number; + quantity: number; + total_cost: number; + notes: string; + }[]; + }[]; +}; + +export type CreateExpenseRealizationPayload = { + realization_date: string; + documents: File[]; + realizations: { + expense_nonstock_id: number; + qty: number; + unit_price: number; + total_price: number; + notes: string; + }[]; +}; + +export type UpdateExpenseRealizationPayload = CreateExpenseRealizationPayload; From 642f966985006ec078aff9ec887a6390cbe90d79 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Tue, 25 Nov 2025 10:47:34 +0700 Subject: [PATCH 22/31] chore: adjust ApprovalSteps component --- src/components/pages/ApprovalSteps.tsx | 80 ++++++++++++++++++++------ 1 file changed, 61 insertions(+), 19 deletions(-) diff --git a/src/components/pages/ApprovalSteps.tsx b/src/components/pages/ApprovalSteps.tsx index 7185e31b..766c17d2 100644 --- a/src/components/pages/ApprovalSteps.tsx +++ b/src/components/pages/ApprovalSteps.tsx @@ -18,6 +18,7 @@ import { useCallback, useMemo } from 'react'; export type ApprovalStepStatus = 'APPROVED' | 'REJECTED' | 'WAITING' | 'IDLE'; export type ApprovalStepLog = { + action: string; action_by?: string; date?: string; notes?: string | null; @@ -65,28 +66,55 @@ const ApprovalSteps = ({ approvals }: ApprovalStepsProps) => { position='right' className={{ wrapper: 'md:tooltip-bottom', + content: 'p-0 rounded overflow-hidden', }} content={ <> {approval.logs && approval.logs.length > 0 && ( -
    - {approval.logs?.map((approvalLog, logIdx) => ( -
    - {approvalLog.date && ( - - {formatDate( - approvalLog.date, - 'YYYY-MM-DD, HH:mm:ss' - )} - - )} - Oleh: {approvalLog.action_by ?? '-'} - Catatan: {approvalLog.notes ?? '-'} -
    - ))} +
    + {approval.logs?.map((approvalLog, logIdx) => { + const action = + approvalLog.action === 'CREATED' + ? 'Dibuat' + : approvalLog.action === 'UPDATED' + ? 'Diperbarui' + : approvalLog.action === 'APPROVED' + ? 'Disetujui' + : approvalLog.action === 'REJECTED' + ? 'Ditolak' + : '-'; + + return ( +
    + {approvalLog.date && ( + + {formatDate( + approvalLog.date, + 'YYYY-MM-DD, HH:mm:ss' + )} + + )} + Aksi: {action} + Oleh: {approvalLog.action_by ?? '-'} + Catatan: {approvalLog.notes ?? '-'} +
    + ); + })}
    )} @@ -130,6 +158,8 @@ export const formatGroupedApprovalsToApprovalSteps = ( const lastStepNumber = groupedApprovals[groupedApprovals.length - 1]?.step_number; + const isLatestApprovalRejected = latestApproval.action === 'REJECTED'; + if (!approvalGroup && currentStepNumber <= lastStepNumber) { throw new Error( `Approval dengan ${approvalLineItem.step_name} tidak ditemukan!` @@ -158,6 +188,7 @@ export const formatGroupedApprovalsToApprovalSteps = ( if (approvalGroup.approvals) { switch (approvalGroup?.approvals[0]?.action) { case 'CREATED': + case 'UPDATED': case 'APPROVED': approvalStatus = 'APPROVED'; break; @@ -171,7 +202,10 @@ export const formatGroupedApprovalsToApprovalSteps = ( break; } } - } else if (approvalGroup.step_number === latestApproval.step_number + 1) { + } else if ( + approvalGroup.step_number === latestApproval.step_number + 1 && + !isLatestApprovalRejected + ) { approvalStatus = 'WAITING'; } else { approvalStatus = 'IDLE'; @@ -182,6 +216,7 @@ export const formatGroupedApprovalsToApprovalSteps = ( action_by: approval.action_by.name, date: approval.action_at, notes: approval.notes, + action: approval.action, })) : []; @@ -192,6 +227,13 @@ export const formatGroupedApprovalsToApprovalSteps = ( }; }); + console.log({ + approvalLine, + groupedApprovals, + latestApproval, + formattedApprovalSteps, + }); + return formattedApprovalSteps; }; From b805fb4ae1debf5856ccf5fc937ff0795394acd8 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Tue, 25 Nov 2025 10:48:27 +0700 Subject: [PATCH 23/31] chore(FE-196): use useApprovalSteps hook --- .../pages/expense/ExpenseRequestContent.tsx | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/components/pages/expense/ExpenseRequestContent.tsx b/src/components/pages/expense/ExpenseRequestContent.tsx index fa3f7d6d..0cdbc22c 100644 --- a/src/components/pages/expense/ExpenseRequestContent.tsx +++ b/src/components/pages/expense/ExpenseRequestContent.tsx @@ -14,6 +14,7 @@ import ExpenseStatusBadge from '@/components/pages/expense/ExpenseStatusBadge'; import DropFileInput from '@/components/input/DropFileInput'; import ApprovalSteps, { formatGroupedApprovalsToApprovalSteps, + useApprovalSteps, } from '@/components/pages/ApprovalSteps'; import { useModal } from '@/components/Modal'; import ConfirmationModal from '@/components/modal/ConfirmationModal'; @@ -40,10 +41,17 @@ const ExpenseRequestContent = ({ }: ExpenseRequestContentProps) => { const router = useRouter(); - const { data: approvalHistory, isLoading: isLoadingApprovalHistory } = useSWR( - initialValues ? [String(initialValues.id)] : null, - ([id]: string[]) => ExpenseApi.getApprovalHistory(Number(id)) - ); + const { approvals: approvalHistory, isLoading: isLoadingApprovalHistory } = + useApprovalSteps({ + latestApproval: initialValues?.latest_approval, + approvalLines: EXPENSE_REQUEST_APPROVAL_LINE, + moduleName: 'EXPENSES', + moduleId: initialValues?.id.toString() ?? '', + params: { + page: 1, + limit: 100, + }, + }); const isLatestApprovalRejected = initialValues?.latest_approval.action === 'REJECTED'; @@ -236,19 +244,11 @@ const ExpenseRequestContent = ({ return ( <>
    - {initialValues && - !isLoadingApprovalHistory && - isResponseSuccess(approvalHistory) && ( -
    - -
    - )} + {initialValues && !isLoadingApprovalHistory && approvalHistory && ( +
    + +
    + )}
    {/* TODO: apply RBAC */} From 507543eff8b20aaa18a2df2006d82a743456193a Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Thu, 27 Nov 2025 09:38:26 +0700 Subject: [PATCH 24/31] chore: remove unnecessary code --- src/components/pages/ApprovalSteps.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/components/pages/ApprovalSteps.tsx b/src/components/pages/ApprovalSteps.tsx index 13ab7560..d5dcabc0 100644 --- a/src/components/pages/ApprovalSteps.tsx +++ b/src/components/pages/ApprovalSteps.tsx @@ -227,13 +227,6 @@ export const formatGroupedApprovalsToApprovalSteps = ( }; }); - console.log({ - approvalLine, - groupedApprovals, - latestApproval, - formattedApprovalSteps, - }); - return formattedApprovalSteps; }; From 909aa3357c65f344681bca60c1fb6020b1ad20aa Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 28 Nov 2025 10:24:56 +0700 Subject: [PATCH 25/31] chore: add MBU logo --- public/assets/img/mbu-logo.png | Bin 0 -> 28241 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 public/assets/img/mbu-logo.png diff --git a/public/assets/img/mbu-logo.png b/public/assets/img/mbu-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..0decc231c1e87010ac25497ea3f90ed73ad269d6 GIT binary patch literal 28241 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 literal 0 HcmV?d00001 From b8590040fff8b687fd016306f7f33c7e6b20f87c Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 28 Nov 2025 10:28:33 +0700 Subject: [PATCH 26/31] chore: remove unnecessary code --- src/components/helper/RequireAuth.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/helper/RequireAuth.tsx b/src/components/helper/RequireAuth.tsx index 33540b41..119d74cb 100644 --- a/src/components/helper/RequireAuth.tsx +++ b/src/components/helper/RequireAuth.tsx @@ -1,7 +1,7 @@ 'use client'; import { ReactNode, useEffect } from 'react'; -import { usePathname, useRouter } from 'next/navigation'; +import { useRouter } from 'next/navigation'; import useSWRImmutable from 'swr/immutable'; import { useAuth } from '@/services/hooks/useAuth'; From 2001cdb843d596fa433ba88a91f8ed15959e505b Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 28 Nov 2025 10:29:36 +0700 Subject: [PATCH 27/31] chore(FE-205): adjust content styling --- .../expense/ExpenseRealizationContent.tsx | 42 ++++++++++--------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/src/components/pages/expense/ExpenseRealizationContent.tsx b/src/components/pages/expense/ExpenseRealizationContent.tsx index b9199102..478cdadf 100644 --- a/src/components/pages/expense/ExpenseRealizationContent.tsx +++ b/src/components/pages/expense/ExpenseRealizationContent.tsx @@ -163,32 +163,36 @@ const ExpenseRealizationContent = ({
    -

    Nominal Pengajuan

    +
    +

    Nominal Pengajuan

    - - {formatCurrency(initialValues?.total_pengajuan as number)} - + + {formatCurrency(initialValues?.total_pengajuan as number)} + - - Terbayar{' '} - {formatCurrency(initialValues?.total_realisasi as number)} - + + Terbayar{' '} + {formatCurrency(initialValues?.total_realisasi as number)} + +
    -

    Nominal Realisasi

    +
    +

    Nominal Realisasi

    - - {formatCurrency(initialValues?.total_realisasi as number)} - + + {formatCurrency(initialValues?.total_realisasi as number)} + - - Selisih{' '} - {formatCurrency( - (initialValues?.total_realisasi as number) - - (initialValues?.total_pengajuan as number) - )} - + + Selisih{' '} + {formatCurrency( + (initialValues?.total_realisasi as number) - + (initialValues?.total_pengajuan as number) + )} + +
    From 68c1e76a4a4a08209fc7a135fcee2093b77411ab Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 28 Nov 2025 10:30:26 +0700 Subject: [PATCH 28/31] feat(FE-196,199): add Expense PDF Preview button --- src/components/pages/expense/ExpenseRequestContent.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/components/pages/expense/ExpenseRequestContent.tsx b/src/components/pages/expense/ExpenseRequestContent.tsx index 0cdbc22c..af8ceddc 100644 --- a/src/components/pages/expense/ExpenseRequestContent.tsx +++ b/src/components/pages/expense/ExpenseRequestContent.tsx @@ -13,12 +13,12 @@ import RealizationStatusBadge from '@/components/pages/expense/RealizationStatus import ExpenseStatusBadge from '@/components/pages/expense/ExpenseStatusBadge'; import DropFileInput from '@/components/input/DropFileInput'; import ApprovalSteps, { - formatGroupedApprovalsToApprovalSteps, useApprovalSteps, } from '@/components/pages/ApprovalSteps'; import { useModal } from '@/components/Modal'; import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes'; +import ExpensePDFPreviewButton from '@/components/pages/expense//pdf/ExpensePDFButton'; import { Expense } from '@/types/api/expense'; import { formatCurrency, formatDate } from '@/lib/helper'; @@ -358,7 +358,12 @@ const ExpenseRequestContent = ({ Nomor PO : - {initialValues?.po_number ?? '-'} + + {!initialValues?.po_number && '-'} + {initialValues?.po_number && ( + + )} + Nomor Referensi From 1de743a40452c1260baa2505e1d6fb1c61b25126 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 28 Nov 2025 10:31:08 +0700 Subject: [PATCH 29/31] feat(FE-196,199): create ExpensePDFButton component --- .../pages/expense/pdf/ExpensePDFButton.tsx | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/components/pages/expense/pdf/ExpensePDFButton.tsx diff --git a/src/components/pages/expense/pdf/ExpensePDFButton.tsx b/src/components/pages/expense/pdf/ExpensePDFButton.tsx new file mode 100644 index 00000000..fe062c25 --- /dev/null +++ b/src/components/pages/expense/pdf/ExpensePDFButton.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { pdf } from '@react-pdf/renderer'; + +import Button from '@/components/Button'; +import { Icon } from '@iconify/react'; +import ExpensePDF from '@/components/pages/expense/pdf/ExpensePDF'; + +import { Expense } from '@/types/api/expense'; + +interface ExpensePDFPreviewButtonProps { + expense?: Expense; +} + +const ExpensePDFPreviewButton = ({ expense }: ExpensePDFPreviewButtonProps) => { + const openPdf = async () => { + const expensePdfBlob = await pdf().toBlob(); + + const expensePdfUrl = URL.createObjectURL(expensePdfBlob); + window.open(expensePdfUrl, '_blank'); + }; + + const downloadPdf = async () => { + const blob = await pdf().toBlob(); + const url = URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.href = url; + link.download = `${expense?.po_number}.pdf`; + link.click(); + + URL.revokeObjectURL(url); + }; + + return ( +
    + + + +
    + ); +}; + +export default ExpensePDFPreviewButton; From 0cc01ae738159d088e53bb1cebc88a13ccbf6186 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 28 Nov 2025 10:31:27 +0700 Subject: [PATCH 30/31] feat(FE-196,199): create ExpensePDF component --- .../pages/expense/pdf/ExpensePDF.tsx | 651 ++++++++++++++++++ 1 file changed, 651 insertions(+) create mode 100644 src/components/pages/expense/pdf/ExpensePDF.tsx diff --git a/src/components/pages/expense/pdf/ExpensePDF.tsx b/src/components/pages/expense/pdf/ExpensePDF.tsx new file mode 100644 index 00000000..664ecee1 --- /dev/null +++ b/src/components/pages/expense/pdf/ExpensePDF.tsx @@ -0,0 +1,651 @@ +'use client'; + +import { + Document, + Image, + Link, + Page, + StyleSheet, + Text, + View, +} from '@react-pdf/renderer'; + +import { Expense } from '@/types/api/expense'; +import { formatCurrency, formatDate, formatNumber } from '@/lib/helper'; + +interface ExpensePDFProps { + expense?: Expense; +} + +const ExpensePDFStyle = StyleSheet.create({ + page: { + paddingTop: 24, + paddingBottom: 64, + paddingHorizontal: 32, + }, + + companyInfoHeader: { + width: '100%', + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: 8, + }, + companyLogo: { + width: 64, + height: 'auto', + }, + companyInfoHeaderDate: { + paddingTop: 8, + fontSize: 12, + }, + companyName: { + fontSize: 12, + fontWeight: 'bold', + marginBottom: 4, + }, + companyAddress: { + fontSize: 8, + maxWidth: 400, + marginBottom: 10, + }, + + title: { + marginTop: 16, + fontSize: 16, + lineHeight: '150%', + textAlign: 'center', + fontFamily: 'Times-Roman', + fontWeight: 'bold', + }, + + footer: { + width: '100%', + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 32, + + position: 'absolute', + fontSize: 10, + bottom: 30, + left: 0, + right: 0, + textAlign: 'center', + color: 'grey', + }, + + // wrapper + generalInfoTable: { + width: '100%', + marginTop: 8, + borderWidth: 1, + borderColor: '#000000', + borderBottomWidth: 0, + fontSize: 12, + }, + + generalInfoTableRow: { + flexDirection: 'row', + borderBottomWidth: 1, + borderBottomColor: '#000000', + }, + + // columns + generalInfoTableColLabel: { + width: '30%', + paddingVertical: 6, + paddingHorizontal: 8, + }, + generalInfoTableColSeparator: { + width: '3%', + justifyContent: 'center', + alignItems: 'center', + paddingVertical: 6, + }, + generalInfoTableColValue: { + width: '67%', + paddingVertical: 6, + paddingHorizontal: 8, + }, + + generalInfoTableLabelText: { + fontWeight: 'bold', + }, + generalInfoTableValueText: {}, + + // expense detail table + expenseDetailContainer: { + width: '100%', + marginTop: 12, + }, + expenseDetailTitle: { + fontSize: 14, + lineHeight: '150%', + fontFamily: 'Times-Roman', + fontWeight: 'bold', + textAlign: 'center', + }, + kandangExpenseContainer: { + width: '100%', + marginTop: 8, + }, + kandangExpenseTitle: { + fontSize: 14, + lineHeight: '150%', + fontFamily: 'Times-Roman', + fontWeight: 'bold', + textAlign: 'center', + }, + kandangExpenseTable: { + width: '100%', + marginTop: 8, + borderWidth: 1, + borderColor: '#000000', + borderBottomWidth: 0, + fontSize: 12, + }, + kandangExpenseTableRow: { + flexDirection: 'row', + borderBottomWidth: 1, + borderBottomColor: '#000000', + }, + kandangExpenseTableColLabel: { + width: '20%', + paddingVertical: 6, + paddingHorizontal: 8, + }, + kandangExpenseTableColLabelBorderRight: { + borderRight: '1px solid #000000', + }, + kandangExpenseTableColNonstock: { + width: '20%', + }, + kandangExpenseTableColNote: { + width: '40%', + }, + kandangExpenseHeaderLabelText: { + fontWeight: 'bold', + }, + kandangExpenseLabelText: { + fontSize: 10, + }, + kandangExpenseTableFooterColTotalExpenseCaption: { + width: '40%', + paddingVertical: 6, + paddingHorizontal: 8, + textAlign: 'right', + }, + kandangExpenseTableFooterColTotalExpenseValue: { + width: '60%', + paddingVertical: 6, + paddingHorizontal: 8, + }, + + // utils + doubleDivider: { + width: '100%', + height: 6, + borderTop: '2px solid black', + borderBottom: '2px solid black', + }, +}); + +const ExpensePDF = ({ expense }: ExpensePDFProps) => { + const isLatestApprovalRejected = + expense?.latest_approval?.action === 'REJECTED'; + const isExpenseRealized = + expense?.latest_approval?.step_number && + expense?.latest_approval.step_number >= 4; + + const realizationStatus = isExpenseRealized + ? 'Sudah Realisasi' + : 'Belum Realisasi'; + + const rows = [ + { label: 'Nomor PO', value: expense?.po_number }, + { label: 'Nomor Referensi', value: expense?.reference_number }, + { + label: 'Kategori', + value: + expense?.category === 'BOP' + ? 'Biaya Operasional' + : expense?.category === 'NON-BOP' + ? 'Non Biaya Operasional' + : '', + }, + { label: 'Lokasi', value: expense?.location.name }, + { + label: 'Kandang', + value: expense?.kandangs.map((item) => item.name).join(', '), + }, + { label: 'Vendor', value: expense?.supplier.name }, + { + label: 'Tanggal Transaksi', + value: formatDate(expense?.expense_date, 'DD MMMM YYYY'), + }, + { + label: 'Tanggal Realisasi', + value: expense?.realization_date + ? formatDate(expense?.realization_date, 'DD MMMM YYYY') + : '-', + }, + { label: 'Nama Pengaju', value: expense?.created_user.name }, + { + label: 'Nominal Biaya', + value: formatCurrency(expense?.grand_total ?? 0), + }, + { + label: 'Nominal Pengajuan', + value: formatCurrency(expense?.total_pengajuan ?? 0), + }, + { + label: 'Nominal Realisasi', + value: expense?.total_realisasi + ? formatCurrency(expense?.total_realisasi ?? 0) + : '-', + }, + { label: 'Status Pencairan', value: realizationStatus }, + { + label: 'Status Biaya', + value: isLatestApprovalRejected + ? 'Ditolak' + : expense?.latest_approval?.step_name, + }, + ]; + + return ( + + + + + + + + {formatDate(Date.now(), 'DD MMMM YYYY')} + + + + + + PT LUMBUNG TELUR INDONESIA + + + SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel. + Cipedes, Kec. Sukajadi, Kota Bandung 40162 + + + + + + + + Laporan{' '} + {expense?.category === 'BOP' + ? 'Biaya Operasional' + : 'Non-Biaya Operasional'}{' '} + {expense?.po_number} + + + {/* General info table */} + + {rows.map((row) => ( + + + + {row.label} + + + + : + + + + {row.value} + + + + ))} + + + {/* Detail expense request */} + + + Rincian Pengajuan Biaya Operasional + + + {expense?.kandangs.map((kandangExpense, kandangExpenseIdx) => { + let expenseRequestTotal = 0; + + kandangExpense.pengajuans?.forEach( + (item) => (expenseRequestTotal += item.total_price) + ); + + return ( + + + {kandangExpense.name} + + + + + + + Nonstock + + + + + Kuantitas + + + + + Total Biaya + + + + + Catatan + + + + + {kandangExpense.pengajuans?.map((pengajuan, pengajuanIdx) => ( + + + + {pengajuan.nonstock.name} + + + + + {formatNumber(pengajuan.qty)} + + + + + {formatCurrency(pengajuan.total_price)} + + + + + {pengajuan.note} + + + + ))} + + + + + Total Biaya Keseluruhan + + + + + {formatCurrency(expenseRequestTotal)} + + + + + + ); + })} + + + {/* Detail expense realization */} + + + Rincian Realisasi Biaya Operasional + + + {expense?.kandangs.map((kandangExpense, kandangExpenseIdx) => { + let expenseRealizationTotal = 0; + + kandangExpense.realisasi?.forEach( + (item) => (expenseRealizationTotal += item.total_price) + ); + + return ( + + + {kandangExpense.name} + + + + + + + Nonstock + + + + + Kuantitas + + + + + Total Biaya + + + + + Catatan + + + + + {kandangExpense.realisasi?.map((realisasi, realisasiIdx) => ( + + + + {realisasi.nonstock.name} + + + + + {formatNumber(realisasi.qty)} + + + + + {formatCurrency(realisasi.total_price)} + + + + + {realisasi.note} + + + + ))} + + + + + Total Biaya Keseluruhan + + + + + {formatCurrency(expenseRealizationTotal)} + + + + + + ); + })} + + + + + {expense?.po_number} + + + + `${pageNumber} / ${totalPages}` + } + fixed + /> + + + + ); +}; + +export default ExpensePDF; From f82ca4f9592af8f1bd6f6a8aeccc098e0e7a9f26 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 28 Nov 2025 10:32:00 +0700 Subject: [PATCH 31/31] chore(FE-195): adjust RowOptionsMenu type --- src/components/pages/expense/ExpensesTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/pages/expense/ExpensesTable.tsx b/src/components/pages/expense/ExpensesTable.tsx index 01573a31..3a50f233 100644 --- a/src/components/pages/expense/ExpensesTable.tsx +++ b/src/components/pages/expense/ExpensesTable.tsx @@ -365,7 +365,7 @@ const ExpensesTable = () => { {currentPageSize <= 3 && (