Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 91 additions & 56 deletions features/project/components/project-detail/project-detail-heading.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
'use client';

import Link from 'next/link';
import { useCallback, useState } from 'react';

import { ArrowLeftIcon, CopyIcon, RoundCheckIcon } from '@/shared/assets/icons';
import { Button } from '@/shared/components/button';
import { CopyFeedbackTooltip } from '@/shared/components/copy-feedback-tooltip';
import { ConfirmationDialog } from '@/shared/components/dialog/confirmation-dialog';
import { useCopyLinkFeedback } from '@/shared/hooks/use-copy-link-feedback';
import { cn } from '@/shared/lib/cn';

Expand All @@ -28,46 +30,95 @@ export const ProjectDetailHeading = ({ projectId, projectStatus, isLeader, proje
const showLeaderActions = isLeader && (isInProgress || isCompleted);
const isConfirmDisabled = !isInProgress;
const { open, message, close, copyLink } = useCopyLinkFeedback();
const { mutate: confirmMembers, isPending: isConfirming } = useConfirmProjectMembers(projectId);
const { mutateAsync: confirmMembers, isPending: isConfirming } = useConfirmProjectMembers(projectId);
const [confirmOpen, setConfirmOpen] = useState(false);
const [confirmError, setConfirmError] = useState<string | null>(null);

const handleCopyInviteLink = () => {
void copyLink(buildProjectInviteUrl(projectLink, inviterName));
};

const handleConfirmOpenChange = useCallback((next: boolean) => {
if (!next) {
setConfirmError(null);
}
setConfirmOpen(next);
}, []);

const handleConfirmMembers = useCallback(async () => {
setConfirmError(null);
try {
await confirmMembers();
setConfirmOpen(false);
} catch {
setConfirmError('팀 확정에 실패했습니다. 다시 시도해주세요.');
}
}, [confirmMembers]);

return (
<header
className={cn(
'lg:border-border-gray-light flex flex-col pt-8 pb-5 lg:gap-0 lg:border-b-[3px] lg:pt-15.5 lg:pb-8',
// 방장: 액션 버튼 행 아래 구분선. 팀원: 모바일은 제목만(Figma), 데스크톱은 구분선 유지.
showLeaderActions && 'border-border-gray-light gap-5 border-b-[3px]',
)}
>
<div className="flex items-end justify-between">
<div className="flex min-w-0 flex-col gap-2">
<div className="flex items-center gap-1 lg:block">
<Link
href="/projects"
aria-label="프로젝트 관리로 돌아가기"
className="text-text-basic inline-flex size-6 shrink-0 items-center justify-center lg:hidden"
>
<ArrowLeftIcon aria-hidden className="size-6" />
</Link>
<h1 className="text-heading-sm lg:text-heading-lg text-text-basic font-bold">프로젝트 상세</h1>
<>
<header
className={cn(
'lg:border-border-gray-light flex flex-col pt-8 pb-5 lg:gap-0 lg:border-b-[3px] lg:pt-15.5 lg:pb-8',
// 방장: 액션 버튼 행 아래 구분선. 팀원: 모바일은 제목만(Figma), 데스크톱은 구분선 유지.
showLeaderActions && 'border-border-gray-light gap-5 border-b-[3px]',
)}
>
<div className="flex items-end justify-between">
<div className="flex min-w-0 flex-col gap-2">
<div className="flex items-center gap-1 lg:block">
<Link
href="/projects"
aria-label="프로젝트 관리로 돌아가기"
className="text-text-basic inline-flex size-6 shrink-0 items-center justify-center lg:hidden"
>
<ArrowLeftIcon aria-hidden className="size-6" />
</Link>
<h1 className="text-heading-sm lg:text-heading-lg text-text-basic font-bold">프로젝트 상세</h1>
</div>
<p className="text-body-base text-text-subtle hidden lg:block">
프로젝트 정보와 팀원을 확인하고, 필요하면 수정할 수 있어요.
</p>
</div>
<p className="text-body-base text-text-subtle hidden lg:block">
프로젝트 정보와 팀원을 확인하고, 필요하면 수정할 수 있어요.
</p>

{showLeaderActions ? (
<div className="hidden items-center gap-2 lg:flex">
<div className="relative inline-flex">
<Button
type="button"
variant="secondary"
size="medium"
leftIcon={<CopyIcon aria-hidden className="size-4" />}
className={cn(open && 'bg-button-secondary-fill-pressed')}
onClick={handleCopyInviteLink}
>
초대 링크 복사
</Button>
<CopyFeedbackTooltip open={open} onClose={close} message={message} />
</div>
<Button
type="button"
variant="primary"
size="medium"
leftIcon={<RoundCheckIcon aria-hidden className="size-4" />}
disabled={isConfirmDisabled || isConfirming}
onClick={() => setConfirmOpen(true)}
>
우리 팀 확정하기
</Button>
</div>
) : null}
</div>

{showLeaderActions ? (
<div className="hidden items-center gap-2 lg:flex">
<div className="relative inline-flex">
<div className="flex items-start gap-2 lg:hidden">
<div className="relative min-w-0 flex-1">
<Button
type="button"
variant="secondary"
size="medium"
leftIcon={<CopyIcon aria-hidden className="size-4" />}
className={cn(open && 'bg-button-secondary-fill-pressed')}
className={cn('h-11 w-full justify-center', open && 'bg-button-secondary-fill-pressed')}
onClick={handleCopyInviteLink}
>
초대 링크 복사
Expand All @@ -79,43 +130,27 @@ export const ProjectDetailHeading = ({ projectId, projectStatus, isLeader, proje
variant="primary"
size="medium"
leftIcon={<RoundCheckIcon aria-hidden className="size-4" />}
className="h-11 min-w-0 flex-1 justify-center"
disabled={isConfirmDisabled || isConfirming}
onClick={() => confirmMembers()}
onClick={() => setConfirmOpen(true)}
>
우리 팀 확정하기
</Button>
</div>
) : null}
</div>
</header>

{showLeaderActions ? (
<div className="flex items-start gap-2 lg:hidden">
<div className="relative min-w-0 flex-1">
<Button
type="button"
variant="secondary"
size="medium"
leftIcon={<CopyIcon aria-hidden className="size-4" />}
className={cn('h-11 w-full justify-center', open && 'bg-button-secondary-fill-pressed')}
onClick={handleCopyInviteLink}
>
초대 링크 복사
</Button>
<CopyFeedbackTooltip open={open} onClose={close} message={message} />
</div>
<Button
type="button"
variant="primary"
size="medium"
leftIcon={<RoundCheckIcon aria-hidden className="size-4" />}
className="h-11 min-w-0 flex-1 justify-center"
disabled={isConfirmDisabled || isConfirming}
onClick={() => confirmMembers()}
>
우리 팀 확정하기
</Button>
</div>
) : null}
</header>
<ConfirmationDialog
open={confirmOpen}
title="팀 확정하기"
description="확정 후에는 팀원 추가 및 프로젝트 정보 수정이 불가능합니다. 진행하시겠습니까?"
confirmText="확정하기"
cancelText="취소"
onOpenChange={handleConfirmOpenChange}
onConfirm={handleConfirmMembers}
isConfirming={isConfirming}
errorMessage={confirmError ?? undefined}
/>
</>
);
};
4 changes: 4 additions & 0 deletions shared/components/date-picker/date-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@

const TriggerButton = forwardRef<HTMLButtonElement, TriggerButtonProps>(
({ displayDate, placeholder, disabled, error, className, ...restProps }, ref) => (
<button

Check warning on line 112 in shared/components/date-picker/date-picker.tsx

View workflow job for this annotation

GitHub Actions / build-and-test

The attribute aria-invalid is not supported by the role button. This role is implicit on the element button
ref={ref}
type="button"
disabled={disabled}
Expand Down Expand Up @@ -181,6 +181,8 @@
onBlur={onBlur}
/>
</PopoverPrimitive.Trigger>
{/* Radix Portal은 asChild로 자식을 단일 엘리먼트에 병합하므로, 오버레이와 Content를 같은 Portal에 형제로 두면
모바일에서 팝오버가 열릴 때 React.Children.only 에러가 발생한다. Portal을 분리해 각각 단일 자식만 갖게 한다. */}
<PopoverPrimitive.Portal>
{!isLg && open ? (
<div
Expand All @@ -192,6 +194,8 @@
}}
/>
) : null}
</PopoverPrimitive.Portal>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
side="bottom"
align="start"
Expand Down
Loading