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: 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/_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'; 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'; 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/app/Router.tsx b/src/app/Router.tsx index f3b4908c..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 }) => ({ @@ -56,16 +57,16 @@ 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, })) ); +const LazyEditExpenses = lazy(() => + import('@/pages/editExpenses').then(({ EditExpensesPage }) => ({ + default: EditExpensesPage, + })) +); const LazyNotFound = lazy(() => import('@/pages/notFound').then(({ NotFoundPage }) => ({ default: NotFoundPage, @@ -115,10 +116,6 @@ function AppRouter() { path: ROUTE.paymentManagement, element: , }, - { - path: ROUTE.selectGroup, - element: , - }, { path: ROUTE.groupSetup, element: , @@ -141,6 +138,11 @@ function AppRouter() { element: , loader: expenseDetailLoader, }, + { + path: ROUTE.editExpenses, + element: , + loader: editExpensesLoader, + }, { path: ROUTE.characterShare, element: , 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/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/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/entities/group/model/group.type.ts b/src/entities/group/model/group.type.ts index 69cbf55c..0c1cf458 100644 --- a/src/entities/group/model/group.type.ts +++ b/src/entities/group/model/group.type.ts @@ -23,9 +23,13 @@ export interface AccountVariable { export interface GroupHeaderResponse { groupName: string; totalAmount: number; - deadline: Date; + deadline: string; bank: string; accountNumber: string; + createdAt?: string; + completedAt: string | null; + totalMemberCount?: number; + completedMemberCount?: number; } export interface GroupListItem { 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/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/entities/settlement/model/settlement.type.ts b/src/entities/settlement/model/settlement.type.ts index 15b63d14..0c516139 100644 --- a/src/entities/settlement/model/settlement.type.ts +++ b/src/entities/settlement/model/settlement.type.ts @@ -6,6 +6,9 @@ export interface MemberSettlement { isPaid: boolean; paidAt: Date | null; profile: string; + paymentRequestId: number | null; + paymentRequestStatus: 'PENDING' | 'APPROVED' | 'REJECTED' | null; + paymentRequestStatusLabel: '확인중' | '승인완료' | '거절' | null; expenses: { content: string; amount: number; 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/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..bd72ecb3 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 토큰 없음 */} + ); } @@ -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} 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')}; `; diff --git a/src/features/expense-management/ui/ExpenseAmountInput/ExpenseAmountInput.tsx b/src/features/expense-management/ui/ExpenseAmountInput/ExpenseAmountInput.tsx index 82ffa042..0aa6b5d5 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, + Chip, PriceDisplay, } from '@/shared/design-system/ui'; import { @@ -86,18 +86,14 @@ function ExpenseAmountInput({ {QUICK_ADD_BUTTONS.map(({ label, amount }) => ( - + /> ))} - + { - return useMutation({ + const queryClient = useQueryClient(); + + return useMutationWithHandlers({ mutationFn: (code: string) => payment.create(code), - onSuccess: () => { + onSuccess: (_data, code) => { queryClient.invalidateQueries({ queryKey: ['payments'] }); + queryClient.invalidateQueries({ queryKey: ['profiles', code] }); + queryClient.invalidateQueries({ queryKey: ['groupHeader', code] }); + }, + // TODO: 400 에러 케이스가 추가되면 백엔드와 에러 코드 세분화 후 분기 처리 필요 + errorHandlers: { + 400: () => + showToast({ + type: 'error', + content: '이미 입금 확인 요청이 진행 중이에요.', + }), }, + ignoreBoundaryErrors: [400], }); }; 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/features/settlement-details/api/useUpdatePaymentStatus.ts b/src/features/settlement-details/api/useUpdatePaymentStatus.ts index 70835c23..bd11f8c9 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,26 @@ const useUpdatePaymentStatus = ({ isPaid, }: UpdatePaymentStatusVariable) => { const queryClient = useQueryClient(); - const mutation = useMutation({ + return useMutationWithHandlers({ mutationFn: () => updatePaymentStatus({ groupToken, groupMemberId, isPaid }), onSuccess: () => { - // 성공하면 memberExpenseDetails 쿼리를 다시 불러온다. queryClient.invalidateQueries({ queryKey: ['memberExpenseDetails', groupToken], }); + queryClient.invalidateQueries({ + queryKey: ['groupHeader', groupToken], + }); + }, + errorHandlers: { + default: () => + showToast({ + type: 'error', + content: '정산 상태 변경에 실패했어요. 다시 시도해 주세요.', + }), }, + ignoreBoundaryErrors: [400, 409], }); - return mutation; }; export default useUpdatePaymentStatus; 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/mocks/handlers/group.ts b/src/mocks/handlers/group.ts index a54d82f8..d8364b25 100644 --- a/src/mocks/handlers/group.ts +++ b/src/mocks/handlers/group.ts @@ -1,6 +1,11 @@ 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'; +import { MemberProfileRaw } from '@/entities/member/model/member.type'; const dummyGroups: Group[] = [ { @@ -15,6 +20,7 @@ const dummyGroups: Group[] = [ userId: 1, isPaid: false, paidAt: null, + paymentRequestId: null, }, { id: 2, @@ -24,6 +30,7 @@ const dummyGroups: Group[] = [ userId: 2, isPaid: false, paidAt: null, + paymentRequestId: 1, }, { id: 3, @@ -33,6 +40,7 @@ const dummyGroups: Group[] = [ userId: 3, isPaid: false, paidAt: null, + paymentRequestId: null, }, ], }, @@ -48,6 +56,7 @@ const dummyGroups: Group[] = [ userId: 1, isPaid: false, paidAt: null, + paymentRequestId: null, }, { id: 4, @@ -57,6 +66,7 @@ const dummyGroups: Group[] = [ userId: 4, isPaid: false, paidAt: null, + paymentRequestId: null, }, { id: 5, @@ -66,12 +76,13 @@ const dummyGroups: Group[] = [ userId: 5, isPaid: false, paidAt: null, + paymentRequestId: null, }, ], }, ]; -const dummyMemberList = [ +const dummyMemberList: MemberProfileRaw[] = [ { id: 1, role: 'MANAGER', @@ -101,15 +112,30 @@ const dummyMemberList = [ }, ]; +let dummyCompletedAt: string | null = null; + +const getDummyGroupHeader = (): GroupHeaderResponse => ({ + groupName: dummyGroups[0].groupName, + totalAmount: 150000, + deadline: new Date( + new Date().setMonth(new Date().getMonth() + 1) + ).toISOString(), + bank: '국민은행', + accountNumber: '123456-78-910111', + createdAt: new Date().toISOString(), + completedAt: dummyCompletedAt, + totalMemberCount: dummyMemberList.length, + completedMemberCount: dummyMemberList.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], - }); + return HttpResponse.json(getDummyGroupHeader()); }), // GET GetGroupOne @@ -212,6 +238,46 @@ const groupHandlers = [ return HttpResponse.json({ success: true }, { status: 200 }); } ), + + 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 }) => { + 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/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; } diff --git a/src/pages/CreateExpensePage/lib/createExpensePageGuardLoader.ts b/src/pages/CreateExpensePage/lib/createExpensePageGuardLoader.ts index 7060aaec..3f1bc6f3 100644 --- a/src/pages/CreateExpensePage/lib/createExpensePageGuardLoader.ts +++ b/src/pages/CreateExpensePage/lib/createExpensePageGuardLoader.ts @@ -12,9 +12,9 @@ const createExpensePageGuardLoader: LoaderFunction = async ({ params }) => { // url 파라미터에서 groupToken 추출 const { groupToken } = params; - // groupToken이 없으면 모임 선택 페이지로 리다이렉트 + // groupToken이 없으면 그룹 설정 페이지로 리다이렉트 if (!groupToken) { - return redirect(ROUTE.selectGroup); + return redirect(ROUTE.groupSetup); } // 토큰 유효성 검사 @@ -28,10 +28,10 @@ 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.selectGroup); + return redirect(ROUTE.groupSetup); } } // 그 외에는 에러를 그대로 던진다 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..8a251d40 100644 --- a/src/pages/characterShare/CharacterSharePage.tsx +++ b/src/pages/characterShare/CharacterSharePage.tsx @@ -1,42 +1,31 @@ -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'; +const sanitizeFilename = (name: string): string => { + return name.replace(/[/\\:*?"<>|]/g, '_'); +}; + 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, `${sanitizeFilename(data.name)}.png`); + showToast({ type: 'success', content: '이미지 저장 완료!' }); + } catch { + showToast({ type: 'error', content: '이미지 저장 실패!' }); } }; @@ -93,23 +82,8 @@ function CharacterSharePage() { 캐릭터를 획득했어요! - - - - - {data.name} - - {data.name} - - {CHARACTER_DATA[data.name].description} - - + + diff --git a/src/pages/confirmStep/ui/SettlementSummary/SettlementSummary.tsx b/src/pages/confirmStep/ui/SettlementSummary/SettlementSummary.tsx index aa4d3786..03967aba 100644 --- a/src/pages/confirmStep/ui/SettlementSummary/SettlementSummary.tsx +++ b/src/pages/confirmStep/ui/SettlementSummary/SettlementSummary.tsx @@ -1,7 +1,7 @@ import { Accordion, useAccordionContext, - NameChip, + Chip, } from '@/shared/design-system/ui'; import SvgCarbonEdit from '@/shared/assets/svgs/icon/CarbonEdit'; import SvgClose from '@/shared/assets/svgs/icon/Close'; @@ -83,7 +83,7 @@ function SettlementSummary({ {memberExpenses.map((member) => ( - ({ + id: 'edit-expenses', + initial: { + step: 'confirm', + context: { isExpenseCreated: false }, + }, + }); + + return ( + { + history.push('edit', { + isExpenseCreated: true, + expenseId, + initialExpense, + }); + }, + add: (_, { history }) => { + history.push('add'); + }, + next: () => { + navigate(ROUTE.expenseDetail.replace(':groupToken', groupToken)); + }, + back: () => { + navigate(ROUTE.expenseDetail.replace(':groupToken', groupToken)); + }, + }, + render: ({ dispatch }) => ( + dispatch('back')} + onNext={() => dispatch('next')} + onEdit={(props: EditExpenseContext) => dispatch('edit', props)} + onAdd={() => dispatch('add')} + /> + ), + })} + // eslint-disable-next-line react/no-unstable-nested-components + add={({ history }) => ( + history.push('confirm')} /> + )} + // eslint-disable-next-line react/no-unstable-nested-components + edit={({ context, history }) => ( + history.push('confirm')} + onBack={() => history.back()} + expenseId={context.expenseId} + initialExpense={context.initialExpense} + /> + )} + /> + ); +} + +export default EditExpensesPage; diff --git a/src/pages/editExpenses/index.ts b/src/pages/editExpenses/index.ts new file mode 100644 index 00000000..83dfeaf3 --- /dev/null +++ b/src/pages/editExpenses/index.ts @@ -0,0 +1 @@ +export { default as EditExpensesPage } from './EditExpensesPage'; diff --git a/src/pages/editExpenses/loader.ts b/src/pages/editExpenses/loader.ts new file mode 100644 index 00000000..eefbfb98 --- /dev/null +++ b/src/pages/editExpenses/loader.ts @@ -0,0 +1,70 @@ +import { getAuth } from '@/entities/auth/api/auth'; +import { getGroupDetail } from '@/entities/group/api/group'; +import { getProfiles } from '@/entities/member/api/getProfiles'; +import payment from '@/entities/payment/api/payment'; +import { queryClient } from '@/shared/api/queryClient'; +import { ROUTE } from '@/shared/config/route'; +import { BoundaryError } from '@/shared/types/error.type'; +import { isAxiosError } from 'axios'; +import { LoaderFunctionArgs, redirect } from 'react-router'; + +async function editExpensesLoader({ params }: LoaderFunctionArgs) { + const { groupToken } = params; + + if (!groupToken) return redirect(ROUTE.home); + + try { + const auth = await queryClient.ensureQueryData({ + queryKey: ['auth', 'user'], + queryFn: getAuth, + }); + if (!auth?.authenticated) { + const returnUrl = encodeURIComponent( + `/expense-detail/${groupToken}/edit-expenses` + ); + return redirect(`/login?returnUrl=${returnUrl}`); + } + + const profiles = await queryClient.ensureQueryData({ + queryKey: ['profiles', groupToken], + queryFn: () => getProfiles(groupToken), + }); + const myProfile = + profiles.find((profile) => profile.userId === auth.user?.id) ?? null; + if (!myProfile) return redirect(`/join/${groupToken}`); + + if (myProfile.role !== 'MANAGER') { + return redirect(`/expense-detail/${groupToken}`); + } + + // 승인됐거나 승인대기 중인 입금 확인 요청이 있으면 수정 불가 + // 버튼 클릭 핸들러에서도 체크하지만 직접 URL 접근 / 뒤로가기 재진입을 막는 가드 + const [{ exists }, groupData] = await Promise.all([ + payment.exists(groupToken), + getGroupDetail(groupToken), + ]); + if (exists) { + return redirect(`/expense-detail/${groupToken}`); + } + + return { groupToken, groupData }; + } catch (error: unknown) { + if (isAxiosError(error)) { + if (error.response?.status === 401) { + throw new BoundaryError({ + title: '접근 권한이 없어요', + description: '참여한 모임의 정산만 확인할 수 있어요.', + }); + } + if (error.response?.status === 404) { + throw new BoundaryError({ + title: '모임을 찾을 수 없어요', + description: '삭제되었거나 존재하지 않는 모임이에요.', + }); + } + } + throw error; + } +} + +export default editExpensesLoader; diff --git a/src/pages/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?.(); } }; 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 45da7253..f3789de3 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 { @@ -11,16 +12,19 @@ import { TabList, showToast, } from '@/shared/design-system/ui'; -import { useGetMemberExpenseDetails } from '@/features/expense-management/api/useGetMemberExpenseDetails'; import generateShareLink from '@/shared/lib/generateShareLink'; 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 { 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'; 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'; @@ -30,13 +34,37 @@ function ExpenseDetailPage() { const { groupToken, groupData, myProfile } = useLoaderData(); const [openBottomSheet, setOpenBottomSheet] = useState(false); const [isPaymentModalOpen, setIsPaymentModalOpen] = useState(false); - const { data: memberExpenseDetails } = useGetMemberExpenseDetails(groupToken); - const { data: headerData } = useGetGroupHeader(groupToken, {}, [401]); + const [isPaymentRequested, setIsPaymentRequested] = useState(false); const [isChecked, setIsChecked] = useState(false); + const { data: headerData, isLoading: isHeaderLoading } = useGetGroupHeader( + groupToken, + {}, + [401] + ); + const { data: memberExpenseDetails = [] } = + useGetMemberExpenseDetails(groupToken); + const { mutateAsync: completeGroupSettlement } = + useCompleteGroupSettlement(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 isManager = currentProfile.role === 'MANAGER'; + const bottomActionProfile = + !isManager && 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'; @@ -44,10 +72,13 @@ function ExpenseDetailPage() { return 'pending'; }, [headerData]); - const [status, setStatus] = useState('pending'); + const [settlementStatus, setSettlementStatus] = + useState('pending'); useEffect(() => { - setStatus(derivedStatus); + setSettlementStatus((prevStatus) => + prevStatus === 'success' ? prevStatus : derivedStatus + ); }, [derivedStatus]); const navigate = useNavigate(); const { mutate: createPaymentRequest } = useCreatePaymentRequest(); @@ -55,23 +86,19 @@ function ExpenseDetailPage() { const handlePaymentRequest = () => { createPaymentRequest(groupToken, { onSuccess: () => { + setIsPaymentRequested(true); setIsPaymentModalOpen(false); showToast({ type: 'success', content: '입금 확인 요청이 전송되었습니다.', }); }, + onError: () => { + setIsPaymentModalOpen(false); + }, }); }; - let MEMBER_TOTAL = 0; - let MEMBER_DONE = 0; - - if (memberExpenseDetails) { - MEMBER_TOTAL = memberExpenseDetails.length; - MEMBER_DONE = memberExpenseDetails.filter((member) => member.isPaid).length; - } - const shareLink = generateShareLink(groupToken); const handleBackToHome = () => { @@ -91,17 +118,24 @@ function ExpenseDetailPage() { onHeadingIconClick={() => { navigate(ROUTE.home); }} - // trailingIcon={관리} // TODO : 추가를 논의중인 기능이기 때문에 삭제하지 않고 주석 처리함 + trailingIcon={ + isManager ? : undefined + } /> setOpenBottomSheet(true)} - status={status} - setStatus={setStatus} + settlementStatus={settlementStatus} + setSettlementStatus={setSettlementStatus} isChecked={isChecked} setIsChecked={setIsChecked} + onCompleteSettlement={completeGroupSettlement} /> @@ -114,15 +148,17 @@ function ExpenseDetailPage() { {activeTab === 'expense' ? ( ) : ( - + )} setIsChecked(false)} onPaymentRequestClick={() => setIsPaymentModalOpen(true)} @@ -135,12 +171,12 @@ function ExpenseDetailPage() { setIsPaymentModalOpen(false)} - ariaLabel={`${myProfile.name}님의 정산 입금을 알릴게요.`} + ariaLabel={`${currentProfile.name}님의 정산 입금을 알릴게요.`} > - {myProfile.name} + {currentProfile.name} {'님의\n정산 입금을 알릴게요.'} } diff --git a/src/pages/expenseDetail/ui/BottomAction/index.tsx b/src/pages/expenseDetail/ui/BottomAction/index.tsx index dcf659bf..80c09702 100644 --- a/src/pages/expenseDetail/ui/BottomAction/index.tsx +++ b/src/pages/expenseDetail/ui/BottomAction/index.tsx @@ -4,10 +4,9 @@ import { MemberProfile } from '@/entities/member/model/member.type'; import { StatusType } from '../ExpenseTimeHeader/index.type'; interface BottomActionProps { - status: StatusType; + settlementStatus: StatusType; myProfile: MemberProfile; - memberTotal: number; - memberDone: number; + isEveryMemberPaid: boolean; shareLink: string; onSettleClick: () => void; onPaymentRequestClick: () => void; @@ -15,10 +14,9 @@ interface BottomActionProps { } function BottomAction({ - status, + settlementStatus, myProfile, - memberTotal, - memberDone, + isEveryMemberPaid, shareLink, onSettleClick, onPaymentRequestClick, @@ -26,7 +24,7 @@ function BottomAction({ }: BottomActionProps) { const share = useShareLink(shareLink); - if (status === 'success') + if (settlementStatus === 'success') return ( ); - if (myProfile.role === 'MANAGER' && 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/ExpenseMemberItem/index.style.ts b/src/pages/expenseDetail/ui/ExpenseMemberItem/index.style.ts index 895698cb..1a37fb51 100644 --- a/src/pages/expenseDetail/ui/ExpenseMemberItem/index.style.ts +++ b/src/pages/expenseDetail/ui/ExpenseMemberItem/index.style.ts @@ -14,88 +14,124 @@ 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')}; + margin-bottom: ${getToken('gap.6')}; `; -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')}; `; -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 RightWrapper = styled.div` +export const MemberTotalAmount = styled.span` + ${applyTypography('typography.heading.small')}; + color: ${getToken('fg.normal')}; +`; + +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 0 ${getToken('gap.6')}; +`; + +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; `; 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` display: flex; justify-content: space-between; align-items: center; - padding: ${getToken('padding.4')} ${getToken('padding.4')} 0; - gap: ${getToken('gap.4')}; `; export const PlaceWrapper = styled.div` display: flex; gap: ${getToken('gap.4')}; align-items: center; + flex: 1; + min-width: 0; & > svg { flex-shrink: 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` display: flex; flex-direction: column; @@ -110,37 +146,12 @@ 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')}; } `; -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` - word-break: keep-all; - ${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..8de2638d 100644 --- a/src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx +++ b/src/pages/expenseDetail/ui/ExpenseMemberItem/index.tsx @@ -1,162 +1,229 @@ 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 { 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 { StatusType } from '../ExpenseTimeHeader/index.type'; import * as S from './index.style'; interface ExpenseMemberItemProps { member: MemberSettlement; groupToken: string; - status: string; + settlementStatus: StatusType; + isManager: boolean; +} + +type ChipStatus = '입금완료' | '확인중' | '미입금'; + +function getChipStatus(member: MemberSettlement): ChipStatus { + if (member.isPaid) return '입금완료'; + if (member.paymentRequestStatus === 'PENDING') 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, + settlementStatus, + 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 queryClient = useQueryClient(); const updatePaymentStatusMutation = useUpdatePaymentStatus({ groupToken, groupMemberId: member.id, isPaid, }); + const approveMutation = useApprovePayment(); + const rejectMutation = useRejectPayment(); - /** 상태 변경 함수 */ - const handleTextButtonClick = (paidUpdate: boolean) => { - if (status === 'success') return; + const isActionPending = approveMutation.isPending || rejectMutation.isPending; + + const displayName = + member.role === 'MANAGER' ? `${member.name}(총무)` : member.name; + const chipStatus = getChipStatus(member); + + const showManagerButtons = isManager && member.paymentRequestId != null; + + const resetSheet = () => { + setIsPaid(member.isPaid); + setIsConfirm(false); + setSheetOpen(false); + }; + + const handleTextButtonClick = (paidUpdate: boolean) => { + if (settlementStatus === 'success') return; setIsPaid(paidUpdate); - if (paidUpdate !== member.isPaid) { - setIsConfirm(true); // 상태가 바뀌면 확인 버튼 활성화 - } else { - setIsConfirm(false); // 상태가 같으면 확인 버튼 비활성화 - } + setIsConfirm(paidUpdate !== member.isPaid); }; - /** confim 버튼 클릭 시 api를 호출하는 함수 */ - const handleChangeButtonSubmit = async () => { - await updatePaymentStatusMutation.mutate(); + const handleConfirm = async () => { + try { + await updatePaymentStatusMutation.mutateAsync(); + } catch { + return; + } setIsConfirm(false); - setOpen(false); + setSheetOpen(false); }; - /** 모든 상태값 초기화 후에 바텀시트 닫기 */ - const resetState = () => { - setIsPaid(member.isPaid); - setIsConfirm(false); - setOpen(false); + const invalidateRelatedQueries = () => { + queryClient.invalidateQueries({ + queryKey: ['memberExpenseDetails', groupToken], + }); }; - // TODO: role에 따라 상태 변경 버튼 클릭 가능 여부 체크 - const handleStatusChipClick = () => { - if (myProfile.role === 'MANAGER') setOpen(true); + 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 ( - - - - + + + {displayName} + + + + {member.totalAmount.toLocaleString()}원 + + + + {settlementStatus !== 'success' && ( + - - - {/* 정산 상태 변경 바텀시트 */} - setSheetOpen(true)} + aria-label={`${displayName}의 정산 상태 변경`} > - - handleTextButtonClick(false)} - > - 미입금 - - - handleTextButtonClick(true)} - > - 입금완료 - - - - - - - + + + )} + + + + + {/* 아코디언 토글 */} + + + {/* 아코디언 콘텐츠 */} - {member.expenses.map((expense) => ( - - - - {expense.content} - - - {expense.amount.toLocaleString()}원 - - - ))} + + {member.expenses.map((expense) => ( + + + + {expense.content} + + + {expense.amount.toLocaleString()}원 + + + ))} + {/* MANAGER 액션 버튼 (paymentRequestId 있을 때만) */} + {showManagerButtons && ( + + )} + + + {/* 정산 상태 변경 바텀시트 (⋮ 클릭 시) */} + + + handleTextButtonClick(false)} + > + 미입금 + + + handleTextButtonClick(true)} + > + 입금 완료 + + + + + ); } diff --git a/src/pages/expenseDetail/ui/ExpenseMembers/index.tsx b/src/pages/expenseDetail/ui/ExpenseMembers/index.tsx index fe14e617..87c4e81f 100644 --- a/src/pages/expenseDetail/ui/ExpenseMembers/index.tsx +++ b/src/pages/expenseDetail/ui/ExpenseMembers/index.tsx @@ -1,13 +1,20 @@ +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; + }; + const { data: memberExpenseData, isLoading, @@ -21,6 +28,8 @@ function ExpenseMembers({ groupToken, status }: ExpenseMembersProps) { return
error...
; } + const isManager = myProfile.role === 'MANAGER'; + return ( {memberExpenseData.map((member) => ( @@ -28,7 +37,8 @@ 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..c54e31d4 100644 --- a/src/pages/expenseDetail/ui/ExpenseTimeHeader/index.tsx +++ b/src/pages/expenseDetail/ui/ExpenseTimeHeader/index.tsx @@ -1,6 +1,5 @@ import React, { useEffect, useRef, useState } from 'react'; import { Copy, DollarCircle } from '@/shared/assets/svgs/icon'; -import { useLoaderData } from 'react-router'; import copyClipboard from '@/shared/lib/copyClipboard'; import { DescriptionField, @@ -9,56 +8,49 @@ import { Modal, showToast, } from '@/shared/design-system/ui'; -import { useGetGroupHeader } from '@/features/settlement-details/api/useGetGroupHeader'; import { getToken } from '@/shared/design-system'; +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 { + headerData?: GroupHeaderResponse; + isLoading: boolean; totalMember: number; paidMember: number; + isEveryMemberPaid: boolean; + isManager: boolean; onShareClick: () => void; - status: StatusType; - setStatus: (status: StatusType) => void; + settlementStatus: StatusType; + setSettlementStatus: (settlementStatus: StatusType) => void; isChecked: boolean; setIsChecked: (isChecked: boolean) => void; + onCompleteSettlement: () => Promise; } function ExpenseTimeHeader({ + headerData, + isLoading, totalMember, paidMember, + isEveryMemberPaid, + isManager, onShareClick, - status, - setStatus, + settlementStatus, + setSettlementStatus, isChecked, setIsChecked, + onCompleteSettlement, }: ExpenseTimeHeaderProps) { const [hours, setHours] = useState(0); const [minutes, setMinutes] = useState(0); const [seconds, setSeconds] = useState(0); const [isBubble, setIsBubble] = useState(false); - const { groupToken } = useLoaderData(); 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)); @@ -73,7 +65,7 @@ function ExpenseTimeHeader({ // 상태 업데이트 함수 const updateStatus = (statusValue: StatusType) => { - setStatus(statusValue); + setSettlementStatus(statusValue); setIsBubble(true); }; @@ -87,18 +79,18 @@ function ExpenseTimeHeader({ // TODO: isChecked를 sessionStorage/localStorage로 관리하여 새로고침 시에도 모달이 다시 뜨지 않도록 개선 필요 (groupToken별 키 사용) // 모든 멤버가 입금 완료된 "순간"에만 모달 표시 useEffect(() => { - if (totalMember > 0 && paidMember === totalMember && !isChecked) { + if (isManager && isEveryMemberPaid && !isChecked) { setIsModalOpen(true); setIsChecked(true); } - }, [paidMember, totalMember, isChecked, setIsChecked]); + }, [isEveryMemberPaid, isChecked, isManager, setIsChecked]); useEffect(() => { - if (!headerData || status !== 'pending') return () => {}; + if (!headerData || settlementStatus !== 'pending') return () => {}; 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) { setHours(0); @@ -113,9 +105,20 @@ function ExpenseTimeHeader({ return () => stopTimer(); // 컴포넌트 언마운트 시 타이머 멈추기 // eslint-disable-next-line react-hooks/exhaustive-deps - }, [headerData, status]); + }, [headerData, settlementStatus]); + + const isSettlementCompleted = Boolean(headerData?.completedAt); - const handleModalButtonClick = () => { + const handleModalButtonClick = async () => { + if (isSettlementCompleted) { + setIsModalOpen(false); + onShareClick(); + return; + } + + if (!isManager) return; + + await onCompleteSettlement(); stopTimer(); setIsModalOpen(false); updateStatus('success'); @@ -135,16 +138,16 @@ function ExpenseTimeHeader({ /** 상수 정의 */ - const percentage = (paidMember / totalMember) * 100; + const percentage = totalMember > 0 ? (paidMember / totalMember) * 100 : 0; const endDate = new Date(headerData.deadline); 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 +190,10 @@ function ExpenseTimeHeader({ /> - - {isBubble && {StatusContent[status].message}} + + {isBubble && ( + {StatusContent[settlementStatus].message} + )} @@ -208,7 +213,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 && :} @@ -230,9 +235,12 @@ function ExpenseTimeHeader({ setIsModalOpen(false), }} /> diff --git a/src/pages/expenseDetail/ui/ExpenseTimelineContent/index.tsx b/src/pages/expenseDetail/ui/ExpenseTimelineContent/index.tsx index bd177a4c..182e97df 100644 --- a/src/pages/expenseDetail/ui/ExpenseTimelineContent/index.tsx +++ b/src/pages/expenseDetail/ui/ExpenseTimelineContent/index.tsx @@ -1,4 +1,4 @@ -import { NameChip } from '@/shared/design-system/ui'; +import { Chip } from '@/shared/design-system/ui'; import { ExpenseDetail } from '@/entities/expense/model/expense.type'; import * as S from './index.styles'; @@ -21,7 +21,7 @@ function ExpenseTimelineContent({ expense }: ExpenseTimelineContentProps) { {expense.groupMembers.map((name) => ( - + ))} diff --git a/src/pages/expenseDetail/ui/ManageMenu/index.styles.ts b/src/pages/expenseDetail/ui/ManageMenu/index.styles.ts new file mode 100644 index 00000000..bc6d7093 --- /dev/null +++ b/src/pages/expenseDetail/ui/ManageMenu/index.styles.ts @@ -0,0 +1,47 @@ +import styled from 'styled-components'; +import { applyTypography, getToken } from '@/shared/design-system'; + +export const TriggerButton = styled.button` + ${applyTypography('typography.body.medium')}; + color: ${getToken('fg.assistive')}; + background: none; + border: none; + padding: 0; + cursor: pointer; +`; + +export const MenuCard = styled.div` + position: fixed; + top: 56px; /* 헤더 높이 */ + /* 앱 Wrapper max-width(600px) 기준, 콘텐츠 우측 끝에서 20px 안쪽으로 정렬 */ + right: max(20px, calc((100vw - 600px) / 2 + 20px)); + width: 140px; + background: ${getToken('bg.normal')}; + border-radius: 12px; + padding: 20px; + display: flex; + flex-direction: column; + gap: 16px; + z-index: 9998; +`; + +export const MenuItemButton = styled.button` + ${applyTypography('typography.body.medium')} + color: ${getToken('fg.assistive')}; + background: none; + border: none; + padding: 0; + cursor: pointer; + text-align: left; + + &:disabled { + cursor: default; + } + + &:hover, + &:active { + /* HACK: Figma --text/strong(#292c30 = gray.20)에 대응하는 token 없음, 의미상 동일한 'fg.strong' 사용 */ + color: ${getToken('fg.strong')}; + ${applyTypography('typography.body.medium-semibold')} + } +`; diff --git a/src/pages/expenseDetail/ui/ManageMenu/index.tsx b/src/pages/expenseDetail/ui/ManageMenu/index.tsx new file mode 100644 index 00000000..d814d37a --- /dev/null +++ b/src/pages/expenseDetail/ui/ManageMenu/index.tsx @@ -0,0 +1,139 @@ +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; +} + +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); + 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); + } + }; + + 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}> + 관리 + + {isOpen && + createPortal( + <> + setIsOpen(false)} /> + + + 정산 내역 수정 + + + 계좌 수정 + + + , + document.querySelector('#modal') ?? document.body + )} + setAccountNumber(e.target.value)} + onCancel={() => setIsAccountEditOpen(false)} + onConfirm={handleAccountEditConfirm} + /> + setIsBlockedModalOpen(false)} + ariaLabel="정산 내역을 수정할 수 없어요" + > + setIsBlockedModalOpen(false), + }} + /> + + + ); +} + +export default ManageMenu; 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'; 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)}> 정산하기 {date} - {items.map((item) => ( - - ))} + {items.map((item) => + item.totalAmount === 0 ? ( + + ) : ( + + ) + )} ); 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')}> + 카카오로 로그인 diff --git a/src/pages/selectGroup/SelectGroupPage.styles.ts b/src/pages/selectGroup/SelectGroupPage.styles.ts deleted file mode 100644 index c8781cf6..00000000 --- a/src/pages/selectGroup/SelectGroupPage.styles.ts +++ /dev/null @@ -1,17 +0,0 @@ -import styled from 'styled-components'; -import { getToken } from '@/shared/design-system'; - -export const SelectGroupContent = styled.div` - display: flex; - flex-direction: column; - justify-content: space-between; - padding-top: ${getToken('layout.gap.y-nav-to-title')}; - flex: 1; -`; - -export const GroupButtonList = styled.div` - display: flex; - flex-direction: column; - margin: 1.25rem 1.25rem 0; - gap: ${getToken('gap.4')}; -`; diff --git a/src/pages/selectGroup/SelectGroupPage.tsx b/src/pages/selectGroup/SelectGroupPage.tsx deleted file mode 100644 index 41ad696e..00000000 --- a/src/pages/selectGroup/SelectGroupPage.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { useNavigate } from 'react-router'; -import { ArrowLeft } from '@/shared/assets/svgs/icon'; -import { DescriptionField, Header } from '@/shared/design-system/ui'; -import { getToken } from '@/shared/design-system'; -import { PageLayout } from '@/shared/ui/PageLayout'; -import { CreateGroupLinkButton, EmptyBox } from './ui'; -import * as S from './SelectGroupPage.styles'; - -function SelectGroupPage() { - const navigate = useNavigate(); - - return ( - - {/** @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/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'; diff --git a/src/shared/config/route.ts b/src/shared/config/route.ts index 4b357ea8..7455fe9e 100644 --- a/src/shared/config/route.ts +++ b/src/shared/config/route.ts @@ -8,9 +8,9 @@ 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', + editExpenses: '/expense-detail/:groupToken/edit-expenses', characterShare: '/expense-detail/:groupToken/character', } as const; 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) => ( - - ))} -
- ), -}; 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; diff --git a/src/shared/design-system/ui/NameChip/NameChip.stories.tsx b/src/shared/design-system/ui/Chip/Chip.stories.tsx similarity index 54% rename from src/shared/design-system/ui/NameChip/NameChip.stories.tsx rename to src/shared/design-system/ui/Chip/Chip.stories.tsx index 4bfba40d..8406da67 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,11 +50,30 @@ export const Showcase: Story = { > {size} {VARIANTS.map((variant) => ( - + ))} +
+ ))} +
+ ), +}; + +export const AsButton: Story = { + render: () => ( +
+ {SIZES.map((size) => ( +
+ {size} + {VARIANTS.map((variant) => ( + alert(`${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 89% rename from src/shared/design-system/ui/NameChip/NameChip.styles.ts rename to src/shared/design-system/ui/Chip/Chip.styles.ts index e53be736..48d87dc4 100644 --- a/src/shared/design-system/ui/NameChip/NameChip.styles.ts +++ b/src/shared/design-system/ui/Chip/Chip.styles.ts @@ -5,9 +5,10 @@ import { applyTypography, } from '@/shared/design-system'; -interface StyledNameChipProps { +interface StyledChipProps { $variant: 'selected' | 'unselected' | 'disabled' | 'red' | 'black'; $size: 'm' | 's'; + $clickable?: boolean; } // HACK: s size는 12px Medium이지만 해당 semantic token 없음. @@ -52,13 +53,19 @@ const variantStyles = { `, }; -export const Chip = styled.div` +export const ChipRoot = styled.div` display: inline-flex; align-items: center; justify-content: center; 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/Chip/Chip.tsx b/src/shared/design-system/ui/Chip/Chip.tsx new file mode 100644 index 00000000..ccda80c0 --- /dev/null +++ b/src/shared/design-system/ui/Chip/Chip.tsx @@ -0,0 +1,31 @@ +import type { MouseEventHandler } from 'react'; +import * as S from './Chip.styles'; + +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/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 + )} = { 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} + + )} ); }); 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 43d46248..00000000 --- a/src/shared/design-system/ui/NameChip/NameChip.tsx +++ /dev/null @@ -1,24 +0,0 @@ -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; -} - -function NameChip(props: NameChipProps) { - const { label, variant = 'selected', size = 'm' } = 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'; 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에 포함된 에러코드인지 확인합니다. diff --git a/vite.config.ts b/vite.config.ts index 58692b34..35b0aaa6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -79,10 +79,13 @@ export default defineConfig(({ mode }) => { 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일