From 75e1393cd31b09505c6a08091dd7026f04444806 Mon Sep 17 00:00:00 2001 From: yeoeun-ex Date: Sun, 17 May 2026 20:41:37 +0900 Subject: [PATCH 01/53] fix: use API settlement completion counts --- src/entities/group/model/group.type.ts | 4 +- src/mocks/handlers/group.ts | 2 + src/pages/expenseDetail/ExpenseDetailPage.tsx | 33 ++++++++------ .../ui/ExpenseTimeHeader/index.stories.tsx | 34 ++++++--------- .../ui/ExpenseTimeHeader/index.tsx | 43 +++++++------------ 5 files changed, 53 insertions(+), 63 deletions(-) diff --git a/src/entities/group/model/group.type.ts b/src/entities/group/model/group.type.ts index dc9023fc..776b632b 100644 --- a/src/entities/group/model/group.type.ts +++ b/src/entities/group/model/group.type.ts @@ -24,9 +24,11 @@ export interface AccountVariable { export interface GroupHeaderResponse { groupName: string; totalAmount: number; - deadline: Date; + deadline: string; bank: string; accountNumber: string; + totalMemberCount: number; + completedMemberCount: number; } export type SettlementStatus = 'ALL' | 'IN_PROGRESS' | 'COMPLETED'; diff --git a/src/mocks/handlers/group.ts b/src/mocks/handlers/group.ts index 27295fe8..e0a136fb 100644 --- a/src/mocks/handlers/group.ts +++ b/src/mocks/handlers/group.ts @@ -112,6 +112,8 @@ const groupHandlers = [ return HttpResponse.json({ ...dummyGroups[0], + totalMemberCount: 3, + completedMemberCount: 0, }); }), diff --git a/src/pages/expenseDetail/ExpenseDetailPage.tsx b/src/pages/expenseDetail/ExpenseDetailPage.tsx index 03794a67..f786b08a 100644 --- a/src/pages/expenseDetail/ExpenseDetailPage.tsx +++ b/src/pages/expenseDetail/ExpenseDetailPage.tsx @@ -7,11 +7,11 @@ import Header from '@/shared/ui/Header'; import Text from '@/shared/ui/Text'; import { BottomButtonContainer } from '@/shared/styles/bottomButton.styles'; import Divider from '@/shared/ui/Divider'; -import { useGetMemberExpenseDetails } from '@/features/expense-management/api/useGetMemberExpenseDetails'; import generateShareLink from '@/shared/lib/generateShareLink'; import { ROUTE } from '@/shared/config/route'; import ShareButton from '@/shared/ui/ShareButton'; import CharacterBottomSheet from '@/features/character-management/ui/CharacterBottomSheet'; +import { useGetGroupHeader } from '@/features/settlement-details/api/useGetGroupHeader'; import { TabsList, Tab } from './ui/Tabs'; import ExpenseTimeline from './ui/ExpenseTimeline'; import ExpenseTimeHeader from './ui/ExpenseTimeHeader'; @@ -25,17 +25,26 @@ function ExpenseDetailPage() { const { groupToken, groupData } = useLoaderData(); const [status, setStatus] = useState('pending'); const [openBottomSheet, setOpenBottomSheet] = useState(false); - const { data: memberExpenseDetails } = useGetMemberExpenseDetails(groupToken); const [isChecked, setIsChecked] = useState(false); const navigate = useNavigate(); - let MEMBER_TOTAL = 0; - let MEMBER_DONE = 0; - - if (memberExpenseDetails) { - MEMBER_TOTAL = memberExpenseDetails.length; - MEMBER_DONE = memberExpenseDetails.filter((member) => member.isPaid).length; - } + const { data: headerData, isLoading: isHeaderLoading } = useGetGroupHeader( + groupToken, + { + // CHECK - API 문서에는 401 에러로 되어 있지만 실제로는 500 에러가 발생함 + // 모임의 참여자가 아닌 사용자가 모임 정보를 요청하는 경우 + // 401: () => { + // throw new BoundaryError({ + // title: '접근할 수 없는 페이지예요', + // description: '참여한 모임의 정산만 확인할 수 있어요.', + // }); + // }, + }, + [401] + ); + const memberTotal = headerData?.totalMemberCount ?? 0; + const memberDone = headerData?.completedMemberCount ?? 0; + const isAllMemberPaid = memberTotal > 0 && memberTotal === memberDone; const shareLink = generateShareLink(groupToken); @@ -66,8 +75,8 @@ function ExpenseDetailPage() { /> setOpenBottomSheet(true)} status={status} setStatus={setStatus} @@ -89,7 +98,7 @@ function ExpenseDetailPage() { {/* eslint-disable-next-line */} - {MEMBER_TOTAL === MEMBER_DONE && status === 'pending' ? ( + {isAllMemberPaid && status === 'pending' ? ( ) : status === 'success' ? ( diff --git a/src/pages/expenseDetail/ui/ExpenseTimeHeader/index.stories.tsx b/src/pages/expenseDetail/ui/ExpenseTimeHeader/index.stories.tsx index 1b9fc529..0aec0951 100644 --- a/src/pages/expenseDetail/ui/ExpenseTimeHeader/index.stories.tsx +++ b/src/pages/expenseDetail/ui/ExpenseTimeHeader/index.stories.tsx @@ -1,7 +1,5 @@ import { Meta, StoryObj } from '@storybook/react'; import { waitFor, within } from '@storybook/test'; -import { createMemoryRouter, RouterProvider } from 'react-router'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { http, HttpResponse } from 'msw'; import ExpenseTimeHeader from './index'; @@ -13,8 +11,6 @@ import ExpenseTimeHeader from './index'; * 나중에 유지보수가 과도해질 경우 삭제해도 괜찮습니다. */ -const queryClient = new QueryClient(); - const meta: Meta = { title: 'ui/ExpenseTimeHeader', component: ExpenseTimeHeader, @@ -29,27 +25,13 @@ const meta: Meta = { deadline: '2025-12-26T23:59:59Z', bank: '국민은행', accountNumber: '123456-78-910111', + totalMemberCount: 6, + completedMemberCount: 3, }); }), ], }, }, - decorators: [ - (Story) => { - const mockRouter = createMemoryRouter([ - { - path: '/*', - element: , - loader: () => ({ groupToken: 'mock-group-token' }), - }, - ]); - return ( - - - - ); - }, - ], }; export default meta; @@ -57,8 +39,16 @@ type Story = StoryObj; export const Default: Story = { args: { - totalMember: 6, - paidMember: 3, + headerData: { + groupName: '모또 정기모임', + totalAmount: 150000, + deadline: '2025-12-26T23:59:59Z', + bank: '국민은행', + accountNumber: '123456-78-910111', + totalMemberCount: 6, + completedMemberCount: 3, + }, + isLoading: false, status: 'success', isChecked: false, }, diff --git a/src/pages/expenseDetail/ui/ExpenseTimeHeader/index.tsx b/src/pages/expenseDetail/ui/ExpenseTimeHeader/index.tsx index 809f7856..53a17092 100644 --- a/src/pages/expenseDetail/ui/ExpenseTimeHeader/index.tsx +++ b/src/pages/expenseDetail/ui/ExpenseTimeHeader/index.tsx @@ -3,21 +3,20 @@ import DescriptionField from '@/shared/ui/DescriptionField'; import { Copy, Crown, DollarCircle } from '@/shared/assets/svgs/icon'; import { useTheme } from 'styled-components'; import Text from '@/shared/ui/Text'; -import { useLoaderData } from 'react-router'; import Modal from '@/shared/ui/Modal'; import copyClipboard from '@/shared/lib/copyClipboard'; import Button from '@/shared/ui/Button'; import { showToast } from '@/shared/ui/Toast'; import Flex from '@/shared/ui/Flex'; -import { useGetGroupHeader } from '@/features/settlement-details/api/useGetGroupHeader'; +import { GroupHeaderResponse } from '@/entities/group/model/group.type'; import CurvedProgressBar from '../CurvedProgressBar'; import { StatusContent, StatusType } from './index.type'; import * as S from './index.style'; import { getFormatDate } from './lib/getFormatDate'; interface ExpenseTimeHeaderProps { - totalMember: number; - paidMember: number; + headerData?: GroupHeaderResponse; + isLoading: boolean; onShareClick: () => void; status: StatusType; setStatus: (status: StatusType) => void; @@ -26,8 +25,8 @@ interface ExpenseTimeHeaderProps { } function ExpenseTimeHeader({ - totalMember, - paidMember, + headerData, + isLoading, onShareClick, status, setStatus, @@ -38,27 +37,10 @@ function ExpenseTimeHeader({ const [minutes, setMinutes] = useState(0); const [seconds, setSeconds] = useState(0); const [isBubble, setIsBubble] = useState(false); - const { groupToken } = useLoaderData(); const theme = useTheme(); const [isModalOpen, setIsModalOpen] = useState(false); const intervalRef = useRef(null); - /** API 호출 관련 로직 */ - const { data: headerData, isLoading } = useGetGroupHeader( - groupToken, - { - // CHECK - API 문서에는 401 에러로 되어 있지만 실제로는 500 에러가 발생함 - // 모임의 참여자가 아닌 사용자가 모임 정보를 요청하는 경우 - // 401: () => { - // throw new BoundaryError({ - // title: '접근할 수 없는 페이지예요', - // description: '참여한 모임의 정산만 확인할 수 있어요.', - // }); - // }, - }, - [401] - ); - // 타이머 업데이트 함수 const updateTimer = (timeDifference: number) => { const newHours = Math.floor(timeDifference / (1000 * 60 * 60)); @@ -87,9 +69,12 @@ function ExpenseTimeHeader({ useEffect(() => { if (!headerData) return () => {}; + const totalMember = headerData.totalMemberCount; + const paidMember = headerData.completedMemberCount; + intervalRef.current = setInterval(() => { const now = new Date(); - const endDate = new Date(headerData!.deadline); + const endDate = new Date(headerData.deadline); const timeDifference = endDate.getTime() - now.getTime(); if (timeDifference <= 0) { if (status === 'success') return; @@ -99,7 +84,7 @@ function ExpenseTimeHeader({ updateStatus('failure'); stopTimer(); } else { - if (totalMember === paidMember && !isChecked) { + if (totalMember > 0 && totalMember === paidMember && !isChecked) { setIsModalOpen(true); setIsChecked(true); } @@ -109,7 +94,7 @@ function ExpenseTimeHeader({ return () => stopTimer(); // 컴포넌트 언마운트 시 타이머 멈추기 // eslint-disable-next-line react-hooks/exhaustive-deps - }, [headerData, totalMember, paidMember, isChecked]); + }, [headerData, isChecked]); const handleModalButtonClick = () => { setIsModalOpen(false); @@ -131,9 +116,11 @@ function ExpenseTimeHeader({ /** 상수 정의 */ - const percentage = (paidMember / totalMember) * 100; + const totalMember = headerData.totalMemberCount; + const paidMember = headerData.completedMemberCount; + const percentage = totalMember > 0 ? (paidMember / totalMember) * 100 : 0; const crownColor = - paidMember === totalMember + totalMember > 0 && paidMember === totalMember ? theme.color.primitive.base.white : theme.color.semantic.secondary.heavy; const endDate = new Date(headerData.deadline); From 8a96274ef49a4feae162749bee36b20499e67347 Mon Sep 17 00:00:00 2001 From: yeoeun-ex Date: Sun, 17 May 2026 20:41:57 +0900 Subject: [PATCH 02/53] fix: refresh settlement header after payment update --- src/features/settlement-details/api/useUpdatePaymentStatus.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/features/settlement-details/api/useUpdatePaymentStatus.ts b/src/features/settlement-details/api/useUpdatePaymentStatus.ts index 70835c23..3e2b4587 100644 --- a/src/features/settlement-details/api/useUpdatePaymentStatus.ts +++ b/src/features/settlement-details/api/useUpdatePaymentStatus.ts @@ -12,10 +12,12 @@ const useUpdatePaymentStatus = ({ mutationFn: () => updatePaymentStatus({ groupToken, groupMemberId, isPaid }), onSuccess: () => { - // 성공하면 memberExpenseDetails 쿼리를 다시 불러온다. queryClient.invalidateQueries({ queryKey: ['memberExpenseDetails', groupToken], }); + queryClient.invalidateQueries({ + queryKey: ['groupHeader', groupToken], + }); }, }); return mutation; From cfa076856996a01f607a44850db8def50e759d08 Mon Sep 17 00:00:00 2001 From: yeoeun-ex Date: Sun, 17 May 2026 21:01:48 +0900 Subject: [PATCH 03/53] fix: align group header mock response --- src/mocks/handlers/group.ts | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/mocks/handlers/group.ts b/src/mocks/handlers/group.ts index 26e78374..ffd30041 100644 --- a/src/mocks/handlers/group.ts +++ b/src/mocks/handlers/group.ts @@ -1,6 +1,10 @@ import { http, HttpResponse, passthrough } from 'msw'; import getIsMocked from '@/mocks/lib/getIsMocked'; -import { AccountVariable, Group } from '@/entities/group/model/group.type'; +import { + AccountVariable, + Group, + GroupHeaderResponse, +} from '@/entities/group/model/group.type'; const dummyGroups: Group[] = [ { @@ -101,17 +105,26 @@ const dummyMemberList = [ }, ]; +const dummyGroupHeader: GroupHeaderResponse = { + groupName: dummyGroups[0].groupName, + totalAmount: 150000, + deadline: new Date( + new Date().setMonth(new Date().getMonth() + 1) + ).toISOString(), + bank: '국민은행', + accountNumber: '123456-78-910111', + totalMemberCount: dummyGroups[0].members.length, + completedMemberCount: dummyGroups[0].members.filter((member) => member.isPaid) + .length, +}; + const groupHandlers = [ // GET GetGroupHeader (path 방식) // 모임 상단 조회 http.get('/api/v1/groups/:groupToken/header', ({ request }) => { if (!getIsMocked(request)) return passthrough(); - return HttpResponse.json({ - ...dummyGroups[0], - totalMemberCount: 3, - completedMemberCount: 0, - }); + return HttpResponse.json(dummyGroupHeader); }), // GET GetGroupOne From cef8d6c7dfb99f11d36a9149b6acc82d628ec541 Mon Sep 17 00:00:00 2001 From: yeoeun-ex Date: Sun, 17 May 2026 21:07:15 +0900 Subject: [PATCH 04/53] fix: stabilize completed settlement flow --- src/pages/expenseDetail/ExpenseDetailPage.tsx | 4 +++- src/pages/expenseDetail/ui/BottomAction/index.tsx | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/pages/expenseDetail/ExpenseDetailPage.tsx b/src/pages/expenseDetail/ExpenseDetailPage.tsx index 5cec2f5b..4807e04f 100644 --- a/src/pages/expenseDetail/ExpenseDetailPage.tsx +++ b/src/pages/expenseDetail/ExpenseDetailPage.tsx @@ -51,7 +51,9 @@ function ExpenseDetailPage() { const [status, setStatus] = useState('pending'); useEffect(() => { - setStatus(derivedStatus); + setStatus((prevStatus) => + prevStatus === 'success' ? prevStatus : derivedStatus + ); }, [derivedStatus]); const navigate = useNavigate(); const { mutate: createPaymentRequest } = useCreatePaymentRequest(); diff --git a/src/pages/expenseDetail/ui/BottomAction/index.tsx b/src/pages/expenseDetail/ui/BottomAction/index.tsx index dcf659bf..494e0ea1 100644 --- a/src/pages/expenseDetail/ui/BottomAction/index.tsx +++ b/src/pages/expenseDetail/ui/BottomAction/index.tsx @@ -37,7 +37,11 @@ function BottomAction({ /> ); - if (myProfile.role === 'MANAGER' && memberTotal === memberDone) + if ( + myProfile.role === 'MANAGER' && + memberTotal > 0 && + memberTotal === memberDone + ) return ( Date: Sun, 17 May 2026 21:15:06 +0900 Subject: [PATCH 05/53] fix: refresh participant payment state --- .../api/useCreatePaymentRequest.ts | 9 ++++++--- src/pages/expenseDetail/ExpenseDetailPage.tsx | 14 +++++++++++--- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/features/payment-management/api/useCreatePaymentRequest.ts b/src/features/payment-management/api/useCreatePaymentRequest.ts index f42110d4..724cb151 100644 --- a/src/features/payment-management/api/useCreatePaymentRequest.ts +++ b/src/features/payment-management/api/useCreatePaymentRequest.ts @@ -1,12 +1,15 @@ -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import payment from '@/entities/payment/api/payment'; -import { queryClient } from '@/shared/api/queryClient'; const useCreatePaymentRequest = () => { + const queryClient = useQueryClient(); + return useMutation({ mutationFn: (code: string) => payment.create(code), - onSuccess: () => { + onSuccess: (_data, code) => { queryClient.invalidateQueries({ queryKey: ['payments'] }); + queryClient.invalidateQueries({ queryKey: ['profiles', code] }); + queryClient.invalidateQueries({ queryKey: ['groupHeader', code] }); }, }); }; diff --git a/src/pages/expenseDetail/ExpenseDetailPage.tsx b/src/pages/expenseDetail/ExpenseDetailPage.tsx index 4807e04f..47748456 100644 --- a/src/pages/expenseDetail/ExpenseDetailPage.tsx +++ b/src/pages/expenseDetail/ExpenseDetailPage.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { useLoaderData, useNavigate } from 'react-router'; +import { useQuery } from '@tanstack/react-query'; import { ArrowLeft } from '@/shared/assets/svgs/icon'; import { PageLayout } from '@/shared/ui/PageLayout'; import { @@ -16,6 +17,7 @@ import { ROUTE } from '@/shared/config/route'; import CharacterBottomSheet from '@/features/character-management/ui/CharacterBottomSheet'; import useCreatePaymentRequest from '@/features/payment-management/api/useCreatePaymentRequest'; import { useGetGroupHeader } from '@/features/settlement-details/api/useGetGroupHeader'; +import { getProfiles } from '@/entities/member/api/getProfiles'; import { getToken } from '@/shared/design-system'; import ExpenseTimeline from './ui/ExpenseTimeline'; import ExpenseTimeHeader from './ui/ExpenseTimeHeader'; @@ -37,6 +39,12 @@ function ExpenseDetailPage() { ); const memberTotal = headerData?.totalMemberCount ?? 0; const memberDone = headerData?.completedMemberCount ?? 0; + const { data: profiles = [] } = useQuery({ + queryKey: ['profiles', groupToken], + queryFn: () => getProfiles(groupToken), + }); + const currentProfile = + profiles.find((profile) => profile.id === myProfile.id) ?? myProfile; // TODO: GroupHeaderResponse에 completedAt 필드를 추가하여 서버에서 정산 완료 여부를 직접 내려받도록 개선 필요 const derivedStatus = useMemo(() => { @@ -118,7 +126,7 @@ function ExpenseDetailPage() { setIsPaymentModalOpen(false)} - ariaLabel={`${myProfile.name}님의 정산 입금을 알릴게요.`} + ariaLabel={`${currentProfile.name}님의 정산 입금을 알릴게요.`} > - {myProfile.name} + {currentProfile.name} {'님의\n정산 입금을 알릴게요.'} } From 677a7b5208185fc413547378a5eb2d0670cfbafb Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sun, 31 May 2026 16:20:51 +0900 Subject: [PATCH 06/53] =?UTF-8?q?design:=20EllipsisVertical(=E2=8B=AE)=20?= =?UTF-8?q?=EC=95=84=EC=9D=B4=EC=BD=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/svgs/icon/ellipsis_vertical.svg | 5 +++++ src/shared/assets/svgs/icon/EllipsisVertical.tsx | 16 ++++++++++++++++ src/shared/assets/svgs/icon/index.ts | 1 + 3 files changed, 22 insertions(+) create mode 100644 public/svgs/icon/ellipsis_vertical.svg create mode 100644 src/shared/assets/svgs/icon/EllipsisVertical.tsx diff --git a/public/svgs/icon/ellipsis_vertical.svg b/public/svgs/icon/ellipsis_vertical.svg new file mode 100644 index 00000000..dff30b8b --- /dev/null +++ b/public/svgs/icon/ellipsis_vertical.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/shared/assets/svgs/icon/EllipsisVertical.tsx b/src/shared/assets/svgs/icon/EllipsisVertical.tsx new file mode 100644 index 00000000..cccab05e --- /dev/null +++ b/src/shared/assets/svgs/icon/EllipsisVertical.tsx @@ -0,0 +1,16 @@ +import type { SVGProps } from 'react'; + +const SvgEllipsisVertical = (props: SVGProps) => ( + + + +); +export default SvgEllipsisVertical; diff --git a/src/shared/assets/svgs/icon/index.ts b/src/shared/assets/svgs/icon/index.ts index 5ae1647b..33059215 100644 --- a/src/shared/assets/svgs/icon/index.ts +++ b/src/shared/assets/svgs/icon/index.ts @@ -16,6 +16,7 @@ export { default as DollarCircle } from './DollarCircle'; export { default as Dot } from './Dot'; export { default as Download } from './Download'; export { default as Dummy } from './Dummy'; +export { default as EllipsisVertical } from './EllipsisVertical'; export { default as Menu } from './Menu'; export { default as Next } from './Next'; export { default as Receipt } from './Receipt'; From 3bd495a1b4fc1c2a88e809cd88e4f08da4be94e4 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sun, 31 May 2026 16:21:49 +0900 Subject: [PATCH 07/53] =?UTF-8?q?refactor:=20paymentRequestId=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Member: /groups/{code} API 응답에 추가된 필드 반영 - MemberSettlement: 컴포넌트에서 사용, member-expenses API 미반영 동안 임시로 optional 처리 --- src/entities/member/model/member.type.ts | 1 + src/entities/settlement/model/settlement.type.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/entities/member/model/member.type.ts b/src/entities/member/model/member.type.ts index b44cdf67..d980c8ff 100644 --- a/src/entities/member/model/member.type.ts +++ b/src/entities/member/model/member.type.ts @@ -10,6 +10,7 @@ export interface Member { userId: number; isPaid: boolean; paidAt: string | null; + paymentRequestId: number | null; } export interface MemberData { diff --git a/src/entities/settlement/model/settlement.type.ts b/src/entities/settlement/model/settlement.type.ts index 15b63d14..9e2c2732 100644 --- a/src/entities/settlement/model/settlement.type.ts +++ b/src/entities/settlement/model/settlement.type.ts @@ -6,6 +6,8 @@ export interface MemberSettlement { isPaid: boolean; paidAt: Date | null; profile: string; + // TEMP: member-expenses API에 paymentRequestId 추가되면 optional 제거 + paymentRequestId?: number | null; expenses: { content: string; amount: number; From 2d08ed4be4f205d7f152475c8d4f9ec0f0e57285 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sun, 31 May 2026 16:22:27 +0900 Subject: [PATCH 08/53] =?UTF-8?q?refactor:=20expenseDetailLoader=EC=97=90?= =?UTF-8?q?=20paymentRequestId=20=EC=9E=84=EC=8B=9C=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - member-expenses API에 paymentRequestId가 없어서 /groups/{code} 응답에서 임시로 가져옴 - TEMP 주석으로 삭제 대상 명시, 백엔드 업데이트 후 제거 예정 --- src/pages/expenseDetail/loader.ts | 14 ++++++++++++-- .../expenseDetail/ui/ExpenseMembers/index.tsx | 17 ++++++++++++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/pages/expenseDetail/loader.ts b/src/pages/expenseDetail/loader.ts index 3cae5868..c26cfdc7 100644 --- a/src/pages/expenseDetail/loader.ts +++ b/src/pages/expenseDetail/loader.ts @@ -2,7 +2,7 @@ // TODO : 기존 groupToken들을 사용하는 방식을 settlementCode를 사용하는 방식으로 변경했음. 동작 확인 필요함. import { getAuth } from '@/entities/auth/api/auth'; -import { getGroupHeader } from '@/entities/group/api/group'; +import { getGroupDetail, 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'; @@ -43,7 +43,17 @@ async function expenseDetailLoader({ params }: LoaderFunctionArgs) { queryFn: () => getGroupHeader(groupToken), }); - return { groupToken, groupData, myProfile }; + // TEMP: member-expenses에 paymentRequestId 추가되면 아래 블록 제거 + const groupDetail = await queryClient.ensureQueryData({ + queryKey: ['groupDetail', groupToken], + queryFn: () => getGroupDetail(groupToken), + }); + const paymentRequestIdMap = new Map( + groupDetail.members.map((m) => [m.id, m.paymentRequestId]) + ); + // TEMP 끝 + + return { groupToken, groupData, myProfile, paymentRequestIdMap }; } catch (error: unknown) { if (isAxiosError(error)) { // CHECK - 문서에는 401 에러로 되어있지만 실제로는 500 에러가 발생함 diff --git a/src/pages/expenseDetail/ui/ExpenseMembers/index.tsx b/src/pages/expenseDetail/ui/ExpenseMembers/index.tsx index fe14e617..cf2b7e13 100644 --- a/src/pages/expenseDetail/ui/ExpenseMembers/index.tsx +++ b/src/pages/expenseDetail/ui/ExpenseMembers/index.tsx @@ -1,4 +1,6 @@ +import { useLoaderData } from 'react-router'; import { useGetMemberExpenseDetails } from '@/features/expense-management/api/useGetMemberExpenseDetails'; +import { MemberProfile } from '@/entities/member/model/member.type'; import ExpenseMemberItem from '@/pages/expenseDetail/ui/ExpenseMemberItem'; import * as S from './index.style'; @@ -8,6 +10,12 @@ interface ExpenseMembersProps { } function ExpenseMembers({ groupToken, status }: ExpenseMembersProps) { + const { myProfile, paymentRequestIdMap } = useLoaderData() as { + myProfile: MemberProfile; + // TEMP: member-expenses에 paymentRequestId 추가되면 paymentRequestIdMap 제거 + paymentRequestIdMap: Map; + }; + const { data: memberExpenseData, isLoading, @@ -21,14 +29,21 @@ function ExpenseMembers({ groupToken, status }: ExpenseMembersProps) { return
error...
; } + const isManager = myProfile.role === 'MANAGER'; + return ( {memberExpenseData.map((member) => ( ))} From 336e48c5e917495095b50a644a6741584d39cfd9 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sun, 31 May 2026 16:23:05 +0900 Subject: [PATCH 09/53] =?UTF-8?q?design:=20Figma=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20ExpenseMemberItem=20UI=20=EC=9E=AC?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 헤더: ProfileImage + InfoColumn(이름 / 칩+금액) + ⋮ 버튼 구조로 변경 - Divider, "자세히보기" 아코디언 토글 행 추가 - isManager prop 추가, useLoaderData 제거 - MANAGER + paymentRequestId 있을 때 아코디언 내부에 ActionArea 버튼 노출 - 이름에 (총무) 표시, 긴 content 말줄임 처리 --- .../ui/ExpenseMemberItem/index.style.ts | 109 +++++----- .../ui/ExpenseMemberItem/index.tsx | 203 ++++++++++-------- 2 files changed, 180 insertions(+), 132 deletions(-) diff --git a/src/pages/expenseDetail/ui/ExpenseMemberItem/index.style.ts b/src/pages/expenseDetail/ui/ExpenseMemberItem/index.style.ts index a9cfcfb1..3cf80730 100644 --- a/src/pages/expenseDetail/ui/ExpenseMemberItem/index.style.ts +++ b/src/pages/expenseDetail/ui/ExpenseMemberItem/index.style.ts @@ -6,6 +6,7 @@ export const Container = styled(Accordion)<{ $isPaid: boolean }>` padding: ${getToken('padding.6')}; display: flex; flex-direction: column; + gap: ${getToken('gap.6')}; width: 100%; background: ${({ $isPaid }) => $isPaid ? getToken('fill.primary.assistive') : getToken('bg.neutral')}; @@ -14,60 +15,78 @@ export const Container = styled(Accordion)<{ $isPaid: boolean }>` flex: 1; `; -export const HeaderContainer = styled.div` +export const HeaderRow = styled.div` display: flex; align-items: center; - justify-content: space-between; - gap: ${getToken('gap.2')}; - width: 100%; + gap: ${getToken('gap.4')}; `; -export const HeaderToggleButton = styled.button` +export const InfoColumn = styled.div` display: flex; - align-items: center; - justify-content: space-between; + flex-direction: column; + gap: ${getToken('gap.1')}; flex: 1; min-width: 0; - border: none; - background: transparent; - padding: 0; - cursor: pointer; `; -export const LeftWrapper = styled.div` +export const InfoSubRow = styled.div` display: flex; align-items: center; - gap: ${getToken('gap.4')}; + gap: ${getToken('gap.2')}; `; -export const SubProfileWrapper = styled.div` - display: flex; - flex-direction: column; - align-items: flex-start; - gap: ${getToken('gap.1')}; +export const MemberName = styled.span` + ${applyTypography('typography.body.medium-semibold')}; + /* HACK: Figma --text/default(#444950 = gray.40)에 대응하는 token 없음, fg.neutral(gray.30) 사용 */ + color: ${getToken('fg.neutral')}; +`; + +export const MemberTotalAmount = styled.span` + ${applyTypography('typography.heading.small')}; + color: ${getToken('fg.normal')}; `; -export const RightWrapper = styled.div` +export const KebabButton = styled.button` display: flex; align-items: center; - gap: ${getToken('gap.2')}; + justify-content: center; + border: none; + background: transparent; + padding: 0; + cursor: pointer; + color: ${getToken('fg.neutral')}; + flex-shrink: 0; `; -export const StatusChipButton = styled.button` - width: fit-content; - height: fit-content; - cursor: pointer; - z-index: 100; +export const Divider = styled.hr` + border: none; + border-top: 1px solid ${getToken('fill.normal-disable')}; + margin: 0; +`; + +export const AccordionToggleButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + gap: ${getToken('gap.2')}; border: none; background: transparent; padding: 0; + cursor: pointer; + width: 100%; +`; + +export const AccordionToggleLabel = styled.span` + ${applyTypography('typography.body.small-medium')}; + color: ${getToken('fg.alternative')}; `; export const ChevronWrapper = styled.span<{ $isOpen: boolean }>` display: flex; align-items: center; justify-content: center; - transform: ${({ $isOpen }) => ($isOpen ? 'rotate(180deg)' : 'rotate(0deg)')}; + color: ${getToken('fg.alternative')}; + transform: ${({ $isOpen }) => ($isOpen ? 'rotate(0deg)' : 'rotate(180deg)')}; transition: transform 0.2s ease-in-out; `; @@ -82,13 +101,27 @@ export const ExpensesWrapper = styled.div` display: flex; justify-content: space-between; align-items: center; - padding: ${getToken('padding.4')} ${getToken('padding.4')} 0; `; export const PlaceWrapper = styled.div` display: flex; - gap: ${getToken('gap.7')}; + gap: ${getToken('gap.4')}; align-items: center; + flex: 1; + min-width: 0; +`; + +export const ExpenseContent = styled.span` + ${applyTypography('typography.body.medium')}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +export const ExpenseAmount = styled.span` + ${applyTypography('typography.body.medium-semibold')}; + color: ${getToken('fg.neutral')}; + flex-shrink: 0; `; export const SheetContentWrapper = styled.div` @@ -115,26 +148,6 @@ export const TextButtonWrapper = styled.button<{ $isActive: boolean }>` } `; -export const MemberName = styled.span` - ${applyTypography('typography.body.medium-semibold')}; - /* HACK: Figma --text/default(#444950 = gray.40)에 대응하는 token 없음, fg.neutral(gray.30) 사용 */ - color: ${getToken('fg.neutral')}; -`; - -export const MemberTotalAmount = styled.span` - ${applyTypography('typography.heading.small')}; - color: ${getToken('fg.normal')}; -`; - -export const ExpenseContent = styled.span` - ${applyTypography('typography.body.medium')}; -`; - -export const ExpenseAmount = styled.span` - ${applyTypography('typography.body.medium-semibold')}; - color: ${getToken('fg.neutral')}; -`; - export const PaymentStatusLabel = styled.span` ${applyTypography('typography.title.small')}; `; diff --git a/src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx b/src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx index 969bb962..73496a88 100644 --- a/src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx +++ b/src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx @@ -1,13 +1,18 @@ import { useState } from 'react'; -import { useLoaderData } from 'react-router'; import { Button, ProfileImage, useAccordionContext, BottomSheet, PaidChip, + ActionArea, } from '@/shared/design-system/ui'; -import { Confirm, Next, Receipt } from '@/shared/assets/svgs/icon'; +import { + Confirm, + Receipt, + ArrowDown, + EllipsisVertical, +} from '@/shared/assets/svgs/icon'; import { MemberSettlement } from '@/entities/settlement/model/settlement.type'; import useUpdatePaymentStatus from '@/features/settlement-details/api/useUpdatePaymentStatus'; import { getToken } from '@/shared/design-system'; @@ -17,129 +22,105 @@ interface ExpenseMemberItemProps { member: MemberSettlement; groupToken: string; status: string; + isManager: boolean; +} + +type ChipStatus = '입금완료' | '확인중' | '미입금'; + +function getChipStatus(member: MemberSettlement): ChipStatus { + if (member.isPaid) return '입금완료'; + if (member.paymentRequestId) return '확인중'; + return '미입금'; } -function MemberHeaderToggle({ member }: { member: MemberSettlement }) { +function MemberAccordionToggle() { const { isOpen, toggle, accordionId } = useAccordionContext(); return ( - - - - - {member.name} - - {member.totalAmount.toLocaleString()}원 - - - + 자세히보기 - + - + ); } -/** 개별 멤버 렌더링 컴포넌트 */ function ExpenseMemberItem({ member, groupToken, status, + isManager, }: ExpenseMemberItemProps) { - const [open, setOpen] = useState(false); - const [isPaid, setIsPaid] = useState(member.isPaid); - const [isConfirm, setIsConfirm] = useState(false); - const { myProfile } = useLoaderData(); + const [sheetOpen, setSheetOpen] = useState(false); + const [isPaid, setIsPaid] = useState(member.isPaid); + const [isConfirm, setIsConfirm] = useState(false); + const updatePaymentStatusMutation = useUpdatePaymentStatus({ groupToken, groupMemberId: member.id, isPaid, }); - /** 상태 변경 함수 */ + const displayName = + member.role === 'MANAGER' ? `${member.name}(총무)` : member.name; + + const chipStatus = getChipStatus(member); + + const showManagerButtons = isManager && member.paymentRequestId != null; + const handleTextButtonClick = (paidUpdate: boolean) => { if (status === 'success') return; - setIsPaid(paidUpdate); - if (paidUpdate !== member.isPaid) { - setIsConfirm(true); // 상태가 바뀌면 확인 버튼 활성화 - } else { - setIsConfirm(false); // 상태가 같으면 확인 버튼 비활성화 - } + setIsConfirm(paidUpdate !== member.isPaid); }; - /** confim 버튼 클릭 시 api를 호출하는 함수 */ - const handleChangeButtonSubmit = async () => { + const handleConfirm = async () => { await updatePaymentStatusMutation.mutate(); setIsConfirm(false); - setOpen(false); + setSheetOpen(false); }; - /** 모든 상태값 초기화 후에 바텀시트 닫기 */ - const resetState = () => { + const resetSheet = () => { setIsPaid(member.isPaid); setIsConfirm(false); - setOpen(false); - }; - - // TODO: role에 따라 상태 변경 버튼 클릭 가능 여부 체크 - const handleStatusChipClick = () => { - if (myProfile.role === 'MANAGER') setOpen(true); + setSheetOpen(false); }; return ( - - - - - - - {/* 정산 상태 변경 바텀시트 */} - - - handleTextButtonClick(false)} - > - 미입금 - - - handleTextButtonClick(true)} - > - 입금완료 - - - - - - - + {/* 헤더: 프로필 + 이름/칩/금액 + ⋮ */} + + + + {displayName} + + + + {member.totalAmount.toLocaleString()}원 + + + + setSheetOpen(true)} + aria-label={`${displayName}의 정산 상태 변경`} + > + + + + + + + {/* 아코디언 토글 */} + + + {/* 아코디언 콘텐츠 */} {member.expenses.map((expense) => ( @@ -148,6 +129,7 @@ function ExpenseMemberItem({ width={24} height={24} color={getToken('fill.primary.normal')} + style={{ flexShrink: 0 }} /> {expense.content} @@ -156,7 +138,60 @@ function ExpenseMemberItem({ ))} + {/* MANAGER 액션 버튼 (paymentRequestId 있을 때만) */} + {showManagerButtons && ( + { + // TODO: useApprovePayment 연결 필요 + }, + disabled: member.isPaid, + }} + alternativeAction={{ + label: '거절', + onClick: () => { + // TODO: useRejectPayment 연결 필요 + }, + disabled: member.isPaid, + }} + /> + )} + + {/* 정산 상태 변경 바텀시트 (⋮ 클릭 시) */} + + + handleTextButtonClick(false)} + > + 미입금 + + + handleTextButtonClick(true)} + > + 입금 완료 + + + + + ); } From 54abc021d25a5f5c7916837748326bd27df9bd6d Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sun, 31 May 2026 16:36:30 +0900 Subject: [PATCH 10/53] =?UTF-8?q?design:=20ExpenseMemberItem=20gap,=20padd?= =?UTF-8?q?ing=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/ExpenseMemberItem/index.style.ts | 10 ++- .../ui/ExpenseMemberItem/index.tsx | 78 ++++++++++--------- 2 files changed, 47 insertions(+), 41 deletions(-) diff --git a/src/pages/expenseDetail/ui/ExpenseMemberItem/index.style.ts b/src/pages/expenseDetail/ui/ExpenseMemberItem/index.style.ts index 3cf80730..214b80ce 100644 --- a/src/pages/expenseDetail/ui/ExpenseMemberItem/index.style.ts +++ b/src/pages/expenseDetail/ui/ExpenseMemberItem/index.style.ts @@ -6,7 +6,6 @@ export const Container = styled(Accordion)<{ $isPaid: boolean }>` padding: ${getToken('padding.6')}; display: flex; flex-direction: column; - gap: ${getToken('gap.6')}; width: 100%; background: ${({ $isPaid }) => $isPaid ? getToken('fill.primary.assistive') : getToken('bg.neutral')}; @@ -19,6 +18,7 @@ export const HeaderRow = styled.div` display: flex; align-items: center; gap: ${getToken('gap.4')}; + margin-bottom: ${getToken('gap.6')}; `; export const InfoColumn = styled.div` @@ -32,7 +32,7 @@ export const InfoColumn = styled.div` export const InfoSubRow = styled.div` display: flex; align-items: center; - gap: ${getToken('gap.2')}; + gap: ${getToken('gap.4')}; `; export const MemberName = styled.span` @@ -61,7 +61,7 @@ export const KebabButton = styled.button` export const Divider = styled.hr` border: none; border-top: 1px solid ${getToken('fill.normal-disable')}; - margin: 0; + margin: 0 0 ${getToken('gap.6')}; `; export const AccordionToggleButton = styled.button` @@ -92,9 +92,13 @@ export const ChevronWrapper = styled.span<{ $isOpen: boolean }>` export const ContentContainer = styled(Accordion.Content)` width: 100%; +`; + +export const ContentInner = styled.div` display: flex; flex-direction: column; gap: ${getToken('gap.5')}; + padding-top: ${getToken('gap.6')}; `; export const ExpensesWrapper = styled.div` diff --git a/src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx b/src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx index 73496a88..e860335c 100644 --- a/src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx +++ b/src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx @@ -122,44 +122,46 @@ function ExpenseMemberItem({ {/* 아코디언 콘텐츠 */} - {member.expenses.map((expense) => ( - - - - {expense.content} - - - {expense.amount.toLocaleString()}원 - - - ))} - {/* MANAGER 액션 버튼 (paymentRequestId 있을 때만) */} - {showManagerButtons && ( - { - // TODO: useApprovePayment 연결 필요 - }, - disabled: member.isPaid, - }} - alternativeAction={{ - label: '거절', - onClick: () => { - // TODO: useRejectPayment 연결 필요 - }, - disabled: member.isPaid, - }} - /> - )} + + {member.expenses.map((expense) => ( + + + + {expense.content} + + + {expense.amount.toLocaleString()}원 + + + ))} + {/* MANAGER 액션 버튼 (paymentRequestId 있을 때만) */} + {showManagerButtons && ( + { + // TODO: useApprovePayment 연결 필요 + }, + disabled: member.isPaid, + }} + alternativeAction={{ + label: '거절', + onClick: () => { + // TODO: useRejectPayment 연결 필요 + }, + disabled: member.isPaid, + }} + /> + )} + {/* 정산 상태 변경 바텀시트 (⋮ 클릭 시) */} From 369c47ce44fbf2191a4840327def5d114017fdc5 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sun, 31 May 2026 17:00:20 +0900 Subject: [PATCH 11/53] =?UTF-8?q?feat:=20=EC=9A=94=EC=B2=AD=ED=99=95?= =?UTF-8?q?=EC=9D=B8,=20=EA=B1=B0=EC=A0=88=20=EB=B2=84=ED=8A=BC=EC=97=90?= =?UTF-8?q?=20API=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useApprovePayment, useRejectPayment 연결 - 성공 시 memberExpenseDetails, groupDetail 쿼리 invalidate - isPending 상태로 중복 요청 방지 --- .../ui/ExpenseMemberItem/index.tsx | 44 +++++++++++++++---- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx b/src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx index e860335c..7a5869ad 100644 --- a/src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx +++ b/src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx @@ -13,8 +13,11 @@ import { ArrowDown, EllipsisVertical, } from '@/shared/assets/svgs/icon'; +import { useQueryClient } from '@tanstack/react-query'; import { MemberSettlement } from '@/entities/settlement/model/settlement.type'; import useUpdatePaymentStatus from '@/features/settlement-details/api/useUpdatePaymentStatus'; +import useApprovePayment from '@/features/payment-management/api/useApprovePayment'; +import useRejectPayment from '@/features/payment-management/api/useRejectPayment'; import { getToken } from '@/shared/design-system'; import * as S from './index.style'; @@ -61,11 +64,16 @@ function ExpenseMemberItem({ const [isPaid, setIsPaid] = useState(member.isPaid); const [isConfirm, setIsConfirm] = useState(false); + const queryClient = useQueryClient(); const updatePaymentStatusMutation = useUpdatePaymentStatus({ groupToken, groupMemberId: member.id, isPaid, }); + const approveMutation = useApprovePayment(); + const rejectMutation = useRejectPayment(); + + const isActionPending = approveMutation.isPending || rejectMutation.isPending; const displayName = member.role === 'MANAGER' ? `${member.name}(총무)` : member.name; @@ -92,6 +100,30 @@ function ExpenseMemberItem({ setSheetOpen(false); }; + const invalidateRelatedQueries = () => { + queryClient.invalidateQueries({ + queryKey: ['memberExpenseDetails', groupToken], + }); + // TEMP: member-expenses에 paymentRequestId 추가되면 groupDetail invalidate 제거 + queryClient.invalidateQueries({ + queryKey: ['groupDetail', groupToken], + }); + }; + + const handleApprove = () => { + if (!member.paymentRequestId) return; + approveMutation.mutate(member.paymentRequestId, { + onSuccess: invalidateRelatedQueries, + }); + }; + + const handleReject = () => { + if (!member.paymentRequestId) return; + rejectMutation.mutate(member.paymentRequestId, { + onSuccess: invalidateRelatedQueries, + }); + }; + return ( {/* 헤더: 프로필 + 이름/칩/금액 + ⋮ */} @@ -147,17 +179,13 @@ function ExpenseMemberItem({ hasHorizontalPadding={false} mainAction={{ label: member.isPaid ? '확인완료' : '요청확인', - onClick: () => { - // TODO: useApprovePayment 연결 필요 - }, - disabled: member.isPaid, + onClick: handleApprove, + disabled: member.isPaid || isActionPending, }} alternativeAction={{ label: '거절', - onClick: () => { - // TODO: useRejectPayment 연결 필요 - }, - disabled: member.isPaid, + onClick: handleReject, + disabled: member.isPaid || isActionPending, }} /> )} From 7252f5c900dd17c862cfcc28912151a54afb3b5a Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sun, 31 May 2026 17:21:17 +0900 Subject: [PATCH 12/53] =?UTF-8?q?fix:=20Member=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=EC=97=90=20=EB=94=B0=EB=A5=B8=20mock=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=88=84=EB=9D=BD=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/mocks/handlers/group.ts | 6 ++++++ src/mocks/handlers/groupMember.ts | 2 ++ 2 files changed, 8 insertions(+) diff --git a/src/mocks/handlers/group.ts b/src/mocks/handlers/group.ts index a54d82f8..f5826e17 100644 --- a/src/mocks/handlers/group.ts +++ b/src/mocks/handlers/group.ts @@ -15,6 +15,7 @@ const dummyGroups: Group[] = [ userId: 1, isPaid: false, paidAt: null, + paymentRequestId: null, }, { id: 2, @@ -24,6 +25,7 @@ const dummyGroups: Group[] = [ userId: 2, isPaid: false, paidAt: null, + paymentRequestId: 1, }, { id: 3, @@ -33,6 +35,7 @@ const dummyGroups: Group[] = [ userId: 3, isPaid: false, paidAt: null, + paymentRequestId: null, }, ], }, @@ -48,6 +51,7 @@ const dummyGroups: Group[] = [ userId: 1, isPaid: false, paidAt: null, + paymentRequestId: null, }, { id: 4, @@ -57,6 +61,7 @@ const dummyGroups: Group[] = [ userId: 4, isPaid: false, paidAt: null, + paymentRequestId: null, }, { id: 5, @@ -66,6 +71,7 @@ const dummyGroups: Group[] = [ userId: 5, isPaid: false, paidAt: null, + paymentRequestId: null, }, ], }, diff --git a/src/mocks/handlers/groupMember.ts b/src/mocks/handlers/groupMember.ts index f25c6fb7..ec17e616 100644 --- a/src/mocks/handlers/groupMember.ts +++ b/src/mocks/handlers/groupMember.ts @@ -17,6 +17,7 @@ export const dummyGroupMembers: Member[] = [ isPaid: true, userId: 1, paidAt: new Date().toISOString(), + paymentRequestId: null, }, ]; @@ -61,6 +62,7 @@ const groupMemberHandlers = [ profile: defaultProfileImg, isPaid: false, paidAt: null, + paymentRequestId: null, }; return newMember; } From 41a246266134991816708035bd56f33d9ebcaaf7 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sun, 31 May 2026 17:59:01 +0900 Subject: [PATCH 13/53] =?UTF-8?q?remove:=20=EA=B2=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=A1=9C=EC=A7=81=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 게스트 로그인 정책이 제거됨에 따라 남아있던 게스트 로그인 코드도 제거함 --- src/entities/auth/api/auth.ts | 15 --------------- src/mocks/handlers/auth.ts | 10 ---------- src/pages/login/LoginPage.tsx | 34 +++++----------------------------- 3 files changed, 5 insertions(+), 54 deletions(-) diff --git a/src/entities/auth/api/auth.ts b/src/entities/auth/api/auth.ts index c4c7527a..e0d8fd79 100644 --- a/src/entities/auth/api/auth.ts +++ b/src/entities/auth/api/auth.ts @@ -1,21 +1,6 @@ import axiosInstance from '@/shared/api/axios'; import { AuthCheckResponse, User } from '../model/user.type'; -// CHECK - 게스트 토큰 정책 제거 가능성 있음 -export interface GuestTokenData { - accessToken: string; - refreshToken: string; - expiredAt: Date; - isMember: boolean; -} - -export const getGuestToken = async (): Promise => { - const response = await axiosInstance.get('/user/guest/token'); - return response.data; -}; - -// ========== - export const getAuth = async (): Promise => { const response = await axiosInstance.get('/auth/check'); return response.data; diff --git a/src/mocks/handlers/auth.ts b/src/mocks/handlers/auth.ts index 1052235c..f4e1df0b 100644 --- a/src/mocks/handlers/auth.ts +++ b/src/mocks/handlers/auth.ts @@ -2,16 +2,6 @@ import { User } from '@/entities/auth/model/user.type'; import { http, HttpResponse, passthrough } from 'msw'; const authHandlers = [ - http.get('/api/v1/user/guest/token', ({ request }) => { - const isMocked = request.headers.get('X-Mock-Request'); - if (!isMocked || isMocked !== 'true') return passthrough(); - return HttpResponse.json({ - accessToken: import.meta.env.VITE_MOCK_ACCESS_TOKEN, - refreshToken: import.meta.env.VITE_MOCK_REFRESH_TOKEN, - expiredAt: '2025-03-06 14:17:26', - isMember: false, - }); - }), http.get('/api/v1/user/auth/check', ({ request }) => { const isMocked = request.headers.get('X-Mock-Request'); if (!isMocked || isMocked !== 'true') return passthrough(); diff --git a/src/pages/login/LoginPage.tsx b/src/pages/login/LoginPage.tsx index 90879aff..400dd2dc 100644 --- a/src/pages/login/LoginPage.tsx +++ b/src/pages/login/LoginPage.tsx @@ -1,42 +1,18 @@ -import { useNavigate, useSearchParams } from 'react-router'; +import { useSearchParams } from 'react-router'; import { useEffect, useState } from 'react'; -import { showToast } from '@/shared/design-system/ui'; -import { getGuestToken } from '@/entities/auth/api/auth'; -import { ROUTE } from '@/shared/config/route'; import kakaoLogin from '@/entities/auth/lib/kakaoLogin'; import { LogoIcon, Kakao } from '@/shared/assets/svgs/logo'; -import { queryClient } from '@/shared/api/queryClient'; import { PageLayout } from '@/shared/ui/PageLayout'; import LoginEntranceView from './LoginEntranceView'; import * as S from './LoginPage.styles'; function LoginPage() { - const navigate = useNavigate(); const [searchParams] = useSearchParams(); const [isEntrance, setIsEntrance] = useState(true); - const [isGuestLoginPending, setIsGuestLoginPending] = useState(false); - const handleLoginButtonClick = async (loginType: 'KAKAO' | 'GUEST') => { - if (loginType === 'KAKAO') { - const redirectPathAfterLogin = - searchParams.get('redirectTo') ?? undefined; - kakaoLogin(redirectPathAfterLogin); - } else { - if (isGuestLoginPending) return; - setIsGuestLoginPending(true); - try { - await getGuestToken(); - queryClient.removeQueries({ queryKey: ['auth', 'user'] }); - navigate(ROUTE.selectGroup); - } catch { - showToast({ - type: 'error', - content: '비회원 로그인에 실패했습니다. 다시 시도해주세요.', - }); - } finally { - setIsGuestLoginPending(false); - } - } + const handleKakaoLogin = () => { + const redirectPathAfterLogin = searchParams.get('redirectTo') ?? undefined; + kakaoLogin(redirectPathAfterLogin); }; useEffect(() => { @@ -60,7 +36,7 @@ function LoginPage() { - handleLoginButtonClick('KAKAO')}> + 카카오로 로그인 From 1a6bdc212bec2152ae669590b723f5e9aa728865 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sun, 31 May 2026 18:05:59 +0900 Subject: [PATCH 14/53] =?UTF-8?q?remove:=20select-group=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존 모임 기능을 제거함에 따라 select-group 페이지 - 기존 select-group 페이지 진입점을 group-setup으로 변경 --- src/app/Router.tsx | 9 ---- .../lib/createExpensePageGuardLoader.ts | 4 +- src/pages/home/ui/HomePageSection/index.tsx | 2 +- .../selectGroup/SelectGroupPage.styles.ts | 17 -------- src/pages/selectGroup/SelectGroupPage.tsx | 43 ------------------- src/pages/selectGroup/index.ts | 1 - .../ui/CreateGroupLinkButton.styles.ts | 27 ------------ .../selectGroup/ui/CreateGroupLinkButton.tsx | 17 -------- src/pages/selectGroup/ui/EmptyBox.styles.ts | 30 ------------- src/pages/selectGroup/ui/EmptyBox.tsx | 20 --------- .../selectGroup/ui/GroupLinkButton.styles.ts | 30 ------------- src/pages/selectGroup/ui/GroupLinkButton.tsx | 28 ------------ src/pages/selectGroup/ui/index.ts | 3 -- src/shared/config/route.ts | 1 - 14 files changed, 3 insertions(+), 229 deletions(-) delete mode 100644 src/pages/selectGroup/SelectGroupPage.styles.ts delete mode 100644 src/pages/selectGroup/SelectGroupPage.tsx delete mode 100644 src/pages/selectGroup/index.ts delete mode 100644 src/pages/selectGroup/ui/CreateGroupLinkButton.styles.ts delete mode 100644 src/pages/selectGroup/ui/CreateGroupLinkButton.tsx delete mode 100644 src/pages/selectGroup/ui/EmptyBox.styles.ts delete mode 100644 src/pages/selectGroup/ui/EmptyBox.tsx delete mode 100644 src/pages/selectGroup/ui/GroupLinkButton.styles.ts delete mode 100644 src/pages/selectGroup/ui/GroupLinkButton.tsx delete mode 100644 src/pages/selectGroup/ui/index.ts diff --git a/src/app/Router.tsx b/src/app/Router.tsx index f3b4908c..24c659a3 100644 --- a/src/app/Router.tsx +++ b/src/app/Router.tsx @@ -56,11 +56,6 @@ const LazyMyEditPage = lazy(() => default: MyEditPage, })) ); -const LazySelectGroup = lazy(() => - import('@/pages/selectGroup').then(({ SelectGroupPage }) => ({ - default: SelectGroupPage, - })) -); const LazyJoinPage = lazy(() => import('@/pages/join').then(({ JoinPage }) => ({ default: JoinPage, @@ -115,10 +110,6 @@ function AppRouter() { path: ROUTE.paymentManagement, element: , }, - { - path: ROUTE.selectGroup, - element: , - }, { path: ROUTE.groupSetup, element: , diff --git a/src/pages/CreateExpensePage/lib/createExpensePageGuardLoader.ts b/src/pages/CreateExpensePage/lib/createExpensePageGuardLoader.ts index 7060aaec..a5795309 100644 --- a/src/pages/CreateExpensePage/lib/createExpensePageGuardLoader.ts +++ b/src/pages/CreateExpensePage/lib/createExpensePageGuardLoader.ts @@ -14,7 +14,7 @@ const createExpensePageGuardLoader: LoaderFunction = async ({ params }) => { // groupToken이 없으면 모임 선택 페이지로 리다이렉트 if (!groupToken) { - return redirect(ROUTE.selectGroup); + return redirect(ROUTE.groupSetup); } // 토큰 유효성 검사 @@ -31,7 +31,7 @@ const createExpensePageGuardLoader: LoaderFunction = async ({ params }) => { // 토큰이 유효하지 않은 경우에는 모임 선택 페이지로 이동 if (isAxiosError(error)) { if (error.response?.status === 401 || error.response?.status === 404) { - return redirect(ROUTE.selectGroup); + return redirect(ROUTE.groupSetup); } } // 그 외에는 에러를 그대로 던진다 diff --git a/src/pages/home/ui/HomePageSection/index.tsx b/src/pages/home/ui/HomePageSection/index.tsx index b8bc2bc9..70714e71 100644 --- a/src/pages/home/ui/HomePageSection/index.tsx +++ b/src/pages/home/ui/HomePageSection/index.tsx @@ -45,7 +45,7 @@ export function SettlementBanner() { return ( <> - navigate(ROUTE.selectGroup)}> + navigate(ROUTE.groupSetup)}> 정산하기 - {/** @Todo Header는 layout으로 분리 -> url 경로에 따라 나오게 변경 */} -
- } - headingIconAriaLabel="뒤로가기" - onHeadingIconClick={() => navigate(-1)} - /> - -
- - - - - -
-
- - ); -} - -export default SelectGroupPage; diff --git a/src/pages/selectGroup/index.ts b/src/pages/selectGroup/index.ts deleted file mode 100644 index 1f5615c6..00000000 --- a/src/pages/selectGroup/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as SelectGroupPage } from './SelectGroupPage'; diff --git a/src/pages/selectGroup/ui/CreateGroupLinkButton.styles.ts b/src/pages/selectGroup/ui/CreateGroupLinkButton.styles.ts deleted file mode 100644 index b6d81425..00000000 --- a/src/pages/selectGroup/ui/CreateGroupLinkButton.styles.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Link } from 'react-router'; -import styled from 'styled-components'; -import { applyTypography, getToken } from '@/shared/design-system'; - -export const LinkButton = styled(Link)` - width: 100%; - @media (min-width: 600px) { - font-size: 22px; - } - background-color: ${getToken('fill.primary.normal')}; - border-radius: ${getToken('radius.lg')}; - padding: ${getToken('padding.5')} ${getToken('padding.3')}; - cursor: pointer; -`; - -export const CreateGroupLabel = styled.span` - ${applyTypography('typography.body.medium-semibold')}; - color: ${getToken('fg.inverse.normal')}; -`; - -export const CreateGroupButtonContent = styled.div` - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - gap: ${getToken('gap.2')}; -`; diff --git a/src/pages/selectGroup/ui/CreateGroupLinkButton.tsx b/src/pages/selectGroup/ui/CreateGroupLinkButton.tsx deleted file mode 100644 index 1652a45c..00000000 --- a/src/pages/selectGroup/ui/CreateGroupLinkButton.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Add } from '@/shared/assets/svgs/icon'; -import { ROUTE } from '@/shared/config/route'; -import { getToken } from '@/shared/design-system'; -import * as S from './CreateGroupLinkButton.styles'; - -function CreateGroupLinkButton() { - return ( - - - - 새로 생성 - - - ); -} - -export default CreateGroupLinkButton; diff --git a/src/pages/selectGroup/ui/EmptyBox.styles.ts b/src/pages/selectGroup/ui/EmptyBox.styles.ts deleted file mode 100644 index 4a80afaa..00000000 --- a/src/pages/selectGroup/ui/EmptyBox.styles.ts +++ /dev/null @@ -1,30 +0,0 @@ -import styled from 'styled-components'; -import { applyTypography, getToken } from '@/shared/design-system'; - -export const EmptyBox = styled.div` - width: 100%; - @media (min-width: 600px) { - font-size: 22px; - } - background-color: ${getToken('bg.normal')}; - border-radius: ${getToken('radius.xl')}; - border: ${`1px dashed ${getToken('border.neutral')}`}; - padding: ${`${getToken('padding.5')} ${getToken('padding.6')}`}; -`; - -export const EmptyBoxMessage = styled.span` - ${applyTypography('typography.body.small')}; - color: ${getToken('fg.alternative')}; -`; - -export const EmptyBoxContent = styled.div` - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - gap: ${getToken('gap.2')}; - svg { - fill: ${getToken('fg.normal-disable')}; - opacity: 0.5; - } -`; diff --git a/src/pages/selectGroup/ui/EmptyBox.tsx b/src/pages/selectGroup/ui/EmptyBox.tsx deleted file mode 100644 index 952ea0fe..00000000 --- a/src/pages/selectGroup/ui/EmptyBox.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { CheckCircle } from '@/shared/assets/svgs/icon'; -import { getToken } from '@/shared/design-system'; -import * as S from './EmptyBox.styles'; - -function EmptyBox() { - return ( - - - - 기존 모임이 없어요. - - - ); -} - -export default EmptyBox; diff --git a/src/pages/selectGroup/ui/GroupLinkButton.styles.ts b/src/pages/selectGroup/ui/GroupLinkButton.styles.ts deleted file mode 100644 index f0ae8039..00000000 --- a/src/pages/selectGroup/ui/GroupLinkButton.styles.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Link } from 'react-router'; -import styled from 'styled-components'; -import { applyTypography, getToken } from '@/shared/design-system'; - -export const LinkButton = styled(Link)` - width: 100%; - @media (min-width: 600px) { - font-size: 22px; - } - background-color: ${getToken('bg.normal')}; - border-radius: ${getToken('radius.lg')}; - border: ${`1px solid ${getToken('border.neutral')}`}; - padding: ${getToken('padding.6')} ${getToken('padding.5')}; - cursor: pointer; -`; - -export const GroupName = styled.span` - ${applyTypography('typography.body.medium-semibold')}; -`; - -export const GroupLinkContent = styled.div` - display: flex; - flex-direction: column; - gap: ${getToken('gap.4')}; -`; - -export const MemberChipRow = styled.div` - display: flex; - gap: ${getToken('gap.2')}; -`; diff --git a/src/pages/selectGroup/ui/GroupLinkButton.tsx b/src/pages/selectGroup/ui/GroupLinkButton.tsx deleted file mode 100644 index 1e89c0f3..00000000 --- a/src/pages/selectGroup/ui/GroupLinkButton.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { generatePath } from 'react-router'; -import { Group } from '@/entities/group/model/group.type'; -import { ROUTE } from '@/shared/config/route'; -import { NameChip } from '@/shared/design-system/ui'; -import * as S from './GroupLinkButton.styles'; - -function GroupLinkButton({ group }: { group: Group }) { - const { groupName, members, id } = group; - const groupToken = String(id); - return ( - - - {groupName} - - {members?.map((member) => ( - - ))} - - - - ); -} - -export default GroupLinkButton; diff --git a/src/pages/selectGroup/ui/index.ts b/src/pages/selectGroup/ui/index.ts deleted file mode 100644 index 39a0a504..00000000 --- a/src/pages/selectGroup/ui/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { default as CreateGroupLinkButton } from './CreateGroupLinkButton'; -export { default as EmptyBox } from './EmptyBox'; -export { default as GroupLinkButton } from './GroupLinkButton'; diff --git a/src/shared/config/route.ts b/src/shared/config/route.ts index 4b357ea8..b233fcc4 100644 --- a/src/shared/config/route.ts +++ b/src/shared/config/route.ts @@ -8,7 +8,6 @@ export const ROUTE = { my: '/my', myEdit: '/my/edit', createExpense: '/create-expense/:groupToken', - selectGroup: '/select-group', groupSetup: '/group-setup', join: '/join/:groupToken', expenseDetail: '/expense-detail/:groupToken', From b21f315229bef852640e63f55b815ca2a072b556 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sun, 31 May 2026 20:53:45 +0900 Subject: [PATCH 15/53] =?UTF-8?q?refactor:=20member-expenses=20paymentRequ?= =?UTF-8?q?estId=20=EC=9E=84=EC=8B=9C=20=EC=9A=B0=ED=9A=8C=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/settlement/model/settlement.type.ts | 3 +-- src/pages/expenseDetail/loader.ts | 14 ++------------ .../expenseDetail/ui/ExpenseMemberItem/index.tsx | 4 ---- .../expenseDetail/ui/ExpenseMembers/index.tsx | 10 ++-------- 4 files changed, 5 insertions(+), 26 deletions(-) diff --git a/src/entities/settlement/model/settlement.type.ts b/src/entities/settlement/model/settlement.type.ts index 9e2c2732..a16f3527 100644 --- a/src/entities/settlement/model/settlement.type.ts +++ b/src/entities/settlement/model/settlement.type.ts @@ -6,8 +6,7 @@ export interface MemberSettlement { isPaid: boolean; paidAt: Date | null; profile: string; - // TEMP: member-expenses API에 paymentRequestId 추가되면 optional 제거 - paymentRequestId?: number | null; + paymentRequestId: number | null; expenses: { content: string; amount: number; diff --git a/src/pages/expenseDetail/loader.ts b/src/pages/expenseDetail/loader.ts index c26cfdc7..3cae5868 100644 --- a/src/pages/expenseDetail/loader.ts +++ b/src/pages/expenseDetail/loader.ts @@ -2,7 +2,7 @@ // TODO : 기존 groupToken들을 사용하는 방식을 settlementCode를 사용하는 방식으로 변경했음. 동작 확인 필요함. import { getAuth } from '@/entities/auth/api/auth'; -import { getGroupDetail, getGroupHeader } from '@/entities/group/api/group'; +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'; @@ -43,17 +43,7 @@ async function expenseDetailLoader({ params }: LoaderFunctionArgs) { queryFn: () => getGroupHeader(groupToken), }); - // TEMP: member-expenses에 paymentRequestId 추가되면 아래 블록 제거 - const groupDetail = await queryClient.ensureQueryData({ - queryKey: ['groupDetail', groupToken], - queryFn: () => getGroupDetail(groupToken), - }); - const paymentRequestIdMap = new Map( - groupDetail.members.map((m) => [m.id, m.paymentRequestId]) - ); - // TEMP 끝 - - return { groupToken, groupData, myProfile, paymentRequestIdMap }; + return { groupToken, groupData, myProfile }; } catch (error: unknown) { if (isAxiosError(error)) { // CHECK - 문서에는 401 에러로 되어있지만 실제로는 500 에러가 발생함 diff --git a/src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx b/src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx index 122a8bce..5f47d489 100644 --- a/src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx +++ b/src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx @@ -104,10 +104,6 @@ function ExpenseMemberItem({ queryClient.invalidateQueries({ queryKey: ['memberExpenseDetails', groupToken], }); - // TEMP: member-expenses에 paymentRequestId 추가되면 groupDetail invalidate 제거 - queryClient.invalidateQueries({ - queryKey: ['groupDetail', groupToken], - }); }; const handleApprove = () => { diff --git a/src/pages/expenseDetail/ui/ExpenseMembers/index.tsx b/src/pages/expenseDetail/ui/ExpenseMembers/index.tsx index cf2b7e13..cd89988a 100644 --- a/src/pages/expenseDetail/ui/ExpenseMembers/index.tsx +++ b/src/pages/expenseDetail/ui/ExpenseMembers/index.tsx @@ -10,10 +10,8 @@ interface ExpenseMembersProps { } function ExpenseMembers({ groupToken, status }: ExpenseMembersProps) { - const { myProfile, paymentRequestIdMap } = useLoaderData() as { + const { myProfile } = useLoaderData() as { myProfile: MemberProfile; - // TEMP: member-expenses에 paymentRequestId 추가되면 paymentRequestIdMap 제거 - paymentRequestIdMap: Map; }; const { @@ -36,11 +34,7 @@ function ExpenseMembers({ groupToken, status }: ExpenseMembersProps) { {memberExpenseData.map((member) => ( Date: Sun, 31 May 2026 20:57:57 +0900 Subject: [PATCH 16/53] fix: derive settlement completion from api state --- src/entities/group/model/group.type.ts | 6 ++- src/mocks/handlers/group.ts | 44 +++++++++++++++--- src/pages/expenseDetail/ExpenseDetailPage.tsx | 26 ++++++++--- .../expenseDetail/ui/BottomAction/index.tsx | 45 +++++++++---------- .../ui/ExpenseTimeHeader/index.tsx | 12 +++-- 5 files changed, 91 insertions(+), 42 deletions(-) diff --git a/src/entities/group/model/group.type.ts b/src/entities/group/model/group.type.ts index 1ba06762..0c1cf458 100644 --- a/src/entities/group/model/group.type.ts +++ b/src/entities/group/model/group.type.ts @@ -26,8 +26,10 @@ export interface GroupHeaderResponse { deadline: string; bank: string; accountNumber: string; - totalMemberCount: number; - completedMemberCount: number; + createdAt?: string; + completedAt: string | null; + totalMemberCount?: number; + completedMemberCount?: number; } export interface GroupListItem { diff --git a/src/mocks/handlers/group.ts b/src/mocks/handlers/group.ts index ffd30041..57f074bc 100644 --- a/src/mocks/handlers/group.ts +++ b/src/mocks/handlers/group.ts @@ -5,6 +5,7 @@ import { Group, GroupHeaderResponse, } from '@/entities/group/model/group.type'; +import { MemberProfileRaw } from '@/entities/member/model/member.type'; const dummyGroups: Group[] = [ { @@ -75,7 +76,7 @@ const dummyGroups: Group[] = [ }, ]; -const dummyMemberList = [ +const dummyMemberList: MemberProfileRaw[] = [ { id: 1, role: 'MANAGER', @@ -105,7 +106,7 @@ const dummyMemberList = [ }, ]; -const dummyGroupHeader: GroupHeaderResponse = { +const getDummyGroupHeader = (): GroupHeaderResponse => ({ groupName: dummyGroups[0].groupName, totalAmount: 150000, deadline: new Date( @@ -113,10 +114,12 @@ const dummyGroupHeader: GroupHeaderResponse = { ).toISOString(), bank: '국민은행', accountNumber: '123456-78-910111', - totalMemberCount: dummyGroups[0].members.length, - completedMemberCount: dummyGroups[0].members.filter((member) => member.isPaid) + createdAt: new Date().toISOString(), + completedAt: null, + totalMemberCount: dummyMemberList.length, + completedMemberCount: dummyMemberList.filter((member) => member.isPaid) .length, -}; +}); const groupHandlers = [ // GET GetGroupHeader (path 방식) @@ -124,7 +127,7 @@ const groupHandlers = [ http.get('/api/v1/groups/:groupToken/header', ({ request }) => { if (!getIsMocked(request)) return passthrough(); - return HttpResponse.json(dummyGroupHeader); + return HttpResponse.json(getDummyGroupHeader()); }), // GET GetGroupOne @@ -227,6 +230,35 @@ const groupHandlers = [ return HttpResponse.json({ success: true }, { status: 200 }); } ), + + http.put<{ groupToken: string; groupMemberId: string }, { isPaid: boolean }>( + '/api/v1/groups/:groupToken/members/:groupMemberId', + async ({ request, params }) => { + if (!getIsMocked(request)) return passthrough(); + + const { groupMemberId } = params; + const { isPaid } = await request.json(); + const target = dummyMemberList.find( + (member) => member.id === Number(groupMemberId) + ); + + if (!target) { + return HttpResponse.json( + { error: 'group member not found' }, + { status: 404 } + ); + } + + target.isPaid = isPaid; + target.paidAt = isPaid ? new Date().toISOString() : null; + + return HttpResponse.json({ + id: target.id, + isPaid: target.isPaid, + paidAt: target.paidAt, + }); + } + ), ]; export default groupHandlers; diff --git a/src/pages/expenseDetail/ExpenseDetailPage.tsx b/src/pages/expenseDetail/ExpenseDetailPage.tsx index 47748456..e058386b 100644 --- a/src/pages/expenseDetail/ExpenseDetailPage.tsx +++ b/src/pages/expenseDetail/ExpenseDetailPage.tsx @@ -17,6 +17,7 @@ import { ROUTE } from '@/shared/config/route'; import CharacterBottomSheet from '@/features/character-management/ui/CharacterBottomSheet'; import useCreatePaymentRequest from '@/features/payment-management/api/useCreatePaymentRequest'; import { useGetGroupHeader } from '@/features/settlement-details/api/useGetGroupHeader'; +import { useGetMemberExpenseDetails } from '@/features/expense-management/api/useGetMemberExpenseDetails'; import { getProfiles } from '@/entities/member/api/getProfiles'; import { getToken } from '@/shared/design-system'; import ExpenseTimeline from './ui/ExpenseTimeline'; @@ -31,24 +32,34 @@ function ExpenseDetailPage() { const { groupToken, groupData, myProfile } = useLoaderData(); const [openBottomSheet, setOpenBottomSheet] = useState(false); const [isPaymentModalOpen, setIsPaymentModalOpen] = useState(false); + const [isPaymentRequested, setIsPaymentRequested] = useState(false); const [isChecked, setIsChecked] = useState(false); const { data: headerData, isLoading: isHeaderLoading } = useGetGroupHeader( groupToken, {}, [401] ); - const memberTotal = headerData?.totalMemberCount ?? 0; - const memberDone = headerData?.completedMemberCount ?? 0; + const { data: memberExpenseDetails = [] } = + useGetMemberExpenseDetails(groupToken); + const memberTotal = memberExpenseDetails.length; + const memberDone = memberExpenseDetails.filter( + (member) => member.isPaid + ).length; + const isEveryMemberPaid = memberTotal > 0 && memberTotal === memberDone; const { data: profiles = [] } = useQuery({ queryKey: ['profiles', groupToken], queryFn: () => getProfiles(groupToken), }); const currentProfile = profiles.find((profile) => profile.id === myProfile.id) ?? myProfile; + const bottomActionProfile = + currentProfile.role === 'PARTICIPANT' && isPaymentRequested + ? { ...currentProfile, isPaid: true } + : currentProfile; - // TODO: GroupHeaderResponse에 completedAt 필드를 추가하여 서버에서 정산 완료 여부를 직접 내려받도록 개선 필요 const derivedStatus = useMemo(() => { if (!headerData) return 'pending'; + if (headerData.completedAt) return 'success'; const isExpired = new Date(headerData.deadline).getTime() < Date.now(); if (isExpired) return 'failure'; @@ -69,6 +80,7 @@ function ExpenseDetailPage() { const handlePaymentRequest = () => { createPaymentRequest(groupToken, { onSuccess: () => { + setIsPaymentRequested(true); setIsPaymentModalOpen(false); showToast({ type: 'success', @@ -103,6 +115,9 @@ function ExpenseDetailPage() { setOpenBottomSheet(true)} status={status} setStatus={setStatus} @@ -126,9 +141,8 @@ function ExpenseDetailPage() { setIsChecked(false)} onPaymentRequestClick={() => setIsPaymentModalOpen(true)} diff --git a/src/pages/expenseDetail/ui/BottomAction/index.tsx b/src/pages/expenseDetail/ui/BottomAction/index.tsx index 494e0ea1..8c2a23c7 100644 --- a/src/pages/expenseDetail/ui/BottomAction/index.tsx +++ b/src/pages/expenseDetail/ui/BottomAction/index.tsx @@ -6,8 +6,7 @@ import { StatusType } from '../ExpenseTimeHeader/index.type'; interface BottomActionProps { status: StatusType; myProfile: MemberProfile; - memberTotal: number; - memberDone: number; + isEveryMemberPaid: boolean; shareLink: string; onSettleClick: () => void; onPaymentRequestClick: () => void; @@ -17,8 +16,7 @@ interface BottomActionProps { function BottomAction({ status, myProfile, - memberTotal, - memberDone, + isEveryMemberPaid, shareLink, onSettleClick, onPaymentRequestClick, @@ -37,11 +35,7 @@ function BottomAction({ /> ); - if ( - myProfile.role === 'MANAGER' && - memberTotal > 0 && - memberTotal === memberDone - ) + if (myProfile.role === 'MANAGER' && isEveryMemberPaid) return ( ); - return ( - <> - - - - ); + if (myProfile.role === 'MANAGER') + return ( + <> + + + + ); + + return null; } export default BottomAction; diff --git a/src/pages/expenseDetail/ui/ExpenseTimeHeader/index.tsx b/src/pages/expenseDetail/ui/ExpenseTimeHeader/index.tsx index d5aab629..0e69009a 100644 --- a/src/pages/expenseDetail/ui/ExpenseTimeHeader/index.tsx +++ b/src/pages/expenseDetail/ui/ExpenseTimeHeader/index.tsx @@ -18,6 +18,9 @@ import { getFormatDate } from './lib/getFormatDate'; interface ExpenseTimeHeaderProps { headerData?: GroupHeaderResponse; isLoading: boolean; + totalMember: number; + paidMember: number; + isEveryMemberPaid: boolean; onShareClick: () => void; status: StatusType; setStatus: (status: StatusType) => void; @@ -28,6 +31,9 @@ interface ExpenseTimeHeaderProps { function ExpenseTimeHeader({ headerData, isLoading, + totalMember, + paidMember, + isEveryMemberPaid, onShareClick, status, setStatus, @@ -40,8 +46,6 @@ function ExpenseTimeHeader({ const [isBubble, setIsBubble] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false); const intervalRef = useRef(null); - const totalMember = headerData?.totalMemberCount ?? 0; - const paidMember = headerData?.completedMemberCount ?? 0; // 타이머 업데이트 함수 const updateTimer = (timeDifference: number) => { @@ -71,11 +75,11 @@ function ExpenseTimeHeader({ // TODO: isChecked를 sessionStorage/localStorage로 관리하여 새로고침 시에도 모달이 다시 뜨지 않도록 개선 필요 (groupToken별 키 사용) // 모든 멤버가 입금 완료된 "순간"에만 모달 표시 useEffect(() => { - if (totalMember > 0 && paidMember === totalMember && !isChecked) { + if (isEveryMemberPaid && !isChecked) { setIsModalOpen(true); setIsChecked(true); } - }, [paidMember, totalMember, isChecked, setIsChecked]); + }, [isEveryMemberPaid, isChecked, setIsChecked]); useEffect(() => { if (!headerData || status !== 'pending') return () => {}; From 8b506e05c1fdbc434f101ce9606a1fef26f8dcde Mon Sep 17 00:00:00 2001 From: yeoeun-ex Date: Sun, 31 May 2026 20:58:55 +0900 Subject: [PATCH 17/53] feat: complete settlement manually --- src/entities/group/api/group.ts | 6 ++++++ .../api/useCompleteGroupSettlement.ts | 14 ++++++++++++++ src/mocks/handlers/group.ts | 15 ++++++++++++++- src/pages/expenseDetail/ExpenseDetailPage.tsx | 4 ++++ .../expenseDetail/ui/ExpenseTimeHeader/index.tsx | 5 ++++- 5 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 src/features/settlement-details/api/useCompleteGroupSettlement.ts diff --git a/src/entities/group/api/group.ts b/src/entities/group/api/group.ts index d9cf2093..d94e906f 100644 --- a/src/entities/group/api/group.ts +++ b/src/entities/group/api/group.ts @@ -46,6 +46,12 @@ export const getGroupHeader = ( .then((res) => res.data); }; +export const completeGroupSettlement = async ( + settlementCode: string +): Promise => { + await axiosInstance.patch(`/groups/${settlementCode}/complete`); +}; + export const getSettlementList = ( status: SettlementStatus, sort: SettlementSort, diff --git a/src/features/settlement-details/api/useCompleteGroupSettlement.ts b/src/features/settlement-details/api/useCompleteGroupSettlement.ts new file mode 100644 index 00000000..90be429c --- /dev/null +++ b/src/features/settlement-details/api/useCompleteGroupSettlement.ts @@ -0,0 +1,14 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { completeGroupSettlement } from '@/entities/group/api/group'; + +export const useCompleteGroupSettlement = (groupToken: string) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => completeGroupSettlement(groupToken), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['groupHeader', groupToken] }); + queryClient.invalidateQueries({ queryKey: ['settlementList'] }); + }, + }); +}; diff --git a/src/mocks/handlers/group.ts b/src/mocks/handlers/group.ts index 57f074bc..1a0c05db 100644 --- a/src/mocks/handlers/group.ts +++ b/src/mocks/handlers/group.ts @@ -106,6 +106,8 @@ const dummyMemberList: MemberProfileRaw[] = [ }, ]; +let dummyCompletedAt: string | null = null; + const getDummyGroupHeader = (): GroupHeaderResponse => ({ groupName: dummyGroups[0].groupName, totalAmount: 150000, @@ -115,7 +117,7 @@ const getDummyGroupHeader = (): GroupHeaderResponse => ({ bank: '국민은행', accountNumber: '123456-78-910111', createdAt: new Date().toISOString(), - completedAt: null, + completedAt: dummyCompletedAt, totalMemberCount: dummyMemberList.length, completedMemberCount: dummyMemberList.filter((member) => member.isPaid) .length, @@ -231,6 +233,17 @@ const groupHandlers = [ } ), + http.patch<{ groupToken: string }>( + '/api/v1/groups/:groupToken/complete', + ({ request }) => { + if (!getIsMocked(request)) return passthrough(); + + dummyCompletedAt = new Date().toISOString(); + + return new HttpResponse(null, { status: 200 }); + } + ), + http.put<{ groupToken: string; groupMemberId: string }, { isPaid: boolean }>( '/api/v1/groups/:groupToken/members/:groupMemberId', async ({ request, params }) => { diff --git a/src/pages/expenseDetail/ExpenseDetailPage.tsx b/src/pages/expenseDetail/ExpenseDetailPage.tsx index e058386b..e133ccc4 100644 --- a/src/pages/expenseDetail/ExpenseDetailPage.tsx +++ b/src/pages/expenseDetail/ExpenseDetailPage.tsx @@ -18,6 +18,7 @@ import CharacterBottomSheet from '@/features/character-management/ui/CharacterBo import useCreatePaymentRequest from '@/features/payment-management/api/useCreatePaymentRequest'; import { useGetGroupHeader } from '@/features/settlement-details/api/useGetGroupHeader'; import { useGetMemberExpenseDetails } from '@/features/expense-management/api/useGetMemberExpenseDetails'; +import { useCompleteGroupSettlement } from '@/features/settlement-details/api/useCompleteGroupSettlement'; import { getProfiles } from '@/entities/member/api/getProfiles'; import { getToken } from '@/shared/design-system'; import ExpenseTimeline from './ui/ExpenseTimeline'; @@ -41,6 +42,8 @@ function ExpenseDetailPage() { ); const { data: memberExpenseDetails = [] } = useGetMemberExpenseDetails(groupToken); + const { mutateAsync: completeGroupSettlement } = + useCompleteGroupSettlement(groupToken); const memberTotal = memberExpenseDetails.length; const memberDone = memberExpenseDetails.filter( (member) => member.isPaid @@ -123,6 +126,7 @@ function ExpenseDetailPage() { setStatus={setStatus} isChecked={isChecked} setIsChecked={setIsChecked} + onCompleteSettlement={completeGroupSettlement} /> diff --git a/src/pages/expenseDetail/ui/ExpenseTimeHeader/index.tsx b/src/pages/expenseDetail/ui/ExpenseTimeHeader/index.tsx index 0e69009a..df21a2f6 100644 --- a/src/pages/expenseDetail/ui/ExpenseTimeHeader/index.tsx +++ b/src/pages/expenseDetail/ui/ExpenseTimeHeader/index.tsx @@ -26,6 +26,7 @@ interface ExpenseTimeHeaderProps { setStatus: (status: StatusType) => void; isChecked: boolean; setIsChecked: (isChecked: boolean) => void; + onCompleteSettlement: () => Promise; } function ExpenseTimeHeader({ @@ -39,6 +40,7 @@ function ExpenseTimeHeader({ setStatus, isChecked, setIsChecked, + onCompleteSettlement, }: ExpenseTimeHeaderProps) { const [hours, setHours] = useState(0); const [minutes, setMinutes] = useState(0); @@ -103,7 +105,8 @@ function ExpenseTimeHeader({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [headerData, status]); - const handleModalButtonClick = () => { + const handleModalButtonClick = async () => { + await onCompleteSettlement(); stopTimer(); setIsModalOpen(false); updateStatus('success'); From d69c65bf3348aa14ace0c2ab7fdaa204a7506e71 Mon Sep 17 00:00:00 2001 From: yeoeun-ex Date: Sun, 31 May 2026 21:10:11 +0900 Subject: [PATCH 18/53] fix: skip complete api for finished settlements --- .../expenseDetail/ui/ExpenseTimeHeader/index.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/pages/expenseDetail/ui/ExpenseTimeHeader/index.tsx b/src/pages/expenseDetail/ui/ExpenseTimeHeader/index.tsx index df21a2f6..f416fa50 100644 --- a/src/pages/expenseDetail/ui/ExpenseTimeHeader/index.tsx +++ b/src/pages/expenseDetail/ui/ExpenseTimeHeader/index.tsx @@ -105,7 +105,15 @@ function ExpenseTimeHeader({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [headerData, status]); + const isSettlementCompleted = Boolean(headerData?.completedAt); + const handleModalButtonClick = async () => { + if (isSettlementCompleted) { + setIsModalOpen(false); + onShareClick(); + return; + } + await onCompleteSettlement(); stopTimer(); setIsModalOpen(false); @@ -221,9 +229,12 @@ function ExpenseTimeHeader({ setIsModalOpen(false), }} /> From 0a3063f5c35a40360de9c4b11b0a646ec252cbe0 Mon Sep 17 00:00:00 2001 From: yeoeun-ex Date: Sun, 31 May 2026 21:17:08 +0900 Subject: [PATCH 19/53] fix: restrict manual settlement completion to managers --- src/pages/expenseDetail/ExpenseDetailPage.tsx | 4 +++- src/pages/expenseDetail/ui/ExpenseTimeHeader/index.tsx | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/pages/expenseDetail/ExpenseDetailPage.tsx b/src/pages/expenseDetail/ExpenseDetailPage.tsx index e133ccc4..b42fad6c 100644 --- a/src/pages/expenseDetail/ExpenseDetailPage.tsx +++ b/src/pages/expenseDetail/ExpenseDetailPage.tsx @@ -55,8 +55,9 @@ function ExpenseDetailPage() { }); const currentProfile = profiles.find((profile) => profile.id === myProfile.id) ?? myProfile; + const isManager = currentProfile.role === 'MANAGER'; const bottomActionProfile = - currentProfile.role === 'PARTICIPANT' && isPaymentRequested + !isManager && isPaymentRequested ? { ...currentProfile, isPaid: true } : currentProfile; @@ -121,6 +122,7 @@ function ExpenseDetailPage() { totalMember={memberTotal} paidMember={memberDone} isEveryMemberPaid={isEveryMemberPaid} + isManager={isManager} onShareClick={() => setOpenBottomSheet(true)} status={status} setStatus={setStatus} diff --git a/src/pages/expenseDetail/ui/ExpenseTimeHeader/index.tsx b/src/pages/expenseDetail/ui/ExpenseTimeHeader/index.tsx index f416fa50..553ac950 100644 --- a/src/pages/expenseDetail/ui/ExpenseTimeHeader/index.tsx +++ b/src/pages/expenseDetail/ui/ExpenseTimeHeader/index.tsx @@ -21,6 +21,7 @@ interface ExpenseTimeHeaderProps { totalMember: number; paidMember: number; isEveryMemberPaid: boolean; + isManager: boolean; onShareClick: () => void; status: StatusType; setStatus: (status: StatusType) => void; @@ -35,6 +36,7 @@ function ExpenseTimeHeader({ totalMember, paidMember, isEveryMemberPaid, + isManager, onShareClick, status, setStatus, @@ -77,11 +79,11 @@ function ExpenseTimeHeader({ // TODO: isChecked를 sessionStorage/localStorage로 관리하여 새로고침 시에도 모달이 다시 뜨지 않도록 개선 필요 (groupToken별 키 사용) // 모든 멤버가 입금 완료된 "순간"에만 모달 표시 useEffect(() => { - if (isEveryMemberPaid && !isChecked) { + if (isManager && isEveryMemberPaid && !isChecked) { setIsModalOpen(true); setIsChecked(true); } - }, [isEveryMemberPaid, isChecked, setIsChecked]); + }, [isEveryMemberPaid, isChecked, isManager, setIsChecked]); useEffect(() => { if (!headerData || status !== 'pending') return () => {}; @@ -114,6 +116,8 @@ function ExpenseTimeHeader({ return; } + if (!isManager) return; + await onCompleteSettlement(); stopTimer(); setIsModalOpen(false); From 5769bafa7075d6ee79cf2e7c5145f16a42327d5e Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sat, 6 Jun 2026 15:16:50 +0900 Subject: [PATCH 20/53] =?UTF-8?q?fix:=20useApiError=20handleError=EA=B0=80?= =?UTF-8?q?=20ErrorBoundary=20=EC=97=90=EB=9F=AC=EC=97=90=EB=8F=84=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/hooks/useApiError.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/shared/hooks/useApiError.ts b/src/shared/hooks/useApiError.ts index c8c61276..57d2d9a3 100644 --- a/src/shared/hooks/useApiError.ts +++ b/src/shared/hooks/useApiError.ts @@ -31,22 +31,19 @@ const useApiError = ({ [errorHandlers] ); - // customErrorHandlers를 우선 처리하고, defaultHandler를 처리합니다. + // ignoreBoundaryErrors에 포함된 에러만 핸들러를 실행합니다. + // 그 외 에러는 shouldThrowError가 true를 반환하여 ErrorBoundary에서 처리합니다. const handleError = useCallback( (error: TError) => { if (isAxiosError(error)) { const status = error.response?.status; - if (status) { + if (status && ignoreBoundaryErrors?.includes(status)) { const customHandler = handlers[status] || handlers.default; customHandler(); - } else { - handlers.default(); } - } else { - handlers.default(); } }, - [handlers] + [handlers, ignoreBoundaryErrors] ); // ignoreBoundaryErrors에 포함된 에러코드인지 확인합니다. From 2d76954e1f1b06eef180fffbc55669abeee507b2 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sat, 6 Jun 2026 15:17:15 +0900 Subject: [PATCH 21/53] =?UTF-8?q?refactor:=20useUpdatePaymentStatus=20useM?= =?UTF-8?q?utationWithHandlers=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useMutation → useMutationWithHandlers 전환, 에러 토스트 처리를 훅 내부로 이동 - handleConfirm에서 mutateAsync로 전환해 뮤테이션 완료 후에만 시트가 닫히도록 수정 ref: https://github.com/moddo-kr/moddo-frontend/pull/57#discussion_r3330176895 --- .../api/useUpdatePaymentStatus.ts | 16 ++++++++++++---- .../expenseDetail/ui/ExpenseMemberItem/index.tsx | 6 +++++- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/features/settlement-details/api/useUpdatePaymentStatus.ts b/src/features/settlement-details/api/useUpdatePaymentStatus.ts index 70835c23..eab707f3 100644 --- a/src/features/settlement-details/api/useUpdatePaymentStatus.ts +++ b/src/features/settlement-details/api/useUpdatePaymentStatus.ts @@ -1,6 +1,8 @@ -import { useQueryClient, useMutation } from '@tanstack/react-query'; +import { useQueryClient } from '@tanstack/react-query'; import { updatePaymentStatus } from '@/entities/settlement/api/updatePaymentStatus'; import { UpdatePaymentStatusVariable } from '@/entities/settlement/model/groupMember.type'; +import useMutationWithHandlers from '@/shared/hooks/useMutationWithHanders'; +import { showToast } from '@/shared/design-system/ui'; const useUpdatePaymentStatus = ({ groupToken, @@ -8,17 +10,23 @@ const useUpdatePaymentStatus = ({ isPaid, }: UpdatePaymentStatusVariable) => { const queryClient = useQueryClient(); - const mutation = useMutation({ + return useMutationWithHandlers({ mutationFn: () => updatePaymentStatus({ groupToken, groupMemberId, isPaid }), onSuccess: () => { - // 성공하면 memberExpenseDetails 쿼리를 다시 불러온다. queryClient.invalidateQueries({ queryKey: ['memberExpenseDetails', groupToken], }); }, + errorHandlers: { + default: () => + showToast({ + type: 'error', + content: '정산 상태 변경에 실패했어요. 다시 시도해 주세요.', + }), + }, + ignoreBoundaryErrors: [400, 409], }); - return mutation; }; export default useUpdatePaymentStatus; diff --git a/src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx b/src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx index 5f47d489..ab3ee7dc 100644 --- a/src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx +++ b/src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx @@ -89,7 +89,11 @@ function ExpenseMemberItem({ }; const handleConfirm = async () => { - await updatePaymentStatusMutation.mutate(); + try { + await updatePaymentStatusMutation.mutateAsync(); + } catch { + return; + } setIsConfirm(false); setSheetOpen(false); }; From fe07a1c9b896ac05809656f67ecb1e76d88a5cac Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sat, 6 Jun 2026 15:17:33 +0900 Subject: [PATCH 22/53] =?UTF-8?q?fix:=20=EC=A0=95=EC=82=B0=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EB=B3=80=EA=B2=BD=20=ED=99=95=EC=9D=B8=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=EC=A4=91=EB=B3=B5=20=EC=A0=9C=EC=B6=9C=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx b/src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx index ab3ee7dc..d15a6276 100644 --- a/src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx +++ b/src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx @@ -215,7 +215,7 @@ function ExpenseMemberItem({ From a2c132ce219cc485830a8a9ee422881e9c39b58a Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sat, 6 Jun 2026 15:47:25 +0900 Subject: [PATCH 23/53] =?UTF-8?q?refactor:=20status=20=E2=86=92=20settleme?= =?UTF-8?q?ntStatus=20=EB=A6=AC=EB=84=A4=EC=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/expenseDetail/ExpenseDetailPage.tsx | 16 +++++++----- .../expenseDetail/ui/BottomAction/index.tsx | 6 ++--- .../ui/ExpenseMemberItem/index.tsx | 21 ++++++++------- .../expenseDetail/ui/ExpenseMembers/index.tsx | 7 ++--- .../ui/ExpenseTimeHeader/index.tsx | 26 ++++++++++--------- 5 files changed, 42 insertions(+), 34 deletions(-) diff --git a/src/pages/expenseDetail/ExpenseDetailPage.tsx b/src/pages/expenseDetail/ExpenseDetailPage.tsx index 45da7253..44727597 100644 --- a/src/pages/expenseDetail/ExpenseDetailPage.tsx +++ b/src/pages/expenseDetail/ExpenseDetailPage.tsx @@ -44,10 +44,11 @@ function ExpenseDetailPage() { return 'pending'; }, [headerData]); - const [status, setStatus] = useState('pending'); + const [settlementStatus, setSettlementStatus] = + useState('pending'); useEffect(() => { - setStatus(derivedStatus); + setSettlementStatus(derivedStatus); }, [derivedStatus]); const navigate = useNavigate(); const { mutate: createPaymentRequest } = useCreatePaymentRequest(); @@ -98,8 +99,8 @@ function ExpenseDetailPage() { totalMember={MEMBER_TOTAL} paidMember={MEMBER_DONE} onShareClick={() => setOpenBottomSheet(true)} - status={status} - setStatus={setStatus} + settlementStatus={settlementStatus} + setSettlementStatus={setSettlementStatus} isChecked={isChecked} setIsChecked={setIsChecked} /> @@ -114,12 +115,15 @@ function ExpenseDetailPage() { {activeTab === 'expense' ? ( ) : ( - + )} { + setIsPaid(member.isPaid); + setIsConfirm(false); + setSheetOpen(false); + }; + const handleTextButtonClick = (paidUpdate: boolean) => { - if (status === 'success') return; + if (settlementStatus === 'success') return; setIsPaid(paidUpdate); setIsConfirm(paidUpdate !== member.isPaid); }; @@ -98,12 +105,6 @@ function ExpenseMemberItem({ setSheetOpen(false); }; - const resetSheet = () => { - setIsPaid(member.isPaid); - setIsConfirm(false); - setSheetOpen(false); - }; - const invalidateRelatedQueries = () => { queryClient.invalidateQueries({ queryKey: ['memberExpenseDetails', groupToken], @@ -193,7 +194,7 @@ function ExpenseMemberItem({ {/* 정산 상태 변경 바텀시트 (⋮ 클릭 시) */} diff --git a/src/pages/expenseDetail/ui/ExpenseMembers/index.tsx b/src/pages/expenseDetail/ui/ExpenseMembers/index.tsx index cd89988a..87c4e81f 100644 --- a/src/pages/expenseDetail/ui/ExpenseMembers/index.tsx +++ b/src/pages/expenseDetail/ui/ExpenseMembers/index.tsx @@ -2,14 +2,15 @@ import { useLoaderData } from 'react-router'; import { useGetMemberExpenseDetails } from '@/features/expense-management/api/useGetMemberExpenseDetails'; import { MemberProfile } from '@/entities/member/model/member.type'; import ExpenseMemberItem from '@/pages/expenseDetail/ui/ExpenseMemberItem'; +import { StatusType } from '../ExpenseTimeHeader/index.type'; import * as S from './index.style'; interface ExpenseMembersProps { groupToken: string; - status: string; + settlementStatus: StatusType; } -function ExpenseMembers({ groupToken, status }: ExpenseMembersProps) { +function ExpenseMembers({ groupToken, settlementStatus }: ExpenseMembersProps) { const { myProfile } = useLoaderData() as { myProfile: MemberProfile; }; @@ -36,7 +37,7 @@ function ExpenseMembers({ groupToken, status }: ExpenseMembersProps) { key={member.id} member={member} groupToken={groupToken} - status={status} + settlementStatus={settlementStatus} isManager={isManager} /> ))} diff --git a/src/pages/expenseDetail/ui/ExpenseTimeHeader/index.tsx b/src/pages/expenseDetail/ui/ExpenseTimeHeader/index.tsx index 15005b6a..f779dd47 100644 --- a/src/pages/expenseDetail/ui/ExpenseTimeHeader/index.tsx +++ b/src/pages/expenseDetail/ui/ExpenseTimeHeader/index.tsx @@ -20,8 +20,8 @@ interface ExpenseTimeHeaderProps { totalMember: number; paidMember: number; onShareClick: () => void; - status: StatusType; - setStatus: (status: StatusType) => void; + settlementStatus: StatusType; + setSettlementStatus: (settlementStatus: StatusType) => void; isChecked: boolean; setIsChecked: (isChecked: boolean) => void; } @@ -30,8 +30,8 @@ function ExpenseTimeHeader({ totalMember, paidMember, onShareClick, - status, - setStatus, + settlementStatus, + setSettlementStatus, isChecked, setIsChecked, }: ExpenseTimeHeaderProps) { @@ -73,7 +73,7 @@ function ExpenseTimeHeader({ // 상태 업데이트 함수 const updateStatus = (statusValue: StatusType) => { - setStatus(statusValue); + setSettlementStatus(statusValue); setIsBubble(true); }; @@ -94,7 +94,7 @@ function ExpenseTimeHeader({ }, [paidMember, totalMember, isChecked, setIsChecked]); useEffect(() => { - if (!headerData || status !== 'pending') return () => {}; + if (!headerData || settlementStatus !== 'pending') return () => {}; intervalRef.current = setInterval(() => { const now = new Date(); @@ -113,7 +113,7 @@ function ExpenseTimeHeader({ return () => stopTimer(); // 컴포넌트 언마운트 시 타이머 멈추기 // eslint-disable-next-line react-hooks/exhaustive-deps - }, [headerData, status]); + }, [headerData, settlementStatus]); const handleModalButtonClick = () => { stopTimer(); @@ -140,11 +140,11 @@ function ExpenseTimeHeader({ const accountFormat = `${headerData.bank} ${headerData.accountNumber}`; // 신한 110123456789 const handleModdoButtonClick = () => { - if (status === 'success') { + if (settlementStatus === 'success') { onShareClick(); return; } - if (status === 'failure') { + if (settlementStatus === 'failure') { return; } setIsBubble(true); @@ -187,8 +187,10 @@ function ExpenseTimeHeader({ /> - - {isBubble && {StatusContent[status].message}} + + {isBubble && ( + {StatusContent[settlementStatus].message} + )} @@ -208,7 +210,7 @@ function ExpenseTimeHeader({ {([hours, minutes, seconds] as number[]).map((time, index, arr) => ( // eslint-disable-next-line react/no-array-index-key - + {String(time).padStart(2, '0')} {index < arr.length - 1 && :} From d73476a6577fe3c46c4ea4f7bee126b433732461 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sat, 6 Jun 2026 15:47:41 +0900 Subject: [PATCH 24/53] =?UTF-8?q?fix:=20=EC=A0=95=EC=82=B0=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=EC=83=81=ED=83=9C=EC=97=90=EC=84=9C=20=EC=BC=80?= =?UTF-8?q?=EB=B0=A5=20=EB=B2=84=ED=8A=BC=20=EC=88=A8=EA=B9=80=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref: https://github.com/moddo-kr/moddo-frontend/pull/57#discussion_r3330176896 --- .../expenseDetail/ui/ExpenseMemberItem/index.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx b/src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx index df2c3cea..ae8d83c3 100644 --- a/src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx +++ b/src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx @@ -139,13 +139,15 @@ function ExpenseMemberItem({ - setSheetOpen(true)} - aria-label={`${displayName}의 정산 상태 변경`} - > - - + {settlementStatus !== 'success' && ( + setSheetOpen(true)} + aria-label={`${displayName}의 정산 상태 변경`} + > + + + )} From 378841bef25f5d07c6c500d07056732deafd689b Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sat, 6 Jun 2026 16:28:32 +0900 Subject: [PATCH 25/53] =?UTF-8?q?fix:=20=EC=9E=85=EA=B8=88=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=20=EC=9A=94=EC=B2=AD=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C?= =?UTF-8?q?=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20=ED=91=9C=EC=8B=9C,=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EB=8B=AB=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useCreatePaymentRequest useMutationWithHandlers 적용, 400 에러 토스트 처리 - 요청 실패 시 다이얼로그가 열린 채로 남는 문제 수정 --- .../api/useCreatePaymentRequest.ts | 13 +++++++++++-- src/pages/expenseDetail/ExpenseDetailPage.tsx | 3 +++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/features/payment-management/api/useCreatePaymentRequest.ts b/src/features/payment-management/api/useCreatePaymentRequest.ts index f42110d4..1366c945 100644 --- a/src/features/payment-management/api/useCreatePaymentRequest.ts +++ b/src/features/payment-management/api/useCreatePaymentRequest.ts @@ -1,13 +1,22 @@ -import { useMutation } from '@tanstack/react-query'; import payment from '@/entities/payment/api/payment'; import { queryClient } from '@/shared/api/queryClient'; +import useMutationWithHandlers from '@/shared/hooks/useMutationWithHanders'; +import { showToast } from '@/shared/design-system/ui'; const useCreatePaymentRequest = () => { - return useMutation({ + return useMutationWithHandlers({ mutationFn: (code: string) => payment.create(code), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['payments'] }); }, + errorHandlers: { + 400: () => + showToast({ + type: 'error', + content: '이미 입금 확인 요청이 진행 중이에요.', + }), + }, + ignoreBoundaryErrors: [400], }); }; diff --git a/src/pages/expenseDetail/ExpenseDetailPage.tsx b/src/pages/expenseDetail/ExpenseDetailPage.tsx index 44727597..16ae001e 100644 --- a/src/pages/expenseDetail/ExpenseDetailPage.tsx +++ b/src/pages/expenseDetail/ExpenseDetailPage.tsx @@ -62,6 +62,9 @@ function ExpenseDetailPage() { content: '입금 확인 요청이 전송되었습니다.', }); }, + onError: () => { + setIsPaymentModalOpen(false); + }, }); }; From 991fcddc2742e85194965d84c841205c51a304dc Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sat, 6 Jun 2026 16:44:40 +0900 Subject: [PATCH 26/53] =?UTF-8?q?fix:=20RouteErrorBoundary=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EC=83=81=ED=83=9C=20=EB=A6=AC=EC=85=8B=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/RouteErrorBoundary/index.tsx | 27 ++++++++++++++++++++++++--- src/pages/error/ErrorPage.tsx | 4 ++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/app/RouteErrorBoundary/index.tsx b/src/app/RouteErrorBoundary/index.tsx index 670c90f6..f50f76b4 100644 --- a/src/app/RouteErrorBoundary/index.tsx +++ b/src/app/RouteErrorBoundary/index.tsx @@ -1,4 +1,6 @@ +import { QueryErrorResetBoundary } from '@tanstack/react-query'; import { ErrorBoundary, FallbackProps } from 'react-error-boundary'; +import { useLocation } from 'react-router'; import { ErrorPage } from '@/pages/error'; import { BoundaryError } from '@/shared/types/error.type'; @@ -6,10 +8,17 @@ type FallbackPageProps = Omit & { error: BoundaryError; }; -function FallbackPage({ error }: FallbackPageProps) { +function FallbackPage({ error, resetErrorBoundary }: FallbackPageProps) { const { title, description, action } = error; - return ; + return ( + + ); } interface RouteErrorBoundaryProps { @@ -17,8 +26,20 @@ interface RouteErrorBoundaryProps { } function RouteErrorBoundary({ children }: RouteErrorBoundaryProps) { + const location = useLocation(); + return ( - {children} + + {({ reset }) => ( + + {children} + + )} + ); } diff --git a/src/pages/error/ErrorPage.tsx b/src/pages/error/ErrorPage.tsx index 9741e9a9..3c9ab4af 100644 --- a/src/pages/error/ErrorPage.tsx +++ b/src/pages/error/ErrorPage.tsx @@ -8,6 +8,7 @@ import * as S from './ErrorPage.style'; interface ErrorPageProps { title?: string; description?: string; + onReset?: () => void; action?: { text?: string; href?: string; @@ -18,6 +19,7 @@ interface ErrorPageProps { function ErrorPage({ title = '잠시 문제가 발생했어요', description = `현재 서버 연결에 문제가 있어요.\n잠시 후 다시 시도해 주세요.`, + onReset, action = { text: '홈으로 돌아가기', href: ROUTE.home, @@ -28,11 +30,13 @@ function ErrorPage({ const handleActionClick = () => { if (action.onClick) { action.onClick(); + onReset?.(); return; } if (action.href) { navigate(action.href); + onReset?.(); } }; From 6b947c75073bc7571297666a48a8ff4f6b20d422 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sat, 6 Jun 2026 18:05:24 +0900 Subject: [PATCH 27/53] =?UTF-8?q?feat:=20paymentRequestStatus=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EC=A0=95=EC=82=B0=20=EC=B9=A9=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/settlement/model/settlement.type.ts | 2 ++ src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/entities/settlement/model/settlement.type.ts b/src/entities/settlement/model/settlement.type.ts index a16f3527..0c516139 100644 --- a/src/entities/settlement/model/settlement.type.ts +++ b/src/entities/settlement/model/settlement.type.ts @@ -7,6 +7,8 @@ export interface MemberSettlement { paidAt: Date | null; profile: string; paymentRequestId: number | null; + paymentRequestStatus: 'PENDING' | 'APPROVED' | 'REJECTED' | null; + paymentRequestStatusLabel: '확인중' | '승인완료' | '거절' | null; expenses: { content: string; amount: number; diff --git a/src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx b/src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx index ae8d83c3..8de2638d 100644 --- a/src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx +++ b/src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx @@ -33,7 +33,7 @@ type ChipStatus = '입금완료' | '확인중' | '미입금'; function getChipStatus(member: MemberSettlement): ChipStatus { if (member.isPaid) return '입금완료'; - if (member.paymentRequestId) return '확인중'; + if (member.paymentRequestStatus === 'PENDING') return '확인중'; return '미입금'; } From 2bcf569daf0e200c0da0ca6dc76cb6e9b86bd7fc Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sat, 6 Jun 2026 18:34:48 +0900 Subject: [PATCH 28/53] =?UTF-8?q?fix:=20CharacterItem=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=ED=95=84=EB=93=9C=EB=A5=BC=20imageBigUrl=EB=A1=9C?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/character-management/ui/CharacterItem/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/character-management/ui/CharacterItem/index.tsx b/src/features/character-management/ui/CharacterItem/index.tsx index 5257a6bb..9ec1a996 100644 --- a/src/features/character-management/ui/CharacterItem/index.tsx +++ b/src/features/character-management/ui/CharacterItem/index.tsx @@ -17,11 +17,11 @@ interface CharacterCardProps { } function CharacterCard({ character }: CharacterCardProps) { - const { imageUrl, name, acquiredAt } = character; + const { imageBigUrl, name, acquiredAt } = character; return ( - + {name} {acquiredAt ? format(acquiredAt, 'yyyy.MM.dd') : null} From ca7385c4fa7f60c62ed4f5ce518c5aff3cc14757 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sat, 6 Jun 2026 20:12:08 +0900 Subject: [PATCH 29/53] =?UTF-8?q?style:=20=EC=A3=BC=EC=84=9D=EC=9D=84=20?= =?UTF-8?q?=EC=8B=A4=EC=A0=9C=20=EB=8F=99=EC=9E=91=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref: https://github.com/moddo-kr/moddo-frontend/pull/59#discussion_r3367145213 --- .../CreateExpensePage/lib/createExpensePageGuardLoader.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/CreateExpensePage/lib/createExpensePageGuardLoader.ts b/src/pages/CreateExpensePage/lib/createExpensePageGuardLoader.ts index a5795309..3f1bc6f3 100644 --- a/src/pages/CreateExpensePage/lib/createExpensePageGuardLoader.ts +++ b/src/pages/CreateExpensePage/lib/createExpensePageGuardLoader.ts @@ -12,7 +12,7 @@ const createExpensePageGuardLoader: LoaderFunction = async ({ params }) => { // url 파라미터에서 groupToken 추출 const { groupToken } = params; - // groupToken이 없으면 모임 선택 페이지로 리다이렉트 + // groupToken이 없으면 그룹 설정 페이지로 리다이렉트 if (!groupToken) { return redirect(ROUTE.groupSetup); } @@ -28,7 +28,7 @@ const createExpensePageGuardLoader: LoaderFunction = async ({ params }) => { return { groupToken, groupData }; } catch (error: unknown) { - // 토큰이 유효하지 않은 경우에는 모임 선택 페이지로 이동 + // 토큰이 유효하지 않은 경우에는 그룹 설정 페이지로 이동 if (isAxiosError(error)) { if (error.response?.status === 401 || error.response?.status === 404) { return redirect(ROUTE.groupSetup); From d11cfcb7fa67e88341f179cf8955fac7dbd6610a Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sat, 6 Jun 2026 22:33:38 +0900 Subject: [PATCH 30/53] =?UTF-8?q?refactor:=20CHARACTER=5FDATA=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0,=20=EC=BA=90=EB=A6=AD=ED=84=B0=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20URL=20=EC=A7=81=EC=A0=91=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/character/config/character.ts | 46 ---------------- .../character/model/character.type.ts | 6 +-- .../ui/CharacterBottomSheet/index.styles.ts | 6 +++ .../ui/CharacterBottomSheet/index.tsx | 9 +--- .../CharacterSharePage.styles.ts | 38 ++------------ .../characterShare/CharacterSharePage.tsx | 52 ++++--------------- 6 files changed, 24 insertions(+), 133 deletions(-) delete mode 100644 src/entities/character/config/character.ts diff --git a/src/entities/character/config/character.ts b/src/entities/character/config/character.ts deleted file mode 100644 index 932953aa..00000000 --- a/src/entities/character/config/character.ts +++ /dev/null @@ -1,46 +0,0 @@ -export const CHARACTER_DATA = { - '천사 모또': { - imageSize: { - big: { width: '15rem' }, - small: { width: '12rem' }, - }, - description: '정산의 수호천사 등장!', - }, - '러키 모또': { - imageSize: { - big: { width: '12.5rem' }, - small: { width: '10rem' }, - }, - description: '정산 성공! 좋은 일만 가득하길~', - }, - '딸기 또또': { - imageSize: { - big: { width: '12.5rem' }, - small: { width: '10rem', height: '10rem' }, - }, - description: '정산 완료! 달콤한 하루 보내~', - }, - '잠꾸러기 또또': { - imageSize: { - big: { width: '12.5rem' }, - small: { width: '10rem' }, - }, - description: '정산 끝났어? 이제 푹 잘 수 있겠네~', - }, - '마법사 또또': { - imageSize: { - big: { height: '13.72844rem' }, - small: { height: '10.98275rem' }, - }, - description: '정산? 아브라카다브라! 해결 완료~', - }, -} satisfies Record< - string, - { - imageSize: { - big: { width?: string; height?: string }; - small: { width?: string; height?: string }; - }; - description: string; - } ->; diff --git a/src/entities/character/model/character.type.ts b/src/entities/character/model/character.type.ts index fad4f658..df7d3670 100644 --- a/src/entities/character/model/character.type.ts +++ b/src/entities/character/model/character.type.ts @@ -1,11 +1,7 @@ -import { CHARACTER_DATA } from '@/entities/character/config/character'; - -// 새 캐릭터 추가 시 CHARACTER_DATA에만 추가하면 타입이 자동으로 확장됨 -export type CharacterType = keyof typeof CHARACTER_DATA; export type StarCount = 1 | 2 | 3; export interface CharacterData { - name: CharacterType; + name: string; rarity: StarCount; imageUrl: string; imageBigUrl: string; diff --git a/src/features/character-management/ui/CharacterBottomSheet/index.styles.ts b/src/features/character-management/ui/CharacterBottomSheet/index.styles.ts index 2b19c3c4..b3c626fa 100644 --- a/src/features/character-management/ui/CharacterBottomSheet/index.styles.ts +++ b/src/features/character-management/ui/CharacterBottomSheet/index.styles.ts @@ -18,6 +18,12 @@ export const CharacterImageContainer = styled.div` height: 11.25rem; `; +export const CharacterImage = styled.img` + max-width: 100%; + max-height: 100%; + object-fit: contain; +`; + export const DescriptionContainer = styled.div` display: flex; flex-direction: column; diff --git a/src/features/character-management/ui/CharacterBottomSheet/index.tsx b/src/features/character-management/ui/CharacterBottomSheet/index.tsx index 996a6954..c7658c37 100644 --- a/src/features/character-management/ui/CharacterBottomSheet/index.tsx +++ b/src/features/character-management/ui/CharacterBottomSheet/index.tsx @@ -1,6 +1,5 @@ import { useNavigate, generatePath, useLoaderData } from 'react-router'; import { BottomSheet, ActionArea } from '@/shared/design-system/ui'; -import { CHARACTER_DATA } from '@/entities/character/config/character'; import { ROUTE } from '@/shared/config/route'; import useGetCharacter from '@/features/character-management/api/useGetCharacter'; import * as S from './index.styles'; @@ -30,13 +29,7 @@ function CharacterBottomSheet({ open, setOpen }: CharacterBottomSheetProps) { > - {data.name} + 두둥, {data.name} 등장! diff --git a/src/pages/characterShare/CharacterSharePage.styles.ts b/src/pages/characterShare/CharacterSharePage.styles.ts index e9366271..f32a7316 100644 --- a/src/pages/characterShare/CharacterSharePage.styles.ts +++ b/src/pages/characterShare/CharacterSharePage.styles.ts @@ -23,24 +23,10 @@ export const CharacterCardContainer = styled.div` display: flex; width: 100%; justify-content: center; - padding: ${getToken('padding.3')}; + padding: 0.5rem 2.5rem; background-color: ${getToken('bg.neutral')}; `; -export const CharacterCard = styled.div` - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - box-sizing: border-box; - width: 19.375rem; - height: 25rem; - flex-shrink: 0; - border-radius: ${getToken('radius.xl')}; - border: 1px solid ${getToken('border.normal')}; - background-color: ${getToken('bg.normal')}; -`; - export const EmptyStateTitle = styled.h1` ${applyTypography('typography.heading.medium')}; margin: 0; @@ -56,27 +42,13 @@ export const PageTitle = styled.h1` margin: 0; `; -export const CharacterName = styled.span` - ${applyTypography('typography.heading.small')}; - color: ${getToken('fg.normal')}; -`; - -export const CharacterDescription = styled.span` - ${applyTypography('typography.body.medium')}; - color: ${getToken('fg.alternative')}; -`; - export const DownloadButton = styled(TextButton)` margin-top: 0.75rem; margin-bottom: 1.25rem; `; -export const CharacterImageContainer = styled.div` - display: flex; - width: 15rem; - height: 14.25rem; - justify-content: center; - align-items: center; - flex-shrink: 0; - margin-top: 1.5rem; +export const CharacterImage = styled.img` + width: 100%; + max-width: 19.375rem; + height: auto; `; diff --git a/src/pages/characterShare/CharacterSharePage.tsx b/src/pages/characterShare/CharacterSharePage.tsx index 88a64d06..904cd4aa 100644 --- a/src/pages/characterShare/CharacterSharePage.tsx +++ b/src/pages/characterShare/CharacterSharePage.tsx @@ -1,13 +1,9 @@ -import { useRef } from 'react'; -import { toPng } from 'html-to-image'; import saveAs from 'file-saver'; import { ActionArea, Header, showToast } from '@/shared/design-system/ui'; import { useLoaderData, useNavigate } from 'react-router'; import { ArrowLeft, Download } from '@/shared/assets/svgs/icon'; import { getToken } from '@/shared/design-system'; import { PageLayout } from '@/shared/ui/PageLayout'; -import { CHARACTER_DATA } from '@/entities/character/config/character'; -import { StarChip } from '@/features/character-management/ui'; import useGetCharacter from '@/features/character-management/api/useGetCharacter'; import * as S from './CharacterSharePage.styles'; @@ -15,28 +11,17 @@ function CharacterSharePage() { const { groupToken } = useLoaderData(); const { data, isLoading, isError } = useGetCharacter(groupToken); const navigate = useNavigate(); - const imageRef = useRef(null); - const handleDownload = () => { + const handleDownload = async () => { if (!data) return; - // 돔 요소를 이미지로 변환 - if (imageRef.current) { - // 390x390 사이즈로 이미지 다운로드 - toPng(imageRef.current, { width: 390, height: 390 }) - .then((dataUrl) => { - // 이미지 다운로드 - saveAs(dataUrl, `${data.name}.png`); - showToast({ - type: 'success', - content: '이미지 저장 완료!', - }); - }) - .catch(() => { - showToast({ - type: 'error', - content: '이미지 저장 실패!', - }); - }); + try { + const response = await fetch(data.imageUrl); + if (!response.ok) throw new Error(); + const blob = await response.blob(); + saveAs(blob, `${data.name}.png`); + showToast({ type: 'success', content: '이미지 저장 완료!' }); + } catch { + showToast({ type: 'error', content: '이미지 저장 실패!' }); } }; @@ -93,23 +78,8 @@ function CharacterSharePage() { 캐릭터를 획득했어요! - - - - - {data.name} - - {data.name} - - {CHARACTER_DATA[data.name].description} - - + + From 34f58b83766020edfccf94fd9d2dc6e6de6e431b Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sat, 6 Jun 2026 23:15:02 +0900 Subject: [PATCH 31/53] =?UTF-8?q?feat:=20=ED=8C=8C=EC=9D=BC=EB=AA=85=20?= =?UTF-8?q?=EC=A0=95=EC=A0=9C=20=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref: https://github.com/moddo-kr/moddo-frontend/pull/60#discussion_r3367487029 --- src/pages/characterShare/CharacterSharePage.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/pages/characterShare/CharacterSharePage.tsx b/src/pages/characterShare/CharacterSharePage.tsx index 904cd4aa..8a251d40 100644 --- a/src/pages/characterShare/CharacterSharePage.tsx +++ b/src/pages/characterShare/CharacterSharePage.tsx @@ -7,6 +7,10 @@ import { PageLayout } from '@/shared/ui/PageLayout'; import useGetCharacter from '@/features/character-management/api/useGetCharacter'; import * as S from './CharacterSharePage.styles'; +const sanitizeFilename = (name: string): string => { + return name.replace(/[/\\:*?"<>|]/g, '_'); +}; + function CharacterSharePage() { const { groupToken } = useLoaderData(); const { data, isLoading, isError } = useGetCharacter(groupToken); @@ -18,7 +22,7 @@ function CharacterSharePage() { const response = await fetch(data.imageUrl); if (!response.ok) throw new Error(); const blob = await response.blob(); - saveAs(blob, `${data.name}.png`); + saveAs(blob, `${sanitizeFilename(data.name)}.png`); showToast({ type: 'success', content: '이미지 저장 완료!' }); } catch { showToast({ type: 'error', content: '이미지 저장 실패!' }); From 87f577ff3686d27016a24f562c758eea3fc1647d Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sun, 7 Jun 2026 00:57:37 +0900 Subject: [PATCH 32/53] =?UTF-8?q?feat:=20editExpenses=20=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=ED=8A=B8,=20=EB=A1=9C=EB=8D=94,=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 로더에서 인증, MANAGER 역할, 입금 요청 존재 여부 순차 검증 - payment.exists(), getGroupDetail()은 Promise.all로 병렬 호출 --- src/app/Router.tsx | 11 +++ src/entities/payment/api/payment.ts | 5 ++ src/pages/editExpenses/EditExpensesPage.tsx | 80 +++++++++++++++++++++ src/pages/editExpenses/index.ts | 1 + src/pages/editExpenses/loader.ts | 70 ++++++++++++++++++ src/shared/config/route.ts | 1 + 6 files changed, 168 insertions(+) create mode 100644 src/pages/editExpenses/EditExpensesPage.tsx create mode 100644 src/pages/editExpenses/index.ts create mode 100644 src/pages/editExpenses/loader.ts 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/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; From 823e41a89173c57448ea458ebedecaf993094e32 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sun, 7 Jun 2026 00:57:54 +0900 Subject: [PATCH 33/53] =?UTF-8?q?feat:=20expenseDetail=EC=97=90=20ManageMe?= =?UTF-8?q?nu=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MANAGER 역할일 때만 헤더에 관리 드롭다운 표시 - 정산 내역 수정 전 입금 요청 존재 여부 확인 후 분기 --- .../expenseDetail/ExpenseDetailPage.styles.ts | 7 +- src/pages/expenseDetail/ExpenseDetailPage.tsx | 5 +- .../ui/ManageMenu/index.styles.ts | 43 +++++++++++ .../expenseDetail/ui/ManageMenu/index.tsx | 76 +++++++++++++++++++ 4 files changed, 124 insertions(+), 7 deletions(-) create mode 100644 src/pages/expenseDetail/ui/ManageMenu/index.styles.ts create mode 100644 src/pages/expenseDetail/ui/ManageMenu/index.tsx 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; From 4d6df19681b52274a5ccf630975520dd654c68c1 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sun, 7 Jun 2026 01:00:15 +0900 Subject: [PATCH 34/53] =?UTF-8?q?design:=20disabled=20=EB=B2=84=ED=8A=BC?= =?UTF-8?q?=EC=97=90=20cursor=20pointer=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/expenseDetail/ui/ManageMenu/index.styles.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/pages/expenseDetail/ui/ManageMenu/index.styles.ts b/src/pages/expenseDetail/ui/ManageMenu/index.styles.ts index ca487bf3..bc6d7093 100644 --- a/src/pages/expenseDetail/ui/ManageMenu/index.styles.ts +++ b/src/pages/expenseDetail/ui/ManageMenu/index.styles.ts @@ -34,6 +34,10 @@ export const MenuItemButton = styled.button` cursor: pointer; text-align: left; + &:disabled { + cursor: default; + } + &:hover, &:active { /* HACK: Figma --text/strong(#292c30 = gray.20)에 대응하는 token 없음, 의미상 동일한 'fg.strong' 사용 */ From 1e3141fdefc9b785fa6f2afaf49158814cfa448c Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sun, 7 Jun 2026 18:23:08 +0900 Subject: [PATCH 35/53] =?UTF-8?q?fix:=20StarChip=20=EB=B9=84=ED=99=9C?= =?UTF-8?q?=EC=84=B1=20=EB=B3=84=20=EC=83=89=EC=83=81=EC=9D=84=20fill.alte?= =?UTF-8?q?rnative=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../character-management/ui/StarChip/StarChip.styles.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/features/character-management/ui/StarChip/StarChip.styles.ts b/src/features/character-management/ui/StarChip/StarChip.styles.ts index 2e507691..479dc7dd 100644 --- a/src/features/character-management/ui/StarChip/StarChip.styles.ts +++ b/src/features/character-management/ui/StarChip/StarChip.styles.ts @@ -18,6 +18,5 @@ export const Star = styled(SvgStar)<{ $active: boolean }>` color: ${({ $active }) => $active ? getToken('fg.accent-yellow.normal') - : // HACK: 비활성 별 색상(구 gray[100]=#d2d4d5)에 대응하는 semantic 토큰이 없어 근사값 사용 - getToken('fg.inverse.neutral')}; + : getToken('fill.alternative')}; `; From ca3b07f1262b3c30e85e14ff2489e5bb6629b764 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sun, 7 Jun 2026 19:28:48 +0900 Subject: [PATCH 36/53] =?UTF-8?q?feat:=20NameChip=20onClick=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90,=20ExpenseAmountInput=20=ED=80=B5=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20=EB=B2=84=ED=8A=BC=EC=9D=84=20NameChip=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/ExpenseAmountInput/ExpenseAmountInput.tsx | 16 ++++++---------- .../design-system/ui/NameChip/NameChip.styles.ts | 7 +++++++ .../design-system/ui/NameChip/NameChip.tsx | 12 ++++++++++-- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/features/expense-management/ui/ExpenseAmountInput/ExpenseAmountInput.tsx b/src/features/expense-management/ui/ExpenseAmountInput/ExpenseAmountInput.tsx index 82ffa042..8c836450 100644 --- a/src/features/expense-management/ui/ExpenseAmountInput/ExpenseAmountInput.tsx +++ b/src/features/expense-management/ui/ExpenseAmountInput/ExpenseAmountInput.tsx @@ -2,10 +2,10 @@ import { useState } from 'react'; import { ActionArea, BottomSheet, - Button, Input, Keypad, KeyValue, + NameChip, PriceDisplay, } from '@/shared/design-system/ui'; import { @@ -86,18 +86,14 @@ function ExpenseAmountInput({ {QUICK_ADD_BUTTONS.map(({ label, amount }) => ( - + /> ))} - + ` gap: ${getToken('gap.1')}; border-radius: ${getToken('radius.full')}; white-space: nowrap; + ${({ $clickable }) => + $clickable && + css` + border: none; + cursor: pointer; + `} ${({ $size }) => sizeStyles[$size]} ${({ $variant }) => variantStyles[$variant]} `; diff --git a/src/shared/design-system/ui/NameChip/NameChip.tsx b/src/shared/design-system/ui/NameChip/NameChip.tsx index 43d46248..bfa4c795 100644 --- a/src/shared/design-system/ui/NameChip/NameChip.tsx +++ b/src/shared/design-system/ui/NameChip/NameChip.tsx @@ -1,3 +1,4 @@ +import type { MouseEventHandler } from 'react'; import * as S from './NameChip.styles'; // HACK : ExpenseTimelineContent 에서 사용하는 NameChip이 정의되어 있지 않아서 임의로 black variant를 추가함. @@ -8,13 +9,20 @@ interface NameChipProps { label: string; variant?: NameChipVariant; size?: NameChipSize; + onClick?: MouseEventHandler; } function NameChip(props: NameChipProps) { - const { label, variant = 'selected', size = 'm' } = props; + const { label, variant = 'selected', size = 'm', onClick } = props; return ( - + {label} ); From 316c85dc9bed89452212be957c0bfdd777d1449c Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sun, 7 Jun 2026 19:35:03 +0900 Subject: [PATCH 37/53] =?UTF-8?q?refactor:=20NameChip=EC=9D=84=20Chip?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=A6=AC=EB=84=A4=EC=9D=B4=EB=B0=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ExpenseAmountInput/ExpenseAmountInput.tsx | 6 ++-- .../SettlementSummary/SettlementSummary.tsx | 4 +-- .../ui/ExpenseTimelineContent/index.tsx | 4 +-- .../Chip.stories.tsx} | 23 ++++++------- .../Chip.styles.ts} | 4 +-- src/shared/design-system/ui/Chip/Chip.tsx | 32 +++++++++++++++++++ src/shared/design-system/ui/Chip/index.ts | 2 ++ .../design-system/ui/NameChip/NameChip.tsx | 32 ------------------- src/shared/design-system/ui/NameChip/index.ts | 2 -- src/shared/design-system/ui/index.ts | 3 +- 10 files changed, 54 insertions(+), 58 deletions(-) rename src/shared/design-system/ui/{NameChip/NameChip.stories.tsx => Chip/Chip.stories.tsx} (65%) rename src/shared/design-system/ui/{NameChip/NameChip.styles.ts => Chip/Chip.styles.ts} (95%) create mode 100644 src/shared/design-system/ui/Chip/Chip.tsx create mode 100644 src/shared/design-system/ui/Chip/index.ts delete mode 100644 src/shared/design-system/ui/NameChip/NameChip.tsx delete mode 100644 src/shared/design-system/ui/NameChip/index.ts diff --git a/src/features/expense-management/ui/ExpenseAmountInput/ExpenseAmountInput.tsx b/src/features/expense-management/ui/ExpenseAmountInput/ExpenseAmountInput.tsx index 8c836450..0aa6b5d5 100644 --- a/src/features/expense-management/ui/ExpenseAmountInput/ExpenseAmountInput.tsx +++ b/src/features/expense-management/ui/ExpenseAmountInput/ExpenseAmountInput.tsx @@ -5,7 +5,7 @@ import { Input, Keypad, KeyValue, - NameChip, + Chip, PriceDisplay, } from '@/shared/design-system/ui'; import { @@ -86,14 +86,14 @@ function ExpenseAmountInput({ {QUICK_ADD_BUTTONS.map(({ label, amount }) => ( - handleQuickAdd(amount)} /> ))} - + {memberExpenses.map((member) => ( - {expense.groupMembers.map((name) => ( - + ))} diff --git a/src/shared/design-system/ui/NameChip/NameChip.stories.tsx b/src/shared/design-system/ui/Chip/Chip.stories.tsx similarity index 65% rename from src/shared/design-system/ui/NameChip/NameChip.stories.tsx rename to src/shared/design-system/ui/Chip/Chip.stories.tsx index 4bfba40d..da888c3e 100644 --- a/src/shared/design-system/ui/NameChip/NameChip.stories.tsx +++ b/src/shared/design-system/ui/Chip/Chip.stories.tsx @@ -1,19 +1,19 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { NameChip } from './NameChip'; -import type { NameChipVariant, NameChipSize } from './NameChip'; +import { Chip } from './Chip'; +import type { ChipVariant, ChipSize } from './Chip'; -const VARIANTS: NameChipVariant[] = [ +const VARIANTS: ChipVariant[] = [ 'selected', 'unselected', 'disabled', 'red', 'black', ]; -const SIZES: NameChipSize[] = ['m', 's']; +const SIZES: ChipSize[] = ['m', 's']; -const meta: Meta = { - title: 'Components/NameChip', - component: NameChip, +const meta: Meta = { + title: 'Components/Chip', + component: Chip, tags: ['autodocs'], argTypes: { variant: { @@ -36,7 +36,7 @@ const meta: Meta = { }; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Default: Story = {}; @@ -50,12 +50,7 @@ export const Showcase: Story = { > {size} {VARIANTS.map((variant) => ( - + ))} ))} diff --git a/src/shared/design-system/ui/NameChip/NameChip.styles.ts b/src/shared/design-system/ui/Chip/Chip.styles.ts similarity index 95% rename from src/shared/design-system/ui/NameChip/NameChip.styles.ts rename to src/shared/design-system/ui/Chip/Chip.styles.ts index 36f12a13..48d87dc4 100644 --- a/src/shared/design-system/ui/NameChip/NameChip.styles.ts +++ b/src/shared/design-system/ui/Chip/Chip.styles.ts @@ -5,7 +5,7 @@ import { applyTypography, } from '@/shared/design-system'; -interface StyledNameChipProps { +interface StyledChipProps { $variant: 'selected' | 'unselected' | 'disabled' | 'red' | 'black'; $size: 'm' | 's'; $clickable?: boolean; @@ -53,7 +53,7 @@ const variantStyles = { `, }; -export const Chip = styled.div` +export const ChipRoot = styled.div` display: inline-flex; align-items: center; justify-content: center; diff --git a/src/shared/design-system/ui/Chip/Chip.tsx b/src/shared/design-system/ui/Chip/Chip.tsx new file mode 100644 index 00000000..180b10de --- /dev/null +++ b/src/shared/design-system/ui/Chip/Chip.tsx @@ -0,0 +1,32 @@ +import type { MouseEventHandler } from 'react'; +import * as S from './Chip.styles'; + +// HACK : ExpenseTimelineContent 에서 사용하는 Chip이 정의되어 있지 않아서 임의로 black variant를 추가함. +type ChipVariant = 'selected' | 'unselected' | 'disabled' | 'red' | 'black'; +type ChipSize = 'm' | 's'; + +interface ChipProps { + label: string; + variant?: ChipVariant; + size?: ChipSize; + onClick?: MouseEventHandler; +} + +function Chip(props: ChipProps) { + const { label, variant = 'selected', size = 'm', onClick } = props; + + return ( + + {label} + + ); +} + +export { Chip }; +export type { ChipProps, ChipVariant, ChipSize }; diff --git a/src/shared/design-system/ui/Chip/index.ts b/src/shared/design-system/ui/Chip/index.ts new file mode 100644 index 00000000..f8c964b4 --- /dev/null +++ b/src/shared/design-system/ui/Chip/index.ts @@ -0,0 +1,2 @@ +export { Chip } from './Chip'; +export type { ChipProps, ChipVariant, ChipSize } from './Chip'; diff --git a/src/shared/design-system/ui/NameChip/NameChip.tsx b/src/shared/design-system/ui/NameChip/NameChip.tsx deleted file mode 100644 index bfa4c795..00000000 --- a/src/shared/design-system/ui/NameChip/NameChip.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import type { MouseEventHandler } from 'react'; -import * as S from './NameChip.styles'; - -// HACK : ExpenseTimelineContent 에서 사용하는 NameChip이 정의되어 있지 않아서 임의로 black variant를 추가함. -type NameChipVariant = 'selected' | 'unselected' | 'disabled' | 'red' | 'black'; -type NameChipSize = 'm' | 's'; - -interface NameChipProps { - label: string; - variant?: NameChipVariant; - size?: NameChipSize; - onClick?: MouseEventHandler; -} - -function NameChip(props: NameChipProps) { - const { label, variant = 'selected', size = 'm', onClick } = props; - - return ( - - {label} - - ); -} - -export { NameChip }; -export type { NameChipProps, NameChipVariant, NameChipSize }; diff --git a/src/shared/design-system/ui/NameChip/index.ts b/src/shared/design-system/ui/NameChip/index.ts deleted file mode 100644 index 16086b1f..00000000 --- a/src/shared/design-system/ui/NameChip/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { NameChip } from './NameChip'; -export type { NameChipProps, NameChipVariant, NameChipSize } from './NameChip'; diff --git a/src/shared/design-system/ui/index.ts b/src/shared/design-system/ui/index.ts index 5cf32d89..017348c2 100644 --- a/src/shared/design-system/ui/index.ts +++ b/src/shared/design-system/ui/index.ts @@ -19,7 +19,8 @@ export { Keypad } from './Keypad'; export type { KeyValue } from './Keypad'; export { Modal } from './Modal'; export type { ModalProps } from './Modal'; -export { NameChip } from './NameChip'; +export { Chip } from './Chip'; +export type { ChipProps, ChipVariant, ChipSize } from './Chip'; export { PaidChip } from './PaidChip'; export type { PaidChipStatus } from './PaidChip'; export type { HeaderProps } from './Header'; From 2d77e2c95eae794dc8c402c105b3a4aed6f43fd1 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sun, 7 Jun 2026 19:37:59 +0900 Subject: [PATCH 38/53] =?UTF-8?q?storybook:=20Chip=20AsButton=20=EC=8A=A4?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../design-system/ui/Chip/Chip.stories.tsx | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/shared/design-system/ui/Chip/Chip.stories.tsx b/src/shared/design-system/ui/Chip/Chip.stories.tsx index da888c3e..8406da67 100644 --- a/src/shared/design-system/ui/Chip/Chip.stories.tsx +++ b/src/shared/design-system/ui/Chip/Chip.stories.tsx @@ -57,3 +57,27 @@ export const Showcase: Story = { ), }; + +export const AsButton: Story = { + render: () => ( +
+ {SIZES.map((size) => ( +
+ {size} + {VARIANTS.map((variant) => ( + alert(`${variant} 클릭`)} + /> + ))} +
+ ))} +
+ ), +}; From 109bc0ea320d272b8ec29fb5690c31c59dddedb5 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sun, 7 Jun 2026 19:54:57 +0900 Subject: [PATCH 39/53] =?UTF-8?q?fix:=20=EC=9E=A0=EA=B8=B4=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EC=B9=B4=EB=93=9C=20border=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EC=A0=81=EC=9A=A9,=20opacity,=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=EC=BD=98=20=EC=83=89=EC=83=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../character-management/ui/CharacterItem/index.styles.ts | 3 ++- src/features/character-management/ui/CharacterItem/index.tsx | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/features/character-management/ui/CharacterItem/index.styles.ts b/src/features/character-management/ui/CharacterItem/index.styles.ts index 42f1096c..d35e000a 100644 --- a/src/features/character-management/ui/CharacterItem/index.styles.ts +++ b/src/features/character-management/ui/CharacterItem/index.styles.ts @@ -28,7 +28,8 @@ export const LockedCharacterCard = styled(CardContainerBase)` padding-bottom: 2.25rem; /* 의도적으로 토큰으로 정의되지 않은 값 사용 */ padding-left: ${getToken('padding.5')}; padding-right: ${getToken('padding.5')}; - border: 1px dashed #d2d4d5; /* HACK: 토큰에 정의되어 있지 않아 임시로 하드코딩함 */ + border: 1px dashed ${getToken('border.normal')}; + opacity: 0.5; `; export const CharacterImage = styled.img` diff --git a/src/features/character-management/ui/CharacterItem/index.tsx b/src/features/character-management/ui/CharacterItem/index.tsx index 5257a6bb..341951ea 100644 --- a/src/features/character-management/ui/CharacterItem/index.tsx +++ b/src/features/character-management/ui/CharacterItem/index.tsx @@ -6,8 +6,8 @@ import * as S from './index.styles'; function LockedCharacterCard() { return ( - {/* HACK : 정의되지 않은 토큰이라 #D2D4D5를 그대로 사용 */} - + {/* HACK: gray.90(#c7c9cb)에 대응하는 아이콘용 semantic 토큰 없음 */} + ); } From 4c1e01b20b48ba950a15bf1ced7e78192042d258 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sun, 7 Jun 2026 19:59:31 +0900 Subject: [PATCH 40/53] =?UTF-8?q?fix:=20ExpenseMemberItem=20=EB=B9=84?= =?UTF-8?q?=ED=99=9C=EC=84=B1=20=EC=83=89=EC=83=81=EC=9D=84=20fg.normal-di?= =?UTF-8?q?sable=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/expenseDetail/ui/ExpenseMemberItem/index.style.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/pages/expenseDetail/ui/ExpenseMemberItem/index.style.ts b/src/pages/expenseDetail/ui/ExpenseMemberItem/index.style.ts index e52bd95a..1a37fb51 100644 --- a/src/pages/expenseDetail/ui/ExpenseMemberItem/index.style.ts +++ b/src/pages/expenseDetail/ui/ExpenseMemberItem/index.style.ts @@ -146,11 +146,7 @@ export const TextButtonWrapper = styled.button<{ $isActive: boolean }>` align-items: center; cursor: pointer; color: ${({ $isActive }) => - $isActive - ? getToken('fg.primary.normal') - : getToken( - 'fill.normal-disable' - )}; /* HACK: #ACAFB2로 정의된 토큰이 없어 의미상 유사한 토큰을 임의로 사용함 */ + $isActive ? getToken('fg.primary.normal') : getToken('fg.normal-disable')}; > svg { display: ${({ $isActive }) => ($isActive ? 'block' : 'none')}; } From d30f19ca1cb696f673f6bf47e9e90eda2643626a Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sun, 7 Jun 2026 20:05:05 +0900 Subject: [PATCH 41/53] =?UTF-8?q?refactor:=20Chip=20black=20variant=20HACK?= =?UTF-8?q?=20=EC=A3=BC=EC=84=9D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/design-system/ui/Chip/Chip.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/shared/design-system/ui/Chip/Chip.tsx b/src/shared/design-system/ui/Chip/Chip.tsx index 180b10de..ccda80c0 100644 --- a/src/shared/design-system/ui/Chip/Chip.tsx +++ b/src/shared/design-system/ui/Chip/Chip.tsx @@ -1,7 +1,6 @@ import type { MouseEventHandler } from 'react'; import * as S from './Chip.styles'; -// HACK : ExpenseTimelineContent 에서 사용하는 Chip이 정의되어 있지 않아서 임의로 black variant를 추가함. type ChipVariant = 'selected' | 'unselected' | 'disabled' | 'red' | 'black'; type ChipSize = 'm' | 's'; From cc9aa9496e593258ae20b1aadd75d7d2dc91fbbf Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sun, 7 Jun 2026 20:39:47 +0900 Subject: [PATCH 42/53] =?UTF-8?q?feat:=20Input=20helpText=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80,=20error=20=EC=83=81=ED=83=9C=20danger=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EC=BD=98=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../design-system/ui/Input/Input.stories.tsx | 20 ++++++++++++ .../design-system/ui/Input/Input.styles.ts | 32 ++++++++++++++++++- src/shared/design-system/ui/Input/Input.tsx | 14 +++++++- 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/shared/design-system/ui/Input/Input.stories.tsx b/src/shared/design-system/ui/Input/Input.stories.tsx index 1c28cfed..ed3cc2fb 100644 --- a/src/shared/design-system/ui/Input/Input.stories.tsx +++ b/src/shared/design-system/ui/Input/Input.stories.tsx @@ -15,6 +15,7 @@ const meta: Meta = { label: { control: 'text' }, required: { control: 'boolean' }, placeholder: { control: 'text' }, + helpText: { control: 'text' }, value: { control: 'text' }, }, args: { @@ -56,6 +57,25 @@ export const Showcase: Story = { ), }; +export const WithHelpText: Story = { + render: () => ( +
+ {STATES.map((state) => ( + + ))} +
+ ), +}; + export const PriceVariant: Story = { render: () => (
` + ${applyTypography('typography.caption.xsmall')} + font-weight: ${getTypographyToken('typography.caption.small-medium') + .fontWeight}; + color: ${({ $state }) => + $state === 'error' ? getToken('fg.accent-red.normal') : '#444950'}; +`; diff --git a/src/shared/design-system/ui/Input/Input.tsx b/src/shared/design-system/ui/Input/Input.tsx index 225e201d..466112ad 100644 --- a/src/shared/design-system/ui/Input/Input.tsx +++ b/src/shared/design-system/ui/Input/Input.tsx @@ -1,5 +1,6 @@ import { useId, forwardRef } from 'react'; import type { ReactNode, ChangeEventHandler, InputHTMLAttributes } from 'react'; +import SvgSystemDanger from '@/shared/assets/svgs/icon/SystemDanger'; import * as S from './Input.styles'; type InputState = 'default' | 'error' | 'disabled'; @@ -11,6 +12,7 @@ interface InputProps state?: InputState; variant?: InputVariant; trailingIcon?: ReactNode; + helpText?: string; value?: string; onChange?: ChangeEventHandler; } @@ -24,6 +26,7 @@ const Input = forwardRef(function Input( state = 'default', variant = 'default', trailingIcon, + helpText, value, onChange, onClick, @@ -67,7 +70,16 @@ const Input = forwardRef(function Input( )} - {/* TODO: 헬프텍스트 - 디자인 확정 후 추가 예정 */} + {helpText && variant === 'default' && ( + + {state === 'error' && ( + + + + )} + {helpText} + + )} ); }); From d0185ef15d745d3e3555a8bc3905fab487ecc739 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sun, 7 Jun 2026 20:58:46 +0900 Subject: [PATCH 43/53] =?UTF-8?q?design:=20=EB=B2=84=ED=8A=BC=20=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit radius 변경 --- src/shared/design-system/ui/Button/Button.styles.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/shared/design-system/ui/Button/Button.styles.ts b/src/shared/design-system/ui/Button/Button.styles.ts index 174d999c..39e3bd90 100644 --- a/src/shared/design-system/ui/Button/Button.styles.ts +++ b/src/shared/design-system/ui/Button/Button.styles.ts @@ -10,15 +10,18 @@ const sizeStyles = { medium: css` padding: ${getToken('padding.4')} ${getToken('padding.5')}; ${applyTypography('typography.body.medium-semibold')} + border-radius: ${getToken('radius.lg')}; `, small: css` padding: ${getToken('padding.3')} ${getToken('padding.5')}; ${applyTypography('typography.body.small-semibold')} + border-radius: ${getToken('radius.lg')}; `, xsmall: css` height: 1.75rem; padding: 0 ${getToken('padding.4')}; ${applyTypography('typography.caption.small-medium')} + border-radius: ${getToken('radius.md')}; `, }; @@ -64,7 +67,6 @@ export const Button = styled.button` justify-content: center; align-items: center; gap: ${getToken('gap.2')}; - border-radius: ${getToken('radius.full')}; border: none; cursor: pointer; white-space: nowrap; From 0f7f4d7340f5cc40614c6d8669de878c0bbe429a Mon Sep 17 00:00:00 2001 From: yeoeun-ex Date: Sun, 7 Jun 2026 20:59:01 +0900 Subject: [PATCH 44/53] =?UTF-8?q?feat:=20Dialog=EC=97=90=20children=20?= =?UTF-8?q?=EC=8A=AC=EB=A1=AF=EA=B3=BC=20action=20disabled=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../design-system/ui/Dialog/Dialog.styles.ts | 13 +++++++++++ src/shared/design-system/ui/Dialog/Dialog.tsx | 23 +++++++++++++++---- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/shared/design-system/ui/Dialog/Dialog.styles.ts b/src/shared/design-system/ui/Dialog/Dialog.styles.ts index 3c5a08b7..5622fa37 100644 --- a/src/shared/design-system/ui/Dialog/Dialog.styles.ts +++ b/src/shared/design-system/ui/Dialog/Dialog.styles.ts @@ -16,6 +16,19 @@ export const Container = styled.div` max-width: 330px; `; +export const Section = styled.div` + display: flex; + flex-direction: column; + /* HACK: 32px에 해당하는 gap 토큰 없음(gap.8=24px이 최대). Figma 스펙(32px) 일치 위해 직접 사용. */ + gap: 32px; +`; + +export const Content = styled.div` + display: flex; + flex-direction: column; + gap: ${getToken('gap.8')}; +`; + export const TextSection = styled.div` display: flex; flex-direction: column; diff --git a/src/shared/design-system/ui/Dialog/Dialog.tsx b/src/shared/design-system/ui/Dialog/Dialog.tsx index cb150b10..3b346608 100644 --- a/src/shared/design-system/ui/Dialog/Dialog.tsx +++ b/src/shared/design-system/ui/Dialog/Dialog.tsx @@ -5,24 +5,37 @@ import * as S from './Dialog.styles'; interface DialogAction { label: string; onClick: () => void; + disabled?: boolean; } interface DialogProps { title: ReactNode; description?: ReactNode; + children?: ReactNode; mainAction: DialogAction; alternativeAction?: DialogAction; } function Dialog(props: DialogProps) { - const { title, description, mainAction, alternativeAction } = props; + const { title, description, children, mainAction, alternativeAction } = props; + + const textSection = ( + + {title} + {description && {description}} + + ); return ( - - {title} - {description && {description}} - + {children ? ( + + {textSection} + {children} + + ) : ( + textSection + )} Date: Sun, 7 Jun 2026 20:59:28 +0900 Subject: [PATCH 45/53] =?UTF-8?q?docs:=20=EB=B2=84=ED=8A=BC=20=EC=8A=A4?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/Button/Button.stories.tsx | 50 +++++-------------- 1 file changed, 12 insertions(+), 38 deletions(-) diff --git a/src/shared/design-system/ui/Button/Button.stories.tsx b/src/shared/design-system/ui/Button/Button.stories.tsx index d37bfd30..1e0fdeda 100644 --- a/src/shared/design-system/ui/Button/Button.stories.tsx +++ b/src/shared/design-system/ui/Button/Button.stories.tsx @@ -43,35 +43,28 @@ type Story = StoryObj; export const Default: Story = {}; +const gridStyle = { + display: 'grid', + gridTemplateColumns: '3rem repeat(6, auto)', + gap: 8, + alignItems: 'center', + justifyItems: 'start', +} as const; + export const Showcase: Story = { render: () => (
-
+
{VARIANTS.map((variant) => ( - + {variant} ))} - disabled + disabled
{SIZES.map((size) => ( -
+
{size} {VARIANTS.map((variant) => (
), }; - -export const Disabled: Story = { - render: () => ( -
- {VARIANTS.map((variant) => ( - - ))} -
- ), -}; From abebbbab9776ad1c97320223c2da84bd574723b2 Mon Sep 17 00:00:00 2001 From: yeoeun-ex Date: Sun, 7 Jun 2026 20:59:33 +0900 Subject: [PATCH 46/53] =?UTF-8?q?feat:=20Dropdown=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/_workspace/Dropdown/Dropdown.styles.ts | 130 +++++++++++++++++++++ src/_workspace/Dropdown/Dropdown.tsx | 104 +++++++++++++++++ src/_workspace/Dropdown/index.ts | 2 + 3 files changed, 236 insertions(+) create mode 100644 src/_workspace/Dropdown/Dropdown.styles.ts create mode 100644 src/_workspace/Dropdown/Dropdown.tsx create mode 100644 src/_workspace/Dropdown/index.ts diff --git a/src/_workspace/Dropdown/Dropdown.styles.ts b/src/_workspace/Dropdown/Dropdown.styles.ts new file mode 100644 index 00000000..461dcaf1 --- /dev/null +++ b/src/_workspace/Dropdown/Dropdown.styles.ts @@ -0,0 +1,130 @@ +import styled, { css } from 'styled-components'; +import { getToken, applyTypography } from '@/shared/design-system'; + +export const Container = styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; + gap: ${getToken('gap.4')}; + width: 100%; +`; + +export const Label = styled.span` + ${applyTypography('typography.body.small-semibold')} + color: ${getToken('fg.neutral')}; +`; + +export const TriggerWrap = styled.div` + position: relative; + width: 100%; +`; + +export const Trigger = styled.button<{ $isOpen: boolean }>` + display: flex; + align-items: center; + gap: ${getToken('gap.4')}; + width: 100%; + padding: ${getToken('padding.4')} ${getToken('padding.5')}; + background: ${getToken('fill.normal')}; + border-radius: ${getToken('radius.lg')}; + cursor: pointer; + ${({ $isOpen }) => + $isOpen + ? css` + border: 2px solid ${getToken('border.primary.normal')}; + ` + : css` + border: 1px solid ${getToken('border.neutral')}; + `} +`; + +export const ValueText = styled.span<{ $isPlaceholder: boolean }>` + flex: 1; + min-width: 0; + text-align: left; + ${applyTypography('typography.body.medium')} + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + ${({ $isPlaceholder }) => + $isPlaceholder + ? css` + color: ${getToken('fg.assistive')}; + opacity: 0.5; + ` + : css` + color: ${getToken('fg.normal')}; + `} +`; + +export const ChevronWrapper = styled.span<{ $isOpen: boolean }>` + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: ${getToken('fg.assistive')}; + transform: ${({ $isOpen }) => ($isOpen ? 'rotate(180deg)' : 'rotate(0deg)')}; + transition: transform 0.2s ease-in-out; +`; + +export const Panel = styled.div` + position: absolute; + top: calc(100% + ${getToken('gap.3')}); + left: 0; + right: 0; + display: flex; + flex-direction: column; + gap: ${getToken('gap.1')}; + background: ${getToken('fill.normal')}; + border: 1px solid ${getToken('border.alternative')}; + border-radius: ${getToken('radius.md')}; + padding: ${getToken('padding.3')}; + /* HACK: shadow2(0px 4px 8px rgba(0,0,0,0.12)) 매핑되는 shadow 시맨틱 토큰 없음 */ + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.12); + /* HACK: 옵션이 많을 때 패널이 넘치지 않도록 max-height 제한. 대응 토큰 없어 직접 사용. */ + max-height: 240px; + overflow-y: auto; + z-index: 10; +`; + +export const OptionItem = styled.button` + display: flex; + align-items: center; + justify-content: space-between; + gap: ${getToken('gap.6')}; + width: 100%; + padding: ${getToken('padding.3')}; + border-radius: ${getToken('radius.sm')}; + background: transparent; + border: none; + cursor: pointer; + + &:hover, + &:active { + background: ${getToken('fill.normal-pressed')}; + } +`; + +export const OptionLabel = styled.span<{ $isSelected: boolean }>` + ${({ $isSelected }) => + $isSelected + ? applyTypography('typography.body.medium-semibold') + : applyTypography('typography.body.medium')} + color: ${({ $isSelected }) => + $isSelected ? getToken('fg.primary.normal') : getToken('fg.normal')}; + flex: 1; + min-width: 0; + text-align: left; + white-space: nowrap; +`; + +export const ConfirmIconWrapper = styled.span` + display: flex; + align-items: center; + flex-shrink: 0; + color: ${getToken('fg.primary.normal')}; + + svg path { + stroke: currentColor; + } +`; diff --git a/src/_workspace/Dropdown/Dropdown.tsx b/src/_workspace/Dropdown/Dropdown.tsx new file mode 100644 index 00000000..9217158b --- /dev/null +++ b/src/_workspace/Dropdown/Dropdown.tsx @@ -0,0 +1,104 @@ +import { useState, useRef, useEffect, useId } from 'react'; +import SvgArrowDown from '@/shared/assets/svgs/icon/ArrowDown'; +import SvgConfirm from '@/shared/assets/svgs/icon/Confirm'; +import * as S from './Dropdown.styles'; + +interface DropdownOption { + label: string; + value: string; +} + +interface DropdownProps { + label?: string; + options: DropdownOption[]; + value: string; + onChange: (value: string) => void; + placeholder?: string; +} + +function Dropdown(props: DropdownProps) { + const { label, options, value, onChange, placeholder = '선택' } = props; + const [isOpen, setIsOpen] = useState(false); + const containerRef = useRef(null); + const listboxId = useId(); + + const selectedOption = options.find((option) => option.value === value); + + useEffect(() => { + if (!isOpen) { + return undefined; + } + + function handleOutsideClick(e: MouseEvent) { + if (!containerRef.current?.contains(e.target as Node)) { + setIsOpen(false); + } + } + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') { + setIsOpen(false); + } + } + + document.addEventListener('mousedown', handleOutsideClick); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('mousedown', handleOutsideClick); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [isOpen]); + + function handleOptionClick(optionValue: string) { + onChange(optionValue); + setIsOpen(false); + } + + return ( + + {label && {label}} + + setIsOpen((prev) => !prev)} + > + + {selectedOption?.label ?? placeholder} + + + + + + {isOpen && ( + + {options.map((option) => ( + handleOptionClick(option.value)} + > + + {option.label} + + {option.value === value && ( + + + + )} + + ))} + + )} + + + ); +} + +export { Dropdown }; +export type { DropdownProps, DropdownOption }; diff --git a/src/_workspace/Dropdown/index.ts b/src/_workspace/Dropdown/index.ts new file mode 100644 index 00000000..73287634 --- /dev/null +++ b/src/_workspace/Dropdown/index.ts @@ -0,0 +1,2 @@ +export { Dropdown } from './Dropdown'; +export type { DropdownProps, DropdownOption } from './Dropdown'; From 8896129b48fb960de9cd80c33949005ac4d00bf4 Mon Sep 17 00:00:00 2001 From: yeoeun-ex Date: Sun, 7 Jun 2026 20:59:43 +0900 Subject: [PATCH 47/53] =?UTF-8?q?feat:=20=EA=B3=84=EC=A2=8C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=8B=A4=EC=9D=B4=EC=96=BC=EB=A1=9C=EA=B7=B8(Accou?= =?UTF-8?q?ntEditDialog)=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AccountEditDialog/AccountEditDialog.tsx | 65 +++++++++++++++++++ src/_workspace/AccountEditDialog/index.ts | 2 + 2 files changed, 67 insertions(+) create mode 100644 src/_workspace/AccountEditDialog/AccountEditDialog.tsx create mode 100644 src/_workspace/AccountEditDialog/index.ts diff --git a/src/_workspace/AccountEditDialog/AccountEditDialog.tsx b/src/_workspace/AccountEditDialog/AccountEditDialog.tsx new file mode 100644 index 00000000..bf64ba3d --- /dev/null +++ b/src/_workspace/AccountEditDialog/AccountEditDialog.tsx @@ -0,0 +1,65 @@ +import type { ChangeEventHandler } from 'react'; +import { Dialog, Input, Modal } from '@/shared/design-system/ui'; +import { Dropdown, type DropdownOption } from '../Dropdown'; + +interface AccountEditDialogProps { + open: boolean; + bankOptions: DropdownOption[]; + bankValue: string; + bankPlaceholder?: string; + onBankChange: (value: string) => void; + accountNumber: string; + accountNumberPlaceholder?: string; + onAccountNumberChange: ChangeEventHandler; + onCancel: () => void; + onConfirm: () => void; +} + +function AccountEditDialog(props: AccountEditDialogProps) { + const { + open, + bankOptions, + bankValue, + bankPlaceholder = '은행을 선택해주세요', + onBankChange, + accountNumber, + accountNumberPlaceholder = '계좌 번호를 입력해주세요', + onAccountNumberChange, + onCancel, + onConfirm, + } = props; + + const isConfirmDisabled = !bankValue || !accountNumber; + + return ( + + + + + + + ); +} + +export { AccountEditDialog }; +export type { AccountEditDialogProps }; diff --git a/src/_workspace/AccountEditDialog/index.ts b/src/_workspace/AccountEditDialog/index.ts new file mode 100644 index 00000000..38e0a982 --- /dev/null +++ b/src/_workspace/AccountEditDialog/index.ts @@ -0,0 +1,2 @@ +export { AccountEditDialog } from './AccountEditDialog'; +export type { AccountEditDialogProps } from './AccountEditDialog'; From 6370c8c5bcc96c4282347eed39ca7293e25ba881 Mon Sep 17 00:00:00 2001 From: yeoeun-ex Date: Sun, 7 Jun 2026 21:00:24 +0900 Subject: [PATCH 48/53] =?UTF-8?q?feat:=20expenseDetail=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EB=A9=94=EB=89=B4=EC=97=90=20=EA=B3=84=EC=A2=8C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EC=97=B0=EA=B2=B0=20=EB=B0=8F=20header=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../expenseDetail/ui/ManageMenu/index.tsx | 67 ++++++++++++++++++- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/src/pages/expenseDetail/ui/ManageMenu/index.tsx b/src/pages/expenseDetail/ui/ManageMenu/index.tsx index dd100f28..d814d37a 100644 --- a/src/pages/expenseDetail/ui/ManageMenu/index.tsx +++ b/src/pages/expenseDetail/ui/ManageMenu/index.tsx @@ -1,11 +1,21 @@ import { useState } from 'react'; import { createPortal } from 'react-dom'; +import { useQueryClient } from '@tanstack/react-query'; import { useNavigate } from 'react-router'; import { Dimmed, Dialog, Modal, showToast } from '@/shared/design-system/ui'; import payment from '@/entities/payment/api/payment'; +import usePutUpdateAccount from '@/features/expense-management/api/usePutUpdateAccount'; import { ROUTE } from '@/shared/config/route'; +import { BoundaryError } from '@/shared/types/error.type'; +import { AccountEditDialog } from '@/_workspace/AccountEditDialog'; +import BANK_LIST from '@/pages/addAccountStep/ui/BankNameDrawer/config/banks'; import * as S from './index.styles'; +const BANK_OPTIONS = BANK_LIST.map((bank) => ({ + label: bank.bankName, + value: bank.bankName, +})); + interface ManageMenuProps { groupToken: string; } @@ -14,7 +24,26 @@ function ManageMenu({ groupToken }: ManageMenuProps) { const [isOpen, setIsOpen] = useState(false); const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false); const [isPending, setIsPending] = useState(false); + const [isAccountEditOpen, setIsAccountEditOpen] = useState(false); + const [bankName, setBankName] = useState(''); + const [accountNumber, setAccountNumber] = useState(''); const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const { mutate: updateAccountMutate } = usePutUpdateAccount( + groupToken, + { + // CHECK - 문서에는 403 에러로 되어 있지만, 실제로는 500 에러가 발생함 + // 유저가 모임 총무가 아닐 경우에 발생하는 에러 + 403: () => { + throw new BoundaryError({ + title: '접근 권한이 없어요.', + description: '계좌는 총무만 수정할 수 있어요.', + }); + }, + }, + [403] + ); const handleEditExpenses = async () => { setIsOpen(false); @@ -36,6 +65,29 @@ function ManageMenu({ groupToken }: ManageMenuProps) { } }; + const handleOpenAccountEdit = () => { + setIsOpen(false); + setIsAccountEditOpen(true); + }; + + const handleAccountEditConfirm = () => { + updateAccountMutate( + { + accountData: { bank: bankName, accountNumber }, + groupToken, + }, + { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['groupHeader', groupToken], + }); + showToast({ type: 'success', content: '계좌를 수정했어요.' }); + setIsAccountEditOpen(false); + }, + } + ); + }; + return ( <> setIsOpen(true)} disabled={isPending}> @@ -49,12 +101,23 @@ function ManageMenu({ groupToken }: ManageMenuProps) { 정산 내역 수정 - {/* TODO: 계좌 수정 기능 구현 시 연결 */} - 계좌 수정 + + 계좌 수정 + , document.querySelector('#modal') ?? document.body )} + setAccountNumber(e.target.value)} + onCancel={() => setIsAccountEditOpen(false)} + onConfirm={handleAccountEditConfirm} + /> setIsBlockedModalOpen(false)} From 09b0c5af9de0d117e07b4f7189c6548272ed4fc6 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sun, 7 Jun 2026 21:23:37 +0900 Subject: [PATCH 49/53] =?UTF-8?q?chore:=20main=20=EB=B8=8C=EB=9E=9C?= =?UTF-8?q?=EC=B9=98=20push=20=EC=8B=9C=20Storybook=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=20=EB=B0=B0=ED=8F=AC=20=ED=8A=B8=EB=A6=AC=EA=B1=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/publish-storybook.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/publish-storybook.yml b/.github/workflows/publish-storybook.yml index 0cd1b212..0257e326 100644 --- a/.github/workflows/publish-storybook.yml +++ b/.github/workflows/publish-storybook.yml @@ -2,6 +2,9 @@ name: Publish Storybook # https://www.chromatic.com/docs/github-actions/ on: + push: + branches: + - main workflow_dispatch: inputs: pr_number: From 4707cb68a5132e766d247845729226e9fa9a420c Mon Sep 17 00:00:00 2001 From: yeoeun-ex Date: Sun, 7 Jun 2026 21:28:26 +0900 Subject: [PATCH 50/53] feat: add EmptySettlementCard component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 지출이 없는 빈 정산을 표시하는 카드 컴포넌트 추가. 클릭 시 지출 생성 페이지(/create-expense/{groupCode})로 이동. Co-Authored-By: Claude Opus 4.7 --- .../EmptySettlementCard.styles.ts | 70 +++++++++++++++++++ .../EmptySettlementCard.tsx | 38 ++++++++++ .../home/ui/EmptySettlementCard/index.ts | 2 + 3 files changed, 110 insertions(+) create mode 100644 src/pages/home/ui/EmptySettlementCard/EmptySettlementCard.styles.ts create mode 100644 src/pages/home/ui/EmptySettlementCard/EmptySettlementCard.tsx create mode 100644 src/pages/home/ui/EmptySettlementCard/index.ts diff --git a/src/pages/home/ui/EmptySettlementCard/EmptySettlementCard.styles.ts b/src/pages/home/ui/EmptySettlementCard/EmptySettlementCard.styles.ts new file mode 100644 index 00000000..e3d37988 --- /dev/null +++ b/src/pages/home/ui/EmptySettlementCard/EmptySettlementCard.styles.ts @@ -0,0 +1,70 @@ +import styled from 'styled-components'; +import { Link } from 'react-router'; +import SvgSystemWarning from '@/shared/assets/svgs/icon/SystemWarning'; +import { getToken, applyTypography } from '@/shared/design-system'; + +export const Container = styled(Link)` + display: flex; + flex-direction: column; + /* HACK: Figma gap 14px, 가장 가까운 gap.5(12px) 사용 */ + gap: ${getToken('gap.5')}; + width: 100%; + background: ${getToken('fill.neutral')}; + border-radius: ${getToken('radius.xl')}; + /* HACK: Figma py 18px, 가장 가까운 padding.6(20px) 사용 */ + padding: ${getToken('padding.6')}; + text-decoration: none; + color: inherit; +`; + +export const TextGroup = styled.div` + display: flex; + flex-direction: column; +`; + +export const GroupName = styled.span` + ${applyTypography('typography.body.medium')} + color: ${getToken('fg.alternative')}; +`; + +export const Amount = styled.span` + ${applyTypography('typography.heading.small')} + color: ${getToken('fg.normal')}; +`; + +export const ProgressSection = styled.div` + display: flex; + flex-direction: column; + gap: ${getToken('gap.4')}; +`; + +export const ProgressBar = styled.div` + position: relative; + width: 100%; + height: 0.5rem; + overflow: hidden; +`; + +export const ProgressTrack = styled.div` + position: absolute; + inset: 0; + border-radius: ${getToken('radius.full')}; + /* 대응되는 시맨틱 토큰이 없어 Figma 지정값 사용 */ + background: #d6d6d6; +`; + +export const MessageRow = styled.div` + display: flex; + align-items: center; + justify-content: flex-end; + gap: ${getToken('gap.2')}; +`; + +export const WarningIcon = styled(SvgSystemWarning)` + color: ${getToken('fg.accent-yellow.normal')}; +`; + +export const Message = styled.span` + ${applyTypography('typography.body.small-semibold')} + color: ${getToken('fg.assistive')}; +`; diff --git a/src/pages/home/ui/EmptySettlementCard/EmptySettlementCard.tsx b/src/pages/home/ui/EmptySettlementCard/EmptySettlementCard.tsx new file mode 100644 index 00000000..7160bf4e --- /dev/null +++ b/src/pages/home/ui/EmptySettlementCard/EmptySettlementCard.tsx @@ -0,0 +1,38 @@ +import * as S from './EmptySettlementCard.styles'; + +interface EmptySettlementCardProps { + groupCode: string; + groupName: string; +} + +function EmptySettlementCard({ + groupCode, + groupName, +}: EmptySettlementCardProps) { + return ( + + + {groupName} + 0원 + + + + + + + + 아직 정산 내역을 입력하지 않았어요 + + + + ); +} + +export { EmptySettlementCard }; +export type { EmptySettlementCardProps }; diff --git a/src/pages/home/ui/EmptySettlementCard/index.ts b/src/pages/home/ui/EmptySettlementCard/index.ts new file mode 100644 index 00000000..750f61d7 --- /dev/null +++ b/src/pages/home/ui/EmptySettlementCard/index.ts @@ -0,0 +1,2 @@ +export { EmptySettlementCard } from './EmptySettlementCard'; +export type { EmptySettlementCardProps } from './EmptySettlementCard'; From 4cfd9dd61fe4cf4b31750afccc251224f89fe8a8 Mon Sep 17 00:00:00 2001 From: yeoeun-ex Date: Sun, 7 Jun 2026 21:28:36 +0900 Subject: [PATCH 51/53] feat: render EmptySettlementCard for empty settlements on home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 홈 정산 리스트에서 totalAmount === 0인 빈 정산은 EmptySettlementCard로, 그 외에는 기존 SettlementProgressCard로 분기 렌더링. Co-Authored-By: Claude Opus 4.7 --- .../SettlementDateSection.tsx | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/src/pages/home/ui/SettlementDateSection/SettlementDateSection.tsx b/src/pages/home/ui/SettlementDateSection/SettlementDateSection.tsx index 3081ec63..74bb7f91 100644 --- a/src/pages/home/ui/SettlementDateSection/SettlementDateSection.tsx +++ b/src/pages/home/ui/SettlementDateSection/SettlementDateSection.tsx @@ -1,4 +1,5 @@ import type { SettlementGroup } from '@/entities/group/model/group.type'; +import { EmptySettlementCard } from '../EmptySettlementCard'; import { SettlementProgressCard } from '../SettlementProgressCard'; import * as S from './SettlementDateSection.styles'; @@ -12,16 +13,24 @@ function SettlementDateSection({ date, items }: SettlementDateSectionProps) { {date} - {items.map((item) => ( - - ))} + {items.map((item) => + item.totalAmount === 0 ? ( + + ) : ( + + ) + )} ); From e0f4584a31ed2fc0cf8e1cf2dea540b5c4ee80aa Mon Sep 17 00:00:00 2001 From: yeoeun-ex Date: Sun, 7 Jun 2026 22:35:01 +0900 Subject: [PATCH 52/53] =?UTF-8?q?fix:=20=EB=B0=B0=ED=8F=AC=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EC=97=90=EC=84=9C=20=EC=BA=90=EB=A6=AD=ED=84=B0=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=A0=80=EC=9E=A5=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서비스워커가 외부 S3 이미지까지 캐싱하며 opaque 응답을 저장해 html-to-image의 CORS fetch가 tainted canvas로 실패하던 문제 수정. - runtimeCaching을 same-origin 자원만 매칭하도록 제한 - 오염된 캐시 폐기를 위해 cacheName bump (static-assets-v2) - img에 crossOrigin="anonymous" 추가로 CORS 로드 보장 Co-Authored-By: Claude Opus 4.7 --- src/pages/characterShare/CharacterSharePage.tsx | 1 + vite.config.ts | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/pages/characterShare/CharacterSharePage.tsx b/src/pages/characterShare/CharacterSharePage.tsx index 88a64d06..2e0ae13e 100644 --- a/src/pages/characterShare/CharacterSharePage.tsx +++ b/src/pages/characterShare/CharacterSharePage.tsx @@ -100,6 +100,7 @@ function CharacterSharePage() { {data.name} { clientsClaim: true, runtimeCaching: [ { - urlPattern: /\.(?:js|css|png|jpg|jpeg|svg)$/, + // same-origin 정적 자원만 캐시 (외부 S3 이미지는 제외 - CORS/tainted canvas 방지) + urlPattern: ({ url, sameOrigin }) => + sameOrigin && + /\.(?:js|css|png|jpg|jpeg|svg)$/.test(url.pathname), handler: 'StaleWhileRevalidate', // 네트워크를 먼저 시도 options: { - cacheName: 'static-assets', + cacheName: 'static-assets-v2', expiration: { maxEntries: 60, // 최대 캐시 항목 수 maxAgeSeconds: 30 * 24 * 60 * 60, // 30일 From f88a26858ffd692810a0a131bfd1f4407fdfc30f Mon Sep 17 00:00:00 2001 From: yeoeun-ex Date: Sun, 7 Jun 2026 23:03:39 +0900 Subject: [PATCH 53/53] =?UTF-8?q?revert:=20=EC=BA=90=EB=A6=AD=ED=84=B0=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20crossOrigin=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit develop의 fetch 기반 다운로드에서는 canvas tainting이 없어 불필요하고, S3 CORS 미허용 origin에서 이미지 표시가 깨지는 부작용이 있어 제거. Co-Authored-By: Claude Opus 4.7 --- src/pages/characterShare/CharacterSharePage.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/pages/characterShare/CharacterSharePage.tsx b/src/pages/characterShare/CharacterSharePage.tsx index 40215a0e..8a251d40 100644 --- a/src/pages/characterShare/CharacterSharePage.tsx +++ b/src/pages/characterShare/CharacterSharePage.tsx @@ -83,11 +83,7 @@ function CharacterSharePage() { 캐릭터를 획득했어요! - +