Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/app/Router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => ({
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -132,6 +138,11 @@ function AppRouter() {
element: <LazyExpenseDetail />,
loader: expenseDetailLoader,
},
{
path: ROUTE.editExpenses,
element: <LazyEditExpenses />,
loader: editExpensesLoader,
},
{
path: ROUTE.characterShare,
element: <LazyCharacterShare />,
Expand Down
5 changes: 5 additions & 0 deletions src/entities/payment/api/payment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ const payment = {

create: (code: string): Promise<PaymentActionResult> =>
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;
80 changes: 80 additions & 0 deletions src/pages/editExpenses/EditExpensesPage.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<funnel.Render
// eslint-disable-next-line react/no-unstable-nested-components
confirm={funnel.Render.with({
events: {
edit: (
{ expenseId, initialExpense }: EditExpenseContext,
{ history }
) => {
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 }) => (
<ConfirmStepPage
onBack={() => 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 }) => (
<AddExpenseStepPage onNext={() => history.push('confirm')} />
)}
// eslint-disable-next-line react/no-unstable-nested-components
edit={({ context, history }) => (
<EditExpenseStepPage
onNext={() => history.push('confirm')}
onBack={() => history.back()}
expenseId={context.expenseId}
initialExpense={context.initialExpense}
/>
)}
/>
);
}

export default EditExpensesPage;
1 change: 1 addition & 0 deletions src/pages/editExpenses/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as EditExpensesPage } from './EditExpensesPage';
70 changes: 70 additions & 0 deletions src/pages/editExpenses/loader.ts
Original file line number Diff line number Diff line change
@@ -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;
7 changes: 1 addition & 6 deletions src/pages/expenseDetail/ExpenseDetailPage.styles.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
5 changes: 4 additions & 1 deletion src/pages/expenseDetail/ExpenseDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -117,7 +118,9 @@ function ExpenseDetailPage() {
onHeadingIconClick={() => {
navigate(ROUTE.home);
}}
// trailingIcon={<S.ManageLabel>관리</S.ManageLabel>} // TODO : 추가를 논의중인 기능이기 때문에 삭제하지 않고 주석 처리함
trailingIcon={
isManager ? <ManageMenu groupToken={groupToken} /> : undefined
}
/>
<S.Content>
<ExpenseTimeHeader
Expand Down
47 changes: 47 additions & 0 deletions src/pages/expenseDetail/ui/ManageMenu/index.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import styled from 'styled-components';
import { applyTypography, getToken } from '@/shared/design-system';

export const TriggerButton = styled.button`
${applyTypography('typography.body.medium')};
color: ${getToken('fg.assistive')};
background: none;
border: none;
padding: 0;
cursor: pointer;
`;

export const MenuCard = styled.div`
position: fixed;
top: 56px; /* 헤더 높이 */
/* 앱 Wrapper max-width(600px) 기준, 콘텐츠 우측 끝에서 20px 안쪽으로 정렬 */
right: max(20px, calc((100vw - 600px) / 2 + 20px));
width: 140px;
background: ${getToken('bg.normal')};
border-radius: 12px;
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
z-index: 9998;
`;

export const MenuItemButton = styled.button`
${applyTypography('typography.body.medium')}
color: ${getToken('fg.assistive')};
background: none;
border: none;
padding: 0;
cursor: pointer;
text-align: left;

&:disabled {
cursor: default;
}

&:hover,
&:active {
/* HACK: Figma --text/strong(#292c30 = gray.20)에 대응하는 token 없음, 의미상 동일한 'fg.strong' 사용 */
color: ${getToken('fg.strong')};
${applyTypography('typography.body.medium-semibold')}
}
`;
76 changes: 76 additions & 0 deletions src/pages/expenseDetail/ui/ManageMenu/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { useState } from 'react';
import { createPortal } from 'react-dom';
import { useNavigate } from 'react-router';
import { Dimmed, Dialog, Modal, showToast } from '@/shared/design-system/ui';
import payment from '@/entities/payment/api/payment';
import { ROUTE } from '@/shared/config/route';
import * as S from './index.styles';

interface ManageMenuProps {
groupToken: string;
}

function ManageMenu({ groupToken }: ManageMenuProps) {
const [isOpen, setIsOpen] = useState(false);
const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false);
const [isPending, setIsPending] = useState(false);
const navigate = useNavigate();

const handleEditExpenses = async () => {
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 (
<>
<S.TriggerButton onClick={() => setIsOpen(true)} disabled={isPending}>
관리
</S.TriggerButton>
{isOpen &&
createPortal(
<>
<Dimmed onClick={() => setIsOpen(false)} />
<S.MenuCard>
<S.MenuItemButton onClick={handleEditExpenses}>
정산 내역 수정
</S.MenuItemButton>
{/* TODO: 계좌 수정 기능 구현 시 연결 */}
<S.MenuItemButton disabled>계좌 수정</S.MenuItemButton>
</S.MenuCard>
</>,
document.querySelector('#modal') ?? document.body
)}
<Modal
open={isBlockedModalOpen}
onClose={() => setIsBlockedModalOpen(false)}
ariaLabel="정산 내역을 수정할 수 없어요"
>
<Dialog
title="정산 내역을 수정할 수 없어요"
description={`이미 정산을 완료한 참여자가 있어\n정산 내역을 수정할 수 없어요.`}
mainAction={{
label: '확인',
onClick: () => setIsBlockedModalOpen(false),
}}
/>
</Modal>
</>
);
}

export default ManageMenu;
1 change: 1 addition & 0 deletions src/shared/config/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Loading