From 0f7f4d7340f5cc40614c6d8669de878c0bbe429a Mon Sep 17 00:00:00 2001 From: yeoeun-ex Date: Sun, 7 Jun 2026 20:59:01 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20Dialog=EC=97=90=20children=20?= =?UTF-8?q?=EC=8A=AC=EB=A1=AF=EA=B3=BC=20action=20disabled=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../design-system/ui/Dialog/Dialog.styles.ts | 13 +++++++++++ src/shared/design-system/ui/Dialog/Dialog.tsx | 23 +++++++++++++++---- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/shared/design-system/ui/Dialog/Dialog.styles.ts b/src/shared/design-system/ui/Dialog/Dialog.styles.ts index 3c5a08b7..5622fa37 100644 --- a/src/shared/design-system/ui/Dialog/Dialog.styles.ts +++ b/src/shared/design-system/ui/Dialog/Dialog.styles.ts @@ -16,6 +16,19 @@ export const Container = styled.div` max-width: 330px; `; +export const Section = styled.div` + display: flex; + flex-direction: column; + /* HACK: 32px에 해당하는 gap 토큰 없음(gap.8=24px이 최대). Figma 스펙(32px) 일치 위해 직접 사용. */ + gap: 32px; +`; + +export const Content = styled.div` + display: flex; + flex-direction: column; + gap: ${getToken('gap.8')}; +`; + export const TextSection = styled.div` display: flex; flex-direction: column; diff --git a/src/shared/design-system/ui/Dialog/Dialog.tsx b/src/shared/design-system/ui/Dialog/Dialog.tsx index cb150b10..3b346608 100644 --- a/src/shared/design-system/ui/Dialog/Dialog.tsx +++ b/src/shared/design-system/ui/Dialog/Dialog.tsx @@ -5,24 +5,37 @@ import * as S from './Dialog.styles'; interface DialogAction { label: string; onClick: () => void; + disabled?: boolean; } interface DialogProps { title: ReactNode; description?: ReactNode; + children?: ReactNode; mainAction: DialogAction; alternativeAction?: DialogAction; } function Dialog(props: DialogProps) { - const { title, description, mainAction, alternativeAction } = props; + const { title, description, children, mainAction, alternativeAction } = props; + + const textSection = ( + + {title} + {description && {description}} + + ); return ( - - {title} - {description && {description}} - + {children ? ( + + {textSection} + {children} + + ) : ( + textSection + )} Date: Sun, 7 Jun 2026 20:59:33 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20Dropdown=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/_workspace/Dropdown/Dropdown.styles.ts | 130 +++++++++++++++++++++ src/_workspace/Dropdown/Dropdown.tsx | 104 +++++++++++++++++ src/_workspace/Dropdown/index.ts | 2 + 3 files changed, 236 insertions(+) create mode 100644 src/_workspace/Dropdown/Dropdown.styles.ts create mode 100644 src/_workspace/Dropdown/Dropdown.tsx create mode 100644 src/_workspace/Dropdown/index.ts diff --git a/src/_workspace/Dropdown/Dropdown.styles.ts b/src/_workspace/Dropdown/Dropdown.styles.ts new file mode 100644 index 00000000..461dcaf1 --- /dev/null +++ b/src/_workspace/Dropdown/Dropdown.styles.ts @@ -0,0 +1,130 @@ +import styled, { css } from 'styled-components'; +import { getToken, applyTypography } from '@/shared/design-system'; + +export const Container = styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; + gap: ${getToken('gap.4')}; + width: 100%; +`; + +export const Label = styled.span` + ${applyTypography('typography.body.small-semibold')} + color: ${getToken('fg.neutral')}; +`; + +export const TriggerWrap = styled.div` + position: relative; + width: 100%; +`; + +export const Trigger = styled.button<{ $isOpen: boolean }>` + display: flex; + align-items: center; + gap: ${getToken('gap.4')}; + width: 100%; + padding: ${getToken('padding.4')} ${getToken('padding.5')}; + background: ${getToken('fill.normal')}; + border-radius: ${getToken('radius.lg')}; + cursor: pointer; + ${({ $isOpen }) => + $isOpen + ? css` + border: 2px solid ${getToken('border.primary.normal')}; + ` + : css` + border: 1px solid ${getToken('border.neutral')}; + `} +`; + +export const ValueText = styled.span<{ $isPlaceholder: boolean }>` + flex: 1; + min-width: 0; + text-align: left; + ${applyTypography('typography.body.medium')} + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + ${({ $isPlaceholder }) => + $isPlaceholder + ? css` + color: ${getToken('fg.assistive')}; + opacity: 0.5; + ` + : css` + color: ${getToken('fg.normal')}; + `} +`; + +export const ChevronWrapper = styled.span<{ $isOpen: boolean }>` + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: ${getToken('fg.assistive')}; + transform: ${({ $isOpen }) => ($isOpen ? 'rotate(180deg)' : 'rotate(0deg)')}; + transition: transform 0.2s ease-in-out; +`; + +export const Panel = styled.div` + position: absolute; + top: calc(100% + ${getToken('gap.3')}); + left: 0; + right: 0; + display: flex; + flex-direction: column; + gap: ${getToken('gap.1')}; + background: ${getToken('fill.normal')}; + border: 1px solid ${getToken('border.alternative')}; + border-radius: ${getToken('radius.md')}; + padding: ${getToken('padding.3')}; + /* HACK: shadow2(0px 4px 8px rgba(0,0,0,0.12)) 매핑되는 shadow 시맨틱 토큰 없음 */ + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.12); + /* HACK: 옵션이 많을 때 패널이 넘치지 않도록 max-height 제한. 대응 토큰 없어 직접 사용. */ + max-height: 240px; + overflow-y: auto; + z-index: 10; +`; + +export const OptionItem = styled.button` + display: flex; + align-items: center; + justify-content: space-between; + gap: ${getToken('gap.6')}; + width: 100%; + padding: ${getToken('padding.3')}; + border-radius: ${getToken('radius.sm')}; + background: transparent; + border: none; + cursor: pointer; + + &:hover, + &:active { + background: ${getToken('fill.normal-pressed')}; + } +`; + +export const OptionLabel = styled.span<{ $isSelected: boolean }>` + ${({ $isSelected }) => + $isSelected + ? applyTypography('typography.body.medium-semibold') + : applyTypography('typography.body.medium')} + color: ${({ $isSelected }) => + $isSelected ? getToken('fg.primary.normal') : getToken('fg.normal')}; + flex: 1; + min-width: 0; + text-align: left; + white-space: nowrap; +`; + +export const ConfirmIconWrapper = styled.span` + display: flex; + align-items: center; + flex-shrink: 0; + color: ${getToken('fg.primary.normal')}; + + svg path { + stroke: currentColor; + } +`; diff --git a/src/_workspace/Dropdown/Dropdown.tsx b/src/_workspace/Dropdown/Dropdown.tsx new file mode 100644 index 00000000..9217158b --- /dev/null +++ b/src/_workspace/Dropdown/Dropdown.tsx @@ -0,0 +1,104 @@ +import { useState, useRef, useEffect, useId } from 'react'; +import SvgArrowDown from '@/shared/assets/svgs/icon/ArrowDown'; +import SvgConfirm from '@/shared/assets/svgs/icon/Confirm'; +import * as S from './Dropdown.styles'; + +interface DropdownOption { + label: string; + value: string; +} + +interface DropdownProps { + label?: string; + options: DropdownOption[]; + value: string; + onChange: (value: string) => void; + placeholder?: string; +} + +function Dropdown(props: DropdownProps) { + const { label, options, value, onChange, placeholder = '선택' } = props; + const [isOpen, setIsOpen] = useState(false); + const containerRef = useRef(null); + const listboxId = useId(); + + const selectedOption = options.find((option) => option.value === value); + + useEffect(() => { + if (!isOpen) { + return undefined; + } + + function handleOutsideClick(e: MouseEvent) { + if (!containerRef.current?.contains(e.target as Node)) { + setIsOpen(false); + } + } + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') { + setIsOpen(false); + } + } + + document.addEventListener('mousedown', handleOutsideClick); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('mousedown', handleOutsideClick); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [isOpen]); + + function handleOptionClick(optionValue: string) { + onChange(optionValue); + setIsOpen(false); + } + + return ( + + {label && {label}} + + setIsOpen((prev) => !prev)} + > + + {selectedOption?.label ?? placeholder} + + + + + + {isOpen && ( + + {options.map((option) => ( + handleOptionClick(option.value)} + > + + {option.label} + + {option.value === value && ( + + + + )} + + ))} + + )} + + + ); +} + +export { Dropdown }; +export type { DropdownProps, DropdownOption }; diff --git a/src/_workspace/Dropdown/index.ts b/src/_workspace/Dropdown/index.ts new file mode 100644 index 00000000..73287634 --- /dev/null +++ b/src/_workspace/Dropdown/index.ts @@ -0,0 +1,2 @@ +export { Dropdown } from './Dropdown'; +export type { DropdownProps, DropdownOption } from './Dropdown'; From 8896129b48fb960de9cd80c33949005ac4d00bf4 Mon Sep 17 00:00:00 2001 From: yeoeun-ex Date: Sun, 7 Jun 2026 20:59:43 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=EA=B3=84=EC=A2=8C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=8B=A4=EC=9D=B4=EC=96=BC=EB=A1=9C=EA=B7=B8(Accou?= =?UTF-8?q?ntEditDialog)=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AccountEditDialog/AccountEditDialog.tsx | 65 +++++++++++++++++++ src/_workspace/AccountEditDialog/index.ts | 2 + 2 files changed, 67 insertions(+) create mode 100644 src/_workspace/AccountEditDialog/AccountEditDialog.tsx create mode 100644 src/_workspace/AccountEditDialog/index.ts diff --git a/src/_workspace/AccountEditDialog/AccountEditDialog.tsx b/src/_workspace/AccountEditDialog/AccountEditDialog.tsx new file mode 100644 index 00000000..bf64ba3d --- /dev/null +++ b/src/_workspace/AccountEditDialog/AccountEditDialog.tsx @@ -0,0 +1,65 @@ +import type { ChangeEventHandler } from 'react'; +import { Dialog, Input, Modal } from '@/shared/design-system/ui'; +import { Dropdown, type DropdownOption } from '../Dropdown'; + +interface AccountEditDialogProps { + open: boolean; + bankOptions: DropdownOption[]; + bankValue: string; + bankPlaceholder?: string; + onBankChange: (value: string) => void; + accountNumber: string; + accountNumberPlaceholder?: string; + onAccountNumberChange: ChangeEventHandler; + onCancel: () => void; + onConfirm: () => void; +} + +function AccountEditDialog(props: AccountEditDialogProps) { + const { + open, + bankOptions, + bankValue, + bankPlaceholder = '은행을 선택해주세요', + onBankChange, + accountNumber, + accountNumberPlaceholder = '계좌 번호를 입력해주세요', + onAccountNumberChange, + onCancel, + onConfirm, + } = props; + + const isConfirmDisabled = !bankValue || !accountNumber; + + return ( + + + + + + + ); +} + +export { AccountEditDialog }; +export type { AccountEditDialogProps }; diff --git a/src/_workspace/AccountEditDialog/index.ts b/src/_workspace/AccountEditDialog/index.ts new file mode 100644 index 00000000..38e0a982 --- /dev/null +++ b/src/_workspace/AccountEditDialog/index.ts @@ -0,0 +1,2 @@ +export { AccountEditDialog } from './AccountEditDialog'; +export type { AccountEditDialogProps } from './AccountEditDialog'; From 6370c8c5bcc96c4282347eed39ca7293e25ba881 Mon Sep 17 00:00:00 2001 From: yeoeun-ex Date: Sun, 7 Jun 2026 21:00:24 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20expenseDetail=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=20=EB=A9=94=EB=89=B4=EC=97=90=20=EA=B3=84=EC=A2=8C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=97=B0=EA=B2=B0=20=EB=B0=8F=20header=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../expenseDetail/ui/ManageMenu/index.tsx | 67 ++++++++++++++++++- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/src/pages/expenseDetail/ui/ManageMenu/index.tsx b/src/pages/expenseDetail/ui/ManageMenu/index.tsx index dd100f28..d814d37a 100644 --- a/src/pages/expenseDetail/ui/ManageMenu/index.tsx +++ b/src/pages/expenseDetail/ui/ManageMenu/index.tsx @@ -1,11 +1,21 @@ import { useState } from 'react'; import { createPortal } from 'react-dom'; +import { useQueryClient } from '@tanstack/react-query'; import { useNavigate } from 'react-router'; import { Dimmed, Dialog, Modal, showToast } from '@/shared/design-system/ui'; import payment from '@/entities/payment/api/payment'; +import usePutUpdateAccount from '@/features/expense-management/api/usePutUpdateAccount'; import { ROUTE } from '@/shared/config/route'; +import { BoundaryError } from '@/shared/types/error.type'; +import { AccountEditDialog } from '@/_workspace/AccountEditDialog'; +import BANK_LIST from '@/pages/addAccountStep/ui/BankNameDrawer/config/banks'; import * as S from './index.styles'; +const BANK_OPTIONS = BANK_LIST.map((bank) => ({ + label: bank.bankName, + value: bank.bankName, +})); + interface ManageMenuProps { groupToken: string; } @@ -14,7 +24,26 @@ function ManageMenu({ groupToken }: ManageMenuProps) { const [isOpen, setIsOpen] = useState(false); const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false); const [isPending, setIsPending] = useState(false); + const [isAccountEditOpen, setIsAccountEditOpen] = useState(false); + const [bankName, setBankName] = useState(''); + const [accountNumber, setAccountNumber] = useState(''); const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const { mutate: updateAccountMutate } = usePutUpdateAccount( + groupToken, + { + // CHECK - 문서에는 403 에러로 되어 있지만, 실제로는 500 에러가 발생함 + // 유저가 모임 총무가 아닐 경우에 발생하는 에러 + 403: () => { + throw new BoundaryError({ + title: '접근 권한이 없어요.', + description: '계좌는 총무만 수정할 수 있어요.', + }); + }, + }, + [403] + ); const handleEditExpenses = async () => { setIsOpen(false); @@ -36,6 +65,29 @@ function ManageMenu({ groupToken }: ManageMenuProps) { } }; + const handleOpenAccountEdit = () => { + setIsOpen(false); + setIsAccountEditOpen(true); + }; + + const handleAccountEditConfirm = () => { + updateAccountMutate( + { + accountData: { bank: bankName, accountNumber }, + groupToken, + }, + { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['groupHeader', groupToken], + }); + showToast({ type: 'success', content: '계좌를 수정했어요.' }); + setIsAccountEditOpen(false); + }, + } + ); + }; + return ( <> setIsOpen(true)} disabled={isPending}> @@ -49,12 +101,23 @@ function ManageMenu({ groupToken }: ManageMenuProps) { 정산 내역 수정 - {/* TODO: 계좌 수정 기능 구현 시 연결 */} - 계좌 수정 + + 계좌 수정 + , document.querySelector('#modal') ?? document.body )} + setAccountNumber(e.target.value)} + onCancel={() => setIsAccountEditOpen(false)} + onConfirm={handleAccountEditConfirm} + /> setIsBlockedModalOpen(false)}