Skip to content
Merged
14 changes: 13 additions & 1 deletion src/app/Router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import RouteErrorElement from '@/app/RouteErrorElement';
import checkAuth from '@/entities/auth/lib/checkAuth';
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';

const LazyExpenseDetail = lazy(() =>
import('@/pages/expenseDetail/').then(({ ExpenseDetailPage }) => ({
Expand Down Expand Up @@ -58,6 +60,11 @@ const LazySelectGroup = lazy(() =>
default: SelectGroupPage,
}))
);
const LazyJoinPage = lazy(() =>
import('@/pages/join').then(({ JoinPage }) => ({
default: JoinPage,
}))
);
const LazyNotFound = lazy(() =>
import('@/pages/notFound').then(({ NotFoundPage }) => ({
default: NotFoundPage,
Expand Down Expand Up @@ -122,10 +129,15 @@ function AppRouter() {
],
},
// TODO : 로그인 기능으로 변경될 예정
{
path: ROUTE.join,
element: <LazyJoinPage />,
loader: joinLoader,
},
{
path: ROUTE.expenseDetail,
element: <LazyExpenseDetail />,
loader: groupTokenUrlLoader,
loader: expenseDetailLoader,
},
{
path: ROUTE.characterShare,
Expand Down
2 changes: 1 addition & 1 deletion src/entities/auth/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export const getAuth = async () => {
};

export const getUserInfo = async () => {
const response = await axiosInstance.get<User>('/user/info', {
const response = await axiosInstance.get<User>('/user', {
useMock: true,
});

Expand Down
5 changes: 3 additions & 2 deletions src/entities/group/api/group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,11 @@ export const putGroupAccount = async ({
return response.data;
};

// NOTE : 기존에 groupToken을 전달하는 방식에서 settlementCode를 전달하는 방식으로 변경함
export const getGroupHeader = (
groupToken: string
settlementCode: string
): Promise<GroupHeaderResponse> => {
return axiosInstance
.get(`/group/header?groupToken=${groupToken}`)
.get(`/groups/${settlementCode}/header`)
.then((res) => res.data);
};
15 changes: 15 additions & 0 deletions src/entities/member/api/assignMember.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import axiosInstance from '@/shared/api/axios';

// 참여자 선택 api (로그인한 참여자가 정산에 참여하도록 프로필 설정)
export const assignMember = async (
settlementCode: string,
memberId: number
): Promise<void> => {
await axiosInstance.post(
`/groups/${settlementCode}/members/assign`,
{
memberId,
},
{ useMock: true }
);
};
14 changes: 14 additions & 0 deletions src/entities/member/api/getProfiles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import axiosInstance from '@/shared/api/axios';
import { MemberProfile, MemberProfileData } from '../model/member.type';

// TODO : 기존 groupToken들을 사용하는 방식을 settlementCode를 사용하는 방식으로 변경해야 함.
// 모임원 조회 API - 정산 참여자 프로필 조회
export const getProfiles = async (
settlementCode: string
): Promise<MemberProfile[]> => {
const response = await axiosInstance.get<MemberProfileData>(
`/groups/${settlementCode}/members`,
{ useMock: true }
);
return response.data.members;
};
15 changes: 15 additions & 0 deletions src/entities/member/model/member.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,18 @@ export interface MemberData {
name: string;
role: MemberRole;
}

// 정산 참여자의 프로필 선택 시 필요한 정보
export interface MemberProfile {
id: number;
role: MemberRole;
name: string;
profile: string;
userId: number | null; // userId는 로그인한 사용자의 ID와 매칭되어야 함
isPaid: boolean;
paidAt: Date | null;
}

export interface MemberProfileData {
members: MemberProfile[];
}
10 changes: 5 additions & 5 deletions src/features/expense-management/ui/MemberExpenses/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import NumberInput from '@/features/expense-management/ui/NumberInput';
import { ExpenseFormMember } from '@/entities/expense/model/expense.type';
import MemberProfile from '@/shared/ui/MemberProfile';
import Profile from '@/shared/ui/Profile';
import * as S from './index.styles';

interface MemberExpensesProps {
Expand All @@ -14,12 +14,12 @@ function MemberExpenses({ members, onDelete }: MemberExpensesProps) {
{members.map((member) => (
<S.MemberContainer key={member.id}>
<S.ProfileContainer>
<MemberProfile
<Profile
id={member.id}
name={member.name}
profile={member.profile}
canDelete
handleDeleteButtonClick={() => onDelete(member.name)}
imageSrc={member.profile}
type="delete"
onDelete={() => onDelete(member.name)}
/>
</S.ProfileContainer>
<NumberInput
Expand Down
14 changes: 14 additions & 0 deletions src/features/join/api/useAssignMember.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { assignMember } from '@/entities/member/api/assignMember';

const useAssignMember = (groupToken: string) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (memberId: number) => assignMember(groupToken, memberId),
onSuccess: () => {
queryClient.removeQueries({ queryKey: ['profiles', groupToken] });
},
});
};

export default useAssignMember;
4 changes: 2 additions & 2 deletions src/features/payment-management/ui/PaymentAlert/index.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { PaymentRequest } from '@/entities/payment/model/payment.type';
import MemberProfileImage from '@/shared/ui/MemberProfileImage';
import Button from '@/shared/ui/Button';
import Text from '@/shared/ui/Text';
import Flex from '@/shared/ui/Flex';
import ProfileImage from '@/shared/ui/ProfileImage';

export interface PaymentAlertProps {
payment: PaymentRequest;
Expand All @@ -13,7 +13,7 @@ export interface PaymentAlertProps {
function PaymentAlert({ payment, onReject, onConfirm }: PaymentAlertProps) {
return (
<Flex direction="row" alignItems="center" gap={8} width="100%">
<MemberProfileImage src={payment.profileUrl} size="md" />
<ProfileImage src={payment.profileUrl} size="40" />
<Flex direction="column" alignItems="flex-start" gap={2} flex={1}>
<Text
variant="body1Sb"
Expand Down
4 changes: 2 additions & 2 deletions src/features/user-profile/ui/MyProfile/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useNavigate } from 'react-router';
import { useTheme } from 'styled-components';
import { useGetUserInfo } from '@/entities/auth/api/useGetUserInfo';
import { ROUTE } from '@/shared/config/route';
import MemberProfileImage from '@/shared/ui/MemberProfileImage';
import ProfileImage from '@/shared/ui/ProfileImage';
import Flex from '@/shared/ui/Flex';
import Text from '@/shared/ui/Text';
import Button from '@/shared/ui/Button';
Expand All @@ -15,7 +15,7 @@ function MyProfile() {

return (
<S.ProfileContainer>
<MemberProfileImage size="sm" src={profile?.profileImageUrl} />
<ProfileImage size="36" src={profile?.profileImageUrl} />
<Flex direction="column" flex={1} gap={4}>
<Text variant="body1Sb">{profile.name}</Text>
{/* TODO: 디자인 시스템 정비 후 다시 디자인 확인이 필요합니다 (Opacity를 계속 쓰는지?) */}
Expand Down
2 changes: 1 addition & 1 deletion src/mocks/handlers/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const authHandlers = [
});
}),

http.get('/api/v1/user/info', ({ request }) => {
http.get('/api/v1/user', ({ request }) => {
const isMocked = request.headers.get('X-Mock-Request');
if (!isMocked || isMocked !== 'true') return passthrough();

Expand Down
75 changes: 75 additions & 0 deletions src/mocks/handlers/group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,36 @@ const dummyGroups: Group[] = [
},
];

const dummyMemberList = [
{
id: 1,
role: 'MANAGER',
name: '김모또',
profile: '',
userId: null as number | null,
isPaid: false,
paidAt: null,
},
{
id: 2,
role: 'PARTICIPANT',
name: '박완숙',
profile: '',
userId: null as number | null,
isPaid: false,
paidAt: null,
},
{
id: 3,
role: 'PARTICIPANT',
name: '정에그',
profile: '',
userId: 3,
isPaid: false,
paidAt: null,
},
];

const groupHandlers = [
// GET GetGroupList
http.get('/api/v1/groups', ({ request }) => {
Expand All @@ -75,7 +105,18 @@ const groupHandlers = [
});
}),

// GET GetGroupHeader (path 방식)
// 모임 상단 조회
http.get('/api/v1/groups/:groupToken/header', ({ request }) => {
if (!getIsMocked(request)) return passthrough();

return HttpResponse.json({
...dummyGroups[0],
});
}),

// GET GetGroupOne
// TODO: /api/v1/groups/:groupToken/header 로 대체 예정, 삭제 필요
http.get(`/api/v1/group`, ({ request }) => {
if (!getIsMocked(request)) return passthrough();

Expand Down Expand Up @@ -140,6 +181,40 @@ const groupHandlers = [
});
}
),

// GET /api/v1/groups/:settlementCode/members
http.get('/api/v1/groups/:settlementCode/members', ({ request, params }) => {
if (!getIsMocked(request)) return passthrough();

const { settlementCode } = params;

if (!settlementCode) {
return HttpResponse.json(
{ error: 'settlementCode is required' },
{ status: 400 }
);
}

return HttpResponse.json({ members: dummyMemberList });
}),

http.post<{ settlementCode: string }, { memberId: number }>(
'/api/v1/groups/:settlementCode/members/assign',
async ({ request, params }) => {
if (!getIsMocked(request)) return passthrough();

const { settlementCode } = params;
const { memberId } = await request.json();

console.log(`settlementCode: ${settlementCode}, memberId: ${memberId}`);

// mock user id: 1 (auth.ts 참고)
const target = dummyMemberList.find((m) => m.id === memberId);
if (target) target.userId = 1;

return HttpResponse.json({ success: true }, { status: 200 });
}
),
];

export default groupHandlers;
63 changes: 63 additions & 0 deletions src/pages/expenseDetail/loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// 정산 상세 페이지 전 거치는 로더
// TODO : 기존 groupToken들을 사용하는 방식을 settlementCode를 사용하는 방식으로 변경했음. 동작 확인 필요함.

import { getUserInfo } from '@/entities/auth/api/auth';
import { getGroupHeader } from '@/entities/group/api/group';
import { getProfiles } from '@/entities/member/api/getProfiles';
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 expenseDetailLoader({ params }: LoaderFunctionArgs) {
// TODO: groupToken → settlementCode 마이그레이션 시 파라미터 이름 변경 필요
const { groupToken } = params;

if (!groupToken) return redirect(ROUTE.home);

try {
// 1. 로그인 여부 확인
// TODO: getUserInfo 401 발생 시 axiosInstance 인터셉터가 window.location.href로 처리해 returnUrl이 무시됨. 인터셉터를 React Router redirect 방식으로 교체 필요. (https://moddo2.atlassian.net/browse/MD-25)
const user = await queryClient.ensureQueryData({
queryKey: ['userInfo'],
queryFn: getUserInfo,
});
// TODO: 로그인 페이지에서 성공 후 returnUrl 처리 필요함
if (!user) {
const returnUrl = encodeURIComponent(`/expense-detail/${groupToken}`);
return redirect(`/login?returnUrl=${returnUrl}`);
}

// 2. 프로필 선택 여부 확인
const profiles = await queryClient.ensureQueryData({
queryKey: ['profiles', groupToken],
queryFn: () => getProfiles(groupToken),
});
const myProfile =
profiles.find((profile) => profile.userId === user.id) ?? null;
if (!myProfile) return redirect(`/join/${groupToken}`);

const groupData = await queryClient.ensureQueryData({
queryKey: ['groupHeader', groupToken],
queryFn: () => getGroupHeader(groupToken),
});

return { groupToken, groupData, myProfile };
} catch (error: unknown) {
if (isAxiosError(error)) {
// CHECK - 문서에는 401 에러로 되어있지만 실제로는 500 에러가 발생함
if (error.response?.status === 401) {
throw new BoundaryError({
title: '접근 권한이 없어요',
description: '참여한 모임의 정산만 확인할 수 있어요.',
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// 그 외에는 그대로 전달
throw error;
}
}

export default expenseDetailLoader;
4 changes: 2 additions & 2 deletions src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { Close, Confirm, Receipt } from '@/shared/assets/svgs/icon';
import BottomSheet from '@/shared/ui/BottomSheet';
import { MemberSettlement } from '@/entities/settlement/model/settlement.type';
import useUpdatePaymentStatus from '@/features/settlement-details/api/useUpdatePaymentStatus';
import MemberProfileImage from '@/shared/ui/MemberProfileImage';
import ProfileImage from '@/shared/ui/ProfileImage';
import * as S from './index.style';
import StatusChip from './ui/StatusChip';

Expand Down Expand Up @@ -65,7 +65,7 @@ function ExpenseMemberItem({
<S.HeaderContainer iconSize={32}>
<S.HeaderContent>
<S.LeftWrapper>
<MemberProfileImage src={member.profile} size="md" />
<ProfileImage src={member.profile} size="40" />
<S.SubProfileWrapper>
<Text variant="body1Sb">
<span style={{ color: theme.color.primitive.gray[500] }}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const meta: Meta<typeof ExpenseTimeHeader> = {
chromatic: { disableSnapshot: false },
msw: {
handlers: [
http.get('/group/header', () => {
http.get('/groups/:settlementCode/header', () => {
return HttpResponse.json({
groupName: '모또 정기모임',
totalAmount: 150000,
Expand Down
Loading
Loading