From 4d2bfa624fba76deb8924602f70e6b6851d2bc24 Mon Sep 17 00:00:00 2001 From: nelee Date: Mon, 11 May 2026 23:47:15 +0900 Subject: [PATCH 01/14] =?UTF-8?q?feat(project):=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=20=EC=83=9D=EC=84=B1=203-step=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존 /project/create 페이지 진입 → 모달로 교체 - Step 1: 이름 / Step 2: 유형 카드 / Step 3: 기간 + 선택적 추가 입력 - 상태 자동 active, 시작일 자동 오늘 설정 - @radix-ui/react-collapsible 추가 Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 72 ++++ package.json | 1 + .../project/ui/ProjectBoardHeader.tsx | 38 +- .../project/ui/ProjectCreationModal.tsx | 372 ++++++++++++++++++ 4 files changed, 464 insertions(+), 19 deletions(-) create mode 100644 src/features/project/ui/ProjectCreationModal.tsx diff --git a/package-lock.json b/package-lock.json index 54195ea..241956b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@dnd-kit/utilities": "^3.2.2", "@lexical/react": "^0.38.2", "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-popover": "^1.1.15", @@ -1844,6 +1845,77 @@ } } }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", diff --git a/package.json b/package.json index f4bc73c..4dd7268 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@dnd-kit/utilities": "^3.2.2", "@lexical/react": "^0.38.2", "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-popover": "^1.1.15", diff --git a/src/features/project/ui/ProjectBoardHeader.tsx b/src/features/project/ui/ProjectBoardHeader.tsx index a329fd0..736ad8c 100644 --- a/src/features/project/ui/ProjectBoardHeader.tsx +++ b/src/features/project/ui/ProjectBoardHeader.tsx @@ -1,29 +1,29 @@ -"use client" +"use client"; +import { useState } from "react"; import { SectionHeader } from "@/components/shared/SectionHeader"; import Button from "@/components/ui/Button"; -import { useRouter } from "next/navigation"; +import { ProjectCreationModal } from "./ProjectCreationModal"; export default function ProjectBoardHeader() { - const router = useRouter(); + const [open, setOpen] = useState(false); - const handleAddProject = () => { - sessionStorage.removeItem("current_Project_Id"); - router.push('/project/create'); - }; - return ( -
- -
- + <> +
+ +
+ +
-
+ + + ); } diff --git a/src/features/project/ui/ProjectCreationModal.tsx b/src/features/project/ui/ProjectCreationModal.tsx new file mode 100644 index 0000000..fc53b9f --- /dev/null +++ b/src/features/project/ui/ProjectCreationModal.tsx @@ -0,0 +1,372 @@ +"use client"; + +import { useState } from "react"; +import * as Dialog from "@radix-ui/react-dialog"; +import * as Collapsible from "@radix-ui/react-collapsible"; +import { X, ChevronDown, ChevronUp, Briefcase, User, BookOpen, Building2 } from "lucide-react"; +import { createProject, updateProjectMember } from "../model"; +import { showToast } from "@/lib/utils/toast"; +import { useQueryClient } from "@tanstack/react-query"; +import { queryKeys } from "@/lib/constants/queryKeys"; +import { ComboBox, type Item } from "./ComboBox"; +import { getUser } from "@/lib/api/users"; +import { useQuery } from "@tanstack/react-query"; + +const PROJECT_TYPES = [ + { value: "팀 프로젝트", label: "팀 프로젝트", description: "여러 사람이 함께하는 협업 프로젝트", icon: Briefcase }, + { value: "개인 프로젝트", label: "개인 프로젝트", description: "나만의 프로젝트를 관리해요", icon: User }, + { value: "학습 프로젝트", label: "학습 프로젝트", description: "새로운 기술을 배우고 연습해요", icon: BookOpen }, + { value: "상용 프로젝트", label: "상용 프로젝트", description: "비즈니스 목적의 프로젝트", icon: Building2 }, +]; + +const DURATION_OPTIONS = [ + { value: 1, label: "1개월" }, + { value: 3, label: "3개월" }, + { value: 6, label: "6개월" }, + { value: "custom" as const, label: "직접 입력" }, +]; + +interface ProjectCreationModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +interface MemberItem { + projectId: string; + userId: string; + userName: string; + email: string; + role: string; +} + +export function ProjectCreationModal({ open, onOpenChange }: ProjectCreationModalProps) { + const queryClient = useQueryClient(); + + const [step, setStep] = useState(1); + const [name, setName] = useState(""); + const [nameError, setNameError] = useState(""); + const [type, setType] = useState(null); + const [duration, setDuration] = useState(null); + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); + const [description, setDescription] = useState(""); + const [techStack, setTechStack] = useState(""); + const [isExpanded, setIsExpanded] = useState(false); + const [selectedUser, setSelectedUser] = useState(null); + const [members, setMembers] = useState([]); + const [isSubmitting, setIsSubmitting] = useState(false); + + const { data: userList = [] } = useQuery({ + queryKey: queryKeys.users.all, + queryFn: async () => { + const result = await getUser(); + return (result.data || []).map(({ user_id, user_name, email }) => ({ + id: user_id, + label: `${user_name} (${email})`, + value: user_name, + email, + })) as Item[]; + }, + staleTime: 1000 * 60 * 10, + enabled: open, + }); + + const reset = () => { + setStep(1); + setName(""); + setNameError(""); + setType(null); + setDuration(null); + setStartDate(""); + setEndDate(""); + setDescription(""); + setTechStack(""); + setIsExpanded(false); + setSelectedUser(null); + setMembers([]); + }; + + const handleClose = () => { + reset(); + onOpenChange(false); + }; + + const handleNext = () => { + if (step === 1) { + if (!name.trim()) { + setNameError("이름을 입력해주세요"); + return; + } + setNameError(""); + } + setStep((s) => s + 1); + }; + + const handleBack = () => setStep((s) => s - 1); + + const handleAddMember = (item: Item | null) => { + if (!item) return; + if (members.some((m) => m.userId === item.id)) { + alert("이미 추가된 멤버입니다."); + return; + } + setMembers((prev) => [...prev, { projectId: "", userId: item.id, userName: item.value, email: item.email, role: "member" }]); + }; + + const handleSubmit = async () => { + if (!name || !type || !duration) return; + + const today = new Date(); + let endedAt: Date; + + if (duration === "custom") { + endedAt = endDate ? new Date(endDate) : today; + } else { + endedAt = new Date(today); + endedAt.setMonth(endedAt.getMonth() + duration); + } + + const startedAt = duration === "custom" && startDate ? new Date(startDate) : today; + + setIsSubmitting(true); + try { + const { data } = await createProject({ + projectName: name, + type, + status: "active", + startedAt, + endedAt, + description: description, + techStack: techStack, + }); + + const projectId = data?.[0]?.project_id; + if (projectId && members.length > 0) { + await updateProjectMember(projectId, members.map((m) => ({ ...m, projectId }))); + } + + queryClient.invalidateQueries({ queryKey: queryKeys.projects.all }); + showToast("프로젝트가 생성되었습니다.", "success"); + handleClose(); + } catch { + showToast("생성에 실패했습니다.", "error"); + } finally { + setIsSubmitting(false); + } + }; + + const canSubmit = !!duration && (duration !== "custom" || (!!startDate && !!endDate)); + + return ( + + + + + {/* 헤더 */} +
+
+
+ {[1, 2, 3].map((s) => ( +
+ ))} +
+

Step {step} / 3

+
+ + + +
+ + {/* 콘텐츠 */} +
+ {step === 1 && ( +
+
+

프로젝트 이름

+
+
+ { setName(e.target.value); setNameError(""); }} + placeholder="프로젝트 이름을 입력해주세요" + className="w-full px-6 py-4 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-main-400 text-foreground text-lg placeholder:text-muted-foreground text-center" + autoFocus + onKeyDown={(e) => e.key === "Enter" && handleNext()} + /> + {nameError &&

{nameError}

} +
+
+ )} + + {step === 2 && ( +
+
+

어떤 프로젝트인가요?

+
+
+ {PROJECT_TYPES.map(({ value, label, description: desc, icon: Icon }) => ( + + ))} +
+
+ )} + + {step === 3 && ( +
+
+

기간을 정해볼까요?

+
+
+ {DURATION_OPTIONS.map((opt) => ( + + ))} +
+ + {duration === "custom" && ( +
+
+ + setStartDate(e.target.value)} + className="w-full px-4 py-2.5 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-main-400 text-foreground" + /> +
+
+ + setEndDate(e.target.value)} + className="w-full px-4 py-2.5 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-main-400 text-foreground" + /> +
+
+ )} + + {/* 선택적 추가 정보 */} +
+ + + + 추가 정보 입력하기 (선택사항) + {isExpanded ? : } + + +
+
+ +