From c72befb5b42ca842e4a7b79264fe712a60c91a4b Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 31 Oct 2025 14:25:01 +0700 Subject: [PATCH 01/48] feat(FE-195): create Expense page --- src/app/expense/page.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/app/expense/page.tsx diff --git a/src/app/expense/page.tsx b/src/app/expense/page.tsx new file mode 100644 index 00000000..d6b00286 --- /dev/null +++ b/src/app/expense/page.tsx @@ -0,0 +1,11 @@ +import ExpensesTable from '@/components/pages/expense/ExpensesTable'; + +const Expense = () => { + return ( +
+ +
+ ); +}; + +export default Expense; From a0cf6c0f56aa48b3454671d5ab2f3b4097a8a7fa Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 31 Oct 2025 14:26:52 +0700 Subject: [PATCH 02/48] feat(FE-188): create Add Expense page --- src/app/expense/add/page.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/app/expense/add/page.tsx diff --git a/src/app/expense/add/page.tsx b/src/app/expense/add/page.tsx new file mode 100644 index 00000000..00560dad --- /dev/null +++ b/src/app/expense/add/page.tsx @@ -0,0 +1,11 @@ +import ExpenseForm from '@/components/pages/expense/form/ExpenseForm'; + +const AddExpense = () => { + return ( +
+ +
+ ); +}; + +export default AddExpense; From 01bfe1cc3b8fcffa00687bb181c92b899533898f Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 31 Oct 2025 14:27:13 +0700 Subject: [PATCH 03/48] chore: export ButtonProps interface --- src/components/Button.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 7cad5b58..2f209ece 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -3,7 +3,7 @@ import Link from 'next/link'; import { cn } from '@/lib/helper'; import { Color } from '@/types/theme'; -interface ButtonProps extends react.ComponentProps<'button'> { +export interface ButtonProps extends react.ComponentProps<'button'> { variant?: 'soft' | 'outline' | 'dash' | 'ghost' | 'link' | 'active'; color?: Color; href?: string; From 00f64b18979e740978d0b38d2fe05dd4f28fb41f Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 31 Oct 2025 14:28:04 +0700 Subject: [PATCH 04/48] chore: add string as the valueKey and labelKey type --- src/components/input/SelectInput.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/input/SelectInput.tsx b/src/components/input/SelectInput.tsx index 6a8d0ac8..833d7d26 100644 --- a/src/components/input/SelectInput.tsx +++ b/src/components/input/SelectInput.tsx @@ -229,8 +229,8 @@ const SelectInput = (props: SelectInputProps) => { const useSelect = ( basePath: string, - valueKey: keyof T, - labelKey: keyof T, + valueKey: keyof T | string, + labelKey: keyof T | string, searchKey: string = 'search', params?: { [key: string]: string } ) => { From 80747bb44199d0505ad686f787263f9b111a8c19 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 31 Oct 2025 14:28:51 +0700 Subject: [PATCH 05/48] chore(FE-195): adjust ConfirmationModal primaryButton and secondaryButton props --- src/components/modal/ConfirmationModal.tsx | 51 +++++++++++++++------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/src/components/modal/ConfirmationModal.tsx b/src/components/modal/ConfirmationModal.tsx index 04c221e6..2358f815 100644 --- a/src/components/modal/ConfirmationModal.tsx +++ b/src/components/modal/ConfirmationModal.tsx @@ -1,30 +1,23 @@ 'use client'; -import { RefObject } from 'react'; +import { MouseEventHandler, RefObject, useState } from 'react'; import { Icon } from '@iconify/react'; import Modal from '@/components/Modal'; -import Button from '@/components/Button'; +import Button, { ButtonProps } from '@/components/Button'; import { cn } from '@/lib/helper'; -import { Color } from '@/types/theme'; interface ConfirmationModalProps { ref: RefObject; type?: 'info' | 'success' | 'error'; text?: string; closeOnBackdrop?: boolean; - primaryButton?: { + primaryButton?: ButtonProps & { text?: string; - color?: Color; - isLoading?: boolean; - onClick?: () => void; }; - secondaryButton?: { + secondaryButton?: ButtonProps & { text?: string; - color?: Color; - isLoading?: boolean; - onClick?: () => void; }; className?: { modal?: string; @@ -41,10 +34,22 @@ const ConfirmationModal = ({ secondaryButton, className, }: ConfirmationModalProps) => { + const [isPrimaryButtonLoading, setIsPrimaryButtonLoading] = useState(false); + const closeModalHandler = () => { ref.current?.close(); }; + const primaryButtonClickHandler: MouseEventHandler< + HTMLButtonElement + > = async (event) => { + setIsPrimaryButtonLoading(true); + + await primaryButton?.onClick?.(event); + + setIsPrimaryButtonLoading(false); + }; + return (
@@ -93,10 +98,15 @@ const ConfirmationModal = ({
{secondaryButton && secondaryButton.text && ( + + + + +
+
+ ); +}; + +const ExpensesTable = () => { + const { + state: tableFilterState, + updateFilter, + setPage, + setPageSize, + toQueryString: getTableFilterQueryString, + } = useTableFilter({ + initial: { search: '', nameSort: '' }, + paramMap: { page: 'page', pageSize: 'limit', nameSort: 'sort_name' }, + }); + + const { + data: expenses, + isLoading, + mutate: refreshExpenses, + } = useSWR( + `${ExpenseApi.basePath}${getTableFilterQueryString()}`, + ExpenseApi.getAllFetcher + ); + + const deleteModal = useModal(); + + const [selectedExpense, setSelectedExpense] = useState( + undefined + ); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + const [sorting, setSorting] = useState([]); + + const expensesColumns: ColumnDef[] = [ + { + header: '#', + cell: (props) => + tableFilterState.pageSize * (tableFilterState.page - 1) + + props.row.index + + 1, + }, + { + accessorKey: 'name', + header: 'Nama', + }, + { + header: 'Aksi', + cell: (props) => { + const currentPageSize = props.table.getPaginationRowModel().rows.length; + const currentPageRows = props.table.getPaginationRowModel().flatRows; + const currentRowRelativeIndex = + currentPageRows.findIndex((r) => r.id === props.row.id) + 1; + + const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2; + + const deleteClickHandler = () => { + setSelectedExpense(props.row.original); + deleteModal.openModal(); + }; + + return ( + <> + {currentPageSize > 2 && ( + + + + )} + + {currentPageSize <= 2 && ( + + + + )} + + ); + }, + }, + ]; + + const confirmationModalDeleteClickHandler = async () => { + setIsDeleteLoading(true); + + await ExpenseApi.delete(selectedExpense?.id as number); + refreshExpenses(); + + deleteModal.closeModal(); + toast.success('Berhasil menghapus biaya operasional!'); + setIsDeleteLoading(false); + }; + + const searchChangeHandler: ChangeEventHandler = (e) => { + updateFilter('search', e.target.value); + }; + + const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { + const newVal = val as OptionType; + + setPageSize(newVal.value as number); + }; + + // track sorting + useEffect(() => { + const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name'); + + if (!isNameSorted) { + updateFilter('nameSort', ''); + } else { + updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc'); + } + }, [sorting, updateFilter]); + + return ( + <> +
+
+
+
+ +
+ + +
+ +
+ +
+
+ + + data={isResponseSuccess(expenses) ? expenses?.data : []} + columns={expensesColumns} + pageSize={tableFilterState.pageSize} + page={isResponseSuccess(expenses) ? expenses?.meta?.page : 0} + totalItems={ + isResponseSuccess(expenses) ? expenses?.meta?.total_results : 0 + } + onPageChange={setPage} + isLoading={isLoading} + sorting={sorting} + setSorting={setSorting} + className={{ + containerClassName: cn({ + 'mb-20': + isResponseSuccess(expenses) && expenses?.data?.length === 0, + }), + tableWrapperClassName: 'overflow-x-auto min-h-full!', + tableClassName: 'font-inter w-full table-auto min-h-full!', + headerRowClassName: 'border-b border-b-gray-200', + headerColumnClassName: + 'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', + bodyRowClassName: 'border-b border-b-gray-200', + bodyColumnClassName: + 'px-6 py-3 last:flex last:flex-row last:justify-end', + }} + /> +
+ + + + ); +}; + +export default ExpensesTable; From a51c7c44ececeeef1c77fd8059d8aa79d796e27a Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 31 Oct 2025 14:29:31 +0700 Subject: [PATCH 07/48] feat(FE-188): create ExpenseForm component --- .../pages/expense/form/ExpenseForm.tsx | 303 ++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 src/components/pages/expense/form/ExpenseForm.tsx diff --git a/src/components/pages/expense/form/ExpenseForm.tsx b/src/components/pages/expense/form/ExpenseForm.tsx new file mode 100644 index 00000000..9361c5c3 --- /dev/null +++ b/src/components/pages/expense/form/ExpenseForm.tsx @@ -0,0 +1,303 @@ +'use client'; + +import { useCallback, useEffect, useMemo, 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 { + ExpenseFormSchema, + ExpenseFormValues, + UpdateExpenseFormSchema, +} from '@/components/pages/expense/form/ExpenseForm.schema'; +import { isResponseError } from '@/lib/api-helper'; +import { + Expense, + CreateExpensePayload, + UpdateExpensePayload, +} from '@/types/api/expense'; +import { ExpenseApi } from '@/services/api/expense'; +import { cn, sleep } from '@/lib/helper'; + +interface ExpenseFormProps { + type?: 'add' | 'edit' | 'detail'; + initialValues?: Expense; +} + +// TODO: integrate this with real API +const ExpenseForm = ({ type = 'add', initialValues }: ExpenseFormProps) => { + const router = useRouter(); + + // Modal hooks + const deleteModal = useModal(); + const approveModal = useModal(); + const rejectModal = useModal(); + + const [expenseFormErrorMessage, setExpenseFormErrorMessage] = useState(''); + + const createExpenseHandler = useCallback( + async (payload: CreateExpensePayload) => { + const createExpenseRes = await ExpenseApi.create(payload); + + if (isResponseError(createExpenseRes)) { + setExpenseFormErrorMessage(createExpenseRes.message); + return; + } + + toast.success(createExpenseRes?.message as string); + router.push('/expense'); + }, + [router] + ); + + const updateExpenseHandler = useCallback( + async (expenseId: number, payload: UpdateExpensePayload) => { + const updateExpenseRes = await ExpenseApi.update(expenseId, payload); + + if (updateExpenseRes?.status === 'error') { + setExpenseFormErrorMessage(updateExpenseRes.message); + return; + } + + toast.success(updateExpenseRes?.message as string); + router.refresh(); + router.push('/expense'); + }, + [router] + ); + + const formikInitialValues = useMemo(() => { + return { + name: initialValues?.name ?? '', + }; + }, [initialValues]); + + const formik = useFormik({ + initialValues: formikInitialValues, + validationSchema: + type === 'edit' ? UpdateExpenseFormSchema : ExpenseFormSchema, + onSubmit: async (values) => { + setExpenseFormErrorMessage(''); + + const expensePayload: CreateExpensePayload = { + name: values.name, + }; + + switch (type) { + case 'add': + await createExpenseHandler(expensePayload); + break; + + case 'edit': + await updateExpenseHandler( + initialValues?.id as number, + expensePayload + ); + break; + } + }, + }); + + const { setValues: formikSetValues } = formik; + + const deleteExpenseClickHandler = () => { + deleteModal.openModal(); + }; + + const confirmationModalRejectClickHandler = async () => { + await sleep(750); + + rejectModal.closeModal(); + toast.success('Berhasil melakukan reject biaya operasional!'); + }; + + const confirmationModalApproveClickHandler = async () => { + await sleep(750); + + approveModal.closeModal(); + toast.success('Berhasil melakukan approve biaya operasional!'); + }; + + const confirmationModalDeleteClickHandler = async () => { + await ExpenseApi.delete(initialValues?.id as number); + + deleteModal.closeModal(); + toast.success('Successfully delete Expense!'); + router.push('/expense'); + }; + + useEffect(() => { + formikSetValues(formikInitialValues); + }, [formikSetValues, formikInitialValues]); + + return ( + <> +
+
+ + +

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

+
+ +
+
+ +
+ +
+ {type !== 'add' && ( +
+ + + {type !== 'edit' && ( + + )} +
+ )} + + {type !== 'detail' && ( +
+ + + +
+ )} +
+ + {expenseFormErrorMessage && ( +
+ + {expenseFormErrorMessage} +
+ )} +
+
+ + {type !== 'add' && ( + + )} + + {type === 'detail' && ( + <> + + + + + )} + + ); +}; + +export default ExpenseForm; From 1a1bf8754e4834a1928073d21c097d9688f85902 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 31 Oct 2025 14:30:00 +0700 Subject: [PATCH 08/48] feat(FE-188): create Expense Form schema --- src/components/pages/expense/form/ExpenseForm.schema.ts | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/components/pages/expense/form/ExpenseForm.schema.ts diff --git a/src/components/pages/expense/form/ExpenseForm.schema.ts b/src/components/pages/expense/form/ExpenseForm.schema.ts new file mode 100644 index 00000000..35cd82f4 --- /dev/null +++ b/src/components/pages/expense/form/ExpenseForm.schema.ts @@ -0,0 +1,9 @@ +import * as Yup from 'yup'; + +export const ExpenseFormSchema = Yup.object({ + name: Yup.string().required('Nama wajib diisi!'), +}); + +export const UpdateExpenseFormSchema = ExpenseFormSchema; + +export type ExpenseFormValues = Yup.InferType; From 21b155e64b744ed3abfcfc53a70417f92f0d7000 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 31 Oct 2025 14:30:36 +0700 Subject: [PATCH 09/48] feat(FE-195): add Expense menu --- src/config/constant.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/config/constant.ts b/src/config/constant.ts index bf2bb0d9..57599702 100644 --- a/src/config/constant.ts +++ b/src/config/constant.ts @@ -40,6 +40,12 @@ export const MAIN_DRAWER_LINKS: MAIN_DRAWER_MENU[] = [ ], }, + { + title: 'Biaya Operasional', + link: '/expense', + icon: 'uil:wallet', + }, + { title: 'Persediaan', link: '/inventory', From 026e60704b4c8937a39c8e5b4c13da087662187c Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 31 Oct 2025 14:33:19 +0700 Subject: [PATCH 10/48] feat(FE-199): create Expense API service --- src/services/api/expense.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 src/services/api/expense.ts diff --git a/src/services/api/expense.ts b/src/services/api/expense.ts new file mode 100644 index 00000000..ec42743c --- /dev/null +++ b/src/services/api/expense.ts @@ -0,0 +1,19 @@ +import { BaseApiService } from '@/services/api/base'; +import { + CreateExpensePayload, + Expense, + UpdateExpensePayload, +} from '@/types/api/expense'; + +// export const ExpenseApi = new BaseApiService< +// Expense, +// CreateExpensePayload, +// UpdateExpensePayload +// >('/expense'); + +// TODO: remove this ASAP +export const ExpenseApi = new BaseApiService< + Expense, + CreateExpensePayload, + UpdateExpensePayload +>('/master-data/uoms'); From 15893c18c91797e0c3db0910d066d51eae8ebccc Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Fri, 31 Oct 2025 14:33:28 +0700 Subject: [PATCH 11/48] feat(FE-199): create Expense API types --- src/types/api/expense.d.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/types/api/expense.d.ts diff --git a/src/types/api/expense.d.ts b/src/types/api/expense.d.ts new file mode 100644 index 00000000..6fdf358c --- /dev/null +++ b/src/types/api/expense.d.ts @@ -0,0 +1,14 @@ +import { BaseMetadata } from '@/types/api/api-general'; + +export type BaseExpense = { + id: number; + name: string; +}; + +export type Expense = BaseMetadata & BaseExpense; + +export type CreateExpensePayload = { + name: string; +}; + +export type UpdateExpensePayload = CreateExpensePayload; From f6d4ef4697909dfbbbe02e7adb16c092e62695a6 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 3 Nov 2025 10:27:51 +0700 Subject: [PATCH 12/48] chore: update DateInput component --- src/components/input/DateInput.tsx | 282 ++++++++++++++++++++++++----- 1 file changed, 241 insertions(+), 41 deletions(-) diff --git a/src/components/input/DateInput.tsx b/src/components/input/DateInput.tsx index 6e2f1d77..92d28397 100644 --- a/src/components/input/DateInput.tsx +++ b/src/components/input/DateInput.tsx @@ -1,14 +1,23 @@ 'use client'; -import { ChangeEventHandler, FocusEventHandler, ReactNode } from 'react'; - -import { cn } from '@/lib/helper'; +import { + ChangeEventHandler, + FocusEventHandler, + useEffect, + useState, +} from 'react'; +import { cn, formatDate } from '@/lib/helper'; +import Modal, { useModal } from '../Modal'; +import { DateRange, DayPicker, Matcher } from 'react-day-picker'; +import 'react-day-picker/dist/style.css'; +import Button from '../Button'; +import { Icon } from '@iconify/react'; export interface DateInputProps { label?: string; bottomLabel?: string; name: string; - value?: string; + value?: string | { from?: string; to?: string }; placeholder?: string; min?: string; max?: string; @@ -24,9 +33,8 @@ export interface DateInputProps { readOnly?: boolean; required?: boolean; isLoading?: boolean; + isRange?: boolean; errorMessage?: string; - startAdornment?: ReactNode; - endAdornment?: ReactNode; onChange?: ChangeEventHandler; onBlur?: FocusEventHandler; } @@ -36,22 +44,144 @@ const DateInput = ({ bottomLabel, name, value, - placeholder, + placeholder = 'dd/mm/yyyy', min, max, className, - isError, - isValid, - errorMessage, - startAdornment, - endAdornment, + isError: externalError, + isValid: externalValid, + errorMessage: externalErrorMessage, disabled = false, required = false, onChange, onBlur, readOnly = false, isLoading = false, + isRange = false, }: DateInputProps) => { + const [internalError, setInternalError] = useState(null); + const [selected, setSelected] = useState(); + const [selectedRange, setSelectedRange] = useState<{ + from?: Date; + to?: Date; + }>({}); + const [displayValue, setDisplayValue] = useState(''); + + const minDate = min + ? new Date(min.split('/').reverse().join('-')) + : undefined; + const maxDate = max + ? new Date(max.split('/').reverse().join('-')) + : undefined; + + const calendarModal = useModal(); + + // --- Sync value props --- + useEffect(() => { + if (!value) return; + if (isRange && typeof value === 'object') { + const from = value.from ? new Date(value.from) : undefined; + const to = value.to ? new Date(value.to) : undefined; + setSelectedRange({ from, to }); + setDisplayValue( + `${from ? formatDate(from, 'DD/MM/YYYY') : ''} ${ + to ? '- ' + formatDate(to, 'DD/MM/YYYY') : '' + }` + ); + } else if (typeof value === 'string') { + const iso = value.includes('/') + ? value.split('/').reverse().join('-') + : value; + const date = new Date(iso); + setSelected(date); + setDisplayValue(formatDate(iso, 'DD/MM/YYYY')); + } + }, [value, isRange]); + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + if (!disabled && !readOnly) calendarModal.openModal(); + }; + + const handleBlur: FocusEventHandler = (e) => { + onBlur?.(e); + }; + + const handleSelectSingle = (selectedDate?: Date) => { + if (!selectedDate) return; + if (minDate && selectedDate < minDate) { + setInternalError(`Tanggal tidak boleh sebelum ${min}`); + return; + } + if (maxDate && selectedDate > maxDate) { + setInternalError(`Tanggal tidak boleh setelah ${max}`); + return; + } + setInternalError(null); + setSelected(selectedDate); + const formattedDisplay = formatDate(selectedDate, 'DD/MM/YYYY'); + const formattedISO = formatDate(selectedDate, 'YYYY-MM-DD'); + setDisplayValue(formattedDisplay); + + const syntheticEvent = { + target: { name, value: formattedISO }, + } as unknown as React.ChangeEvent; + onChange?.(syntheticEvent); + calendarModal.closeModal(); + }; + + const handleSelectRange = (range?: { from?: Date; to?: Date }) => { + if (!range) return; + setSelectedRange(range); + + const fromStr = range.from ? formatDate(range.from, 'DD/MM/YYYY') : ''; + const toStr = range.to ? formatDate(range.to, 'DD/MM/YYYY') : ''; + setDisplayValue(`${fromStr}${toStr ? ' - ' + toStr : ''}`); + + // Jika kedua tanggal sudah terpilih + if (range.from && range.to) { + if (minDate && range.from < minDate) { + setInternalError(`Tanggal mulai tidak boleh sebelum ${min}`); + return; + } + if (maxDate && range.to > maxDate) { + setInternalError(`Tanggal akhir tidak boleh setelah ${max}`); + return; + } + + setInternalError(null); + const syntheticEvent = { + target: { + name, + value: { + from: formatDate(range.from, 'YYYY-MM-DD'), + to: formatDate(range.to, 'YYYY-MM-DD'), + }, + }, + } as unknown as React.ChangeEvent; + onChange?.(syntheticEvent); + } + }; + + const handleResetDate = () => { + setSelected(undefined); + setSelectedRange({}); + setDisplayValue(''); + const syntheticEvent = { + target: { name, value: isRange ? { from: '', to: '' } : '' }, + } as unknown as React.ChangeEvent; + onChange?.(syntheticEvent); + calendarModal.closeModal(); + }; + + const handleSaveDate = () => { + if (internalError) return; + calendarModal.closeModal(); + }; + + const finalIsError = externalError || !!internalError; + const finalErrorMessage = internalError || externalErrorMessage; + return (
{label} {required && ( - <> - {' '} - - * - - + + * + )} )}
- {startAdornment && startAdornment} - - {(isLoading || endAdornment) && ( + {isLoading && (
- {isLoading && } - {endAdornment && endAdornment} +
)} + + handleClick(e as unknown as React.MouseEvent) + } + />
- {!isError && bottomLabel && ( + {!finalIsError && bottomLabel && (

{bottomLabel}

)} - {isError && errorMessage && ( -

{errorMessage}

+ {finalIsError && finalErrorMessage && ( +

{finalErrorMessage}

)} + + + {isRange ? ( + {displayValue}
} + disabled={ + [ + minDate ? { before: minDate } : undefined, + maxDate ? { after: maxDate } : undefined, + ].filter(Boolean) as Matcher[] + } + /> + ) : ( + + )} +
+ {isRange && ( + + Tekan dua kali untuk memilih tanggal awal + + )} + +
+ + {isRange && ( + + )} +
+
+
); }; From 176e1e7cb8f90525e3d77f8ba9d5fe38b8868ab9 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 3 Nov 2025 10:29:23 +0700 Subject: [PATCH 13/48] chore: install react-day-picker --- package-lock.json | 44 ++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 45 insertions(+) diff --git a/package-lock.json b/package-lock.json index 33b7c640..2cac4bc7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "moment": "^2.30.1", "next": "15.5.3", "react": "19.1.0", + "react-day-picker": "^9.11.1", "react-dom": "19.1.0", "react-hot-toast": "^2.6.0", "react-number-format": "^5.4.4", @@ -196,6 +197,12 @@ "node": ">=6.9.0" } }, + "node_modules/@date-fns/tz": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz", + "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", + "license": "MIT" + }, "node_modules/@emnapi/core": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.6.0.tgz", @@ -2873,6 +2880,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-fns-jalali": { + "version": "4.1.0-0", + "resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz", + "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -5749,6 +5772,27 @@ "node": ">=0.10.0" } }, + "node_modules/react-day-picker": { + "version": "9.11.1", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.11.1.tgz", + "integrity": "sha512-l3ub6o8NlchqIjPKrRFUCkTUEq6KwemQlfv3XZzzwpUeGwmDJ+0u0Upmt38hJyd7D/vn2dQoOoLV/qAp0o3uUw==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.4.1", + "date-fns": "^4.1.0", + "date-fns-jalali": "^4.1.0-0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/react-dom": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", diff --git a/package.json b/package.json index 10fe9598..033c2963 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "moment": "^2.30.1", "next": "15.5.3", "react": "19.1.0", + "react-day-picker": "^9.11.1", "react-dom": "19.1.0", "react-hot-toast": "^2.6.0", "react-number-format": "^5.4.4", From ae560c245155841cf3b449652a8e694e3d68bf3e Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 3 Nov 2025 10:31:44 +0700 Subject: [PATCH 14/48] chore: run prettier formatting before every commit --- .husky/pre-commit | 1 + 1 file changed, 1 insertion(+) diff --git a/.husky/pre-commit b/.husky/pre-commit index 66ff6a67..e7bb3165 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,2 +1,3 @@ +npm run format npm run lint npm run build From 1afa6f7fad16c35c445441f4892497d0be7baaf6 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 3 Nov 2025 16:15:19 +0700 Subject: [PATCH 15/48] chore: rename ExpenseForm.schema.ts to ExpenseRequestForm.schema.ts --- .../pages/expense/form/ExpenseForm.schema.ts | 9 ---- .../expense/form/ExpenseRequestForm.schema.ts | 48 +++++++++++++++++++ 2 files changed, 48 insertions(+), 9 deletions(-) delete mode 100644 src/components/pages/expense/form/ExpenseForm.schema.ts create mode 100644 src/components/pages/expense/form/ExpenseRequestForm.schema.ts diff --git a/src/components/pages/expense/form/ExpenseForm.schema.ts b/src/components/pages/expense/form/ExpenseForm.schema.ts deleted file mode 100644 index 35cd82f4..00000000 --- a/src/components/pages/expense/form/ExpenseForm.schema.ts +++ /dev/null @@ -1,9 +0,0 @@ -import * as Yup from 'yup'; - -export const ExpenseFormSchema = Yup.object({ - name: Yup.string().required('Nama wajib diisi!'), -}); - -export const UpdateExpenseFormSchema = ExpenseFormSchema; - -export type ExpenseFormValues = Yup.InferType; diff --git a/src/components/pages/expense/form/ExpenseRequestForm.schema.ts b/src/components/pages/expense/form/ExpenseRequestForm.schema.ts new file mode 100644 index 00000000..67a9f87e --- /dev/null +++ b/src/components/pages/expense/form/ExpenseRequestForm.schema.ts @@ -0,0 +1,48 @@ +import * as Yup from 'yup'; + +type ExpenseFormSchemaType = { + name: string; + location?: { + value: number; + label: string; + }; + transaction_date?: string; + kandangs?: number[]; + vendor?: { + value: number; + label: string; + }; + requestDocuments?: File[]; + // kandangExpenses: { + // nonstock?: { + // value: number; + // label: 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!')), + + 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(), + }); + +export const UpdateExpenseRequestFormSchema = ExpenseRequestFormSchema; + +export type ExpenseRequestFormValues = Yup.InferType< + typeof ExpenseRequestFormSchema +>; From afa0c6c83fbb598b50e61de1c99bc194f387faba Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 3 Nov 2025 16:16:12 +0700 Subject: [PATCH 16/48] chore: rename ExpenseForm to ExpenseRequestForm --- ...ExpenseForm.tsx => ExpenseRequestForm.tsx} | 56 +++++++++++++++---- 1 file changed, 45 insertions(+), 11 deletions(-) rename src/components/pages/expense/form/{ExpenseForm.tsx => ExpenseRequestForm.tsx} (84%) diff --git a/src/components/pages/expense/form/ExpenseForm.tsx b/src/components/pages/expense/form/ExpenseRequestForm.tsx similarity index 84% rename from src/components/pages/expense/form/ExpenseForm.tsx rename to src/components/pages/expense/form/ExpenseRequestForm.tsx index 9361c5c3..e3a435b5 100644 --- a/src/components/pages/expense/form/ExpenseForm.tsx +++ b/src/components/pages/expense/form/ExpenseRequestForm.tsx @@ -10,12 +10,15 @@ 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 DateInput from '@/components/input/DateInput'; +import ExpenseKandangsTable from '@/components/pages/expense/form/ExpenseKandangsTable'; import { - ExpenseFormSchema, - ExpenseFormValues, - UpdateExpenseFormSchema, -} from '@/components/pages/expense/form/ExpenseForm.schema'; + ExpenseRequestFormSchema, + ExpenseRequestFormValues, + UpdateExpenseRequestFormSchema, +} from '@/components/pages/expense/form/ExpenseRequestForm.schema'; import { isResponseError } from '@/lib/api-helper'; import { Expense, @@ -31,7 +34,10 @@ interface ExpenseFormProps { } // TODO: integrate this with real API -const ExpenseForm = ({ type = 'add', initialValues }: ExpenseFormProps) => { +const ExpenseRequestForm = ({ + type = 'add', + initialValues, +}: ExpenseFormProps) => { const router = useRouter(); // Modal hooks @@ -72,16 +78,18 @@ const ExpenseForm = ({ type = 'add', initialValues }: ExpenseFormProps) => { [router] ); - const formikInitialValues = useMemo(() => { + const formikInitialValues = useMemo(() => { return { name: initialValues?.name ?? '', }; }, [initialValues]); - const formik = useFormik({ + const formik = useFormik({ initialValues: formikInitialValues, validationSchema: - type === 'edit' ? UpdateExpenseFormSchema : ExpenseFormSchema, + type === 'edit' + ? UpdateExpenseRequestFormSchema + : ExpenseRequestFormSchema, onSubmit: async (values) => { setExpenseFormErrorMessage(''); @@ -161,8 +169,15 @@ const ExpenseForm = ({ type = 'add', initialValues }: ExpenseFormProps) => { onReset={formik.handleReset} className='w-full mt-8 flex flex-col gap-6' > -
- + + + {/* { isError={formik.touched.name && Boolean(formik.errors.name)} errorMessage={formik.errors.name} readOnly={type === 'detail'} + className={{ + wrapper: 'col-span-12 sm:col-span-6', + }} + /> */} + + + +
@@ -300,4 +334,4 @@ const ExpenseForm = ({ type = 'add', initialValues }: ExpenseFormProps) => { ); }; -export default ExpenseForm; +export default ExpenseRequestForm; From c438a8f6aac0912822865ad79f7e60bfaa1d2f2c Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Tue, 4 Nov 2025 15:39:55 +0700 Subject: [PATCH 17/48] chore: install react-dropzone --- package-lock.json | 39 +++++++++++++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 40 insertions(+) diff --git a/package-lock.json b/package-lock.json index 2cac4bc7..f64e3a8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "react": "19.1.0", "react-day-picker": "^9.11.1", "react-dom": "19.1.0", + "react-dropzone": "^14.3.8", "react-hot-toast": "^2.6.0", "react-number-format": "^5.4.4", "react-select": "^5.10.2", @@ -2524,6 +2525,15 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/attr-accept": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -3744,6 +3754,18 @@ "node": ">=16.0.0" } }, + "node_modules/file-selector": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", + "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", + "license": "MIT", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -5805,6 +5827,23 @@ "react": "^19.1.0" } }, + "node_modules/react-dropzone": { + "version": "14.3.8", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz", + "integrity": "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==", + "license": "MIT", + "dependencies": { + "attr-accept": "^2.2.4", + "file-selector": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8 || 18.0.0" + } + }, "node_modules/react-fast-compare": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", diff --git a/package.json b/package.json index 033c2963..c2f4f4e6 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "react": "19.1.0", "react-day-picker": "^9.11.1", "react-dom": "19.1.0", + "react-dropzone": "^14.3.8", "react-hot-toast": "^2.6.0", "react-number-format": "^5.4.4", "react-select": "^5.10.2", From 17c316c4af58977c498046eec522c4f44066d1ed Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Tue, 4 Nov 2025 15:45:36 +0700 Subject: [PATCH 18/48] 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 19/48] 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 20/48] 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 21/48] 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 22/48] 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 23/48] 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 24/48] 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 25/48] 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 26/48] 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 27/48] 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 28/48] 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 29/48] 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 30/48] 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 31/48] 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 32/48] 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 33/48] 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 34/48] 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 35/48] 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 36/48] 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 37/48] 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 38/48] 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 39/48] 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 40/48] 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 41/48] 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 42/48] 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 43/48] 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 44/48] 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 45/48] 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 46/48] 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 47/48] 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 48/48] 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;