diff --git a/src/app/Router.tsx b/src/app/Router.tsx index 24c659a3..4134bb87 100644 --- a/src/app/Router.tsx +++ b/src/app/Router.tsx @@ -9,6 +9,7 @@ import groupTokenUrlLoader from '@/entities/auth/lib/groupTokenUrlLoader'; import createExpensePageGuardLoader from '@/pages/CreateExpensePage/lib/createExpensePageGuardLoader'; import joinLoader from '@/pages/join/loader'; import expenseDetailLoader from '@/pages/expenseDetail/loader'; +import editExpensesLoader from '@/pages/editExpenses/loader'; const LazyExpenseDetail = lazy(() => import('@/pages/expenseDetail/').then(({ ExpenseDetailPage }) => ({ @@ -61,6 +62,11 @@ const LazyJoinPage = lazy(() => default: JoinPage, })) ); +const LazyEditExpenses = lazy(() => + import('@/pages/editExpenses').then(({ EditExpensesPage }) => ({ + default: EditExpensesPage, + })) +); const LazyNotFound = lazy(() => import('@/pages/notFound').then(({ NotFoundPage }) => ({ default: NotFoundPage, @@ -132,6 +138,11 @@ function AppRouter() { element: , loader: expenseDetailLoader, }, + { + path: ROUTE.editExpenses, + element: , + loader: editExpensesLoader, + }, { path: ROUTE.characterShare, element: , diff --git a/src/entities/payment/api/payment.ts b/src/entities/payment/api/payment.ts index 796e4280..1b385089 100644 --- a/src/entities/payment/api/payment.ts +++ b/src/entities/payment/api/payment.ts @@ -20,6 +20,11 @@ const payment = { create: (code: string): Promise => axiosInstance.post(`/groups/${code}/payments`).then((res) => res.data), + + exists: (groupCode: string): Promise<{ exists: boolean }> => + axiosInstance + .get(`/groups/${groupCode}/payments/exists`) + .then((res) => res.data), }; export default payment; diff --git a/src/pages/editExpenses/EditExpensesPage.tsx b/src/pages/editExpenses/EditExpensesPage.tsx new file mode 100644 index 00000000..2b98fa76 --- /dev/null +++ b/src/pages/editExpenses/EditExpensesPage.tsx @@ -0,0 +1,80 @@ +import { useNavigate, useLoaderData } from 'react-router'; +import { ROUTE } from '@/shared/config/route'; +import { useFunnel } from '@use-funnel/react-router'; +import { + EditExpenseContext, + EditExpenseStepContext, + ExpenseStepContext, +} from '@/features/expense-management/lib/createExpenseFunnel.type'; +import { ConfirmStepPage } from '@/pages/confirmStep'; +import { AddExpenseStepPage } from '@/pages/addExpenseStep'; +import { EditExpenseStepPage } from '@/pages/editExpenseStep'; + +function EditExpensesPage() { + const { groupToken } = useLoaderData() as { groupToken: string }; + const navigate = useNavigate(); + + const funnel = useFunnel<{ + confirm: ExpenseStepContext; + add: ExpenseStepContext; + edit: EditExpenseStepContext; + }>({ + id: 'edit-expenses', + initial: { + step: 'confirm', + context: { isExpenseCreated: false }, + }, + }); + + return ( + { + history.push('edit', { + isExpenseCreated: true, + expenseId, + initialExpense, + }); + }, + add: (_, { history }) => { + history.push('add'); + }, + next: () => { + navigate(ROUTE.expenseDetail.replace(':groupToken', groupToken)); + }, + back: () => { + navigate(ROUTE.expenseDetail.replace(':groupToken', groupToken)); + }, + }, + render: ({ dispatch }) => ( + dispatch('back')} + onNext={() => dispatch('next')} + onEdit={(props: EditExpenseContext) => dispatch('edit', props)} + onAdd={() => dispatch('add')} + /> + ), + })} + // eslint-disable-next-line react/no-unstable-nested-components + add={({ history }) => ( + history.push('confirm')} /> + )} + // eslint-disable-next-line react/no-unstable-nested-components + edit={({ context, history }) => ( + history.push('confirm')} + onBack={() => history.back()} + expenseId={context.expenseId} + initialExpense={context.initialExpense} + /> + )} + /> + ); +} + +export default EditExpensesPage; diff --git a/src/pages/editExpenses/index.ts b/src/pages/editExpenses/index.ts new file mode 100644 index 00000000..83dfeaf3 --- /dev/null +++ b/src/pages/editExpenses/index.ts @@ -0,0 +1 @@ +export { default as EditExpensesPage } from './EditExpensesPage'; diff --git a/src/pages/editExpenses/loader.ts b/src/pages/editExpenses/loader.ts new file mode 100644 index 00000000..eefbfb98 --- /dev/null +++ b/src/pages/editExpenses/loader.ts @@ -0,0 +1,70 @@ +import { getAuth } from '@/entities/auth/api/auth'; +import { getGroupDetail } from '@/entities/group/api/group'; +import { getProfiles } from '@/entities/member/api/getProfiles'; +import payment from '@/entities/payment/api/payment'; +import { queryClient } from '@/shared/api/queryClient'; +import { ROUTE } from '@/shared/config/route'; +import { BoundaryError } from '@/shared/types/error.type'; +import { isAxiosError } from 'axios'; +import { LoaderFunctionArgs, redirect } from 'react-router'; + +async function editExpensesLoader({ params }: LoaderFunctionArgs) { + const { groupToken } = params; + + if (!groupToken) return redirect(ROUTE.home); + + try { + const auth = await queryClient.ensureQueryData({ + queryKey: ['auth', 'user'], + queryFn: getAuth, + }); + if (!auth?.authenticated) { + const returnUrl = encodeURIComponent( + `/expense-detail/${groupToken}/edit-expenses` + ); + return redirect(`/login?returnUrl=${returnUrl}`); + } + + const profiles = await queryClient.ensureQueryData({ + queryKey: ['profiles', groupToken], + queryFn: () => getProfiles(groupToken), + }); + const myProfile = + profiles.find((profile) => profile.userId === auth.user?.id) ?? null; + if (!myProfile) return redirect(`/join/${groupToken}`); + + if (myProfile.role !== 'MANAGER') { + return redirect(`/expense-detail/${groupToken}`); + } + + // 승인됐거나 승인대기 중인 입금 확인 요청이 있으면 수정 불가 + // 버튼 클릭 핸들러에서도 체크하지만 직접 URL 접근 / 뒤로가기 재진입을 막는 가드 + const [{ exists }, groupData] = await Promise.all([ + payment.exists(groupToken), + getGroupDetail(groupToken), + ]); + if (exists) { + return redirect(`/expense-detail/${groupToken}`); + } + + return { groupToken, groupData }; + } catch (error: unknown) { + if (isAxiosError(error)) { + if (error.response?.status === 401) { + throw new BoundaryError({ + title: '접근 권한이 없어요', + description: '참여한 모임의 정산만 확인할 수 있어요.', + }); + } + if (error.response?.status === 404) { + throw new BoundaryError({ + title: '모임을 찾을 수 없어요', + description: '삭제되었거나 존재하지 않는 모임이에요.', + }); + } + } + throw error; + } +} + +export default editExpensesLoader; diff --git a/src/pages/expenseDetail/ExpenseDetailPage.styles.ts b/src/pages/expenseDetail/ExpenseDetailPage.styles.ts index 5ed4140f..e85ed266 100644 --- a/src/pages/expenseDetail/ExpenseDetailPage.styles.ts +++ b/src/pages/expenseDetail/ExpenseDetailPage.styles.ts @@ -1,16 +1,11 @@ import styled from 'styled-components'; -import { applyTypography, getToken } from '@/shared/design-system'; +import { getToken } from '@/shared/design-system'; import { ACTION_AREA_BOTTOM_FIXED_PADDING } from '@/shared/design-system/ui'; export const NameHighlight = styled.span` color: ${getToken('fg.primary.normal')}; `; -export const ManageLabel = styled.span` - ${applyTypography('typography.body.medium')}; - color: ${getToken('fg.alternative')}; -`; - export const Content = styled.div` display: flex; flex-direction: column; diff --git a/src/pages/expenseDetail/ExpenseDetailPage.tsx b/src/pages/expenseDetail/ExpenseDetailPage.tsx index a64afb0c..f3789de3 100644 --- a/src/pages/expenseDetail/ExpenseDetailPage.tsx +++ b/src/pages/expenseDetail/ExpenseDetailPage.tsx @@ -24,6 +24,7 @@ import { getToken } from '@/shared/design-system'; import ExpenseTimeline from './ui/ExpenseTimeline'; import ExpenseTimeHeader from './ui/ExpenseTimeHeader'; import ExpenseMembers from './ui/ExpenseMembers'; +import ManageMenu from './ui/ManageMenu'; import { StatusType } from './ui/ExpenseTimeHeader/index.type'; import BottomAction from './ui/BottomAction'; import * as S from './ExpenseDetailPage.styles'; @@ -117,7 +118,9 @@ function ExpenseDetailPage() { onHeadingIconClick={() => { navigate(ROUTE.home); }} - // trailingIcon={관리} // TODO : 추가를 논의중인 기능이기 때문에 삭제하지 않고 주석 처리함 + trailingIcon={ + isManager ? : undefined + } /> { + setIsOpen(false); + setIsPending(true); + try { + const { exists } = await payment.exists(groupToken); + if (exists) { + setIsBlockedModalOpen(true); + } else { + navigate(ROUTE.editExpenses.replace(':groupToken', groupToken)); + } + } catch { + showToast({ + type: 'error', + content: '일시적인 오류가 발생했어요. 다시 시도해 주세요.', + }); + } finally { + setIsPending(false); + } + }; + + return ( + <> + setIsOpen(true)} disabled={isPending}> + 관리 + + {isOpen && + createPortal( + <> + setIsOpen(false)} /> + + + 정산 내역 수정 + + {/* TODO: 계좌 수정 기능 구현 시 연결 */} + 계좌 수정 + + , + document.querySelector('#modal') ?? document.body + )} + setIsBlockedModalOpen(false)} + ariaLabel="정산 내역을 수정할 수 없어요" + > + setIsBlockedModalOpen(false), + }} + /> + + + ); +} + +export default ManageMenu; diff --git a/src/shared/config/route.ts b/src/shared/config/route.ts index b233fcc4..7455fe9e 100644 --- a/src/shared/config/route.ts +++ b/src/shared/config/route.ts @@ -11,5 +11,6 @@ export const ROUTE = { groupSetup: '/group-setup', join: '/join/:groupToken', expenseDetail: '/expense-detail/:groupToken', + editExpenses: '/expense-detail/:groupToken/edit-expenses', characterShare: '/expense-detail/:groupToken/character', } as const;