diff --git a/package-lock.json b/package-lock.json index 2cac4bc7..f64e3a8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "react": "19.1.0", "react-day-picker": "^9.11.1", "react-dom": "19.1.0", + "react-dropzone": "^14.3.8", "react-hot-toast": "^2.6.0", "react-number-format": "^5.4.4", "react-select": "^5.10.2", @@ -2524,6 +2525,15 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/attr-accept": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -3744,6 +3754,18 @@ "node": ">=16.0.0" } }, + "node_modules/file-selector": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", + "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", + "license": "MIT", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -5805,6 +5827,23 @@ "react": "^19.1.0" } }, + "node_modules/react-dropzone": { + "version": "14.3.8", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz", + "integrity": "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==", + "license": "MIT", + "dependencies": { + "attr-accept": "^2.2.4", + "file-selector": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8 || 18.0.0" + } + }, "node_modules/react-fast-compare": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", diff --git a/package.json b/package.json index 033c2963..c2f4f4e6 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "react": "19.1.0", "react-day-picker": "^9.11.1", "react-dom": "19.1.0", + "react-dropzone": "^14.3.8", "react-hot-toast": "^2.6.0", "react-number-format": "^5.4.4", "react-select": "^5.10.2", diff --git a/src/app/expense/add/page.tsx b/src/app/expense/add/page.tsx new file mode 100644 index 00000000..afa40f48 --- /dev/null +++ b/src/app/expense/add/page.tsx @@ -0,0 +1,11 @@ +import ExpenseRequestForm from '@/components/pages/expense/form/ExpenseRequestForm'; + +const AddExpense = () => { + return ( +
+ +
+ ); +}; + +export default AddExpense; diff --git a/src/app/expense/detail/edit/page.tsx b/src/app/expense/detail/edit/page.tsx new file mode 100644 index 00000000..b37fdb8f --- /dev/null +++ b/src/app/expense/detail/edit/page.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +import ExpenseRequestForm from '@/components/pages/expense/form/ExpenseRequestForm'; + +import { ExpenseApi } from '@/services/api/expense'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; + +const ExpenseEditPage = () => { + 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 isExpenseRejectedOrApproved = + !isLoadingExpense && + isResponseSuccess(expense) && + (expense.data.approval.action === 'REJECTED' || + expense.data.approval.step_number === 5); + + if (isExpenseRejectedOrApproved) { + router.back(); + return; + } + + return ( +
+ {isLoadingExpense && ( + + )} + + {!isLoadingExpense && isResponseSuccess(expense) && ( + + )} +
+ ); +}; + +export default ExpenseEditPage; diff --git a/src/app/expense/detail/layout.tsx b/src/app/expense/detail/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/expense/detail/layout.tsx @@ -0,0 +1,11 @@ +import SuspenseHelper from '@/components/helper/SuspenseHelper'; + +const Layout = ({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) => { + return {children}; +}; + +export default Layout; diff --git a/src/app/expense/detail/page.tsx b/src/app/expense/detail/page.tsx new file mode 100644 index 00000000..a0d90f70 --- /dev/null +++ b/src/app/expense/detail/page.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +import ExpenseDetail from '@/components/pages/expense/ExpenseDetail'; + +import { ExpenseApi } from '@/services/api/expense'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; + +const ExpenseDetailPage = () => { + 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; + } + + return ( +
+ {isLoadingExpense && ( + + )} + + {!isLoadingExpense && isResponseSuccess(expense) && ( + + )} +
+ ); +}; + +export default ExpenseDetailPage; diff --git a/src/app/expense/page.tsx b/src/app/expense/page.tsx new file mode 100644 index 00000000..d6b00286 --- /dev/null +++ b/src/app/expense/page.tsx @@ -0,0 +1,11 @@ +import ExpensesTable from '@/components/pages/expense/ExpensesTable'; + +const Expense = () => { + return ( +
+ +
+ ); +}; + +export default Expense; diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 7cad5b58..2f209ece 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -3,7 +3,7 @@ import Link from 'next/link'; import { cn } from '@/lib/helper'; import { Color } from '@/types/theme'; -interface ButtonProps extends react.ComponentProps<'button'> { +export interface ButtonProps extends react.ComponentProps<'button'> { variant?: 'soft' | 'outline' | 'dash' | 'ghost' | 'link' | 'active'; color?: Color; href?: string; diff --git a/src/components/input/DateInput.tsx b/src/components/input/DateInput.tsx index 7317b038..a85c1f10 100644 --- a/src/components/input/DateInput.tsx +++ b/src/components/input/DateInput.tsx @@ -201,6 +201,7 @@ const DateInput = ({ {label} {required && ( + {' '} * )} diff --git a/src/components/input/DropFileInput.tsx b/src/components/input/DropFileInput.tsx new file mode 100644 index 00000000..e146a994 --- /dev/null +++ b/src/components/input/DropFileInput.tsx @@ -0,0 +1,194 @@ +import { useEffect } from 'react'; +import { useDropzone, type Accept } from 'react-dropzone'; + +import { Icon } from '@iconify/react'; +import Button from '@/components/Button'; + +import { cn } from '@/lib/helper'; + +interface DropFileInputProps { + name: string; + label?: string; + bottomLabel?: string; + caption?: string; + values?: File[]; + accept?: Accept; + required?: boolean; + maxFiles?: number; // defaults to 1 + maxSize?: number; // defaults to 2097152 (2 MB) + isError?: boolean; + errorMessage?: string; + disabled?: boolean; + onChange?: (files: File[]) => void; + onDelete?: (index: number) => void; + className?: { + wrapper?: string; + inputContainer?: string; + label?: string; + inputWrapper?: string; + caption?: string; + bottomLabel?: string; + errorMessage?: string; + fileItemContainer?: string; + }; +} + +const DropFileInput: React.FC = ({ + name, + label, + bottomLabel, + caption = 'Seret atau Pilih Dokumen', + values, + accept, + required, + maxFiles = Infinity, + maxSize, + isError, + errorMessage, + disabled, + onChange, + onDelete, + className, +}) => { + const isDisabled = + Boolean(values && maxFiles && values.length >= maxFiles) || disabled; + + const { + acceptedFiles, + getRootProps, + getInputProps, + isFocused, + isDragAccept, + isDragReject, + } = useDropzone({ + maxSize, + maxFiles, + accept: accept, + disabled: isDisabled, + }); + + useEffect(() => { + if (values && maxFiles && values.length <= maxFiles) { + onChange?.([...values, ...acceptedFiles]); + } + }, [acceptedFiles]); + + return ( +
+
+ {label && ( + + )} + +
+ + {caption && ( +

+ {caption} +

+ )} +
+ + {!isError && bottomLabel && ( +

+ {bottomLabel} +

+ )} + {isError && ( +

+ {errorMessage} +

+ )} +
+ + {values && values.length > 0 && ( +
+ {values.map((file, idx) => ( +
+
+ +
+ +
+

{file.name}

+
+ + +
+ ))} +
+ )} +
+ ); +}; + +export default DropFileInput; diff --git a/src/components/input/SelectInput.tsx b/src/components/input/SelectInput.tsx index 8fa8b555..d35e7589 100644 --- a/src/components/input/SelectInput.tsx +++ b/src/components/input/SelectInput.tsx @@ -179,9 +179,12 @@ const SelectInput = (props: SelectInputProps) => { > {label} {required && ( - - * - + <> + {' '} + + * + + )} )} diff --git a/src/components/modal/ConfirmationModal.tsx b/src/components/modal/ConfirmationModal.tsx index 683345f5..00b63c86 100644 --- a/src/components/modal/ConfirmationModal.tsx +++ b/src/components/modal/ConfirmationModal.tsx @@ -1,30 +1,23 @@ 'use client'; -import { RefObject } from 'react'; +import { MouseEventHandler, RefObject, useState } from 'react'; import { Icon } from '@iconify/react'; import Modal from '@/components/Modal'; -import Button from '@/components/Button'; +import Button, { ButtonProps } from '@/components/Button'; import { cn } from '@/lib/helper'; -import { Color } from '@/types/theme'; export interface ConfirmationModalProps { ref: RefObject; type?: 'info' | 'success' | 'error'; text?: string; closeOnBackdrop?: boolean; - primaryButton?: { + primaryButton?: ButtonProps & { text?: string; - color?: Color; - isLoading?: boolean; - onClick?: () => void; }; - secondaryButton?: { + secondaryButton?: ButtonProps & { text?: string; - color?: Color; - isLoading?: boolean; - onClick?: () => void; }; className?: { modal?: string; @@ -43,10 +36,22 @@ const ConfirmationModal = ({ className, children, }: ConfirmationModalProps) => { + const [isPrimaryButtonLoading, setIsPrimaryButtonLoading] = useState(false); + const closeModalHandler = () => { ref.current?.close(); }; + const primaryButtonClickHandler: MouseEventHandler< + HTMLButtonElement + > = async (event) => { + setIsPrimaryButtonLoading(true); + + await primaryButton?.onClick?.(event); + + setIsPrimaryButtonLoading(false); + }; + return (
@@ -97,10 +102,15 @@ const ConfirmationModal = ({
{secondaryButton && secondaryButton.text && ( + +

+ Detail Biaya Operasional +

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

+ Rincian Pengajuan Biaya Operasional +

+ +
+ {initialValues?.kandang_expenses.map( + (kandangExpense, kandangExpenseIdx) => { + let expenseGrandTotal = 0; + + kandangExpense.expenses.forEach( + (item) => (expenseGrandTotal += item.total_expense) + ); + + return ( +
+ + + + + + + + + + + + + + {kandangExpense.expenses.map( + (expenseItem, expenseIdx) => ( + + + + + + + ) + )} + + + + + + + +
+ Biaya {kandangExpense.kandang.name} +
NonstockTotal KuantitasTotal BiayaCatatan
{expenseItem.nonstock.name}{expenseItem.total_quantity} + {formatCurrency(expenseItem.total_expense)} + + {expenseItem.notes ?? '-'} +
+ Total Biaya Keseluruhan: + + {formatCurrency(expenseGrandTotal)} +
+
+ ); + } + )} +
+
+ + + + + + + + + ); +}; + +export default ExpenseDetail; diff --git a/src/components/pages/expense/ExpenseStatusBadge.tsx b/src/components/pages/expense/ExpenseStatusBadge.tsx new file mode 100644 index 00000000..3a84f6bc --- /dev/null +++ b/src/components/pages/expense/ExpenseStatusBadge.tsx @@ -0,0 +1,57 @@ +import PillBadge from '@/components/PillBadge'; + +import { BaseApproval } from '@/types/api/api-general'; + +interface ExpenseStatusBadgeProps { + approval?: BaseApproval; +} + +const ExpenseStatusBadge = ({ approval }: ExpenseStatusBadgeProps) => { + const isLatestApprovalRejected = approval?.action === 'REJECTED'; + + const latestApprovalStepNumber = approval?.step_number; + + let expenseStatusPillBadgeColor: + | 'yellow' + | 'green' + | 'gray' + | 'red' + | 'purple' + | 'blue' = 'gray'; + + switch (latestApprovalStepNumber) { + case 1: + expenseStatusPillBadgeColor = 'yellow'; + break; + + case 2: + expenseStatusPillBadgeColor = 'purple'; + break; + + case 3: + expenseStatusPillBadgeColor = 'blue'; + break; + + case 4: + expenseStatusPillBadgeColor = 'red'; + break; + + case 5: + expenseStatusPillBadgeColor = 'green'; + break; + } + + if (isLatestApprovalRejected) { + expenseStatusPillBadgeColor = 'red'; + } + + return ( + + ); +}; + +export default ExpenseStatusBadge; diff --git a/src/components/pages/expense/ExpensesTable.tsx b/src/components/pages/expense/ExpensesTable.tsx new file mode 100644 index 00000000..fc6f6d13 --- /dev/null +++ b/src/components/pages/expense/ExpensesTable.tsx @@ -0,0 +1,699 @@ +'use client'; + +import { ChangeEventHandler, useEffect, useState } from 'react'; +import useSWR from 'swr'; +import { + CellContext, + ColumnDef, + Row, + SortingState, +} from '@tanstack/react-table'; +import toast from 'react-hot-toast'; + +import { Icon } from '@iconify/react'; +import Table from '@/components/Table'; +import DebouncedTextInput from '@/components/input/DebouncedTextInput'; +import Button from '@/components/Button'; +import { useModal } from '@/components/Modal'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; +import SelectInput, { + OptionType, + useSelect, +} from '@/components/input/SelectInput'; +import RowDropdownOptions from '@/components/table/RowDropdownOptions'; +import RowCollapseOptions from '@/components/table/RowCollapseOptions'; +import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper'; +import RealizationStatusBadge from '@/components/pages/expense/RealizationStatusBadge'; +import ExpenseStatusBadge from '@/components/pages/expense/ExpenseStatusBadge'; +import CheckboxInput from '@/components/input/CheckboxInput'; +import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes'; +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 { 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'; + +const RowOptionsMenu = ({ + type = 'dropdown', + props, + approveClickHandler, + rejectClickHandler, + deleteClickHandler, +}: { + type: 'dropdown' | 'collapse'; + props: CellContext; + approveClickHandler: () => void; + rejectClickHandler: () => void; + 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; + + // TODO: apply RBAC + const showApproveButton = showEditButton; + const showRejectButton = showEditButton; + + return ( + + + + {showEditButton && ( + + )} + + {/* TODO: apply RBAC */} + {showApproveButton && ( + + )} + + {showRejectButton && ( + + )} + + {showDeleteButton && ( + + )} + + ); +}; + +const ExpensesTable = () => { + const { + state: tableFilterState, + updateFilter, + setPage, + setPageSize, + toQueryString: getTableFilterQueryString, + } = useTableFilter({ + initial: { + search: '', + nameSort: '', + transactionDate: '', + realizationDate: '', + locationId: '', + vendorId: '', + userId: '', + }, + paramMap: { + page: 'page', + pageSize: 'limit', + nameSort: 'sort_name', + transactionDate: 'transaction_date', + realizationDate: 'realization_date', + locationId: 'location_id', + vendorId: 'vendor_id', + userId: 'user_id', + }, + }); + + const { + data: expenses, + isLoading, + mutate: refreshExpenses, + } = useSWR( + `${ExpenseApi.basePath}${getTableFilterQueryString()}`, + ExpenseApi.getAllFetcher + ); + + const deleteModal = useModal(); + const approveModal = useModal(); + const rejectModal = useModal(); + + const [selectedExpense, setSelectedExpense] = useState( + undefined + ); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + const [isApproveLoading, setIsApproveLoading] = useState(false); + const [isRejectLoading, setIsRejectLoading] = useState(false); + + const [sorting, setSorting] = useState([]); + const [rowSelection, setRowSelection] = useState>({}); + const selectedRowIds = Object.keys(rowSelection).map((item) => + parseInt(item) + ); + + const expensesColumns: ColumnDef[] = [ + { + id: 'select', + header: ({ table }) => ( +
+ +
+ ), + cell: ({ row }) => { + const isCheckboxDisabled = + !row.getCanSelect() || row.original.approval.action === 'REJECTED'; + + return ( +
+ +
+ ); + }, + }, + { + accessorKey: 'transaction_date', + header: 'Tanggal Pengajuan', + }, + { + accessorKey: 'realization_date', + header: 'Tanggal Realisasi', + cell: (props) => props.getValue() ?? '-', + }, + { + accessorKey: 'location', + header: 'Lokasi', + cell: (props) => props.row.original.location.name ?? '-', + }, + { + accessorFn: (row) => row.created_user.name ?? '-', + header: 'Nama Pengaju', + }, + { + accessorFn: (row) => row.vendor.name ?? '-', + header: 'Vendor', + }, + { + accessorKey: 'nominal', + 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)}` + : '-', + }, + { + header: 'Status Pencairan', + cell: (props) => ( + + ), + }, + { + header: 'Status BOP', + cell: (props) => ( + + ), + }, + { + header: 'Aksi', + cell: (props) => { + const currentPageSize = props.table.getPaginationRowModel().rows.length; + const currentPageRows = props.table.getPaginationRowModel().flatRows; + const currentRowRelativeIndex = + currentPageRows.findIndex((r) => r.id === props.row.id) + 1; + + const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2; + + const approveClickHandler = () => { + setSelectedExpense(props.row.original); + + // Set row selection + setRowSelection({ + [String(props.row.original.id)]: true, + }); + + approveModal.openModal(); + }; + + const rejectClickHandler = () => { + setSelectedExpense(props.row.original); + + // Set row selection + setRowSelection({ + [String(props.row.original.id)]: true, + }); + + rejectModal.openModal(); + }; + + const deleteClickHandler = () => { + setSelectedExpense(props.row.original); + deleteModal.openModal(); + }; + + return ( + <> + {currentPageSize > 2 && ( + + + + )} + + {currentPageSize <= 2 && ( + + + + )} + + ); + }, + }, + ]; + + const tableEnableRowSelectionHandler: (row: Row) => boolean = ( + row + ) => { + return row.original.approval.action !== 'REJECTED'; + }; + + const bulkApproveClickHandler = () => { + approveModal.openModal(); + }; + + const bulkRejectClickHandler = () => { + rejectModal.openModal(); + }; + + const confirmationModalDeleteClickHandler = async () => { + setIsDeleteLoading(true); + + await ExpenseApi.delete(selectedExpense?.id as number); + refreshExpenses(); + + deleteModal.closeModal(); + toast.success('Berhasil menghapus biaya operasional!'); + setIsDeleteLoading(false); + }; + + const confirmationModalApproveClickHandler = async (notes: string) => { + setIsApproveLoading(true); + + const bulkApproveResponse = await ExpenseApi.bulkApprove( + selectedRowIds, + notes + ); + + if (isResponseSuccess(bulkApproveResponse)) { + refreshExpenses(); + approveModal.closeModal(); + + toast.success( + `Berhasil approve ${selectedRowIds.length} data transfer ke laying!` + ); + + setRowSelection({}); + } else { + approveModal.closeModal(); + + toast.error( + `Gagal approve ${selectedRowIds.length} data transfer ke laying!` + ); + } + + setIsApproveLoading(false); + }; + + const confirmationModalRejectClickHandler = async (notes: string) => { + setIsRejectLoading(true); + + const bulkRejectResponse = await ExpenseApi.bulkReject( + selectedRowIds, + notes + ); + + if (isResponseSuccess(bulkRejectResponse)) { + refreshExpenses(); + rejectModal.closeModal(); + + toast.success( + `Berhasil reject ${selectedRowIds.length} data transfer ke laying!` + ); + setRowSelection({}); + } else { + rejectModal.closeModal(); + + toast.error( + `Gagal reject ${selectedRowIds.length} data transfer ke laying!` + ); + } + + setIsRejectLoading(false); + }; + + const { + setInputValue: setLocationInputValue, + options: locationOptions, + isLoadingOptions: isLoadingLocationOptions, + } = useSelect(LocationApi.basePath, 'id', 'name'); + + const [selectedLocation, setSelectedLocation] = useState( + null + ); + + const locationChangeHandler = (val: OptionType | OptionType[] | null) => { + setSelectedLocation(val as OptionType); + updateFilter( + 'locationId', + val ? ((val as OptionType).value as string) : '' + ); + }; + + const { + setInputValue: setVendorInputValue, + options: vendorOptions, + isLoadingOptions: isLoadingVendorOptions, + } = useSelect(SupplierApi.basePath, 'id', 'name'); + + const [selectedVendor, setSelectedVendor] = useState(null); + + const vendorChangeHandler = (val: OptionType | OptionType[] | null) => { + setSelectedVendor(val as OptionType); + updateFilter('vendorId', val ? ((val as OptionType).value as string) : ''); + }; + + const searchChangeHandler: ChangeEventHandler = (e) => { + updateFilter('search', e.target.value); + }; + + const transactionDateChangeHandler: ChangeEventHandler = ( + e + ) => { + updateFilter('transactionDate', e.target.value); + }; + + const realizationDateChangeHandler: ChangeEventHandler = ( + e + ) => { + updateFilter('realizationDate', e.target.value); + }; + + const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { + const newVal = val as OptionType; + + setPageSize(newVal.value as number); + }; + + // track sorting + useEffect(() => { + const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name'); + + if (!isNameSorted) { + updateFilter('nameSort', ''); + } else { + updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc'); + } + }, [sorting, updateFilter]); + + return ( + <> +
+
+
+
+
+ + + {selectedRowIds.length > 0 && ( + <> + {/* TODO: apply RBAC */} + + + + + )} +
+ + +
+ +
+ + + + + + + + + +
+
+
+ + + data={isResponseSuccess(expenses) ? expenses?.data : []} + columns={expensesColumns} + pageSize={tableFilterState.pageSize} + page={isResponseSuccess(expenses) ? expenses?.meta?.page : 0} + totalItems={ + isResponseSuccess(expenses) ? expenses?.meta?.total_results : 0 + } + onPageChange={setPage} + isLoading={isLoading} + sorting={sorting} + setSorting={setSorting} + rowSelection={rowSelection} + setRowSelection={setRowSelection} + enableRowSelection={tableEnableRowSelectionHandler} + className={{ + containerClassName: cn({ + 'mb-20': + isResponseSuccess(expenses) && expenses?.data?.length === 0, + }), + tableWrapperClassName: 'overflow-x-auto min-h-full!', + tableClassName: 'font-inter w-full table-auto min-h-full!', + headerRowClassName: 'border-b border-b-gray-200', + headerColumnClassName: + 'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', + bodyRowClassName: 'border-b border-b-gray-200', + bodyColumnClassName: + 'px-6 py-3 last:flex last:flex-row last:justify-end', + }} + /> +
+ + + + + + + + ); +}; + +export default ExpensesTable; diff --git a/src/components/pages/expense/RealizationStatusBadge.tsx b/src/components/pages/expense/RealizationStatusBadge.tsx new file mode 100644 index 00000000..e042c022 --- /dev/null +++ b/src/components/pages/expense/RealizationStatusBadge.tsx @@ -0,0 +1,39 @@ +import PillBadge from '@/components/PillBadge'; + +import { BaseApproval } from '@/types/api/api-general'; + +interface RealizationStatusBadgeProps { + approval?: BaseApproval; +} + +const RealizationStatusBadge = ({ approval }: RealizationStatusBadgeProps) => { + const isLatestApprovalRejected = approval?.action === 'REJECTED'; + + const isExpenseRealized = approval?.step_number && approval.step_number >= 4; + + const realizationStatus = isExpenseRealized + ? 'Sudah Realisasi' + : 'Belum Realisasi'; + + let realizationStatusPillBadgeColor: + | 'yellow' + | 'green' + | 'gray' + | 'red' + | 'purple' + | 'blue' = isExpenseRealized ? 'green' : 'yellow'; + + if (isLatestApprovalRejected) { + realizationStatusPillBadgeColor = 'red'; + } + + return ( + + ); +}; + +export default RealizationStatusBadge; diff --git a/src/components/pages/expense/form/ExpenseKandangsTable.tsx b/src/components/pages/expense/form/ExpenseKandangsTable.tsx new file mode 100644 index 00000000..b3c9f46d --- /dev/null +++ b/src/components/pages/expense/form/ExpenseKandangsTable.tsx @@ -0,0 +1,237 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import useSWR from 'swr'; + +import { Icon } from '@iconify/react'; +import Collapse from '@/components/Collapse'; +import Card from '@/components/Card'; +import Table from '@/components/Table'; +import CheckboxInput from '@/components/input/CheckboxInput'; + +import { ColumnDef, ColumnSort, SortingState } from '@tanstack/react-table'; +import { cn, convertRowSelectionArrToObj } from '@/lib/helper'; +import { Kandang } from '@/types/api/master-data/kandang'; +import { useTableFilter } from '@/services/hooks/useTableFilter'; +import { KandangApi } from '@/services/api/master-data'; +import { isResponseSuccess } from '@/lib/api-helper'; + +interface ExpenseKandangsTableProps { + locationId?: number; + type: 'add' | 'edit' | 'detail'; + selectedKandangs: { + id: number; + name: string; + }[]; + onChange: (kandangs: { id: number; name: string }[]) => void; + className?: { + wrapper?: string; + }; +} + +const ExpenseKandangsTable = ({ + type, + locationId, + selectedKandangs, + onChange, + className, +}: ExpenseKandangsTableProps) => { + const { + state: tableFilterState, + updateFilter, + setPage, + toQueryString: getTableFilterQueryString, + } = useTableFilter({ + initial: { + search: '', + nameSort: '', + picSort: '', + locationId, + }, + paramMap: { + page: 'page', + pageSize: 'limit', + nameSort: 'sort_name', + picSort: 'sort_pic', + locationId: 'location_id', + }, + }); + + const { data: kandangs, isLoading } = useSWR( + locationId ? `${KandangApi.basePath}${getTableFilterQueryString()}` : null, + KandangApi.getAllFetcher + ); + + const [open, setOpen] = useState( + isResponseSuccess(kandangs) ? kandangs.data.length > 0 : false + ); + const [sorting, setSorting] = useState([]); + const [rowSelection, setRowSelection] = useState>( + convertRowSelectionArrToObj(selectedKandangs.map((item) => item.id)) + ); + + const kandangsColumns: ColumnDef[] = [ + { + id: 'select', + header: ({ table }) => ( +
+ +
+ ), + cell: ({ row }) => ( +
+ +
+ ), + }, + { + accessorKey: 'name', + header: 'Nama', + }, + { + accessorKey: 'pic', + header: 'PIC', + cell: (props) => props.row.original.pic.name, + }, + ]; + + const updateSortingFilter = useCallback( + ( + sortName: Exclude, + sortFilter: ColumnSort | undefined + ) => { + if (!sortFilter) { + updateFilter(sortName, ''); + } else { + updateFilter(sortName, sortFilter.desc ? 'desc' : 'asc'); + } + }, + [updateFilter] + ); + + useEffect(() => { + if (locationId) updateFilter('locationId', locationId); + }, [locationId, updateFilter]); + + useEffect(() => { + setOpen(isResponseSuccess(kandangs) ? kandangs.data.length > 0 : false); + }, [kandangs, isResponseSuccess]); + + useEffect(() => { + if (Object.keys(rowSelection).length !== 0 && isResponseSuccess(kandangs)) { + const formattedSelectedKandangs = Object.keys(rowSelection).map( + (item) => { + const selectedKandang = kandangs.data.find( + (kandang) => kandang.id === parseInt(item) + ); + + return { + id: parseInt(item), + name: selectedKandang?.name ?? 'Kandang tidak ditemukan!', + }; + } + ); + + onChange(formattedSelectedKandangs); + } else { + onChange([]); + } + }, [rowSelection]); + + useEffect(() => { + if ( + selectedKandangs.length === 0 && + Object.keys(rowSelection).length !== 0 + ) { + setRowSelection({}); + } + }, [selectedKandangs]); + + // track sorting + useEffect(() => { + const nameSortFilter = sorting.find((sortItem) => sortItem.id === 'name'); + const picSortFilter = sorting.find((sortItem) => sortItem.id === 'pic'); + + updateSortingFilter('nameSort', nameSortFilter); + updateSortingFilter('picSort', picSortFilter); + }, [sorting, updateSortingFilter]); + + return ( + + +
Pilih Kandang
+ + +
+ } + className='w-full!' + titleClassName='w-full p-0!' + > + + data={isResponseSuccess(kandangs) ? kandangs?.data : []} + columns={kandangsColumns} + pageSize={tableFilterState.pageSize} + page={isResponseSuccess(kandangs) ? kandangs?.meta?.page : 0} + totalItems={ + isResponseSuccess(kandangs) ? kandangs?.meta?.total_results : 0 + } + onPageChange={setPage} + isLoading={isLoading} + sorting={sorting} + setSorting={setSorting} + rowSelection={rowSelection} + setRowSelection={setRowSelection} + className={{ + containerClassName: cn({ + 'mb-20': + isResponseSuccess(kandangs) && kandangs?.data?.length === 0, + }), + tableWrapperClassName: 'overflow-x-auto min-h-full!', + tableClassName: 'font-inter w-full table-auto min-h-full!', + headerRowClassName: 'border-b border-b-gray-200', + headerColumnClassName: + 'px-6 py-3 text-xs font-semibold text-gray-500 first:flex first:flex-row first:justify-start', + bodyRowClassName: 'border-b border-b-gray-200', + bodyColumnClassName: + 'px-6 py-3 first:flex first:flex-row first:justify-start', + paginationClassName: cn({ + hidden: + isResponseSuccess(kandangs) && + kandangs?.meta?.total_pages === 1, + }), + }} + /> + + + ); +}; + +export default ExpenseKandangsTable; diff --git a/src/components/pages/expense/form/ExpenseRequestForm.schema.ts b/src/components/pages/expense/form/ExpenseRequestForm.schema.ts new file mode 100644 index 00000000..4c2ae600 --- /dev/null +++ b/src/components/pages/expense/form/ExpenseRequestForm.schema.ts @@ -0,0 +1,144 @@ +import * as Yup from 'yup'; +import { Expense } from '@/types/api/expense'; +import { formatDate } from '@/lib/helper'; + +type ExpenseFormSchemaType = { + location?: { + value: number; + label: string; + }; + transaction_date?: string; + kandangs?: { id: number; name: string }[]; + vendor?: { + value: number; + label: string; + }; + existing_documents?: { name: string; url: string }[]; + request_documents?: File[]; + kandangExpenses: { + kandangId: number; + expenses: { + nonstock?: { + value: number; + label: string; + }; + totalQuantity?: number; + totalExpense?: number; + notes?: string; + }[]; + }[]; +}; + +export const ExpenseRequestFormSchema: Yup.ObjectSchema = + Yup.object({ + location: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).required('Lokasi wajib diisi!'), + + transaction_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!'), + + vendor: 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(), + }) + ), + + request_documents: Yup.array().of(Yup.mixed().required()).optional(), + + kandangExpenses: Yup.array() + .of( + Yup.object({ + kandangId: Yup.number().min(1, 'Wajib memilih kandang!').required(), + expenses: 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!'), + 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 UpdateExpenseRequestFormSchema = ExpenseRequestFormSchema; + +export const UploadRequestDocumentsFormSchema = Yup.object({ + request_documents: Yup.array().of(Yup.mixed().required()).required(), +}); + +export type ExpenseRequestFormValues = Yup.InferType< + typeof ExpenseRequestFormSchema +>; + +export type UploadRequestDocumentsFormValues = Yup.InferType< + typeof UploadRequestDocumentsFormSchema +>; + +export const getExpenseFormInitialValues = ( + initialValues?: Expense +): ExpenseRequestFormValues => { + return { + location: initialValues?.location + ? { + value: initialValues.location.id, + label: initialValues.location.name, + } + : undefined, + transaction_date: initialValues?.transaction_date + ? formatDate(initialValues.transaction_date, 'YYYY-MM-DD') + : undefined, + kandangs: initialValues?.kandangs.map((kandang) => ({ + id: kandang.id, + name: kandang.name, + })), + vendor: initialValues?.vendor + ? { + value: initialValues.vendor.id, + label: initialValues.vendor.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, + })), + })) + : [], + }; +}; diff --git a/src/components/pages/expense/form/ExpenseRequestForm.tsx b/src/components/pages/expense/form/ExpenseRequestForm.tsx new file mode 100644 index 00000000..0fd68c88 --- /dev/null +++ b/src/components/pages/expense/form/ExpenseRequestForm.tsx @@ -0,0 +1,492 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useFormik } from 'formik'; +import { toast } from 'react-hot-toast'; + +import { Icon } from '@iconify/react'; +import Link from 'next/link'; +import Button from '@/components/Button'; +import { useModal } from '@/components/Modal'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; +import SelectInput, { + OptionType, + useSelect, +} from '@/components/input/SelectInput'; +import DateInput from '@/components/input/DateInput'; +import ExpenseKandangsTable from '@/components/pages/expense/form/ExpenseKandangsTable'; +import DropFileInput from '@/components/input/DropFileInput'; +import ExpenseRequestKandangDetailExpense from '@/components/pages/expense/form/ExpenseRequestKandangDetailExpense'; + +import { + ExpenseRequestFormSchema, + ExpenseRequestFormValues, + getExpenseFormInitialValues, + UpdateExpenseRequestFormSchema, +} from '@/components/pages/expense/form/ExpenseRequestForm.schema'; +import { isResponseError } from '@/lib/api-helper'; +import { + Expense, + CreateExpensePayload, + UpdateExpensePayload, +} from '@/types/api/expense'; +import { ExpenseApi } from '@/services/api/expense'; +import { cn, sleep } from '@/lib/helper'; +import { LocationApi, SupplierApi } from '@/services/api/master-data'; +import { ACCEPTED_FILE_TYPE } from '@/config/constant'; +import { Supplier } from '@/types/api/master-data/supplier'; + +interface ExpenseFormProps { + type?: 'add' | 'edit' | 'detail'; + initialValues?: Expense; +} + +// TODO: integrate this with real API +const ExpenseRequestForm = ({ + type = 'add', + initialValues, +}: ExpenseFormProps) => { + const router = useRouter(); + + // Modal hooks + const deleteModal = useModal(); + const approveModal = useModal(); + const rejectModal = useModal(); + + const [expenseFormErrorMessage, setExpenseFormErrorMessage] = useState(''); + + const createExpenseHandler = useCallback( + async (payload: CreateExpensePayload) => { + const createExpenseRes = await ExpenseApi.create( + ExpenseApi.convertPayloadToFormData(payload) + ); + + if (isResponseError(createExpenseRes)) { + setExpenseFormErrorMessage(createExpenseRes.message); + return; + } + + toast.success(createExpenseRes?.message as string); + router.push('/expense'); + }, + [router] + ); + + const updateExpenseHandler = useCallback( + async (expenseId: number, payload: UpdateExpensePayload) => { + const updateExpenseRes = await ExpenseApi.update( + expenseId, + ExpenseApi.convertPayloadToFormData(payload) + ); + + if (updateExpenseRes?.status === 'error') { + setExpenseFormErrorMessage(updateExpenseRes.message); + return; + } + + toast.success(updateExpenseRes?.message as string); + router.refresh(); + router.push('/expense'); + }, + [router] + ); + + const formik = useFormik({ + initialValues: getExpenseFormInitialValues(initialValues), + validationSchema: + type === 'edit' + ? UpdateExpenseRequestFormSchema + : ExpenseRequestFormSchema, + onSubmit: async (values) => { + 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, + })), + })), + }; + + switch (type) { + case 'add': + await createExpenseHandler(expensePayload); + break; + + case 'edit': + await updateExpenseHandler( + initialValues?.id as number, + expensePayload + ); + break; + } + }, + }); + + const { setValues: formikSetValues } = formik; + + const { + setInputValue: setLocationInputValue, + options: locationOptions, + isLoadingOptions: isLoadingLocationOptions, + } = useSelect(LocationApi.basePath, 'id', 'name'); + + const { + setInputValue: setVendorInputValue, + options: vendorOptions, + isLoadingOptions: isLoadingVendorOptions, + } = useSelect(SupplierApi.basePath, 'id', 'name'); + + const locationChangeHandler = (val: OptionType | OptionType[] | null) => { + formik.setFieldTouched('location', true); + formik.setFieldValue('location', val); + + formik.setFieldValue('kandangs', []); + formik.setFieldValue('kandangExpenses', []); + }; + + const kandangsChangeHandler = (kandangs: { id: number; name: string }[]) => { + formik.setFieldTouched('kandangs', true); + formik.setFieldValue('kandangs', kandangs); + + const newKandangExpenses = [...(formik.values.kandangExpenses ?? [])]; + + // add new kandangExpenses + kandangs.forEach((kandangItem) => { + const isKandangExistInKandangExpense = newKandangExpenses.find( + (kandangExpenseItem) => kandangExpenseItem.kandangId === kandangItem.id + ); + + if (isKandangExistInKandangExpense) return; + + newKandangExpenses.push({ + kandangId: kandangItem.id, + expenses: [ + { + nonstock: undefined, + totalExpense: undefined, + totalQuantity: undefined, + notes: '', + }, + ], + }); + }); + + // prune kandangExpenses + const kandangIds = new Set(kandangs.map((kandang) => kandang.id)); + const deletedKandangExpensesIdx: number[] = []; + + newKandangExpenses.forEach((kandangExpense, idx) => { + const isKandangExpenseValid = kandangIds.has(kandangExpense.kandangId); + + if (!isKandangExpenseValid) { + deletedKandangExpensesIdx.push(idx); + } + }); + + deletedKandangExpensesIdx.forEach((deletedKandangExpenseIdx) => { + newKandangExpenses.splice(deletedKandangExpenseIdx, 1); + }); + + formik.setFieldValue('kandangExpenses', newKandangExpenses); + }; + + const vendorChangeHandler = (val: OptionType | OptionType[] | null) => { + formik.setFieldTouched('vendor', true); + formik.setFieldValue('vendor', val); + }; + + const requestDocumentsChangeHandler = (val: File[]) => { + formik.setFieldTouched('request_documents', true); + formik.setFieldValue('request_documents', val); + }; + + const deleteExpenseClickHandler = () => { + deleteModal.openModal(); + }; + + const confirmationModalRejectClickHandler = async () => { + await sleep(750); + + rejectModal.closeModal(); + toast.success('Berhasil melakukan reject biaya operasional!'); + }; + + const confirmationModalApproveClickHandler = async () => { + await sleep(750); + + approveModal.closeModal(); + toast.success('Berhasil melakukan approve biaya operasional!'); + }; + + const confirmationModalDeleteClickHandler = async () => { + await ExpenseApi.delete(initialValues?.id as number); + + deleteModal.closeModal(); + toast.success('Successfully delete Expense!'); + router.push('/expense'); + }; + + useEffect(() => { + formikSetValues(getExpenseFormInitialValues(initialValues)); + }, [formikSetValues, getExpenseFormInitialValues, initialValues]); + + return ( + <> +
+
+ + +

+ {type === 'add' && 'Tambah Biaya Operasional'} + {type === 'edit' && 'Edit Biaya Operasional'} + {type === 'detail' && 'Detail Biaya Operasional'} +

+
+ +
+
+ + + + + + + + + + + {formik.values.existing_documents && + formik.values.existing_documents.length > 0 && ( +
+
    + {formik.values.existing_documents.map( + (existingDocument, existingDocumentIdx) => ( +
  • + + {existingDocument.name}{' '} + + +
  • + ) + )} +
+
+ )} + + +
+ +
+ {type !== 'add' && ( +
+ + + {type !== 'edit' && ( + + )} +
+ )} + + {type !== 'detail' && ( +
+ + + +
+ )} +
+ + {expenseFormErrorMessage && ( +
+ + {expenseFormErrorMessage} +
+ )} +
+
+ + {type !== 'add' && ( + + )} + + {type === 'detail' && ( + <> + + + + + )} + + ); +}; + +export default ExpenseRequestForm; diff --git a/src/components/pages/expense/form/ExpenseRequestKandangDetailExpense.tsx b/src/components/pages/expense/form/ExpenseRequestKandangDetailExpense.tsx new file mode 100644 index 00000000..ca9edf37 --- /dev/null +++ b/src/components/pages/expense/form/ExpenseRequestKandangDetailExpense.tsx @@ -0,0 +1,290 @@ +'use client'; + +import { FormikContextType } from 'formik'; + +import { Icon } from '@iconify/react'; +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 Button from '@/components/Button'; + +import { ExpenseRequestFormValues } from '@/components/pages/expense/form/ExpenseRequestForm.schema'; +import { cn } from '@/lib/helper'; +import { NonstockApi } from '@/services/api/master-data'; +import { Nonstock } from '@/types/api/master-data/nonstock'; +import { removeArrayItemAndSync } from '@/lib/utils/formik'; + +interface ExpenseRequestKandangDetailExpenseProps { + type?: 'add' | 'edit' | 'detail'; + formik: FormikContextType; + className?: { + wrapper?: string; + }; +} + +const ExpenseRequestKandangDetailExpense: React.FC< + ExpenseRequestKandangDetailExpenseProps +> = ({ type, formik, className }) => { + const { + setInputValue: setNonstockInputValue, + options: nonstockOptions, + isLoadingOptions: isLoadingNonstockOptions, + } = useSelect(NonstockApi.basePath, 'id', 'name'); + + const nonstockChangeHandler = ( + kandangExpenseIdx: number, + expenseIdx: number, + val: OptionType | OptionType[] | null + ) => { + formik.setFieldTouched( + `kandangExpenses[${kandangExpenseIdx}].expenses[${expenseIdx}].nonstock`, + true + ); + formik.setFieldValue( + `kandangExpenses[${kandangExpenseIdx}].expenses[${expenseIdx}].nonstock`, + val + ); + }; + + const addExpenseItemHandler = (kandangExpenseIdx: number) => { + const newExpensesValue = [ + ...formik.values.kandangExpenses[kandangExpenseIdx].expenses, + { + nonstock: undefined, + totalExpense: undefined, + totalQuantity: undefined, + notes: '', + }, + ]; + + formik.setFieldValue( + `kandangExpenses[${kandangExpenseIdx}].expenses`, + newExpensesValue + ); + }; + + const deleteExpenseItemHandler = ( + kandangExpenseIdx: number, + expenseIdx: number + ) => { + const path = `kandangExpenses[${kandangExpenseIdx}].expenses`; + + // trims values, errors, and touched at expenseIdx + removeArrayItemAndSync(formik, path, expenseIdx); + }; + + const isExpenseRepeaterInputError = ( + column: 'nonstock' | 'totalQuantity' | 'totalExpense' | 'notes', + kandangExpenseIdx: number, + expenseIdx: number + ) => { + return ( + formik.touched.kandangExpenses?.[kandangExpenseIdx]?.expenses?.[ + expenseIdx + ]?.[column] && + Boolean( + formik.errors.kandangExpenses?.[kandangExpenseIdx] instanceof Object && + formik.errors.kandangExpenses?.[kandangExpenseIdx].expenses?.[ + expenseIdx + ] instanceof Object && + formik.errors.kandangExpenses?.[kandangExpenseIdx].expenses?.[ + expenseIdx + ]?.[column] + ) + ); + }; + + return ( + +
+

+ Rincian Pengajuan Biaya Operasional +

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

+ Pilih kandang terlebih dahulu! +

+
+ )} + + {formik.values.kandangExpenses.map( + (kandangExpense, kandangExpenseIdx) => { + const kandangName = formik.values.kandangs?.find( + (kandang) => kandang.id === kandangExpense.kandangId + ); + + return ( + kandangName?.name && ( +
+
+
+ Biaya {kandangName?.name} +
+ +
+ + + + + + + + {type !== 'detail' && } + + + + + {kandangExpense.expenses.map( + (expenseItem, expenseIdx) => ( + + + + + + + + + + {type !== 'detail' && ( + + )} + + ) + )} + +
NonstockTotal KuantitasTotal BiayaCatatanAksi
+ { + nonstockChangeHandler( + kandangExpenseIdx, + expenseIdx, + val + ); + }} + options={nonstockOptions} + isLoading={isLoadingNonstockOptions} + onInputChange={setNonstockInputValue} + className={{ wrapper: 'min-w-48' }} + /> + + + + + Rp + + } + className={{ wrapper: 'min-w-24' }} + /> + + + + +
+
+
+ + {type !== 'detail' && ( + + )} +
+ ) + ); + } + )} +
+
+ ); +}; + +export default ExpenseRequestKandangDetailExpense; diff --git a/src/config/approval-line.ts b/src/config/approval-line.ts index 997d473f..9021ef03 100644 --- a/src/config/approval-line.ts +++ b/src/config/approval-line.ts @@ -47,3 +47,26 @@ export const MARKETING_APPROVAL_LINE: ApprovalLine = [ step_name: 'Delivery Order', }, ]; + +export const EXPENSE_REQUEST_APPROVAL_LINE: ApprovalLine = [ + { + step_number: 1, + step_name: 'Pengajuan', + }, + { + step_number: 2, + step_name: 'Approval Manager Area', + }, + { + step_number: 3, + step_name: 'Approval Finance', + }, + { + step_number: 4, + step_name: 'Realisasi', + }, + { + step_number: 5, + step_name: 'Selesai', + }, +] as const; diff --git a/src/config/constant.ts b/src/config/constant.ts index 933ba2ab..e82a1e7b 100644 --- a/src/config/constant.ts +++ b/src/config/constant.ts @@ -40,6 +40,12 @@ export const MAIN_DRAWER_LINKS: MAIN_DRAWER_MENU[] = [ ], }, + { + title: 'Biaya Operasional', + link: '/expense', + icon: 'uil:wallet', + }, + { title: 'Penjualan', link: '/marketing/sales-orders', @@ -231,3 +237,12 @@ export const RECORDING_FLAG_OPTIONS = [ { label: 'Ayam Culling', value: 'Ayam Culling' }, { label: 'Ayam Mati', value: 'Ayam Mati' }, ]; + +export const ACCEPTED_FILE_TYPE = { + PDF: { + 'application/pdf': ['.pdf'], + }, + IMAGE: { + 'image/*': [], + }, +}; diff --git a/src/lib/helper.ts b/src/lib/helper.ts index d210c646..ca35035c 100644 --- a/src/lib/helper.ts +++ b/src/lib/helper.ts @@ -100,3 +100,23 @@ export function getByPath( return cur as D; } + +export const convertRowSelectionArrToObj = ( + rowSelectionArr: string[] | number[] +) => { + const result: Record = {}; + + rowSelectionArr.forEach((item) => { + result[item] = true; + }); + + return result; +}; + +export const convertRowSelectionObjToArr = ( + rowSelection: string[] | number[] +) => { + const result = Object.keys(rowSelection).map(Number); + + return result; +}; diff --git a/src/lib/utils/formik.ts b/src/lib/utils/formik.ts new file mode 100644 index 00000000..e474c8c6 --- /dev/null +++ b/src/lib/utils/formik.ts @@ -0,0 +1,48 @@ +import { FormikContextType, getIn, setIn } from 'formik'; + +function spliceArray(arr: T[] | undefined, index: number) { + const a = Array.isArray(arr) ? arr.slice() : []; + if (index >= 0 && index < a.length) a.splice(index, 1); + return a; +} + +/** + * Remove one item from an array field and also trim Formik's errors & touched + * at the SAME index to keep everything aligned. + * + * @param formik - your useFormik instance + * @param arrayPath - path to the array field, e.g. "kandangExpenses[0].expenses" + * @param index - the index to remove + * @param validateAfter - optional: revalidate after removal (default false) + */ +export async function removeArrayItemAndSync( + formik: FormikContextType, + arrayPath: string, + index: number, + validateAfter: boolean = false +) { + // 1) VALUES: remove at index + const currValues = getIn(formik.values, arrayPath); + const nextValues = spliceArray(currValues, index); + formik.setFieldValue(arrayPath, nextValues, false); + + // 2) ERRORS: remove the same index (if array exists) + const currErrors = getIn(formik.errors, arrayPath); + if (Array.isArray(currErrors)) { + const nextErrors = spliceArray(currErrors, index); + formik.setErrors(setIn(formik.errors, arrayPath, nextErrors)); + } + + // 3) TOUCHED: remove the same index (if array exists) + const currTouched = getIn(formik.touched, arrayPath); + if (Array.isArray(currTouched)) { + const nextTouched = spliceArray(currTouched, index); + formik.setTouched(setIn(formik.touched, arrayPath, nextTouched), false); + } + + // 4) (optional) revalidate to rebuild a perfectly clean error tree + if (validateAfter) { + const newErrors = await formik.validateForm(); + formik.setErrors(newErrors); + } +} diff --git a/src/services/api/expense.ts b/src/services/api/expense.ts new file mode 100644 index 00000000..24931316 --- /dev/null +++ b/src/services/api/expense.ts @@ -0,0 +1,1102 @@ +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 { 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, + FormData +> { + constructor(basePath: string) { + 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); + + const sentPayload = new Map(); + for (const pair of payload.entries()) { + sentPayload.set(pair[0], pair[1]); + } + + 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( + id: number, + payload: FormData + ): Promise | undefined> { + await sleep(750); + + const sentPayload = new Map(); + for (const pair of payload.entries()) { + sentPayload.set(pair[0], pair[1]); + } + + 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); + + return { + code: 200, + status: 'success', + message: 'Successfully delete expense data!', + data: { + id, + }, + }; + } + + // TODO: remove this and integrate to real API + async uploadRequestDocuments( + id: number, + files: File[] + ): Promise { + try { + // const requestDocumentsFormData = new FormData(); + + // // 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, + }; + } catch (error) { + if (axios.isAxiosError(error)) { + return error.response?.data; + } + + return undefined; + } + } + + // TODO: integrate to real API + async approve( + id: number, + notes?: string + ): Promise | undefined> { + try { + // const approveRes = await httpClient>( + // `${this.basePath}/approvals`, + // { + // method: 'POST', + // body: { + // action: 'APPROVED', + // approvable_ids: [id], + // notes: notes, + // }, + // } + // ); + // + // return approveRes; + + 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], + }; + } catch (error) { + if (axios.isAxiosError>(error)) { + return error.response?.data; + } + + return undefined; + } + } + + // TODO: integrate to real API + async bulkApprove( + ids: number[], + notes?: string + ): Promise | undefined> { + 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) + ); + + 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 formData; + }; +} + +export const ExpenseApi = new ExpenseApiService('/expense'); diff --git a/src/types/api/expense.d.ts b/src/types/api/expense.d.ts new file mode 100644 index 00000000..78a8ac76 --- /dev/null +++ b/src/types/api/expense.d.ts @@ -0,0 +1,54 @@ +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'; + +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; + }[]; + kandang_expenses: { + kandang: Kandang; + expenses: { + nonstock: Nonstock; + total_quantity: number; + total_expense: number; + notes?: string; + }[]; + }[]; + nominal: number; + paid?: number; + remaining_cost?: number; + approval: BaseApproval; +}; + +export type Expense = BaseMetadata & BaseExpense; + +export type CreateExpensePayload = { + locationId: number; + 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; + }[]; + }[]; +}; + +export type UpdateExpensePayload = CreateExpensePayload;