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 (
<>
-