From 17c316c4af58977c498046eec522c4f44066d1ed Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Tue, 4 Nov 2025 15:45:36 +0700 Subject: [PATCH 01/31] fix(FE-188): required symbol space --- src/components/input/SelectInput.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/components/input/SelectInput.tsx b/src/components/input/SelectInput.tsx index 833d7d26..7887004f 100644 --- a/src/components/input/SelectInput.tsx +++ b/src/components/input/SelectInput.tsx @@ -139,9 +139,12 @@ const SelectInput = (props: SelectInputProps) => { > {label} {required && ( - - * - + <> + {' '} + + * + + )} )} From a12ae51f3ae21dab3917b836d4af0011ed2a8658 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Tue, 4 Nov 2025 15:46:08 +0700 Subject: [PATCH 02/31] fix(FE-188): rename to ExpenseRequestForm --- src/app/expense/add/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/expense/add/page.tsx b/src/app/expense/add/page.tsx index 00560dad..afa40f48 100644 --- a/src/app/expense/add/page.tsx +++ b/src/app/expense/add/page.tsx @@ -1,9 +1,9 @@ -import ExpenseForm from '@/components/pages/expense/form/ExpenseForm'; +import ExpenseRequestForm from '@/components/pages/expense/form/ExpenseRequestForm'; const AddExpense = () => { return (
- +
); }; From 9daa6aaf8cdbc942aa04002bb1bac199bb6a4e70 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Tue, 4 Nov 2025 15:46:30 +0700 Subject: [PATCH 03/31] chore(FE-188): update Modal component --- src/components/Modal.tsx | 61 +++++++++++++++++++++++++--------------- 1 file changed, 38 insertions(+), 23 deletions(-) diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index a84c1827..958d88dd 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -1,6 +1,13 @@ 'use client'; -import { ReactNode, RefObject, useCallback, useRef, useState } from 'react'; +import { + ReactNode, + RefObject, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; import { cn } from '@/lib/helper'; export const useModal = () => { @@ -8,31 +15,35 @@ export const useModal = () => { const [open, setOpen] = useState(false); const openModal = useCallback(() => { + if (!ref.current) return; + ref.current.showModal(); setOpen(true); - - ref.current?.showModal(); }, []); const closeModal = useCallback(() => { + if (!ref.current) return; + ref.current.close(); setOpen(false); - ref.current?.close(); }, []); const toggle = useCallback(() => { - if (open) { - closeModal(); - } else { - openModal(); - } + open ? closeModal() : openModal(); }, [open, closeModal, openModal]); - if (ref.current) { - ref.current.addEventListener('close', () => { - closeModal(); - }); - } + // Gunakan useEffect agar event listener tidak didaftarkan berulang kali + useEffect(() => { + const dialog = ref.current; + if (!dialog) return; - return { ref, open, setOpen, openModal, closeModal, toggle } as const; + const handleClose = () => setOpen(false); + dialog.addEventListener('close', handleClose); + + return () => { + dialog.removeEventListener('close', handleClose); + }; + }, []); + + return { ref, open, openModal, closeModal, toggle } as const; }; interface ModalProps { @@ -46,15 +57,19 @@ interface ModalProps { } const Modal = ({ ref, children, closeOnBackdrop, className }: ModalProps) => { - return ( - -
{children}
+ const handleBackdropClick = (e: React.MouseEvent) => { + if (closeOnBackdrop && e.target === ref.current) { + ref.current?.close(); + } + }; - {closeOnBackdrop && ( -
- -
- )} + return ( + +
{children}
); }; From 0de2e87221fa4cddacf26433bb124dbb8058991c Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Tue, 4 Nov 2025 15:46:49 +0700 Subject: [PATCH 04/31] fix(FE-188): required symbol space --- src/components/input/DateInput.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/input/DateInput.tsx b/src/components/input/DateInput.tsx index 92d28397..b2c3a81c 100644 --- a/src/components/input/DateInput.tsx +++ b/src/components/input/DateInput.tsx @@ -201,6 +201,7 @@ const DateInput = ({ {label} {required && ( + {' '} * )} From 3b69286a8e1d71d2e3aeefd51c8a09f988da4808 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Tue, 4 Nov 2025 15:47:01 +0700 Subject: [PATCH 05/31] chore(FE-188): add DropFileInput component --- src/components/input/DropFileInput.tsx | 193 +++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 src/components/input/DropFileInput.tsx diff --git a/src/components/input/DropFileInput.tsx b/src/components/input/DropFileInput.tsx new file mode 100644 index 00000000..4e3f11bd --- /dev/null +++ b/src/components/input/DropFileInput.tsx @@ -0,0 +1,193 @@ +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; From 6a070e39da7775d137ecc066ee4580275b7a3963 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Tue, 4 Nov 2025 15:54:13 +0700 Subject: [PATCH 06/31] chore(FE-188): updateDropFileInput component --- src/components/input/DropFileInput.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/input/DropFileInput.tsx b/src/components/input/DropFileInput.tsx index 4e3f11bd..e146a994 100644 --- a/src/components/input/DropFileInput.tsx +++ b/src/components/input/DropFileInput.tsx @@ -106,6 +106,7 @@ const DropFileInput: React.FC = ({ 'aria-disabled': isDisabled, className: cn( 'dropzone w-full px-4 py-2 border border-dashed border-gray-300 rounded cursor-pointer transition-all', + 'hover:border-primary hover:bg-primary/10', { 'border-success bg-success/10': isDragAccept, 'border-error bg-error/10': isDragReject || isError, From e9eee6eb3e18baee3e9beace8b7031f39c5a068c Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Thu, 6 Nov 2025 15:22:03 +0700 Subject: [PATCH 07/31] feat(FE-188,193): create ExpenseKandangsTable component --- .../expense/form/ExpenseKandangsTable.tsx | 230 ++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 src/components/pages/expense/form/ExpenseKandangsTable.tsx diff --git a/src/components/pages/expense/form/ExpenseKandangsTable.tsx b/src/components/pages/expense/form/ExpenseKandangsTable.tsx new file mode 100644 index 00000000..78b53541 --- /dev/null +++ b/src/components/pages/expense/form/ExpenseKandangsTable.tsx @@ -0,0 +1,230 @@ +'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); + } + }, [rowSelection]); + + useEffect(() => { + setRowSelection({}); + }, [locationId]); + + // 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; From 2a71734583d436e85063558065af45d9beef0089 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Thu, 6 Nov 2025 15:26:54 +0700 Subject: [PATCH 08/31] feat(FE-188,193): add Vendor, Request Documents, and Kandang Detail Expense input --- .../pages/expense/form/ExpenseRequestForm.tsx | 174 ++++++++++++++---- 1 file changed, 140 insertions(+), 34 deletions(-) diff --git a/src/components/pages/expense/form/ExpenseRequestForm.tsx b/src/components/pages/expense/form/ExpenseRequestForm.tsx index e3a435b5..4268ca92 100644 --- a/src/components/pages/expense/form/ExpenseRequestForm.tsx +++ b/src/components/pages/expense/form/ExpenseRequestForm.tsx @@ -1,22 +1,27 @@ 'use client'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +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 Button from '@/components/Button'; -import TextInput from '@/components/input/TextInput'; import { useModal } from '@/components/Modal'; import ConfirmationModal from '@/components/modal/ConfirmationModal'; -import SelectInput from '@/components/input/SelectInput'; +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'; @@ -27,6 +32,9 @@ import { } 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'; @@ -49,7 +57,9 @@ const ExpenseRequestForm = ({ const createExpenseHandler = useCallback( async (payload: CreateExpensePayload) => { - const createExpenseRes = await ExpenseApi.create(payload); + const createExpenseRes = await ExpenseApi.create( + ExpenseApi.convertPayloadToFormData(payload) + ); if (isResponseError(createExpenseRes)) { setExpenseFormErrorMessage(createExpenseRes.message); @@ -64,7 +74,10 @@ const ExpenseRequestForm = ({ const updateExpenseHandler = useCallback( async (expenseId: number, payload: UpdateExpensePayload) => { - const updateExpenseRes = await ExpenseApi.update(expenseId, payload); + const updateExpenseRes = await ExpenseApi.update( + expenseId, + ExpenseApi.convertPayloadToFormData(payload) + ); if (updateExpenseRes?.status === 'error') { setExpenseFormErrorMessage(updateExpenseRes.message); @@ -78,14 +91,8 @@ const ExpenseRequestForm = ({ [router] ); - const formikInitialValues = useMemo(() => { - return { - name: initialValues?.name ?? '', - }; - }, [initialValues]); - const formik = useFormik({ - initialValues: formikInitialValues, + initialValues: getExpenseFormInitialValues(initialValues), validationSchema: type === 'edit' ? UpdateExpenseRequestFormSchema @@ -94,7 +101,22 @@ const ExpenseRequestForm = ({ setExpenseFormErrorMessage(''); const expensePayload: CreateExpensePayload = { - name: values.name, + 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) { @@ -114,6 +136,58 @@ const ExpenseRequestForm = ({ 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); + }; + + const kandangsChangeHandler = (kandangs: { id: number; name: string }[]) => { + formik.setFieldTouched('kandangs', true); + formik.setFieldValue('kandangs', kandangs); + + kandangs.forEach((kandangItem) => { + const isKandangExistInKandangExpense = formik.values.kandangExpenses.find( + (kandangExpenseItem) => kandangExpenseItem.kandangId === kandangItem.id + ); + + if (isKandangExistInKandangExpense) return; + + formik.values.kandangExpenses.push({ + kandangId: kandangItem.id, + expenses: [ + { + nonstock: undefined, + totalExpense: undefined, + totalQuantity: undefined, + notes: '', + }, + ], + }); + }); + }; + + 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(); }; @@ -141,8 +215,12 @@ const ExpenseRequestForm = ({ }; useEffect(() => { - formikSetValues(formikInitialValues); - }, [formikSetValues, formikInitialValues]); + formikSetValues(getExpenseFormInitialValues(initialValues)); + }, [formikSetValues, getExpenseFormInitialValues, initialValues]); + + useEffect(() => { + formik.setFieldValue('kandangs', undefined); + }, [formik.values.location]); return ( <> @@ -172,30 +250,22 @@ const ExpenseRequestForm = ({
- {/* */} - + + + + + +
@@ -287,7 +393,7 @@ const ExpenseRequestForm = ({ Date: Thu, 6 Nov 2025 15:28:12 +0700 Subject: [PATCH 09/31] feat(FE-198): add kandangs, existing_documents, kandangExpenses to ExpenseFormSchemaType and create helper function to get form initial values --- .../expense/form/ExpenseRequestForm.schema.ts | 114 ++++++++++++++++-- 1 file changed, 101 insertions(+), 13 deletions(-) diff --git a/src/components/pages/expense/form/ExpenseRequestForm.schema.ts b/src/components/pages/expense/form/ExpenseRequestForm.schema.ts index 67a9f87e..8a86ad5a 100644 --- a/src/components/pages/expense/form/ExpenseRequestForm.schema.ts +++ b/src/components/pages/expense/form/ExpenseRequestForm.schema.ts @@ -1,44 +1,90 @@ import * as Yup from 'yup'; +import { Expense } from '@/types/api/expense'; +import { formatDate } from '@/lib/helper'; type ExpenseFormSchemaType = { - name: string; location?: { value: number; label: string; }; transaction_date?: string; - kandangs?: number[]; + kandangs?: { id: number; name: string }[]; vendor?: { value: number; label: string; }; - requestDocuments?: File[]; - // kandangExpenses: { - // nonstock?: { - // 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({ - name: Yup.string().required('Nama wajib diisi!'), - 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.number().required('Kandang wajib dipilih!')), + 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!'), - requestDocuments: Yup.array().of(Yup.mixed().required()).optional(), + 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; @@ -46,3 +92,45 @@ export const UpdateExpenseRequestFormSchema = ExpenseRequestFormSchema; export type ExpenseRequestFormValues = Yup.InferType< typeof ExpenseRequestFormSchema >; + +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, + })), + })) + : [], + }; +}; From f011f5b7f9f5a64a9fe60bc7c86ac4d6d2d17162 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Thu, 6 Nov 2025 15:28:42 +0700 Subject: [PATCH 10/31] feat(FE-188,193): create ExpenseRequestKandangDetailExpense component --- .../ExpenseRequestKandangDetailExpense.tsx | 285 ++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 src/components/pages/expense/form/ExpenseRequestKandangDetailExpense.tsx diff --git a/src/components/pages/expense/form/ExpenseRequestKandangDetailExpense.tsx b/src/components/pages/expense/form/ExpenseRequestKandangDetailExpense.tsx new file mode 100644 index 00000000..6b670069 --- /dev/null +++ b/src/components/pages/expense/form/ExpenseRequestKandangDetailExpense.tsx @@ -0,0 +1,285 @@ +'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' }} + /> + + + + + + + + +
+
+
+ + {type !== 'detail' && ( + + )} +
+ ) + ); + } + )} +
+
+ ); +}; + +export default ExpenseRequestKandangDetailExpense; From 6e582c4e7c4703f9ded2b28d7a510ff045186003 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Thu, 6 Nov 2025 15:29:00 +0700 Subject: [PATCH 11/31] chore(FE-188,193): create ACCEPTED_FILE_TYPES constant --- src/config/constant.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/config/constant.ts b/src/config/constant.ts index 57599702..4de5bd8b 100644 --- a/src/config/constant.ts +++ b/src/config/constant.ts @@ -231,3 +231,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/*': [], + }, +}; From a8b1f6f8c21c7e012bb5df111bd26b0a5c622e1b Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Thu, 6 Nov 2025 15:29:15 +0700 Subject: [PATCH 12/31] chore(FE-188,193): create convertRowSelectionArrToObj and convertRowSelectionObjToArr helper function --- src/lib/helper.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/lib/helper.ts b/src/lib/helper.ts index aeda46f6..5363a4c7 100644 --- a/src/lib/helper.ts +++ b/src/lib/helper.ts @@ -77,3 +77,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; +}; From c59a88bbcb95f38fc4b923b1e1fbeeeddddbb2b3 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Thu, 6 Nov 2025 15:29:42 +0700 Subject: [PATCH 13/31] chore(FE-188,193): create formik helper function utils and create removeArrayItemAndSync formik helper function --- src/lib/utils/formik.ts | 48 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/lib/utils/formik.ts 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); + } +} From e4b61cfe050347fe2ba42478e801f08799966d79 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Thu, 6 Nov 2025 15:30:22 +0700 Subject: [PATCH 14/31] chore(FE-199): create Expense API service with dummy data --- src/services/api/expense.ts | 290 ++++++++++++++++++++++++++++++++++-- 1 file changed, 277 insertions(+), 13 deletions(-) diff --git a/src/services/api/expense.ts b/src/services/api/expense.ts index ec42743c..92e87c43 100644 --- a/src/services/api/expense.ts +++ b/src/services/api/expense.ts @@ -1,19 +1,283 @@ +import { sleep } from '@/lib/helper'; import { BaseApiService } from '@/services/api/base'; -import { - CreateExpensePayload, - Expense, - UpdateExpensePayload, -} from '@/types/api/expense'; +import { BaseApiResponse } from '@/types/api/api-general'; +import { CreateExpensePayload, Expense } from '@/types/api/expense'; +const DUMMY_EXPENSE: Expense = { + created_user: { + id: 1, + id_user: 101, + email: 'admin@example.com', + name: 'Admin User', + }, + created_at: '2025-11-05T10:30:00Z', + updated_at: '2025-11-05T12:00:00Z', + + id: 1, + location: { + id: 10, + name: 'Farm A', + address: 'Jl. Raya Peternakan No. 45, Bandung', + area: { + id: 100, + name: 'Jawa Barat', + }, + created_user: { + id: 2, + id_user: 102, + email: 'manager@example.com', + name: 'Farm Manager', + }, + created_at: '2025-10-01T08:00:00Z', + updated_at: '2025-10-05T09:30:00Z', + }, + transaction_date: '2025-11-04', + + kandangs: [ + { + id: 201, + name: 'Kandang 1', + status: 'ACTIVE', + location: { + id: 10, + name: 'Farm A', + address: 'Jl. Raya Peternakan No. 45, Bandung', + area: { id: 100, name: 'Jawa Barat' }, + }, + pic: { + id: 3, + id_user: 103, + email: 'kandang1@example.com', + name: 'PIC Kandang 1', + }, + created_user: { + id: 4, + id_user: 104, + email: 'creator@example.com', + name: 'Creator User', + }, + created_at: '2025-10-10T08:00:00Z', + updated_at: '2025-10-20T09:00:00Z', + }, + ], + + vendor: { + id: 301, + name: 'PT. Pakan Makmur', + alias: 'PakanMakmur', + pic: 'Budi Santoso', + type: 'Supplier', + category: 'Pakan', + hatchery: 'Makmur Hatchery', + phone: '08123456789', + email: 'contact@pakanmakmur.com', + address: 'Jl. Industri No. 5, Bekasi', + npwp: '12.345.678.9-012.345', + account_number: '1234567890', + due_date: 30, + balance: 5000000, + created_user: { + id: 5, + id_user: 105, + email: 'finance@example.com', + name: 'Finance User', + }, + created_at: '2025-09-01T09:00:00Z', + updated_at: '2025-09-15T10:00:00Z', + }, + + request_documents: [ + { + name: 'invoice_001.pdf', + url: 'https://example.com/files/invoice_001.pdf', + }, + { + name: 'receipt_001.jpg', + url: 'https://example.com/files/receipt_001.jpg', + }, + ], + + kandang_expenses: [ + { + kandang: { + id: 201, + name: 'Kandang 1', + status: 'ACTIVE', + location: { + id: 10, + name: 'Farm A', + address: 'Jl. Raya Peternakan No. 45, Bandung', + area: { id: 100, name: 'Jawa Barat' }, + }, + pic: { + id: 3, + id_user: 103, + email: 'kandang1@example.com', + name: 'PIC Kandang 1', + }, + created_user: { + id: 4, + id_user: 104, + email: 'creator@example.com', + name: 'Creator User', + }, + created_at: '2025-10-10T08:00:00Z', + updated_at: '2025-10-20T09:00:00Z', + }, + expenses: [ + { + nonstock: { + id: 501, + name: 'Pakan Ayam Broiler', + uom_id: 1, + uom: { + id: 1, + name: 'Kg', + }, + suppliers: [ + { + id: 301, + name: 'PT. Pakan Makmur', + alias: 'PakanMakmur', + pic: 'Budi Santoso', + type: 'Supplier', + category: 'Pakan', + hatchery: 'Makmur Hatchery', + phone: '08123456789', + email: 'contact@pakanmakmur.com', + address: 'Jl. Industri No. 5, Bekasi', + npwp: '12.345.678.9-012.345', + account_number: '1234567890', + due_date: 30, + balance: 5000000, + }, + ], + flags: ['PAKAN', 'IS_ACTIVE'], + created_user: { + id: 6, + id_user: 106, + email: 'staff@example.com', + name: 'Inventory Staff', + }, + created_at: '2025-09-25T08:00:00Z', + updated_at: '2025-09-30T09:00:00Z', + }, + total_quantity: 500, + total_expense: 2500000, + notes: 'Pakan untuk minggu pertama', + }, + { + nonstock: { + id: 502, + name: 'Vitamin C Ayam', + uom_id: 2, + uom: { + id: 2, + name: 'Botol', + }, + suppliers: [], + flags: ['VITAMIN', 'IS_ACTIVE'], + created_user: { + id: 6, + id_user: 106, + email: 'staff@example.com', + name: 'Inventory Staff', + }, + created_at: '2025-09-25T08:00:00Z', + updated_at: '2025-09-30T09:00:00Z', + }, + total_quantity: 20, + total_expense: 400000, + notes: 'Untuk menjaga daya tahan ayam', + }, + ], + }, + ], +}; + +export class ExpenseApiService extends BaseApiService< + Expense, + FormData, + FormData +> { + constructor(basePath: string) { + super(basePath); + } + + // 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, + }; + } + + 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'); + +// TODO: remove this ASAP // export const ExpenseApi = new BaseApiService< // Expense, // CreateExpensePayload, // UpdateExpensePayload -// >('/expense'); - -// TODO: remove this ASAP -export const ExpenseApi = new BaseApiService< - Expense, - CreateExpensePayload, - UpdateExpensePayload ->('/master-data/uoms'); +// >('/master-data/uoms'); From 57ffd50558f8cdb2d2c505d7d7f82b02b90b6a35 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Thu, 6 Nov 2025 21:10:50 +0700 Subject: [PATCH 15/31] feat(FE-195): set expense table column header and cell --- .../pages/expense/ExpensesTable.tsx | 155 ++++++++++++------ 1 file changed, 103 insertions(+), 52 deletions(-) diff --git a/src/components/pages/expense/ExpensesTable.tsx b/src/components/pages/expense/ExpensesTable.tsx index f188200a..36e701a6 100644 --- a/src/components/pages/expense/ExpensesTable.tsx +++ b/src/components/pages/expense/ExpensesTable.tsx @@ -14,10 +14,11 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal'; import SelectInput, { OptionType } from '@/components/input/SelectInput'; import RowDropdownOptions from '@/components/table/RowDropdownOptions'; import RowCollapseOptions from '@/components/table/RowCollapseOptions'; +import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper'; import { Expense } from '@/types/api/expense'; import { ExpenseApi } from '@/services/api/expense'; -import { cn } from '@/lib/helper'; +import { cn, formatCurrency } from '@/lib/helper'; import { isResponseSuccess } from '@/lib/api-helper'; import { useTableFilter } from '@/services/hooks/useTableFilter'; import { ROWS_OPTIONS } from '@/config/constant'; @@ -32,53 +33,42 @@ const RowOptionsMenu = ({ deleteClickHandler: () => void; }) => { return ( -
-
- + + - + - -
-
+ + ); }; @@ -121,8 +111,64 @@ const ExpensesTable = () => { 1, }, { - accessorKey: 'name', - header: 'Nama', + 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) => { + // TODO: integrate this to API + return 'test'; + }, + }, + { + header: 'Status BOP', + cell: (props) => { + // TODO: integrate this to API + return 'test'; + }, }, { header: 'Aksi', @@ -203,10 +249,15 @@ const ExpensesTable = () => {
-
-
@@ -265,7 +316,7 @@ const ExpensesTable = () => { Date: Thu, 6 Nov 2025 21:11:18 +0700 Subject: [PATCH 16/31] chore(FE-199): adjust Expense type --- src/types/api/expense.d.ts | 41 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/src/types/api/expense.d.ts b/src/types/api/expense.d.ts index 6fdf358c..cab20a4b 100644 --- a/src/types/api/expense.d.ts +++ b/src/types/api/expense.d.ts @@ -1,14 +1,51 @@ import { 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; - name: 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; }; export type Expense = BaseMetadata & BaseExpense; export type CreateExpensePayload = { - name: string; + 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; From 6ffb6e1560d818baedf8ff612816ac6a4375098c Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Thu, 13 Nov 2025 14:25:03 +0700 Subject: [PATCH 17/31] chore: prettier format --- .gitlab-ci.yml | 25 ++++++++++++------------- docker-compose.yaml | 12 ++++++------ 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 345f305f..951e5472 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,7 +1,7 @@ stages: - build - deploy - + .build_template: &build_template stage: build image: node:20-alpine @@ -10,15 +10,15 @@ stages: paths: - node_modules/ variables: - NPM_CONFIG_PRODUCTION: "false" - NODE_ENV: "" + NPM_CONFIG_PRODUCTION: 'false' + NODE_ENV: '' script: - echo "Installing dependencies..." - npm ci --no-audit --no-fund - echo "Building Next.js static export..." - npx next build artifacts: - name: "out-$CI_COMMIT_SHORT_SHA" + name: 'out-$CI_COMMIT_SHORT_SHA' paths: - out/ expire_in: 1 week @@ -27,7 +27,7 @@ stages: stage: deploy image: name: amazon/aws-cli:latest - entrypoint: ["/bin/sh", "-c"] + entrypoint: ['/bin/sh', '-c'] script: - set -e - aws --version @@ -106,22 +106,21 @@ build:dev: environment: name: development variables: - NEXT_PUBLIC_API_BASE_URL: "https://dev-api-lti.mbugroup.id" - NEXT_PUBLIC_SSO_LOGIN_URL: "https://dev-api-sso.mbugroup.id" + NEXT_PUBLIC_API_BASE_URL: 'https://dev-api-lti.mbugroup.id' + NEXT_PUBLIC_SSO_LOGIN_URL: 'https://dev-api-sso.mbugroup.id' deploy:dev: <<: *deploy_template - needs: ["build:dev"] + needs: ['build:dev'] rules: - if: '$CI_COMMIT_BRANCH == "development"' variables: - S3_BUCKET: "dev-lti-erp.mbugroup.id" - AWS_REGION: "ap-southeast-3" - CLOUDFRONT_DISTRIBUTION_ID: "E1Z8XTA8XF1GIV" + S3_BUCKET: 'dev-lti-erp.mbugroup.id' + AWS_REGION: 'ap-southeast-3' + CLOUDFRONT_DISTRIBUTION_ID: 'E1Z8XTA8XF1GIV' environment: name: development url: https://dev-lti-erp.mbugroup.id - # ====== PRODUCTION ====== # build:production: # <<: *build_template @@ -143,5 +142,5 @@ deploy:dev: # CLOUDFRONT_DISTRIBUTION_ID: "ddfd" # environment: # name: production - # url: https://royalgoldcapital.com +# url: https://royalgoldcapital.com diff --git a/docker-compose.yaml b/docker-compose.yaml index 8d658170..b89f441b 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,4 +1,4 @@ -version: "3.9" +version: '3.9' services: dev-web-lti: @@ -7,7 +7,7 @@ services: context: . dockerfile: Dockerfile ports: - - "3002:3000" + - '3002:3000' env_file: - .env environment: @@ -19,13 +19,13 @@ services: deploy: resources: limits: - cpus: "3.0" + cpus: '3.0' memory: 3G reservations: - cpus: "1.0" + cpus: '1.0' memory: 512M extra_hosts: - - "host.docker.internal:host-gateway" + - 'host.docker.internal:host-gateway' # Optional: aktifkan healthcheck jika punya endpoint # healthcheck: # test: ["CMD-SHELL", "curl -fsS http://localhost:3000/api/healthz || exit 1"] @@ -36,4 +36,4 @@ services: networks: dev-lti-network: - external: true \ No newline at end of file + external: true From e14d10c503b86eb43d61192041804c4362775798 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 17 Nov 2025 11:57:44 +0700 Subject: [PATCH 18/31] chore(FE-193,196): create expense detail layout file --- src/app/expense/detail/layout.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/app/expense/detail/layout.tsx 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; From a151abfbe9171f93980b7523e83c41a800442cd3 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 17 Nov 2025 11:58:34 +0700 Subject: [PATCH 19/31] feat(FE-196): create Expense Detail page --- src/app/expense/detail/page.tsx | 50 +++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/app/expense/detail/page.tsx 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; From 6067c00219fcd1c4331f317b0d374c17094e8b94 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 17 Nov 2025 13:40:40 +0700 Subject: [PATCH 20/31] feat(FE-193): create Expense Edit page --- src/app/expense/detail/edit/page.tsx | 61 ++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 src/app/expense/detail/edit/page.tsx 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; From ac227f778080c3cbf04d67b870b24538df3dd4a2 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 17 Nov 2025 13:48:07 +0700 Subject: [PATCH 21/31] feat(FE-196): create ExpenseDetail component for expense detail page --- .../pages/expense/ExpenseDetail.tsx | 507 ++++++++++++++++++ 1 file changed, 507 insertions(+) create mode 100644 src/components/pages/expense/ExpenseDetail.tsx diff --git a/src/components/pages/expense/ExpenseDetail.tsx b/src/components/pages/expense/ExpenseDetail.tsx new file mode 100644 index 00000000..af7ec7c7 --- /dev/null +++ b/src/components/pages/expense/ExpenseDetail.tsx @@ -0,0 +1,507 @@ +'use client'; + +import { useState } from 'react'; +import toast from 'react-hot-toast'; +import { useRouter } from 'next/navigation'; +import { useFormik } from 'formik'; + +import Link from 'next/link'; +import { Icon } from '@iconify/react'; +import Button from '@/components/Button'; +import { useModal } from '@/components/Modal'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; +import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes'; +import RealizationStatusBadge from '@/components/pages/expense/RealizationStatusBadge'; +import ExpenseStatusBadge from '@/components/pages/expense/ExpenseStatusBadge'; +import DropFileInput from '@/components/input/DropFileInput'; + +import { Expense } from '@/types/api/expense'; +import { formatCurrency, formatDate } from '@/lib/helper'; +import { ExpenseApi } from '@/services/api/expense'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { ACCEPTED_FILE_TYPE } from '@/config/constant'; +import { + UploadRequestDocumentsFormSchema, + UploadRequestDocumentsFormValues, +} from '@/components/pages/expense/form/ExpenseRequestForm.schema'; + +interface ExpenseDetailProps { + initialValues?: Expense; +} + +const ExpenseDetail: React.FC = ({ initialValues }) => { + const router = useRouter(); + + // Modal hooks + const deleteModal = useModal(); + const approveModal = useModal(); + const rejectModal = useModal(); + + // Modal loading state + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + const [isApproveLoading, setIsApproveLoading] = useState(false); + const [isRejectLoading, setIsRejectLoading] = useState(false); + + const isLatestApprovalRejectedOrDone = + initialValues?.approval && + (initialValues.approval.action === 'REJECTED' || + initialValues.approval.step_number === 5); + + const formik = useFormik({ + initialValues: { + request_documents: [], + }, + validationSchema: UploadRequestDocumentsFormSchema, + onSubmit: async (values) => { + const addRequestDocumentsRes = await ExpenseApi.uploadRequestDocuments( + initialValues?.id as number, + values.request_documents + ); + + if (isResponseSuccess(addRequestDocumentsRes)) { + toast.success(addRequestDocumentsRes.message); + window.location.reload(); + } else { + toast.error(String(addRequestDocumentsRes?.message)); + } + }, + }); + + const deleteExpenseClickHandler = () => { + deleteModal.openModal(); + }; + + const approveClickHandler = () => { + approveModal.openModal(); + }; + + const rejectClickHandler = () => { + rejectModal.openModal(); + }; + + // Modal confirm click handler + const confirmationModalDeleteClickHandler = async () => { + setIsDeleteLoading(true); + + try { + await ExpenseApi.delete(initialValues?.id as number); + + toast.success('Berhasil menghapus data biaya operasional!'); + router.push('/expense'); + } catch (error) { + toast.error('Gagal menghapus data biaya operasional!'); + } finally { + deleteModal.closeModal(); + setIsDeleteLoading(false); + } + }; + + const confirmationModalApproveClickHandler = async (notes: string) => { + setIsApproveLoading(true); + + const approveResponse = await ExpenseApi.approve( + initialValues?.id as number, + notes + ); + + if (isResponseSuccess(approveResponse)) { + approveModal.closeModal(); + + toast.success('Berhasil approve pengajuan biaya operasional!'); + router.push('/expense'); + } else { + approveModal.closeModal(); + + toast.error('Gagal approve pengajuan biaya operasional!'); + } + + setIsApproveLoading(false); + }; + + const confirmationModalRejectClickHandler = async (notes: string) => { + setIsRejectLoading(true); + + const rejectResponse = await ExpenseApi.reject( + initialValues?.id as number, + notes + ); + + if (isResponseSuccess(rejectResponse)) { + rejectModal.closeModal(); + + toast.success('Berhasil reject pengajuan biaya operasional!'); + router.push('/expense'); + } else { + rejectModal.closeModal(); + + toast.error('Gagal reject pengajuan biaya operasional!'); + } + + setIsRejectLoading(false); + }; + + const requestDocumentsChangeHandler = (val: File[]) => { + formik.setFieldTouched('request_documents', true); + formik.setFieldValue('request_documents', val); + }; + + const requestDocumentsDeleteHandler = (deletedFileIdx: number) => { + const newRequestDocuments = formik.values.request_documents; + + newRequestDocuments?.splice(deletedFileIdx, 1); + + formik.setFieldValue('request_documents', newRequestDocuments); + }; + + return ( + <> +
+
+ + +

+ Detail Biaya Operasional +

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

+ Rincian Pengajuan Biaya Operasional +

+ +
+ {initialValues?.kandang_expenses.map( + (kandangExpense, kandangExpenseIdx) => { + let expenseGrandTotal = 0; + + kandangExpense.expenses.forEach( + (item) => (expenseGrandTotal += item.total_expense) + ); + + return ( +
+ + + + + + + + + + + + + + {kandangExpense.expenses.map( + (expenseItem, expenseIdx) => ( + + + + + + + ) + )} + + + + + + + +
+ Biaya {kandangExpense.kandang.name} +
NonstockTotal KuantitasTotal BiayaCatatan
{expenseItem.nonstock.name}{expenseItem.total_quantity} + {formatCurrency(expenseItem.total_expense)} + + {expenseItem.notes ?? '-'} +
+ Total Biaya Keseluruhan: + + {formatCurrency(expenseGrandTotal)} +
+
+ ); + } + )} +
+
+
+ + + + + + + + ); +}; + +export default ExpenseDetail; From f01e764d9c2eca9f12aaa328bcc99e34660f36f6 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 17 Nov 2025 13:56:26 +0700 Subject: [PATCH 22/31] feat(FE-195): add filter and approve/reject functionality --- .../pages/expense/ExpensesTable.tsx | 505 +++++++++++++++--- 1 file changed, 435 insertions(+), 70 deletions(-) diff --git a/src/components/pages/expense/ExpensesTable.tsx b/src/components/pages/expense/ExpensesTable.tsx index 36e701a6..fc6f6d13 100644 --- a/src/components/pages/expense/ExpensesTable.tsx +++ b/src/components/pages/expense/ExpensesTable.tsx @@ -2,7 +2,12 @@ import { ChangeEventHandler, useEffect, useState } from 'react'; import useSWR from 'swr'; -import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; +import { + CellContext, + ColumnDef, + Row, + SortingState, +} from '@tanstack/react-table'; import toast from 'react-hot-toast'; import { Icon } from '@iconify/react'; @@ -11,10 +16,18 @@ 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 } from '@/components/input/SelectInput'; +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'; @@ -22,16 +35,34 @@ 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 ( - - + > + + Edit + + )} + + {/* TODO: apply RBAC */} + {showApproveButton && ( + + )} + + {showRejectButton && ( + + )} + + {showDeleteButton && ( + + )} ); }; @@ -80,8 +140,25 @@ const ExpensesTable = () => { setPageSize, toQueryString: getTableFilterQueryString, } = useTableFilter({ - initial: { search: '', nameSort: '' }, - paramMap: { page: 'page', pageSize: 'limit', nameSort: 'sort_name' }, + 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 { @@ -94,21 +171,51 @@ const ExpensesTable = () => { ); 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[] = [ { - header: '#', - cell: (props) => - tableFilterState.pageSize * (tableFilterState.page - 1) + - props.row.index + - 1, + id: 'select', + header: ({ table }) => ( +
+ +
+ ), + cell: ({ row }) => { + const isCheckboxDisabled = + !row.getCanSelect() || row.original.approval.action === 'REJECTED'; + + return ( +
+ +
+ ); + }, }, { accessorKey: 'transaction_date', @@ -158,17 +265,15 @@ const ExpensesTable = () => { }, { header: 'Status Pencairan', - cell: (props) => { - // TODO: integrate this to API - return 'test'; - }, + cell: (props) => ( + + ), }, { header: 'Status BOP', - cell: (props) => { - // TODO: integrate this to API - return 'test'; - }, + cell: (props) => ( + + ), }, { header: 'Aksi', @@ -180,6 +285,28 @@ const ExpensesTable = () => { 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(); @@ -192,6 +319,8 @@ const ExpensesTable = () => { @@ -202,6 +331,8 @@ const ExpensesTable = () => { @@ -212,6 +343,20 @@ const ExpensesTable = () => { }, ]; + 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); @@ -223,10 +368,108 @@ const ExpensesTable = () => { 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; @@ -248,39 +491,128 @@ const ExpensesTable = () => { <>
-
-
- +
+
+
+ + + {selectedRowIds.length > 0 && ( + <> + {/* TODO: apply RBAC */} + + + + + )} +
+ +
- -
+
+ -
- + + + + + + + +
@@ -296,6 +628,9 @@ const ExpensesTable = () => { isLoading={isLoading} sorting={sorting} setSorting={setSorting} + rowSelection={rowSelection} + setRowSelection={setRowSelection} + enableRowSelection={tableEnableRowSelectionHandler} className={{ containerClassName: cn({ 'mb-20': @@ -327,6 +662,36 @@ const ExpensesTable = () => { onClick: confirmationModalDeleteClickHandler, }} /> + + + + ); }; From dbe6ced6028e8a4567e18bebd332410a4689dddd Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 17 Nov 2025 13:58:37 +0700 Subject: [PATCH 23/31] feat(FE-195,196): create ExpenseStatusBadge component --- .../pages/expense/ExpenseStatusBadge.tsx | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 src/components/pages/expense/ExpenseStatusBadge.tsx 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; From fc76b442794571d850b66425fb63010536622aaf Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 17 Nov 2025 14:00:16 +0700 Subject: [PATCH 24/31] feat(FE-195,196): create RealizationStatusBadge component --- .../pages/expense/RealizationStatusBadge.tsx | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 src/components/pages/expense/RealizationStatusBadge.tsx 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; From 83f1ba46a72c649e64a5ac3763c13a9e5fbe22cd Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 17 Nov 2025 14:05:24 +0700 Subject: [PATCH 25/31] chore(FE-188,193): adjust ExpenseKandangsTable component --- .../pages/expense/form/ExpenseKandangsTable.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/components/pages/expense/form/ExpenseKandangsTable.tsx b/src/components/pages/expense/form/ExpenseKandangsTable.tsx index 78b53541..b3c9f46d 100644 --- a/src/components/pages/expense/form/ExpenseKandangsTable.tsx +++ b/src/components/pages/expense/form/ExpenseKandangsTable.tsx @@ -145,12 +145,19 @@ const ExpenseKandangsTable = ({ ); onChange(formattedSelectedKandangs); + } else { + onChange([]); } }, [rowSelection]); useEffect(() => { - setRowSelection({}); - }, [locationId]); + if ( + selectedKandangs.length === 0 && + Object.keys(rowSelection).length !== 0 + ) { + setRowSelection({}); + } + }, [selectedKandangs]); // track sorting useEffect(() => { From e6ac11893a1fd8343419e5f064bbe9d9ef86a605 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 17 Nov 2025 14:07:14 +0700 Subject: [PATCH 26/31] chore(FE-198): create UploadRequestDocumentsFormSchema and UploadRequestDocumentsFormValues --- .../pages/expense/form/ExpenseRequestForm.schema.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/components/pages/expense/form/ExpenseRequestForm.schema.ts b/src/components/pages/expense/form/ExpenseRequestForm.schema.ts index 8a86ad5a..4c2ae600 100644 --- a/src/components/pages/expense/form/ExpenseRequestForm.schema.ts +++ b/src/components/pages/expense/form/ExpenseRequestForm.schema.ts @@ -89,10 +89,18 @@ export const ExpenseRequestFormSchema: Yup.ObjectSchema = 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 => { From 4e88e76538346c2df193655bb6c96695819568e9 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 17 Nov 2025 14:10:08 +0700 Subject: [PATCH 27/31] feat(FE-193): add existing documents link --- .../pages/expense/form/ExpenseRequestForm.tsx | 61 +++++++++++++++++-- 1 file changed, 55 insertions(+), 6 deletions(-) diff --git a/src/components/pages/expense/form/ExpenseRequestForm.tsx b/src/components/pages/expense/form/ExpenseRequestForm.tsx index 4268ca92..0fd68c88 100644 --- a/src/components/pages/expense/form/ExpenseRequestForm.tsx +++ b/src/components/pages/expense/form/ExpenseRequestForm.tsx @@ -6,6 +6,7 @@ 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'; @@ -151,20 +152,26 @@ const ExpenseRequestForm = ({ 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 = formik.values.kandangExpenses.find( + const isKandangExistInKandangExpense = newKandangExpenses.find( (kandangExpenseItem) => kandangExpenseItem.kandangId === kandangItem.id ); if (isKandangExistInKandangExpense) return; - formik.values.kandangExpenses.push({ + newKandangExpenses.push({ kandangId: kandangItem.id, expenses: [ { @@ -176,6 +183,24 @@ const ExpenseRequestForm = ({ ], }); }); + + // 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) => { @@ -218,10 +243,6 @@ const ExpenseRequestForm = ({ formikSetValues(getExpenseFormInitialValues(initialValues)); }, [formikSetValues, getExpenseFormInitialValues, initialValues]); - useEffect(() => { - formik.setFieldValue('kandangs', undefined); - }, [formik.values.location]); - return ( <>
@@ -308,6 +329,34 @@ const ExpenseRequestForm = ({ }} /> + {formik.values.existing_documents && + formik.values.existing_documents.length > 0 && ( +
+
    + {formik.values.existing_documents.map( + (existingDocument, existingDocumentIdx) => ( +
  • + + {existingDocument.name}{' '} + + +
  • + ) + )} +
+
+ )} + Date: Mon, 17 Nov 2025 14:23:35 +0700 Subject: [PATCH 28/31] chore(FE-188,193): add IDR prefix --- .../expense/form/ExpenseRequestKandangDetailExpense.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/pages/expense/form/ExpenseRequestKandangDetailExpense.tsx b/src/components/pages/expense/form/ExpenseRequestKandangDetailExpense.tsx index 6b670069..ca9edf37 100644 --- a/src/components/pages/expense/form/ExpenseRequestKandangDetailExpense.tsx +++ b/src/components/pages/expense/form/ExpenseRequestKandangDetailExpense.tsx @@ -208,6 +208,11 @@ const ExpenseRequestKandangDetailExpense: React.FC< kandangExpenseIdx, expenseIdx )} + inputPrefix={ + + Rp + + } className={{ wrapper: 'min-w-24' }} /> @@ -219,7 +224,7 @@ const ExpenseRequestKandangDetailExpense: React.FC< value={ formik.values.kandangExpenses[ kandangExpenseIdx - ].expenses[expenseIdx].notes + ].expenses[expenseIdx].notes ?? '' } onChange={formik.handleChange} onBlur={formik.handleBlur} From da40e7d7beab6c344e3901cd3ce5095035523472 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 17 Nov 2025 14:55:27 +0700 Subject: [PATCH 29/31] chore(FE-196): create expense request approval line --- src/config/approval-line.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/config/approval-line.ts b/src/config/approval-line.ts index 52e4290f..8174057d 100644 --- a/src/config/approval-line.ts +++ b/src/config/approval-line.ts @@ -32,3 +32,26 @@ export const TRANSFER_TO_LAYING_APPROVAL_LINE: ApprovalLine = [ step_name: 'Disetujui', }, ] as const; + +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; From 470cdb8b029d32bfda1cd1fa46ad1bf8b34ffa6b Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 17 Nov 2025 14:56:25 +0700 Subject: [PATCH 30/31] chore(FE-199): create Expense dummy data --- src/services/api/expense.ts | 1213 +++++++++++++++++++++++++++++------ 1 file changed, 1016 insertions(+), 197 deletions(-) diff --git a/src/services/api/expense.ts b/src/services/api/expense.ts index 92e87c43..24931316 100644 --- a/src/services/api/expense.ts +++ b/src/services/api/expense.ts @@ -1,200 +1,738 @@ +import axios from 'axios'; import { sleep } from '@/lib/helper'; import { BaseApiService } from '@/services/api/base'; -import { BaseApiResponse } from '@/types/api/api-general'; +import { BaseApiResponse, CreatedUser } from '@/types/api/api-general'; import { CreateExpensePayload, Expense } from '@/types/api/expense'; +import { httpClient } from '@/services/http/client'; -const DUMMY_EXPENSE: Expense = { - created_user: { - id: 1, - id_user: 101, - email: 'admin@example.com', - name: 'Admin User', - }, - created_at: '2025-11-05T10:30:00Z', - updated_at: '2025-11-05T12:00:00Z', +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, - location: { - id: 10, - name: 'Farm A', - address: 'Jl. Raya Peternakan No. 45, Bandung', - area: { - id: 100, - name: 'Jawa Barat', - }, - created_user: { - id: 2, - id_user: 102, - email: 'manager@example.com', - name: 'Farm Manager', - }, - created_at: '2025-10-01T08:00:00Z', - updated_at: '2025-10-05T09:30:00Z', - }, - transaction_date: '2025-11-04', - - kandangs: [ - { - id: 201, - name: 'Kandang 1', - status: 'ACTIVE', - location: { - id: 10, - name: 'Farm A', - address: 'Jl. Raya Peternakan No. 45, Bandung', - area: { id: 100, name: 'Jawa Barat' }, - }, - pic: { - id: 3, - id_user: 103, - email: 'kandang1@example.com', - name: 'PIC Kandang 1', - }, - created_user: { - id: 4, - id_user: 104, - email: 'creator@example.com', - name: 'Creator User', - }, - created_at: '2025-10-10T08:00:00Z', - updated_at: '2025-10-20T09:00:00Z', - }, - ], - - vendor: { - id: 301, - name: 'PT. Pakan Makmur', - alias: 'PakanMakmur', - pic: 'Budi Santoso', - type: 'Supplier', - category: 'Pakan', - hatchery: 'Makmur Hatchery', - phone: '08123456789', - email: 'contact@pakanmakmur.com', - address: 'Jl. Industri No. 5, Bekasi', - npwp: '12.345.678.9-012.345', - account_number: '1234567890', - due_date: 30, - balance: 5000000, - created_user: { - id: 5, - id_user: 105, - email: 'finance@example.com', - name: 'Finance User', - }, - created_at: '2025-09-01T09:00:00Z', - updated_at: '2025-09-15T10:00:00Z', - }, - - request_documents: [ - { - name: 'invoice_001.pdf', - url: 'https://example.com/files/invoice_001.pdf', - }, - { - name: 'receipt_001.jpg', - url: 'https://example.com/files/receipt_001.jpg', - }, - ], - - kandang_expenses: [ - { - kandang: { - id: 201, - name: 'Kandang 1', - status: 'ACTIVE', - location: { - id: 10, - name: 'Farm A', - address: 'Jl. Raya Peternakan No. 45, Bandung', - area: { id: 100, name: 'Jawa Barat' }, - }, - pic: { - id: 3, - id_user: 103, - email: 'kandang1@example.com', - name: 'PIC Kandang 1', - }, - created_user: { - id: 4, - id_user: 104, - email: 'creator@example.com', - name: 'Creator User', - }, - created_at: '2025-10-10T08:00:00Z', - updated_at: '2025-10-20T09:00:00Z', - }, - expenses: [ - { - nonstock: { - id: 501, - name: 'Pakan Ayam Broiler', - uom_id: 1, - uom: { - id: 1, - name: 'Kg', - }, - suppliers: [ - { - id: 301, - name: 'PT. Pakan Makmur', - alias: 'PakanMakmur', - pic: 'Budi Santoso', - type: 'Supplier', - category: 'Pakan', - hatchery: 'Makmur Hatchery', - phone: '08123456789', - email: 'contact@pakanmakmur.com', - address: 'Jl. Industri No. 5, Bekasi', - npwp: '12.345.678.9-012.345', - account_number: '1234567890', - due_date: 30, - balance: 5000000, - }, - ], - flags: ['PAKAN', 'IS_ACTIVE'], - created_user: { - id: 6, - id_user: 106, - email: 'staff@example.com', - name: 'Inventory Staff', - }, - created_at: '2025-09-25T08:00:00Z', - updated_at: '2025-09-30T09:00:00Z', - }, - total_quantity: 500, - total_expense: 2500000, - notes: 'Pakan untuk minggu pertama', - }, - { - nonstock: { - id: 502, - name: 'Vitamin C Ayam', - uom_id: 2, - uom: { - id: 2, - name: 'Botol', - }, - suppliers: [], - flags: ['VITAMIN', 'IS_ACTIVE'], - created_user: { - id: 6, - id_user: 106, - email: 'staff@example.com', - name: 'Inventory Staff', - }, - created_at: '2025-09-25T08:00:00Z', - updated_at: '2025-09-30T09:00:00Z', - }, - total_quantity: 20, - total_expense: 400000, - notes: 'Untuk menjaga daya tahan ayam', - }, - ], - }, - ], + 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, @@ -204,6 +742,46 @@ 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 @@ -221,10 +799,258 @@ export class ExpenseApiService extends BaseApiService< code: 200, status: 'success', message: 'Berhasil membuat pengajuan biaya operasional!', - data: DUMMY_EXPENSE, + 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(); @@ -274,10 +1100,3 @@ export class ExpenseApiService extends BaseApiService< } export const ExpenseApi = new ExpenseApiService('/expense'); - -// TODO: remove this ASAP -// export const ExpenseApi = new BaseApiService< -// Expense, -// CreateExpensePayload, -// UpdateExpensePayload -// >('/master-data/uoms'); From 8b02d0df1c55c0c2de972cfa8150b2e3329bcaaf Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 17 Nov 2025 14:57:55 +0700 Subject: [PATCH 31/31] chore(FE-199): add reference_number, po_number, and approval property --- src/types/api/expense.d.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/types/api/expense.d.ts b/src/types/api/expense.d.ts index cab20a4b..78a8ac76 100644 --- a/src/types/api/expense.d.ts +++ b/src/types/api/expense.d.ts @@ -1,4 +1,4 @@ -import { BaseMetadata } from '@/types/api/api-general'; +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'; @@ -6,6 +6,8 @@ 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; @@ -27,6 +29,7 @@ export type BaseExpense = { nominal: number; paid?: number; remaining_cost?: number; + approval: BaseApproval; }; export type Expense = BaseMetadata & BaseExpense;