mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 13:32:00 +00:00
Merge branch 'feat/expense-enhancement' into 'development'
[FEAT/FE] Expense Enhancement See merge request mbugroup/lti-web-client!404
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import { Icon } from '@iconify/react';
|
||||
import Button from '@/components/Button';
|
||||
@@ -9,6 +10,7 @@ import ExpenseRequestContent from '@/components/pages/expense/ExpenseRequestCont
|
||||
import ExpenseRealizationContent from '@/components/pages/expense/ExpenseRealizationContent';
|
||||
|
||||
import { Expense } from '@/types/api/expense';
|
||||
import { getExpenseListReturnTo } from '@/lib/expense-list-navigation';
|
||||
|
||||
interface ExpenseDetailProps {
|
||||
initialValues?: Expense;
|
||||
@@ -16,6 +18,8 @@ interface ExpenseDetailProps {
|
||||
|
||||
const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => {
|
||||
const [activeTab, setActiveTab] = useState<string>('request');
|
||||
const searchParams = useSearchParams();
|
||||
const returnTo = getExpenseListReturnTo(searchParams);
|
||||
|
||||
const expenseDetailTabs = useMemo(() => {
|
||||
const validTabs = [
|
||||
@@ -46,7 +50,7 @@ const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => {
|
||||
<section className='w-full max-w-full pb-16'>
|
||||
<header className='flex flex-col gap-4'>
|
||||
<Button
|
||||
href='/expense'
|
||||
href={returnTo}
|
||||
variant='link'
|
||||
className='w-fit p-0 text-primary'
|
||||
>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useFormik } from 'formik';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Icon } from '@iconify/react';
|
||||
@@ -16,6 +19,7 @@ import {
|
||||
} from '@/components/pages/expense/form/ExpenseRequestForm.schema';
|
||||
import { ExpenseApi } from '@/services/api/expense';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { buildExpenseActionHref } from '@/lib/expense-list-navigation';
|
||||
import { ACCEPTED_FILE_TYPE, S3_PUBLIC_BASE_URL } from '@/config/constant';
|
||||
|
||||
interface ExpenseRealizationContentProps {
|
||||
@@ -25,6 +29,8 @@ interface ExpenseRealizationContentProps {
|
||||
const ExpenseRealizationContent = ({
|
||||
initialValues,
|
||||
}: ExpenseRealizationContentProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const formik = useFormik<UploadRequestDocumentsFormValues>({
|
||||
initialValues: {
|
||||
documents: [],
|
||||
@@ -74,7 +80,11 @@ const ExpenseRealizationContent = ({
|
||||
<Button
|
||||
type='button'
|
||||
color='warning'
|
||||
href={`/expense/realization/edit/?expenseId=${initialValues?.id}`}
|
||||
href={buildExpenseActionHref(
|
||||
'/expense/realization/edit/',
|
||||
initialValues?.id as number,
|
||||
searchParams
|
||||
)}
|
||||
className='px-4 grow sm:grow-0'
|
||||
>
|
||||
<Icon icon='mdi:pencil-outline' width={24} height={24} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useFormik } from 'formik';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
@@ -31,6 +31,10 @@ import { ExpenseApi } from '@/services/api/expense';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { EXPENSE_REQUEST_APPROVAL_LINE } from '@/config/approval-line';
|
||||
import { BaseApiResponse } from '@/types/api/api-general';
|
||||
import {
|
||||
buildExpenseActionHref,
|
||||
getExpenseListReturnTo,
|
||||
} from '@/lib/expense-list-navigation';
|
||||
|
||||
interface ExpenseRequestContentProps {
|
||||
initialValues?: Expense;
|
||||
@@ -40,6 +44,8 @@ const ExpenseRequestContent = ({
|
||||
initialValues,
|
||||
}: ExpenseRequestContentProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const returnTo = getExpenseListReturnTo(searchParams);
|
||||
|
||||
const { approvals: approvalHistory, isLoading: isLoadingApprovalHistory } =
|
||||
useApprovalSteps({
|
||||
@@ -148,7 +154,7 @@ const ExpenseRequestContent = ({
|
||||
|
||||
if (isResponseSuccess(deleteResponse)) {
|
||||
toast.success('Berhasil menghapus data biaya operasional!');
|
||||
router.push('/expense');
|
||||
router.push(returnTo);
|
||||
} else {
|
||||
toast.error('Gagal menghapus data biaya operasional!');
|
||||
}
|
||||
@@ -164,7 +170,7 @@ const ExpenseRequestContent = ({
|
||||
|
||||
if (isResponseSuccess(completeRes)) {
|
||||
toast.success(completeRes.message);
|
||||
router.push('/expense');
|
||||
router.push(returnTo);
|
||||
} else {
|
||||
toast.error(completeRes?.message as string);
|
||||
}
|
||||
@@ -204,7 +210,7 @@ const ExpenseRequestContent = ({
|
||||
|
||||
toast.success(approveResponse?.message);
|
||||
setApprovalNotes('');
|
||||
router.push('/expense');
|
||||
router.push(returnTo);
|
||||
} else {
|
||||
approveModal.closeModal();
|
||||
|
||||
@@ -239,7 +245,7 @@ const ExpenseRequestContent = ({
|
||||
|
||||
toast.success(rejectResponse.message);
|
||||
setApprovalNotes('');
|
||||
router.push('/expense');
|
||||
router.push(returnTo);
|
||||
} else {
|
||||
rejectModal.closeModal();
|
||||
|
||||
@@ -365,7 +371,11 @@ const ExpenseRequestContent = ({
|
||||
<Button
|
||||
variant='outline'
|
||||
color='info'
|
||||
href={`/expense/realization/?expenseId=${initialValues?.id}`}
|
||||
href={buildExpenseActionHref(
|
||||
'/expense/realization/',
|
||||
initialValues?.id as number,
|
||||
searchParams
|
||||
)}
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon
|
||||
@@ -384,7 +394,11 @@ const ExpenseRequestContent = ({
|
||||
<Button
|
||||
type='button'
|
||||
color='warning'
|
||||
href={`/expense/detail/edit/?expenseId=${initialValues?.id}`}
|
||||
href={buildExpenseActionHref(
|
||||
'/expense/detail/edit/',
|
||||
initialValues?.id as number,
|
||||
searchParams
|
||||
)}
|
||||
className='px-4 grow sm:grow-0'
|
||||
>
|
||||
<Icon icon='mdi:pencil-outline' width={24} height={24} />
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import {
|
||||
ChangeEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import useSWR from 'swr';
|
||||
import {
|
||||
@@ -31,19 +37,32 @@ import ExpenseTableSkeleton from '@/components/pages/expense/skeleton/ExpenseTab
|
||||
|
||||
import { Expense } from '@/types/api/expense';
|
||||
import { ExpenseApi } from '@/services/api/expense';
|
||||
import { buildExpenseActionHref } from '@/lib/expense-list-navigation';
|
||||
import { cn, formatCurrency, formatDate } from '@/lib/helper';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { BaseApiResponse } from '@/types/api/api-general';
|
||||
|
||||
type ExpenseTableFilters = {
|
||||
search: string;
|
||||
nameSort: string;
|
||||
transactionDate: string;
|
||||
realizationDate: string;
|
||||
locationId: string;
|
||||
vendorId: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
popoverPosition = 'bottom',
|
||||
props,
|
||||
deleteClickHandler,
|
||||
returnToSearchParams,
|
||||
}: {
|
||||
popoverPosition: 'bottom' | 'top';
|
||||
props: CellContext<Expense, unknown>;
|
||||
deleteClickHandler: () => void;
|
||||
returnToSearchParams: URLSearchParams;
|
||||
}) => {
|
||||
const popoverId = `expense#${props.row.original.id}`;
|
||||
const popoverAnchorName = `--anchor-expense#${props.row.original.id}`;
|
||||
@@ -86,7 +105,11 @@ const RowOptionsMenu = ({
|
||||
<div className='flex flex-col bg-base-100 rounded-xl'>
|
||||
<RequirePermission permissions='lti.expense.detail'>
|
||||
<Button
|
||||
href={`/expense/detail/?expenseId=${props.row.original.id}`}
|
||||
href={buildExpenseActionHref(
|
||||
'/expense/detail/',
|
||||
props.row.original.id,
|
||||
returnToSearchParams
|
||||
)}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-3 justify-start text-sm font-semibold w-full'
|
||||
@@ -100,7 +123,11 @@ const RowOptionsMenu = ({
|
||||
{showEditButton && (
|
||||
<RequirePermission permissions='lti.expense.update'>
|
||||
<Button
|
||||
href={`/expense/detail/edit/?expenseId=${props.row.original.id}`}
|
||||
href={buildExpenseActionHref(
|
||||
'/expense/detail/edit/',
|
||||
props.row.original.id,
|
||||
returnToSearchParams
|
||||
)}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-3 justify-start text-sm font-semibold w-full'
|
||||
@@ -115,7 +142,11 @@ const RowOptionsMenu = ({
|
||||
{showRealizationButton && (
|
||||
<RequirePermission permissions='lti.expense.create.realization'>
|
||||
<Button
|
||||
href={`/expense/realization/?expenseId=${props.row.original.id}`}
|
||||
href={buildExpenseActionHref(
|
||||
'/expense/realization/',
|
||||
props.row.original.id,
|
||||
returnToSearchParams
|
||||
)}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-3 justify-start text-sm font-semibold w-full'
|
||||
@@ -155,6 +186,8 @@ const RowOptionsMenu = ({
|
||||
const ExpensesTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
@@ -162,9 +195,11 @@ const ExpensesTable = () => {
|
||||
setPage,
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
} = useTableFilter<ExpenseTableFilters>({
|
||||
initial: {
|
||||
search: '',
|
||||
page: Number(searchParams.get('page')) || 1,
|
||||
pageSize: Number(searchParams.get('limit')) || 10,
|
||||
search: searchValue,
|
||||
nameSort: '',
|
||||
transactionDate: '',
|
||||
realizationDate: '',
|
||||
@@ -193,6 +228,54 @@ const ExpensesTable = () => {
|
||||
ExpenseApi.getAllFetcher
|
||||
);
|
||||
|
||||
const syncPaginationToUrl = useCallback(
|
||||
(page: number, pageSize: number) => {
|
||||
const nextQueryString = new URLSearchParams({
|
||||
page: String(page),
|
||||
limit: String(pageSize),
|
||||
}).toString();
|
||||
|
||||
router.replace(
|
||||
nextQueryString ? `${pathname}?${nextQueryString}` : pathname,
|
||||
{
|
||||
scroll: false,
|
||||
}
|
||||
);
|
||||
},
|
||||
[pathname, router]
|
||||
);
|
||||
|
||||
const pageChangeHandler = useCallback(
|
||||
(page: number) => {
|
||||
setPage(page);
|
||||
syncPaginationToUrl(page, tableFilterState.pageSize);
|
||||
},
|
||||
[setPage, syncPaginationToUrl, tableFilterState.pageSize]
|
||||
);
|
||||
|
||||
const pageSizeChangeHandler = useCallback(
|
||||
(pageSize: number) => {
|
||||
setPageSize(pageSize);
|
||||
syncPaginationToUrl(1, pageSize);
|
||||
},
|
||||
[setPageSize, syncPaginationToUrl]
|
||||
);
|
||||
|
||||
const returnToSearchParams = useMemo(() => {
|
||||
const returnToParams = new URLSearchParams();
|
||||
const queryString = new URLSearchParams({
|
||||
page: String(tableFilterState.page),
|
||||
limit: String(tableFilterState.pageSize),
|
||||
}).toString();
|
||||
|
||||
returnToParams.set(
|
||||
'returnTo',
|
||||
queryString ? `${pathname}?${queryString}` : pathname
|
||||
);
|
||||
|
||||
return returnToParams;
|
||||
}, [pathname, tableFilterState.page, tableFilterState.pageSize]);
|
||||
|
||||
const deleteModal = useModal();
|
||||
const approveModal = useModal();
|
||||
const rejectModal = useModal();
|
||||
@@ -373,6 +456,7 @@ const ExpensesTable = () => {
|
||||
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
|
||||
props={props}
|
||||
deleteClickHandler={deleteClickHandler}
|
||||
returnToSearchParams={returnToSearchParams}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -512,10 +596,6 @@ const ExpensesTable = () => {
|
||||
setIsRejectLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('expense-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
@@ -554,7 +634,7 @@ const ExpensesTable = () => {
|
||||
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
|
||||
|
||||
if (!isNameSorted) {
|
||||
updateFilter('nameSort', '');
|
||||
updateFilter('nameSort', '', false);
|
||||
} else {
|
||||
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
|
||||
}
|
||||
@@ -734,8 +814,8 @@ const ExpensesTable = () => {
|
||||
totalItems={
|
||||
isResponseSuccess(expenses) ? expenses?.meta?.total_results : 0
|
||||
}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
onPageChange={pageChangeHandler}
|
||||
onPageSizeChange={pageSizeChangeHandler}
|
||||
isLoading={isLoading}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useFormik } from 'formik';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
@@ -35,6 +35,7 @@ import { isResponseError } from '@/lib/api-helper';
|
||||
import { LocationApi, SupplierApi } from '@/services/api/master-data';
|
||||
import { Supplier } from '@/types/api/master-data/supplier';
|
||||
import { ACCEPTED_FILE_TYPE } from '@/config/constant';
|
||||
import { getExpenseListReturnTo } from '@/lib/expense-list-navigation';
|
||||
import { cn } from '@/lib/helper';
|
||||
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
|
||||
|
||||
@@ -48,6 +49,8 @@ const ExpenseRealizationForm = ({
|
||||
initialValues,
|
||||
}: ExpenseRealizationFormProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const returnTo = getExpenseListReturnTo(searchParams);
|
||||
|
||||
const [expenseFormErrorMessage, setExpenseFormErrorMessage] = useState('');
|
||||
|
||||
@@ -64,9 +67,9 @@ const ExpenseRealizationForm = ({
|
||||
}
|
||||
|
||||
toast.success(createExpenseRes?.message as string);
|
||||
router.push('/expense');
|
||||
router.push(returnTo);
|
||||
},
|
||||
[router]
|
||||
[initialValues?.id, returnTo, router]
|
||||
);
|
||||
|
||||
const updateExpenseHandler = useCallback(
|
||||
@@ -83,9 +86,9 @@ const ExpenseRealizationForm = ({
|
||||
|
||||
toast.success(updateExpenseRes?.message as string);
|
||||
router.refresh();
|
||||
router.push('/expense');
|
||||
router.push(returnTo);
|
||||
},
|
||||
[router]
|
||||
[returnTo, router]
|
||||
);
|
||||
|
||||
const formik = useFormik<ExpenseRealizationFormValues>({
|
||||
@@ -258,7 +261,7 @@ const ExpenseRealizationForm = ({
|
||||
<section className='w-full'>
|
||||
<header className='flex flex-col gap-4'>
|
||||
<Button
|
||||
href='/expense'
|
||||
href={returnTo}
|
||||
variant='link'
|
||||
className='w-fit p-0 text-primary'
|
||||
>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
type SearchParamsLike = {
|
||||
get: (name: string) => string | null;
|
||||
};
|
||||
|
||||
const EXPENSE_LIST_PATH = '/expense';
|
||||
|
||||
export const getExpenseListReturnTo = (searchParams: SearchParamsLike) => {
|
||||
const existingReturnTo = searchParams.get('returnTo');
|
||||
|
||||
if (existingReturnTo?.startsWith(EXPENSE_LIST_PATH)) {
|
||||
return existingReturnTo;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
const page = searchParams.get('page');
|
||||
const limit = searchParams.get('limit');
|
||||
|
||||
if (page) params.set('page', page);
|
||||
if (limit) params.set('limit', limit);
|
||||
|
||||
const queryString = params.toString();
|
||||
|
||||
return queryString
|
||||
? `${EXPENSE_LIST_PATH}?${queryString}`
|
||||
: EXPENSE_LIST_PATH;
|
||||
};
|
||||
|
||||
export const buildExpenseActionHref = (
|
||||
path: string,
|
||||
expenseId: number | string,
|
||||
searchParams: SearchParamsLike
|
||||
) => {
|
||||
const params = new URLSearchParams({
|
||||
expenseId: String(expenseId),
|
||||
returnTo: getExpenseListReturnTo(searchParams),
|
||||
});
|
||||
|
||||
return `${path}?${params.toString()}`;
|
||||
};
|
||||
@@ -154,7 +154,7 @@ export function useTableFilter<TExtra extends Record<string, unknown>>(
|
||||
);
|
||||
|
||||
const updateFilter = useCallback(
|
||||
<K extends keyof TExtra>(key: K, value: TExtra[K], resetPage = true) => {
|
||||
<K extends keyof TExtra>(key: K, value: TExtra[K], resetPage = false) => {
|
||||
dispatch({ type: 'UPDATE_FILTER', key, value, resetPage });
|
||||
},
|
||||
[dispatch]
|
||||
|
||||
Reference in New Issue
Block a user