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="정산 내역을 수정할 수 없어요"
+ >
+
+ >
+ );
+}
+
+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;