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/app/(main)/project/update/[id]/page.tsx b/src/app/(main)/project/update/[id]/page.tsx index a235d98..dffd846 100644 --- a/src/app/(main)/project/update/[id]/page.tsx +++ b/src/app/(main)/project/update/[id]/page.tsx @@ -1,10 +1,10 @@ -export default async function UpdateProject({params}:{params:Promise<{id:string}>}) { - const {id} = await params; +import { ProjectForm } from "@/features/project"; - // id를 통해서 프로젝트 API 요청 작성 - return ( +export default async function UpdateProjectPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return (
- {/* */} +
- ); + ); } \ No newline at end of file diff --git a/src/app/(main)/project/update/page.tsx b/src/app/(main)/project/update/page.tsx index f2ac069..b9a1540 100644 --- a/src/app/(main)/project/update/page.tsx +++ b/src/app/(main)/project/update/page.tsx @@ -1,12 +1,5 @@ -import { ProjectForm } from "@/features/project"; +import { redirect } from "next/navigation"; - -export default async function UpdateProject() { - - // id를 통해서 프로젝트 API 요청 작성 - return ( -
- -
- ); -} \ No newline at end of file +export default function UpdatePage() { + redirect("/"); +} diff --git a/src/app/(main)/project/workspace/[id]/page.tsx b/src/app/(main)/project/workspace/[id]/page.tsx new file mode 100644 index 0000000..be7ffb9 --- /dev/null +++ b/src/app/(main)/project/workspace/[id]/page.tsx @@ -0,0 +1,385 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import dynamic from "next/dynamic"; +import { queryKeys } from "@/lib/constants/queryKeys"; + +import BottomNavigation from "@/components/layout/BottomNavigation"; + +import { Task } from "@/types/kanban"; +import { showToast } from "@/lib/utils/toast"; + +import { + getTasksByBoardId, + createTask, + updateTask, + deleteTask, +} from "@/app/api/tasks/tasks"; + +import { useSession } from "next-auth/react"; +import { supabase } from "@/lib/supabase/supabase"; +import { ProjectRole } from "@/types"; + +const CalendarView = dynamic( + () => import("@/components/features/calendarView/CalendarView") +); +const KanbanBoard = dynamic( + () => import("@/components/features/kanban/KanbanBoard") +); +const MemoView = dynamic(() => import("@/components/features/kanban/MemoView")); +const ProjectInfoPanel = dynamic( + () => import("@/features/project").then((mod) => mod.ProjectInfoPanel) +); + +type NavItem = "calendar" | "kanban" | "memo" | "project"; + +export default function ProjectPage() { + const { id: projectId } = useParams<{ id: string }>(); + const router = useRouter(); + const { data: session } = useSession(); + const queryClient = useQueryClient(); + + const [kanbanBoardId, setKanbanBoardId] = useState(""); + const [currentView, setCurrentView] = useState("kanban"); + const [showMemoPanel, setShowMemoPanel] = useState(false); + const [showProjectInfoPanel, setShowProjectInfoPanel] = useState(false); + + const userId = session?.user?.user_id; + const taskQueryKey = queryKeys.tasks.list(projectId); + + const { data: projectInfo } = useQuery({ + queryKey: queryKeys.workspace.info(projectId), + queryFn: async () => { + const res = await fetch(`/api/projects/${projectId}`); + if (!res.ok) return { project_name: "알 수 없는 프로젝트", started_at: "", ended_at: "" }; + return res.json(); + }, + enabled: !!projectId, + staleTime: 1000 * 60 * 5, + }); + + const { data: tasks = [], isLoading: tasksLoading } = useQuery({ + queryKey: taskQueryKey, + queryFn: async () => { + const { data, error } = await getTasksByBoardId(projectId); + if (error) throw error; + return data || []; + }, + enabled: !!projectId, + staleTime: 0, + }); + + useQuery({ + queryKey: queryKeys.workspace.role(projectId, userId), + queryFn: async () => { + if (!userId || !projectId) return null; + const { data, error } = await supabase + .from("project_members") + .select("role") + .eq("project_id", projectId) + .eq("user_id", userId) + .maybeSingle(); + if (error) { + console.error("프로젝트 멤버 역할 조회 오류:", error); + return null; + } + return (data?.role as ProjectRole) ?? null; + }, + enabled: !!projectId && !!userId, + staleTime: 1000 * 60 * 5, + }); + + const projectName = projectInfo?.project_name || "이름 없는 프로젝트"; + const projectStartDate = projectInfo?.started_at || ""; + const projectEndDate = projectInfo?.ended_at || ""; + + useEffect(() => { + if (!projectId || projectId === "undefined") { + router.push("/"); + } + }, [projectId, router]); + + useEffect(() => { + if (!projectId || projectId === "undefined" || projectId === "null") return; + + const initKanbanBoard = async () => { + const kanbanRes = await fetch(`/api/kanban/boards?projectId=${projectId}`); + if (!kanbanRes.ok) return; + + const kanbanData = await kanbanRes.json(); + + if (kanbanData && kanbanData.length > 0) { + setKanbanBoardId(kanbanData[0].id); + } else { + const createRes = await fetch("/api/kanban/boards", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + project_id: projectId, + columns: "todo,inprogress,done", + }), + }); + if (createRes.ok) { + const newKanban = await createRes.json(); + setKanbanBoardId(newKanban.id); + } + } + }; + + initKanbanBoard(); + }, [projectId]); + + useEffect(() => { + if (!projectId || !kanbanBoardId) return; + + const channel = supabase + .channel(`taskry-board-${kanbanBoardId}`) + .on( + "postgres_changes", + { + event: "*", + schema: "public", + table: "tasks", + filter: `kanban_board_id=eq.${kanbanBoardId}`, + }, + (payload) => { + if (payload.eventType === "INSERT") { + const newTaskRaw = payload.new as Task; + + const enrichTask = async () => { + let assignee = null; + + if (newTaskRaw.assigned_user_id) { + const { data: userData } = await supabase + .from("users") + .select("user_id, user_name, email") + .eq("user_id", newTaskRaw.assigned_user_id) + .single(); + + if (userData) { + assignee = { + user_id: userData.user_id, + name: userData.user_name, + email: userData.email, + }; + } + } + + const enrichedTask = { ...newTaskRaw, assignee } as Task; + + queryClient.setQueryData(taskQueryKey, (prev: Task[]) => { + if (!prev) return [enrichedTask]; + if (prev.some((t) => t.id === enrichedTask.id)) return prev; + return [...prev, enrichedTask]; + }); + }; + + enrichTask(); + } else if (payload.eventType === "UPDATE") { + const updatedTaskRaw = payload.new as Task; + + const enrichUpdateTask = async () => { + let assignee = null; + + if (updatedTaskRaw.assigned_user_id) { + const { data: userData } = await supabase + .from("users") + .select("user_id, user_name, email") + .eq("user_id", updatedTaskRaw.assigned_user_id) + .single(); + + if (userData) { + assignee = { + user_id: userData.user_id, + name: userData.user_name, + email: userData.email, + }; + } + } + + const enrichedTask = { ...updatedTaskRaw, assignee } as Task; + + queryClient.setQueryData(taskQueryKey, (prev: Task[]) => + (prev || []).map((t) => (t.id === enrichedTask.id ? enrichedTask : t)) + ); + }; + + enrichUpdateTask(); + } else if (payload.eventType === "DELETE") { + const deletedTask = payload.old as Task; + queryClient.setQueryData(taskQueryKey, (prev: Task[]) => + (prev || []).filter((t) => t.id !== deletedTask.id) + ); + } + } + ) + .subscribe(); + + return () => { + supabase.removeChannel(channel); + }; + }, [projectId, kanbanBoardId, queryClient, taskQueryKey]); + + const createTaskMutation = useMutation({ + mutationFn: async (taskData: Omit) => { + const { data, error } = await createTask(taskData); + if (error) throw error; + return data; + }, + onSuccess: (data) => { + if (data) { + queryClient.setQueryData(taskQueryKey, (prev: Task[]) => { + if ((prev || []).some((t) => t.id === data.id)) return prev; + return [...(prev || []), data]; + }); + showToast("작업이 생성되었습니다.", "success"); + } + }, + onError: () => showToast("작업 생성 실패", "error"), + }); + + const updateTaskMutation = useMutation({ + mutationFn: async ({ taskId, updates }: { taskId: string; updates: Partial }) => { + const { error } = await updateTask(taskId, updates); + if (error) throw error; + return { taskId, updates }; + }, + onSuccess: ({ taskId, updates }) => { + queryClient.setQueryData(taskQueryKey, (prev: Task[]) => + (prev || []).map((t) => (t.id === taskId ? { ...t, ...updates } : t)) + ); + }, + }); + + const deleteTaskMutation = useMutation({ + mutationFn: async (taskId: string) => { + const { error } = await deleteTask(taskId); + if (error) throw error; + return taskId; + }, + onSuccess: (taskId) => { + queryClient.setQueryData(taskQueryKey, (prev: Task[]) => + (prev || []).filter((t) => t.id !== taskId) + ); + showToast("작업이 삭제되었습니다.", "success"); + }, + onError: () => showToast("작업 삭제 실패", "error"), + }); + + const handleCreateTask = (taskData: Omit) => { + createTaskMutation.mutate(taskData); + }; + + const handleUpdateTask = (taskId: string, updates: Partial) => { + updateTaskMutation.mutate({ taskId, updates }); + }; + + const handleDeleteTask = (taskId: string) => { + deleteTaskMutation.mutate(taskId); + }; + + const handleRefresh = () => { + queryClient.invalidateQueries({ queryKey: taskQueryKey }); + }; + + const handleViewChange = (view: NavItem) => { + if (view === "memo") { + setShowMemoPanel((prev) => !prev); + } else if (view === "project") { + router.push("/"); + } else { + setCurrentView(view); + setShowMemoPanel(false); + } + }; + + if (tasksLoading) { + return ( +
+

불러오는 중...

+
+ ); + } + + return ( +
+
+ + +
+
+ {currentView === "kanban" && ( + setShowProjectInfoPanel((prev) => !prev)} + project={{ + project_id: projectId, + project_name: projectName, + started_at: projectStartDate, + ended_at: projectEndDate, + }} + /> + )} + + {currentView === "calendar" && ( + {}} + onTaskCreated={handleRefresh} + onProjectInfoClick={() => setShowProjectInfoPanel((prev) => !prev)} + /> + )} +
+
+ + +
+ +
+ +
+
+ ); +} diff --git a/src/app/(main)/project/workspace/page.tsx b/src/app/(main)/project/workspace/page.tsx index a6cead5..1489114 100644 --- a/src/app/(main)/project/workspace/page.tsx +++ b/src/app/(main)/project/workspace/page.tsx @@ -1,483 +1,5 @@ -// src/app/(main)/project/workspace/page.tsx - 프로젝트 워크스페이스 메인 페이지 +import { redirect } from "next/navigation"; -"use client"; - -// React Hooks - 상태 관리 및 생명주기 관리 -import { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import dynamic from "next/dynamic"; -import { queryKeys } from "@/lib/constants/queryKeys"; - -// 메인 기능 컴포넌트들 - 칸반보드, 캘린더, 네비게이션 -import BottomNavigation from "@/components/layout/BottomNavigation"; - -// 타입 정의 및 유틸리티 -import { Task } from "@/types/kanban"; -import { showToast } from "@/lib/utils/toast"; - -// Task CRUD API 함수들 - 서버와의 데이터 통신 -import { - getTasksByBoardId, - createTask, - updateTask, - deleteTask, -} from "@/app/api/tasks/tasks"; - -// 인증 관련 - NextAuth 세션 관리 -import { useSession } from "next-auth/react"; - -// Supabase 실시간 구독 - 다중 사용자 동시 작업 지원 -import { supabase } from "@/lib/supabase/supabase"; -import { ProjectRole } from "@/types"; - -const CalendarView = dynamic( - () => import("@/components/features/calendarView/CalendarView") -); -const KanbanBoard = dynamic( - () => import("@/components/features/kanban/KanbanBoard") -); -const MemoView = dynamic(() => import("@/components/features/kanban/MemoView")); -const ProjectInfoPanel = dynamic( - () => import("@/features/project").then((mod) => mod.ProjectInfoPanel) -); - -// 네비게이션 타입 정의 - 하단 탭 네비게이션용 -type NavItem = "calendar" | "kanban" | "memo" | "project"; - -/** - * 프로젝트 워크스페이스 메인 페이지 - * - * 기능: - * - 칸반보드와 캘린더 뷰 전환 - * - 실시간 협업 메모 패널 - * - Supabase Realtime을 통한 다중 사용자 동시 작업 - * - 역할 기반 UI 제어 (Leader/Member - 클라이언트 사이드만) - * - sessionStorage 기반 워크플로우 제어 - */ -export default function ProjectPage() { - // URL 파라미터 대신 sessionStorage 사용 (워크플로우 제어) - // const params = useParams(); - // const projectId = params.id as string; - - const router = useRouter(); - const { data: session } = useSession(); - const queryClient = useQueryClient(); - - // === 핵심 상태 관리 === - const [projectId] = useState(() => { - if (typeof window === "undefined") return ""; - return sessionStorage.getItem("current_Project_Id") ?? ""; - }); // sessionStorage에서 가져올 프로젝트 ID - const [kanbanBoardId, setKanbanBoardId] = useState(""); // 칸반보드 ID (실시간 구독용) - - // === UI 상태 관리 === - const [currentView, setCurrentView] = useState("kanban"); // 메인 뷰 (칸반/캘린더) - const [showMemoPanel, setShowMemoPanel] = useState(false); // 메모 패널 토글 상태 - const [showProjectInfoPanel, setShowProjectInfoPanel] = useState(false); // 프로젝트 정보 패널 토글 상태 - - const userId = session?.user?.user_id; - const taskQueryKey = queryKeys.tasks.list(projectId); - - // === 프로젝트 정보 조회 === - const { data: projectInfo } = useQuery({ - queryKey: queryKeys.workspace.info(projectId), - queryFn: async () => { - const res = await fetch(`/api/projects/${projectId}`); - if (!res.ok) return { project_name: "알 수 없는 프로젝트", started_at: "", ended_at: "" }; - return res.json(); - }, - enabled: !!projectId, - staleTime: 1000 * 60 * 5, - }); - - // === 태스크 목록 조회 === - const { data: tasks = [], isLoading: tasksLoading } = useQuery({ - queryKey: taskQueryKey, - queryFn: async () => { - const { data, error } = await getTasksByBoardId(projectId); - if (error) throw error; - return data || []; - }, - enabled: !!projectId, - staleTime: 0, - }); - - // === 사용자 역할 조회 === - useQuery({ - queryKey: queryKeys.workspace.role(projectId, userId), - queryFn: async () => { - if (!userId || !projectId) return null; - const { data, error } = await supabase - .from("project_members") - .select("role") - .eq("project_id", projectId) - .eq("user_id", userId) - .maybeSingle(); - if (error) { - console.error("프로젝트 멤버 역할 조회 오류:", error); - return null; - } - return (data?.role as ProjectRole) ?? null; - }, - enabled: !!projectId && !!userId, - staleTime: 1000 * 60 * 5, - }); - - // 파생 값 - const projectName = projectInfo?.project_name || "이름 없는 프로젝트"; - const projectStartDate = projectInfo?.started_at || ""; - const projectEndDate = projectInfo?.ended_at || ""; - const loading = tasksLoading; - - /** - * 🔄 워크플로우 제어: sessionStorage 기반 접근 관리 - * - * 설계 의도: - * 1. URL 직접 접근 차단 - 프로젝트 ID가 URL에 노출되지 않음 - * 2. 워크플로우 강제 - 반드시 홈 → 프로젝트 선택 → 워크스페이스 순서 - * 3. 북마크/링크 공유 방지 - UX상 일관성 유지 - * 4. 새로고침 시 홈으로 리다이렉트 -> 이게 장점일지 단점일지는 고민 필요 - * - * ⚠️ 주의: 현재는 클라이언트 사이드 제어만 구현됨 (실제 보안은 아님) - */ - useEffect(() => { - if (!projectId) { - // 세션에 프로젝트 ID가 없으면 홈으로 리다이렉트 (워크플로우 제어) - showToast("프로젝트를 먼저 선택해주세요", "error"); - router.push("/"); - } - }, [projectId, router]); - - /** - * � 칸반보드 초기화 (최초 1회 자동 생성 포함) - * - 프로젝트 정보/태스크는 useQuery로 이동 - * - 보드 자동 생성은 조건부 mutation이라 useEffect 유지 - */ - useEffect(() => { - if (!projectId || projectId === "undefined" || projectId === "null") return; - - const initKanbanBoard = async () => { - const kanbanRes = await fetch(`/api/kanban/boards?projectId=${projectId}`); - if (!kanbanRes.ok) return; - - const kanbanData = await kanbanRes.json(); - - if (kanbanData && kanbanData.length > 0) { - setKanbanBoardId(kanbanData[0].id); - } else { - // 레거시 프로젝트: 칸반보드 자동 생성 - const createRes = await fetch("/api/kanban/boards", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - project_id: projectId, - columns: "todo,inprogress,done", - }), - }); - if (createRes.ok) { - const newKanban = await createRes.json(); - setKanbanBoardId(newKanban.id); - } - } - }; - - initKanbanBoard(); - }, [projectId]); - - /** - * 🔄 Supabase Realtime: 다중 사용자 동시 업데이트 - * - * 실시간 협업 구현: - * - PostgreSQL의 WAL(Write-Ahead Log) 기반 - * - 칸반보드별 채널 분리로 성능 최적화 - * - INSERT/UPDATE/DELETE 이벤트 실시간 동기화 - * - * 중복 방지: - * - React Strict Mode 중복 실행 대응 - * - 네트워크 재연결 시 이벤트 중복 처리 - * - 동시 접속자 간 경합 상태 방지 - */ - useEffect(() => { - if (!projectId || !kanbanBoardId) return; - - // 칸반보드별 채널 생성 (네임스페이스 분리) - const channel = supabase - .channel(`taskry-board-${kanbanBoardId}`) - .on( - "postgres_changes", - { - event: "*", // 모든 이벤트 수신 - schema: "public", - table: "tasks", - filter: `kanban_board_id=eq.${kanbanBoardId}`, // 현재 보드의 태스크만 - }, - (payload) => { - if (payload.eventType === "INSERT") { - const newTaskRaw = payload.new as Task; - - // 🔄 담당자 정보 추가 조회 (Realtime에는 JOIN 데이터 없음) - const enrichTask = async () => { - let assignee = null; - - if (newTaskRaw.assigned_user_id) { - const { data: userData } = await supabase - .from("users") - .select("user_id, user_name, email") - .eq("user_id", newTaskRaw.assigned_user_id) - .single(); - - if (userData) { - assignee = { - user_id: userData.user_id, - name: userData.user_name, - email: userData.email, - }; - } - } - - const enrichedTask = { - ...newTaskRaw, - assignee, - } as Task; - - queryClient.setQueryData(taskQueryKey, (prev: Task[]) => { - if (!prev) return [enrichedTask]; - if (prev.some((t) => t.id === enrichedTask.id)) return prev; - return [...prev, enrichedTask]; - }); - }; - - enrichTask(); - } else if (payload.eventType === "UPDATE") { - const updatedTaskRaw = payload.new as Task; - - // 🔄 담당자 정보 추가 조회 (UPDATE 시에도 필요) - const enrichUpdateTask = async () => { - let assignee = null; - - if (updatedTaskRaw.assigned_user_id) { - const { data: userData } = await supabase - .from("users") - .select("user_id, user_name, email") - .eq("user_id", updatedTaskRaw.assigned_user_id) - .single(); - - if (userData) { - assignee = { - user_id: userData.user_id, - name: userData.user_name, - email: userData.email, - }; - } - } - - const enrichedTask = { - ...updatedTaskRaw, - assignee, - } as Task; - - queryClient.setQueryData(taskQueryKey, (prev: Task[]) => - (prev || []).map((t) => (t.id === enrichedTask.id ? enrichedTask : t)) - ); - }; - - enrichUpdateTask(); - } else if (payload.eventType === "DELETE") { - const deletedTask = payload.old as Task; - queryClient.setQueryData(taskQueryKey, (prev: Task[]) => - (prev || []).filter((t) => t.id !== deletedTask.id) - ); - } - } - ) - .subscribe(); - - // 🧹 컴포넌트 언마운트 시 채널 정리 (메모리 누수 방지) - return () => { - supabase.removeChannel(channel); - }; - }, [projectId, kanbanBoardId, queryClient, taskQueryKey]); - - // === Task CRUD (useMutation) === - const createTaskMutation = useMutation({ - mutationFn: async (taskData: Omit) => { - const { data, error } = await createTask(taskData); - if (error) throw error; - return data; - }, - onSuccess: (data) => { - if (data) { - queryClient.setQueryData(taskQueryKey, (prev: Task[]) => { - if ((prev || []).some((t) => t.id === data.id)) return prev; - return [...(prev || []), data]; - }); - showToast("작업이 생성되었습니다.", "success"); - } - }, - onError: () => showToast("작업 생성 실패", "error"), - }); - - const updateTaskMutation = useMutation({ - mutationFn: async ({ taskId, updates }: { taskId: string; updates: Partial }) => { - const { error } = await updateTask(taskId, updates); - if (error) throw error; - return { taskId, updates }; - }, - onSuccess: ({ taskId, updates }) => { - queryClient.setQueryData(taskQueryKey, (prev: Task[]) => - (prev || []).map((t) => (t.id === taskId ? { ...t, ...updates } : t)) - ); - }, - }); - - const deleteTaskMutation = useMutation({ - mutationFn: async (taskId: string) => { - const { error } = await deleteTask(taskId); - if (error) throw error; - return taskId; - }, - onSuccess: (taskId) => { - queryClient.setQueryData(taskQueryKey, (prev: Task[]) => - (prev || []).filter((t) => t.id !== taskId) - ); - showToast("작업이 삭제되었습니다.", "success"); - }, - onError: () => showToast("작업 삭제 실패", "error"), - }); - - const handleCreateTask = (taskData: Omit) => { - createTaskMutation.mutate(taskData); - }; - - const handleUpdateTask = (taskId: string, updates: Partial) => { - updateTaskMutation.mutate({ taskId, updates }); - }; - - const handleDeleteTask = (taskId: string) => { - deleteTaskMutation.mutate(taskId); - }; - - const handleRefresh = () => { - queryClient.invalidateQueries({ queryKey: taskQueryKey }); - }; - - /** - * 🧭 스마트 뷰 전환 + 메모 패널 토글 - */ - const handleViewChange = (view: NavItem) => { - if (view === "memo") { - setShowMemoPanel((prev) => !prev); - } else if (view === "project") { - sessionStorage.removeItem("current_Project_Id"); - window.location.href = "/"; - } else { - setCurrentView(view); - setShowMemoPanel(false); - } - }; - - if (loading) { - return ( -
-

- 불러오는 중... -

-
- ); - } - - return ( -
-
- {/* 📋 프로젝트 정보 패널 - 왼쪽 사이드바 */} - - - {/* 🖥️ 메인 콘텐츠 영역 */} -
-
- {/* 📋 칸반보드 뷰 - dnd-kit 드래그앤드롭 */} - {currentView === "kanban" && ( - - setShowProjectInfoPanel((prev) => !prev) - } - project={{ - project_id: projectId, - project_name: projectName, - started_at: projectStartDate, - ended_at: projectEndDate, - }} - /> - )} - - {/* 📅 캘린더 뷰 - react-big-calendar 기반 */} - {currentView === "calendar" && ( - {}} // 태스크 선택 시 처리 (미구현) - onTaskCreated={handleRefresh} // 캘린더에서 생성 후 새로고침 - onProjectInfoClick={() => - setShowProjectInfoPanel((prev) => !prev) - } - /> - )} -
-
- - {/* 📝 메모 패널 - 오른쪽 사이드바 */} - -
- - {/* 🧭 하단 네비게이션 - 고정 위치 */} -
- -
-
- ); +export default function WorkspacePage() { + redirect("/"); } diff --git a/src/app/globals.css b/src/app/globals.css index 2cb6df5..d96e7b4 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -42,13 +42,16 @@ --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); - --color-main-100: #aee2eb; - --color-main-200: #87bac3; - --color-main-300: #6a929a; - --color-main-400: #4d6d72; - --color-main-500: #33494d; - --color-main-600: #1b282b; - --color-main-700: #0a1214; + --color-main-50: #E8F5F7; + --color-main-100: #D5EEF3; + --color-main-200: #BDE3EC; + --color-main-300: #9DD8E3; + --color-main-400: #6DD5E8; + --color-main-500: #5BBECE; + --color-main-600: #3A8A9A; + --color-main-700: #2A6070; + --color-main-800: #1A4A5A; + --color-main-900: #0D2530; --color-gray-100: #ebe9e9; --color-gray-200: #c5c0c1; @@ -71,81 +74,83 @@ :root { --radius: 0.625rem; - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); + --background: #EEF7F9; + --foreground: #1A4A5A; + --card: #FFFFFF; + --card-foreground: #1A4A5A; + --popover: #FFFFFF; + --popover-foreground: #1A4A5A; + --primary: #1A4A5A; + --primary-foreground: #FFFFFF; + --secondary: #3A8A9A; + --secondary-foreground: #FFFFFF; + --muted: #D5EEF3; + --muted-foreground: #3A8A9A; + --accent: #5BBECE; + --accent-foreground: #FFFFFF; + --destructive: #E57373; + --destructive-foreground: #FFFFFF; + --border: #BDE3EC; + --input: #FFFFFF; + --ring: #5BBECE; --chart-1: oklch(0.646 0.222 41.116); --chart-2: oklch(0.6 0.118 184.704); --chart-3: oklch(0.398 0.07 227.392); --chart-4: oklch(0.828 0.189 84.429); --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + --sidebar: #EEF7F9; + --sidebar-foreground: #1A4A5A; + --sidebar-primary: #1A4A5A; + --sidebar-primary-foreground: #FFFFFF; + --sidebar-accent: #D5EEF3; + --sidebar-accent-foreground: #1A4A5A; + --sidebar-border: #BDE3EC; + --sidebar-ring: #5BBECE; - --dark-title: #171415; - --dark-body: #171415; - --dark-description: #5a5153; - --dark-border: #c5c0c1; + --dark-title: #1A4A5A; + --dark-body: #1A4A5A; + --dark-description: #3A8A9A; + --dark-border: #BDE3EC; } .dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); + --background: #0D2530; + --foreground: #E8F5F7; + --card: #1A4A5A; + --card-foreground: #E8F5F7; + --popover: #1A4A5A; + --popover-foreground: #E8F5F7; + --primary: #E8F5F7; + --primary-foreground: #0D2530; + --secondary: #9DD8E3; + --secondary-foreground: #0D2530; + --muted: #2A6070; + --muted-foreground: #9DD8E3; + --accent: #6DD5E8; + --accent-foreground: #0D2530; + --destructive: #EF9A9A; + --destructive-foreground: #0D2530; + --border: #2A6070; + --input: #1A4A5A; + --ring: #6DD5E8; --chart-1: oklch(0.488 0.243 264.376); --chart-2: oklch(0.696 0.17 162.48); --chart-3: oklch(0.769 0.188 70.08); --chart-4: oklch(0.627 0.265 303.9); --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); - - --dark-title: #f5f5f5; - --dark-body: #171415; - --dark-description: #c5c0c1; - --dark-border: #373133; + --sidebar: #0D2530; + --sidebar-foreground: #E8F5F7; + --sidebar-primary: #6DD5E8; + --sidebar-primary-foreground: #0D2530; + --sidebar-accent: #2A6070; + --sidebar-accent-foreground: #E8F5F7; + --sidebar-border: #2A6070; + --sidebar-ring: #6DD5E8; + + --dark-title: #E8F5F7; + --dark-body: #E8F5F7; + --dark-description: #9DD8E3; + --dark-border: #2A6070; } @layer base { diff --git a/src/components/features/invite/InviteDecisionModal_V2.tsx b/src/components/features/invite/InviteDecisionModal_V2.tsx index d62071f..60fb5ef 100644 --- a/src/components/features/invite/InviteDecisionModal_V2.tsx +++ b/src/components/features/invite/InviteDecisionModal_V2.tsx @@ -107,14 +107,12 @@ export default function InviteDecisionModal_V2({ invite, onCloseModal }: { invit // 3) invite_id 제거 localStorage.removeItem("invite_id"); - sessionStorage.setItem("current_Project_Id", project_id!); //null 타입 절대 아님을 선언 - showToast("프로젝트에 참여했습니다!", "success"); // 4) 프로젝트 페이지 이동 setTimeout(() => { - router.push("/project/workspace"); - }, 0.3); + router.push(`/project/workspace/${project_id}`); + }, 300); } catch (err) { console.error("초대 수락 오류:", err); showToast("처리 중 오류가 발생했습니다.", "error"); diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 7a3dc17..2c64270 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -29,41 +29,29 @@ export function Header() { return ( <> -
-
- - - Taskry +
+
+ + Taskry + 프로젝트 관리 플랫폼 -
+
+ /> + className="w-[42px] h-[42px] rounded-[10px] border border-border" + /> {session?.user?.role === "admin" && ( @@ -72,31 +60,21 @@ export function Header() { btnType="icon" icon="crown" size={20} - className="w-10 h-10" - > + className="w-[42px] h-[42px] rounded-[10px] border border-border" + /> )} - +
diff --git a/src/features/project/ui/Card.tsx b/src/features/project/ui/Card.tsx index fa810b3..5e9126a 100644 --- a/src/features/project/ui/Card.tsx +++ b/src/features/project/ui/Card.tsx @@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
-
+
{sortedProjectList.map((project, index) => { return ( -
- -
+ const [open, setOpen] = useState(false); -
- 프로젝트 없음 -
-
- 나만의 첫 프로젝트를 만들어 시작합니다. + return ( + <> +
+
+ +
+
+

+ 프로젝트를 시작해볼까요? +

+

+ 새로운 프로젝트를 만들고 체계적으로 관리하세요 +

+
+
-
+ + ); } diff --git a/src/features/project/ui/ProjectBoardFilter.tsx b/src/features/project/ui/ProjectBoardFilter.tsx index 7f40461..e11b586 100644 --- a/src/features/project/ui/ProjectBoardFilter.tsx +++ b/src/features/project/ui/ProjectBoardFilter.tsx @@ -1,70 +1,94 @@ "use client"; import { useProjectBoard } from "@/providers/ProjectBoardProvider"; -import { DateSelect } from "./DateSelect"; -import { SortSelect } from "./SortSelect"; -import { ViewSelect } from "./ViewSelect"; +import { ArrowUpNarrowWide, ArrowDownWideNarrow, RotateCcw } from "lucide-react"; import { cn } from "@/lib/utils/utils"; -export default function PorjectBoardFilter() { +const VIEW_OPTIONS = [ + { value: "all", label: "전체" }, + { value: "personal", label: "내 프로젝트" }, +]; + +const DATE_OPTIONS = [ + { value: "startedAt", label: "시작일" }, + { value: "endedAt", label: "마감일" }, + { value: "createdAt", label: "생성일" }, + { value: "updatedAt", label: "수정일" }, +]; + +const DEFAULT_FILTER = { view: "all", date: "startedAt", sort: "asc" }; + +const pill = (active: boolean) => + cn( + "px-2.5 py-1 rounded-md text-sm transition-colors", + active + ? "bg-main-500/10 dark:bg-main-400/10 text-main-600 dark:text-main-300 font-medium" + : "text-gray-500 dark:text-gray-400 hover:text-foreground hover:bg-muted/40" + ); + +const Divider = () => ; + +export default function ProjectBoardFilter() { const { filter, setFilter } = useProjectBoard(); - const handleSelectChange = (name: string, value: string) => { - setFilter((prevFilter) => ({ - ...prevFilter, - [name]: value, - })); - }; + + const isNonDefault = + filter.view !== DEFAULT_FILTER.view || + filter.date !== DEFAULT_FILTER.date || + filter.sort !== DEFAULT_FILTER.sort; + return ( -
-
-
-
View Type:
-
- { - handleSelectChange("view", value); - }} - /> -
-
+
+ {VIEW_OPTIONS.map((opt) => ( + + ))} + + + + {DATE_OPTIONS.map((opt) => ( + + ))} -
-
정렬 기준:
-
- { - handleSelectChange("date", value); - }} - /> -
-
+ -
-
정렬:
-
- { - handleSelectChange("sort", value); - }} - /> -
-
-
+ + + + {isNonDefault && ( + <> + + + + )}
); } diff --git a/src/features/project/ui/ProjectBoardHeader.tsx b/src/features/project/ui/ProjectBoardHeader.tsx index a329fd0..0f69016 100644 --- a/src/features/project/ui/ProjectBoardHeader.tsx +++ b/src/features/project/ui/ProjectBoardHeader.tsx @@ -1,29 +1,27 @@ -"use client" +"use client"; -import { SectionHeader } from "@/components/shared/SectionHeader"; +import { useState } from "react"; 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/ProjectCard.tsx b/src/features/project/ui/ProjectCard.tsx index 72e3933..e0943c3 100644 --- a/src/features/project/ui/ProjectCard.tsx +++ b/src/features/project/ui/ProjectCard.tsx @@ -1,107 +1,114 @@ "use client"; -import Button from "@/components/ui/Button"; import { Icon } from "@/components/shared/Icon"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "./Card"; import { DeleteDialog } from "./DeleteDialog"; import { deleteProject, deleteProjectMember } from "../model"; import { useRouter } from "next/navigation"; import { showToast } from "@/lib/utils/toast"; import { useQueryClient } from "@tanstack/react-query"; import { queryKeys } from "@/lib/constants/queryKeys"; -import type { Project } from "../model"; +import type { Project, ProjectStatus } from "../model"; +import { Pencil, CalendarDays } from "lucide-react"; interface ProjectCardProps { project: Project; projectMember: Record | null; } -export default function ProjectCard({ - project, - projectMember, -}: ProjectCardProps) { - const router = useRouter(); - const queryClient = useQueryClient(); +const STATUS_CONFIG: Record = { + active: { label: "진행중", dot: "bg-emerald-400", text: "text-emerald-600 dark:text-emerald-400" }, + completed: { label: "완료", dot: "bg-gray-300 dark:bg-gray-500", text: "text-gray-400 dark:text-gray-500" }, + archived: { label: "일시정지", dot: "bg-amber-400", text: "text-amber-500 dark:text-amber-400" }, +}; - const handleSelectProject = (projectId: string) => { - // 세션 스토리지에 선택한 프로젝트 ID 저장 - sessionStorage.setItem("current_Project_Id", projectId); - // URL에 ID 노출없이 프로젝트 페이지로 이동 - router.push("/project/workspace"); - }; +function formatDate(d: string) { + const date = new Date(d); + const dateStr = date.toLocaleDateString("ko-KR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + const timeStr = date.toLocaleTimeString("ko-KR", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + return `${dateStr.replace(/\. /g, ".").replace(/\.$/, "")} ${timeStr}`; +} - const handleEditProject = (projectId: string) => { - // 세션 스토리지에 선택한 프로젝트 ID 저장 - sessionStorage.setItem("current_Project_Id", projectId); +function formatDateRange(start?: string, end?: string) { + if (!start && !end) return null; + if (!end) return formatDate(start!); + return `${formatDate(start!)} – ${formatDate(end)}`; +} - // URL에 ID 노출없이 프로젝트 페이지로 이동 - router.push("/project/update/"); - }; +export default function ProjectCard({ project, projectMember }: ProjectCardProps) { + const router = useRouter(); + const queryClient = useQueryClient(); - async function handleDeleteProject(id: string) { - // 프로젝트 및 프로젝트 멤버 정보 삭제 - await deleteProject(id); - await deleteProjectMember(id); + const memberCount = projectMember?.[project.project_id] ?? 1; + const status = STATUS_CONFIG[project.status] ?? STATUS_CONFIG.active; + const dateRange = formatDateRange(project.started_at, project.ended_at); - // 캐시 무효화 → 목록 자동 재조회 + async function handleDelete() { + await deleteProject(project.project_id); + await deleteProjectMember(project.project_id); queryClient.invalidateQueries({ queryKey: queryKeys.projects.all }); - showToast("삭제되었습니다.", "deleted"); } return ( - <> - { - handleSelectProject(project.project_id); - }} - > -
- - {project.project_name} - - -
- {project.description} -
-
+
router.push(`/project/workspace/${project.project_id}`)} + className="group relative flex flex-col bg-card rounded-2xl border border-border p-5 cursor-pointer transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5 hover:border-main-200 dark:hover:border-main-700" + > + {/* 상태 */} +
+ + {status.label} +
+ + {/* 제목 + 설명 */} +
+

+ {project.project_name} +

+

+ {project.description || "설명이 없습니다."} +

+
+ + {/* 날짜 */} + {dateRange && ( +
+ + {dateRange}
- -
- -
- {projectMember ? projectMember[project.project_id] : 1}팀원 -
-
-
- -
) => e.stopPropagation()}> -
-
) => e.stopPropagation()}> - handleDeleteProject(project.project_id)} - /> + )} + + {/* 팀원 수 + 호버 액션 */} +
+ + + {memberCount}명 + + + {/* 호버 시 나타나는 액션 버튼 */} +
e.stopPropagation()} + > + +
+
- - - +
+
+
); } diff --git a/src/features/project/ui/ProjectCreationModal.tsx b/src/features/project/ui/ProjectCreationModal.tsx new file mode 100644 index 0000000..2575dd2 --- /dev/null +++ b/src/features/project/ui/ProjectCreationModal.tsx @@ -0,0 +1,377 @@ +"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 { useRouter } from "next/navigation"; +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 router = useRouter(); + + 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(); + if (projectId) { + router.push(`/project/workspace/${projectId}`); + } + } 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-[10px] focus:outline-none focus:ring-2 focus:ring-main-500 text-foreground text-lg placeholder:text-main-600 dark:placeholder:text-main-400 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-[10px] focus:outline-none focus:ring-2 focus:ring-main-500 text-foreground" + /> +
+
+ + setEndDate(e.target.value)} + className="w-full px-4 py-2.5 bg-background border border-border rounded-[10px] focus:outline-none focus:ring-2 focus:ring-main-500 text-foreground" + /> +
+
+ )} + + {/* 선택적 추가 정보 */} +
+ + + + 추가 정보 입력하기 (선택사항) + {isExpanded ? : } + + +
+
+ +