From b3e0d72c551702517471b60392a80594f6b93ce2 Mon Sep 17 00:00:00 2001 From: hjkl Date: Sat, 25 Jul 2026 16:48:34 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EC=83=81=EC=84=B8=20=EB=B0=98=EC=9D=91=ED=98=95=20?= =?UTF-8?q?ui=201=EC=B0=A8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project-detail/project-detail-heading.tsx | 58 +++++++- .../project-detail-page-content.tsx | 2 +- .../project-detail/project-info-card.tsx | 60 ++++---- .../project-detail/project-members-card.tsx | 140 +++++++++++++----- .../components/project-member-chip.tsx | 64 ++++---- 5 files changed, 217 insertions(+), 107 deletions(-) diff --git a/features/project/components/project-detail/project-detail-heading.tsx b/features/project/components/project-detail/project-detail-heading.tsx index c38b9f38..ef8137ab 100644 --- a/features/project/components/project-detail/project-detail-heading.tsx +++ b/features/project/components/project-detail/project-detail-heading.tsx @@ -1,6 +1,8 @@ 'use client'; -import { CopyIcon, RoundCheckIcon } from '@/shared/assets/icons'; +import Link from 'next/link'; + +import { ArrowLeftIcon, CopyIcon, RoundCheckIcon } from '@/shared/assets/icons'; import { Button } from '@/shared/components/button'; import { CopyFeedbackTooltip } from '@/shared/components/copy-feedback-tooltip'; import { useCopyLinkFeedback } from '@/shared/hooks/use-copy-link-feedback'; @@ -28,20 +30,61 @@ export const ProjectDetailHeading = ({ projectId, projectStatus, isLeader, proje }; return ( -
-
-

프로젝트 상세

-

프로젝트 정보와 팀원을 확인하고, 필요하면 수정할 수 있어요.

+
+
+
+
+ + + +

프로젝트 상세

+
+

+ 프로젝트 정보와 팀원을 확인하고, 필요하면 수정할 수 있어요. +

+
+ + {isLeader && isInProgress ? ( +
+
+ + +
+ +
+ ) : null}
{isLeader && isInProgress ? ( -
-
+
+
+ )} + /> {errors.root?.message ? {errors.root.message} : null} @@ -306,11 +298,11 @@ const ProjectImageEditField = ({ project, value, onChange, disabled, errorMessag }, [syncPreview, value]); return ( -
+
{`${project.projectName}
-

{IMAGE_GUIDE_TEXT}

+

{IMAGE_GUIDE_TEXT}

{errorMessage ? {errorMessage} : null}
diff --git a/features/project/components/project-detail/project-members-card.tsx b/features/project/components/project-detail/project-members-card.tsx index ef8b620f..574dfd1c 100644 --- a/features/project/components/project-detail/project-members-card.tsx +++ b/features/project/components/project-detail/project-members-card.tsx @@ -54,7 +54,12 @@ export const ProjectMembersCard = ({ projectId, members, projectStatus, isLeader return ( {isEditing ? ( - + ) : ( + member.userId === myUserId ? 'self' : member.reviewWritten === true ? 'completed' : 'writable'; + const ProjectMembersViewFields = ({ projectId, members, myUserId, projectStatus }: ViewFieldsProps) => { const router = useRouter(); return (
-
    +
      {members.map((member) => { - const reviewStatus: ProjectMemberReviewStatus = - member.userId === myUserId ? 'self' : member.reviewWritten === true ? 'completed' : 'writable'; + const reviewStatus = getMemberReviewStatus(member, myUserId); if (reviewStatus === 'writable') { return ( -
    • +
    • ); } + return ( -
    • +
    • { +const ProjectMembersEditFields = ({ projectId, members, myUserId, projectStatus }: EditFieldsProps) => { + const router = useRouter(); const { mutate: deleteMember, isPending } = useDeleteProjectMember(projectId); + const handleRemove = (userId: number) => { + if (isPending) { + return; + } + deleteMember(userId); + }; + return ( - -
        - {members.map((member) => { - // 편집 모드에서는 후기 작성/작성 완료 버튼을 노출하지 않는다. 본인은 일반 태그, 나머지 팀원은 삭제 가능한 태그로 표시한다. - if (member.userId === myUserId) { +
        + +
          + {members.map((member) => { + const reviewStatus = getMemberReviewStatus(member, myUserId); + const canRemove = member.userId !== myUserId; + + if (reviewStatus === 'writable' && canRemove) { + return ( +
        • + + router.push( + buildReviewWriteUrl({ + projectId, + revieweeId: member.userId, + revieweeName: member.username, + }), + ) + } + onRemove={() => handleRemove(member.userId)} + /> +
        • + ); + } + + if (reviewStatus === 'writable') { + return ( +
        • + + router.push( + buildReviewWriteUrl({ + projectId, + revieweeId: member.userId, + revieweeName: member.username, + }), + ) + } + /> +
        • + ); + } + + if (canRemove) { + return ( +
        • + handleRemove(member.userId)} + /> +
        • + ); + } + return ( -
        • - +
        • +
        • ); - } - - return ( -
        • - { - if (isPending) { - return; - } - deleteMember(member.userId); - }} - /> -
        • - ); - })} -
        -
        + })} +
      +
      + + {projectStatus === 'IN_PROGRESS' ? ( +

      + * 팀원이 모두 참여했다면 확정을 눌러주세요 +

      + ) : null} +
); }; diff --git a/features/project/components/project-member-chip.tsx b/features/project/components/project-member-chip.tsx index 313479b4..50b66cdc 100644 --- a/features/project/components/project-member-chip.tsx +++ b/features/project/components/project-member-chip.tsx @@ -56,42 +56,46 @@ export const ProjectMemberChip = ({ return (
- - - - {name.charAt(0)} - - - {name} +
+ + + + {name.charAt(0)} + + + {name} +
- {showReviewButton ? ( - - ) : null} +
+ {showReviewButton ? ( + + ) : null} - {removable ? ( - } - className="text-icon-gray" - onClick={onRemove} - /> - ) : null} + {removable ? ( + } + className="text-icon-gray" + onClick={onRemove} + /> + ) : null} +
); }; From c508b0dffa7a7bec311968d80d823b38d06b0e01 Mon Sep 17 00:00:00 2001 From: hjkl Date: Sat, 25 Jul 2026 17:00:42 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=EB=82=A0=EC=A7=9C=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91,=20=ED=8C=80=ED=99=95=EC=A0=95=ED=9B=84=20=EB=B0=A9?= =?UTF-8?q?=EC=9E=A5=ED=99=94=EB=A9=B4,=20=EB=A7=81=ED=81=AC=20=EB=B3=B5?= =?UTF-8?q?=EC=82=AC=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EB=B0=98=EC=9D=91?= =?UTF-8?q?=ED=98=95=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project-detail/project-detail-heading.tsx | 16 +- .../project-detail/project-info-card.tsx | 9 +- .../project-detail/project-members-card.tsx | 8 +- shared/components/date-picker/date-picker.tsx | 273 +++++++++++------- 4 files changed, 190 insertions(+), 116 deletions(-) diff --git a/features/project/components/project-detail/project-detail-heading.tsx b/features/project/components/project-detail/project-detail-heading.tsx index ef8137ab..161e8772 100644 --- a/features/project/components/project-detail/project-detail-heading.tsx +++ b/features/project/components/project-detail/project-detail-heading.tsx @@ -6,6 +6,7 @@ import { ArrowLeftIcon, CopyIcon, RoundCheckIcon } from '@/shared/assets/icons'; import { Button } from '@/shared/components/button'; import { CopyFeedbackTooltip } from '@/shared/components/copy-feedback-tooltip'; import { useCopyLinkFeedback } from '@/shared/hooks/use-copy-link-feedback'; +import { cn } from '@/shared/lib/cn'; import type { ProjectStatus } from '../../project.types'; @@ -22,6 +23,10 @@ interface Props { export const ProjectDetailHeading = ({ projectId, projectStatus, isLeader, projectLink, inviterName }: Props) => { const isInProgress = projectStatus === 'IN_PROGRESS'; + const isCompleted = projectStatus === 'COMPLETED'; + // 팀 확정 후에도 초대 링크 복사·비활성 확정 버튼을 노출한다 (Figma 방장 확정 완료 시점). + const showLeaderActions = isLeader && (isInProgress || isCompleted); + const isConfirmDisabled = !isInProgress; const { open, message, close, copyLink } = useCopyLinkFeedback(); const { mutate: confirmMembers, isPending: isConfirming } = useConfirmProjectMembers(projectId); @@ -48,7 +53,7 @@ export const ProjectDetailHeading = ({ projectId, projectStatus, isLeader, proje

- {isLeader && isInProgress ? ( + {showLeaderActions ? (
- {isLeader && isInProgress ? ( + {showLeaderActions ? (
diff --git a/features/project/components/project-detail/project-members-card.tsx b/features/project/components/project-detail/project-members-card.tsx index 574dfd1c..95700cef 100644 --- a/features/project/components/project-detail/project-members-card.tsx +++ b/features/project/components/project-detail/project-members-card.tsx @@ -23,10 +23,11 @@ interface Props { export const ProjectMembersCard = ({ projectId, members, projectStatus, isLeader, myUserId }: Props) => { const [isEditing, setIsEditing] = useState(false); - // 팀 확정(IN_PROGRESS 종료) 이후에는 팀원을 수정할 수 없으므로 수정 버튼을 숨긴다. + // 팀 확정 후에는 수정 불가. 방장에게는 disabled 수정 버튼을 노출한다 (Figma). const isEditable = isLeader && projectStatus === 'IN_PROGRESS'; + const showEditAction = isLeader && (projectStatus === 'IN_PROGRESS' || projectStatus === 'COMPLETED'); - const action = !isEditable ? null : isEditing ? ( + const action = !showEditAction ? null : isEditing ? (
diff --git a/shared/components/date-picker/date-picker.tsx b/shared/components/date-picker/date-picker.tsx index 1d2c6edd..3a0cc32c 100644 --- a/shared/components/date-picker/date-picker.tsx +++ b/shared/components/date-picker/date-picker.tsx @@ -1,14 +1,28 @@ 'use client'; -import { Popover as PopoverPrimitive } from 'radix-ui'; -import { useEffect, useRef } from 'react'; +import { AnimatePresence } from 'motion/react'; +import { Dialog, Popover as PopoverPrimitive } from 'radix-ui'; +import { useSyncExternalStore } from 'react'; import { ArrowLeftIcon, ArrowRightIcon, CalendarIcon } from '@/shared/assets/icons'; +import { DialogAnimatedOverlay } from '@/shared/components/dialog'; import { cn } from '@/shared/lib/cn'; import { useDatePicker } from './use-date-picker'; const WEEKDAY_LABELS = ['일', '월', '화', '수', '목', '금', '토'] as const; +const LG_MEDIA_QUERY = '(min-width: 1024px)'; + +const subscribeLg = (onStoreChange: () => void) => { + const mediaQuery = window.matchMedia(LG_MEDIA_QUERY); + mediaQuery.addEventListener('change', onStoreChange); + return () => mediaQuery.removeEventListener('change', onStoreChange); +}; + +const getLgSnapshot = () => window.matchMedia(LG_MEDIA_QUERY).matches; +const getLgServerSnapshot = () => false; + +const useIsLg = () => useSyncExternalStore(subscribeLg, getLgSnapshot, getLgServerSnapshot); interface Props { value?: Date | null; @@ -22,6 +36,105 @@ interface Props { className?: string; } +type CalendarPanelProps = { + monthLabel: string; + weeks: ReturnType['weeks']; + onPrevMonth: () => void; + onNextMonth: () => void; + onSelect: (date: Date) => void; +}; + +const CalendarPanel = ({ monthLabel, weeks, onPrevMonth, onNextMonth, onSelect }: CalendarPanelProps) => ( + <> +
+ +

{monthLabel}

+ +
+ +
+ {WEEKDAY_LABELS.map((label) => ( + + {label} + + ))} +
+ +
+ {weeks.map((week) => ( +
+ {week.map((cell) => ( + + ))} +
+ ))} +
+ +); + +type TriggerButtonProps = { + displayDate: string | null; + placeholder: string; + disabled?: boolean; + error: boolean; + className?: string; + onBlur?: () => void; +}; + +const TriggerButton = ({ displayDate, placeholder, disabled, error, className, onBlur }: TriggerButtonProps) => ( + +); + export const DatePicker = ({ value, defaultValue, @@ -33,7 +146,7 @@ export const DatePicker = ({ error = false, className, }: Props) => { - const triggerRef = useRef(null); + const isLg = useIsLg(); const { open, handleOpenChange, handleSelect, goToPrevMonth, goToNextMonth, displayDate, monthLabel, weeks } = useDatePicker({ value, defaultValue, onChange }); @@ -42,112 +155,62 @@ export const DatePicker = ({ onOpenChange?.(next); }; - // 모바일 바텀시트 등에서 달력이 화면 밖으로 잘리지 않도록, 트리거를 스크롤 영역 상단 쪽으로 올린다. - useEffect(() => { - if (!open) { - return; - } + const calendarProps: CalendarPanelProps = { + monthLabel, + weeks, + onPrevMonth: goToPrevMonth, + onNextMonth: goToNextMonth, + onSelect: handleSelect, + }; - const frameId = requestAnimationFrame(() => { - triggerRef.current?.scrollIntoView({ block: 'start', behavior: 'smooth', inline: 'nearest' }); - }); + const triggerProps: TriggerButtonProps = { + displayDate, + placeholder, + disabled, + error, + className, + onBlur, + }; - return () => cancelAnimationFrame(frameId); - }, [open]); + if (isLg) { + return ( + + + + + + + + + + + ); + } return ( - - - - - - - -
- -

{monthLabel}

- -
- -
- {WEEKDAY_LABELS.map((label) => ( - - {label} - - ))} -
- -
- {weeks.map((week) => ( -
- {week.map((cell) => ( - - ))} -
- ))} -
-
-
-
+ + + + ) : null} + + ); }; From e9d2ff7d620fc33bcc218c51478aa0613a7f2915 Mon Sep 17 00:00:00 2001 From: hjkl Date: Sat, 25 Jul 2026 17:09:30 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=ED=8C=80=EC=9B=90=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=EC=83=81=EC=84=B8=20=EB=B0=8F=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EB=AA=A8=EB=8B=AC=20=EB=B0=98=EC=9D=91?= =?UTF-8?q?=ED=98=95=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- features/project/components/project-card.tsx | 8 +++++++- .../components/project-detail/project-detail-heading.tsx | 8 +++++++- .../components/project-detail/project-members-card.tsx | 8 ++++---- shared/components/dialog/confirmation-dialog.tsx | 3 ++- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/features/project/components/project-card.tsx b/features/project/components/project-card.tsx index cc2b4544..b4491ed9 100644 --- a/features/project/components/project-card.tsx +++ b/features/project/components/project-card.tsx @@ -156,7 +156,13 @@ const ProjectCardHeader = ({ isLeader, projectName, projectStatus, startDate, on {isLeader ? ( // 카드 전체가 상세 페이지 Link이므로 드롭다운 클릭이 네비게이션으로 전파되지 않도록 막는다 -
e.stopPropagation()}> +
{ + e.preventDefault(); + e.stopPropagation(); + }} + onPointerDown={(e) => e.stopPropagation()} + > +
diff --git a/features/project/components/project-detail/project-members-card.tsx b/features/project/components/project-detail/project-members-card.tsx index 95700cef..c7bd8d95 100644 --- a/features/project/components/project-detail/project-members-card.tsx +++ b/features/project/components/project-detail/project-members-card.tsx @@ -129,8 +129,8 @@ const ProjectMembersViewFields = ({ projectId, members, myUserId, projectStatus {projectStatus === 'IN_PROGRESS' ? ( -

- * 팀원이 모두 참여했다면 확정을 눌러주세요 +

+ 팀원이 모두 참여했다면 확정을 눌러주세요

) : null}
@@ -235,8 +235,8 @@ const ProjectMembersEditFields = ({ projectId, members, myUserId, projectStatus {projectStatus === 'IN_PROGRESS' ? ( -

- * 팀원이 모두 참여했다면 확정을 눌러주세요 +

+ 팀원이 모두 참여했다면 확정을 눌러주세요

) : null}
diff --git a/shared/components/dialog/confirmation-dialog.tsx b/shared/components/dialog/confirmation-dialog.tsx index a706e9e1..6c93f5b7 100644 --- a/shared/components/dialog/confirmation-dialog.tsx +++ b/shared/components/dialog/confirmation-dialog.tsx @@ -44,7 +44,8 @@ export const ConfirmationDialog = ({ From 0ecb7046c3b6fb878e8e0b2207a36e5d274b94ed Mon Sep 17 00:00:00 2001 From: hjkl Date: Sat, 25 Jul 2026 17:28:17 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=EC=8B=9C=EB=82=A0=EC=A7=9C=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/project-member-chip.tsx | 13 +- shared/components/date-picker/date-picker.tsx | 184 ++++++++++-------- 2 files changed, 111 insertions(+), 86 deletions(-) diff --git a/features/project/components/project-member-chip.tsx b/features/project/components/project-member-chip.tsx index 50b66cdc..5c56e2d1 100644 --- a/features/project/components/project-member-chip.tsx +++ b/features/project/components/project-member-chip.tsx @@ -56,12 +56,14 @@ export const ProjectMemberChip = ({ return (
-
+
@@ -78,8 +80,11 @@ export const ProjectMemberChip = ({ variant="tertiary" size="small" disabled={reviewCompleted} - leftIcon={} - className="self-center lg:ml-3" + leftIcon={} + className={cn( + 'text-detail-xs h-8 shrink-0 self-center px-2 lg:ml-3', + !reviewCompleted && 'border-border-disabled border', + )} onClick={onWriteReview} > {reviewCompleted ? '작성 완료' : '후기 남기기'} diff --git a/shared/components/date-picker/date-picker.tsx b/shared/components/date-picker/date-picker.tsx index 3a0cc32c..8a43b12f 100644 --- a/shared/components/date-picker/date-picker.tsx +++ b/shared/components/date-picker/date-picker.tsx @@ -1,11 +1,11 @@ 'use client'; -import { AnimatePresence } from 'motion/react'; -import { Dialog, Popover as PopoverPrimitive } from 'radix-ui'; -import { useSyncExternalStore } from 'react'; +import type { ComponentPropsWithoutRef } from 'react'; + +import { Popover as PopoverPrimitive } from 'radix-ui'; +import { forwardRef, useEffect, useSyncExternalStore } from 'react'; import { ArrowLeftIcon, ArrowRightIcon, CalendarIcon } from '@/shared/assets/icons'; -import { DialogAnimatedOverlay } from '@/shared/components/dialog'; import { cn } from '@/shared/lib/cn'; import { useDatePicker } from './use-date-picker'; @@ -101,40 +101,42 @@ const CalendarPanel = ({ monthLabel, weeks, onPrevMonth, onNextMonth, onSelect } ); -type TriggerButtonProps = { +type TriggerButtonProps = ComponentPropsWithoutRef<'button'> & { displayDate: string | null; placeholder: string; - disabled?: boolean; error: boolean; - className?: string; - onBlur?: () => void; }; -const TriggerButton = ({ displayDate, placeholder, disabled, error, className, onBlur }: TriggerButtonProps) => ( - + + {displayDate ?? placeholder} + + + + ), ); +TriggerButton.displayName = 'DatePickerTriggerButton'; + export const DatePicker = ({ value, defaultValue, @@ -155,62 +157,80 @@ export const DatePicker = ({ onOpenChange?.(next); }; - const calendarProps: CalendarPanelProps = { - monthLabel, - weeks, - onPrevMonth: goToPrevMonth, - onNextMonth: goToNextMonth, - onSelect: handleSelect, - }; - - const triggerProps: TriggerButtonProps = { - displayDate, - placeholder, - disabled, - error, - className, - onBlur, - }; - - if (isLg) { - return ( - - - - - - - - - - - ); - } + // 모바일 바텀시트 열린 동안 배경 스크롤 잠금 (생성 모달 중첩 Dialog 대신 Popover 사용) + useEffect(() => { + if (!open || isLg) { + return; + } + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = previousOverflow; + }; + }, [open, isLg]); return ( - - - - - - {open ? ( - - - - - - + + + + + + {!isLg && open ? ( +
{ + event.preventDefault(); + handleRootOpenChange(false); + }} + /> ) : null} - - + { + if (!isLg) { + event.preventDefault(); + } + }} + className={cn( + 'bg-surface-white z-[70] flex flex-col outline-none', + isLg + ? 'w-90 max-w-[calc(100vw-32px)] rounded-2xl shadow-[0px_4px_16px_rgba(96,96,96,0.3)]' + : // 생성 모달·상세 수정 모두에서 동작하는 모바일 바텀시트 + 'fixed inset-x-0 bottom-0 w-screen max-w-none translate-x-0 rounded-t-2xl shadow-[0px_4px_16px_rgba(96,96,96,0.3)] data-[state=open]:animate-none', + )} + style={ + isLg + ? undefined + : { + // Floating UI transform을 무시하고 화면 하단에 고정 + top: 'auto', + left: 0, + right: 0, + bottom: 0, + transform: 'none', + } + } + > + + + + ); }; From 8fff791583d99ae8c4224644139e2b04b0238b10 Mon Sep 17 00:00:00 2001 From: hjkl Date: Sat, 25 Jul 2026 20:18:57 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=ED=99=95=EC=A0=95(COMPLETED)=20=EC=8B=9C=20?= =?UTF-8?q?=EB=B0=A9=EC=9E=A5=20=ED=99=94=EB=A9=B4=20=EB=B9=84=ED=99=9C?= =?UTF-8?q?=EC=84=B1=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project-detail/project-info-card.tsx | 1 + .../project-detail/project-members-card.tsx | 140 ++++++++++++++---- .../components/project-member-chip.tsx | 4 +- .../components/dialog/confirmation-dialog.tsx | 4 +- 4 files changed, 119 insertions(+), 30 deletions(-) diff --git a/features/project/components/project-detail/project-info-card.tsx b/features/project/components/project-detail/project-info-card.tsx index 23925d79..e33e0f2f 100644 --- a/features/project/components/project-detail/project-info-card.tsx +++ b/features/project/components/project-detail/project-info-card.tsx @@ -78,6 +78,7 @@ export const ProjectInfoCard = ({ project, isLeader }: Props) => { +
+
+ ) : null} + ); }; @@ -142,18 +187,41 @@ interface EditFieldsProps { members: ProjectMemberResponse[]; myUserId: number; projectStatus: ProjectStatus; + onMemberRemoved: (username: string) => void; } -const ProjectMembersEditFields = ({ projectId, members, myUserId, projectStatus }: EditFieldsProps) => { +const ProjectMembersEditFields = ({ + projectId, + members, + myUserId, + projectStatus, + onMemberRemoved, +}: EditFieldsProps) => { const router = useRouter(); - const { mutate: deleteMember, isPending } = useDeleteProjectMember(projectId); + const { mutateAsync: deleteMember, isPending } = useDeleteProjectMember(projectId); + const [pendingRemove, setPendingRemove] = useState(null); + const [removeError, setRemoveError] = useState(null); + + const closeRemoveDialog = useCallback((open: boolean) => { + if (!open) { + setPendingRemove(null); + setRemoveError(null); + } + }, []); - const handleRemove = (userId: number) => { - if (isPending) { + const handleConfirmRemove = useCallback(async () => { + if (!pendingRemove) { return; } - deleteMember(userId); - }; + setRemoveError(null); + try { + await deleteMember(pendingRemove.userId); + onMemberRemoved(pendingRemove.username); + setPendingRemove(null); + } catch { + setRemoveError('팀원 내보내기에 실패했습니다. 다시 시도해주세요.'); + } + }, [deleteMember, onMemberRemoved, pendingRemove]); return (
@@ -162,6 +230,7 @@ const ProjectMembersEditFields = ({ projectId, members, myUserId, projectStatus {members.map((member) => { const reviewStatus = getMemberReviewStatus(member, myUserId); const canRemove = member.userId !== myUserId; + const requestRemove = () => setPendingRemove({ userId: member.userId, username: member.username }); if (reviewStatus === 'writable' && canRemove) { return ( @@ -180,7 +249,7 @@ const ProjectMembersEditFields = ({ projectId, members, myUserId, projectStatus }), ) } - onRemove={() => handleRemove(member.userId)} + onRemove={requestRemove} /> ); @@ -215,7 +284,7 @@ const ProjectMembersEditFields = ({ projectId, members, myUserId, projectStatus profileImageUrl={member.profileImageUrl} state={reviewStatus} removable - onRemove={() => handleRemove(member.userId)} + onRemove={requestRemove} /> ); @@ -239,6 +308,25 @@ const ProjectMembersEditFields = ({ projectId, members, myUserId, projectStatus 팀원이 모두 참여했다면 확정을 눌러주세요

) : null} + + +

{pendingRemove.username}님을 목록에서 삭제할까요?

+

삭제된 팀원은 되돌릴 수 없습니다.

+ + ) : null + } + confirmText="내보내기" + cancelText="취소" + onOpenChange={closeRemoveDialog} + onConfirm={handleConfirmRemove} + isConfirming={isPending} + errorMessage={removeError ?? undefined} + />
); }; diff --git a/features/project/components/project-member-chip.tsx b/features/project/components/project-member-chip.tsx index 5c56e2d1..713612ba 100644 --- a/features/project/components/project-member-chip.tsx +++ b/features/project/components/project-member-chip.tsx @@ -94,9 +94,9 @@ export const ProjectMemberChip = ({ {removable ? ( } - className="text-icon-gray" + className="text-icon-gray size-7 shrink-0 bg-transparent" onClick={onRemove} /> ) : null} diff --git a/shared/components/dialog/confirmation-dialog.tsx b/shared/components/dialog/confirmation-dialog.tsx index 6c93f5b7..bdbd35b1 100644 --- a/shared/components/dialog/confirmation-dialog.tsx +++ b/shared/components/dialog/confirmation-dialog.tsx @@ -44,8 +44,8 @@ export const ConfirmationDialog = ({