From 678d63e27fcc2b0032efaf4d76eec322230ee4f7 Mon Sep 17 00:00:00 2001 From: nelee Date: Thu, 14 May 2026 22:34:24 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(dashboard):=20=EC=A0=84=EC=97=AD=20?= =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /dashboard 라우트 신규 생성 (로그인 후 첫 화면) - 위젯 4개: 내 태스크 현황, 마감 임박(7일), 참여 프로젝트 진행률, 최근 공지 - AppSidebar에 대시보드 nav item 첫 번째로 추가 - 로그인 후 리디렉션을 /dashboard로 변경 (auth/callback, middleware) - 브랜드 디자인 시스템 적용 (main-50 헤더, rounded-[14px], /10 투명도 뱃지) - 공지 쿼리는 /api/announcements 라우트 경유 (RLS 정책 없어 브라우저 직접 쿼리 불가) --- src/app/(main)/dashboard/page.tsx | 5 + src/app/auth/callback/route.ts | 2 +- src/components/layout/AppSidebar.tsx | 11 + src/features/dashboard/DashboardPage.tsx | 412 ++++++++++++++++++++ src/features/dashboard/DashboardSummary.tsx | 274 +++++++++++++ src/lib/constants/queryKeys.ts | 4 + src/middleware.ts | 4 +- 7 files changed, 709 insertions(+), 3 deletions(-) create mode 100644 src/app/(main)/dashboard/page.tsx create mode 100644 src/features/dashboard/DashboardPage.tsx create mode 100644 src/features/dashboard/DashboardSummary.tsx diff --git a/src/app/(main)/dashboard/page.tsx b/src/app/(main)/dashboard/page.tsx new file mode 100644 index 0000000..8a51134 --- /dev/null +++ b/src/app/(main)/dashboard/page.tsx @@ -0,0 +1,5 @@ +import DashboardPage from "@/features/dashboard/DashboardPage"; + +export default function Dashboard() { + return ; +} diff --git a/src/app/auth/callback/route.ts b/src/app/auth/callback/route.ts index 720b5b7..d169a2a 100644 --- a/src/app/auth/callback/route.ts +++ b/src/app/auth/callback/route.ts @@ -34,7 +34,7 @@ export async function GET(req: NextRequest) { return NextResponse.redirect(new URL("/login?error=auth_failed", req.url)); } - return NextResponse.redirect(new URL("/", req.url)); + return NextResponse.redirect(new URL("/dashboard", req.url)); } catch (error) { console.error("Authentication callback error:", error); return NextResponse.redirect(new URL("/login?error=server_error", req.url)); diff --git a/src/components/layout/AppSidebar.tsx b/src/components/layout/AppSidebar.tsx index 7754cbf..5d41cd0 100644 --- a/src/components/layout/AppSidebar.tsx +++ b/src/components/layout/AppSidebar.tsx @@ -6,6 +6,16 @@ import { useSession } from "next-auth/react"; import { useWorkspaceNav } from "@/providers/WorkspaceNavProvider"; const globalNavItems = [ + { + href: "/dashboard", + label: "대시보드", + icon: ( + + + + ), + }, { href: "/", label: "프로젝트", @@ -68,6 +78,7 @@ export default function AppSidebar() { const isGlobalActive = (href: string) => { if (href === "/") return pathname === "/"; + if (href === "/dashboard") return pathname === "/dashboard"; return pathname.startsWith(href); }; diff --git a/src/features/dashboard/DashboardPage.tsx b/src/features/dashboard/DashboardPage.tsx new file mode 100644 index 0000000..07324c6 --- /dev/null +++ b/src/features/dashboard/DashboardPage.tsx @@ -0,0 +1,412 @@ +"use client"; + +import { useMemo } from "react"; +import { useRouter } from "next/navigation"; +import { useSession } from "next-auth/react"; +import { useQuery } from "@tanstack/react-query"; +import { + format, + isToday, + isBefore, + addDays, + startOfDay, + differenceInDays, + formatDistanceToNow, +} from "date-fns"; +import { ko } from "date-fns/locale"; +import { ChevronRight } from "lucide-react"; +import Link from "next/link"; +import { supabase } from "@/lib/supabase/supabase"; +import { queryKeys } from "@/lib/constants/queryKeys"; +import { Task, TaskPriority } from "@/types"; +import Badge from "@/components/ui/Badge"; + +// ─── 타입 ──────────────────────────────────────────── + +type TaskWithProject = Task & { project_name: string }; + +type ProjectWithProgress = { + project_id: string; + project_name: string; + status: string; + total: number; + done: number; + rate: number; +}; + +type Notice = { + announcement_id: string; + title: string; + is_important: boolean; + created_at: string; +}; + +// ─── 상수 ──────────────────────────────────────────── + +const PRIORITY_MAP: Record = { + high: "high", + normal: "normal", + low: "low", +}; + +// ─── 메인 컴포넌트 ─────────────────────────────────── + +export default function DashboardPage() { + const router = useRouter(); + const { data: session } = useSession(); + const userId = session?.user?.user_id; + const userName = session?.user?.name; + + const todayStr = format(new Date(), "yyyy년 M월 d일 (E)", { locale: ko }); + const today = startOfDay(new Date()); + const sevenDaysLater = addDays(today, 7); + + // ── 내 태스크 쿼리 ────────────────────────────────── + const { data: myTasks = [], isLoading: tasksLoading } = useQuery({ + queryKey: queryKeys.dashboard.myTasks(userId), + queryFn: async () => { + const { data, error } = await supabase + .from("tasks") + .select(` + *, + kanban_boards!inner( + project_id, + projects!inner(project_name) + ) + `) + .eq("assigned_user_id", userId) + .order("ended_at", { ascending: true, nullsFirst: false }); + + if (error) throw error; + + return (data || []).map((row: any) => { + const { kanban_boards, ...task } = row; + return { + ...task, + project_id: kanban_boards.project_id, + project_name: kanban_boards.projects.project_name, + } as TaskWithProject; + }); + }, + enabled: !!userId, + staleTime: 1000 * 60 * 2, + }); + + // ── 참여 프로젝트 쿼리 ────────────────────────────── + const { data: projects = [], isLoading: projectsLoading } = useQuery({ + queryKey: ["dashboard", "projects", userId], + queryFn: async () => { + const { data, error } = await supabase + .from("project_members") + .select(` + project_id, + projects!inner( + project_id, + project_name, + status, + kanban_boards( + tasks(status) + ) + ) + `) + .eq("user_id", userId) + .limit(5); + + if (error) throw error; + + return (data || []).map((row: any) => { + const proj = row.projects; + const allTasks = (proj.kanban_boards || []).flatMap((b: any) => b.tasks || []); + const total = allTasks.length; + const done = allTasks.filter((t: any) => t.status === "done").length; + return { + project_id: proj.project_id, + project_name: proj.project_name, + status: proj.status, + total, + done, + rate: total > 0 ? Math.round((done / total) * 100) : 0, + } as ProjectWithProgress; + }); + }, + enabled: !!userId, + staleTime: 1000 * 60 * 3, + }); + + // ── 최근 공지 쿼리 ────────────────────────────────── + const { data: notices = [], isLoading: noticesLoading } = useQuery({ + queryKey: ["dashboard", "notices"], + queryFn: async () => { + const res = await fetch("/api/announcements?page=1&limit=3"); + if (!res.ok) throw new Error("공지 조회 실패"); + const json = await res.json(); + return (json.data || []) as Notice[]; + }, + staleTime: 1000 * 60 * 5, + }); + + // ── 통계 계산 ──────────────────────────────────────── + const stats = useMemo(() => { + const total = myTasks.length; + const inprogress = myTasks.filter((t) => t.status === "inprogress").length; + const done = myTasks.filter((t) => t.status === "done").length; + const overdue = myTasks.filter( + (t) => t.status !== "done" && t.ended_at && isBefore(new Date(t.ended_at), today) + ).length; + return { total, inprogress, done, overdue }; + }, [myTasks, today]); + + // ── 마감 임박 (7일 이내, 미완료) ───────────────────── + const urgentTasks = useMemo( + () => + myTasks + .filter( + (t) => + t.status !== "done" && + t.ended_at && + !isBefore(new Date(t.ended_at), today) && + isBefore(new Date(t.ended_at), sevenDaysLater) + ) + .slice(0, 7), + [myTasks, today, sevenDaysLater] + ); + + const getDDay = (endedAt: string) => { + const end = startOfDay(new Date(endedAt)); + if (isToday(end)) return "D-day"; + const diff = differenceInDays(end, today); + return `D-${diff}`; + }; + + return ( +
+ + {/* 인사 헤더 */} +
+

{todayStr}

+

+ 안녕하세요, {userName ?? "사용자"}님 +

+
+ + {/* Row 1 — 내 태스크 현황 */} +
+ 내 태스크 현황 +
+ + + + 0 ? "text-red-600 dark:text-red-400" : undefined} + /> +
+
+ + {/* Row 2 — 마감 임박 + 참여 프로젝트 */} +
+ + {/* 마감 임박 */} +
+ 마감 임박 · 7일 이내 +
+ {tasksLoading ? ( + + ) : urgentTasks.length === 0 ? ( + + ) : ( +
    + {urgentTasks.map((task) => { + const dday = getDDay(task.ended_at!); + const isOverdueTask = isBefore(new Date(task.ended_at!), today); + const isTodayTask = isToday(new Date(task.ended_at!)); + return ( +
  • router.push(`/project/workspace/${task.project_id}`)} + className="flex items-center gap-2.5 px-4 py-3 hover:bg-main-50 dark:hover:bg-main-900/10 cursor-pointer transition-colors group" + > + {task.priority && ( + + + + )} + + {task.title} + + {task.project_name} + + {dday} + +
  • + ); + })} +
+ )} +
+
+ + {/* 참여 프로젝트 진행률 */} +
+
+ 참여 프로젝트 + + 전체보기 + +
+
+ {projectsLoading ? ( + + ) : projects.length === 0 ? ( + + ) : ( +
    + {projects.map((proj) => ( +
  • router.push(`/project/workspace/${proj.project_id}`)} + className="px-4 py-3.5 hover:bg-main-50 dark:hover:bg-main-900/10 cursor-pointer transition-colors group" + > +
    + + {proj.project_name} + + + {proj.rate}% + +
    +
    +
    +
    +

    + {proj.done}/{proj.total}개 완료 +

    +
  • + ))} +
+ )} +
+
+
+ + {/* Row 3 — 최근 공지 */} +
+
+ 최근 공지사항 + + 전체보기 + +
+
+ {noticesLoading ? ( + + ) : notices.length === 0 ? ( + + ) : ( +
    + {notices.map((notice) => { + const isNew = differenceInDays(new Date(), new Date(notice.created_at)) < 7; + return ( +
  • + + {notice.is_important && ( + + 중요 + + )} + + {notice.title} + + {isNew && ( + + NEW + + )} + + {formatDistanceToNow(new Date(notice.created_at), { locale: ko, addSuffix: true })} + + +
  • + ); + })} +
+ )} +
+
+
+ ); +} + +// ─── 공통 서브 컴포넌트 ────────────────────────────── + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( +

{children}

+ ); +} + +interface StatCardProps { + label: string; + value: string; + iconBg: string; + dot: string; + valueColor?: string; +} + +function StatCard({ label, value, iconBg, dot, valueColor }: StatCardProps) { + return ( +
+
+
+ +
+

{label}

+
+

{value}

+
+ ); +} + +function EmptyWidget({ message }: { message: string }) { + return ( +
{message}
+ ); +} + +function SkeletonList({ count }: { count: number }) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( +
+ ))} +
+ ); +} diff --git a/src/features/dashboard/DashboardSummary.tsx b/src/features/dashboard/DashboardSummary.tsx new file mode 100644 index 0000000..a019c18 --- /dev/null +++ b/src/features/dashboard/DashboardSummary.tsx @@ -0,0 +1,274 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useSession } from "next-auth/react"; +import { useQuery } from "@tanstack/react-query"; +import { format, isToday, isBefore, addDays, startOfDay } from "date-fns"; +import { ko } from "date-fns/locale"; +import { AlertTriangle, Clock, CheckCircle2, PlayCircle, ListTodo } from "lucide-react"; +import { supabase } from "@/lib/supabase/supabase"; +import { queryKeys } from "@/lib/constants/queryKeys"; +import { Task, TaskPriority } from "@/types"; + +type TaskWithProject = Task & { project_name: string }; + +type UrgencyTab = "overdue" | "today" | "upcoming"; + +const PRIORITY_LABEL: Record = { + high: "높음", + normal: "보통", + low: "낮음", +}; + +const PRIORITY_COLOR: Record = { + high: "bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400", + normal: "bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400", + low: "bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400", +}; + +export default function DashboardSummary() { + const router = useRouter(); + const { data: session } = useSession(); + const userId = session?.user?.user_id; + const userName = session?.user?.name; + + const [activeTab, setActiveTab] = useState("overdue"); + + const { data: tasks = [], isLoading } = useQuery({ + queryKey: queryKeys.dashboard.myTasks(userId), + queryFn: async () => { + const { data, error } = await supabase + .from("tasks") + .select(` + *, + kanban_boards!inner( + project_id, + projects!inner(project_name) + ) + `) + .eq("assigned_user_id", userId) + .order("ended_at", { ascending: true, nullsFirst: false }); + + if (error) throw error; + + return (data || []).map((row: any) => { + const { kanban_boards, ...task } = row; + return { + ...task, + project_id: kanban_boards.project_id, + project_name: kanban_boards.projects.project_name, + } as TaskWithProject; + }); + }, + enabled: !!userId, + staleTime: 1000 * 60 * 2, + }); + + const today = startOfDay(new Date()); + const threeDaysLater = addDays(today, 3); + + const stats = useMemo(() => { + const total = tasks.length; + const inprogress = tasks.filter((t) => t.status === "inprogress").length; + const done = tasks.filter((t) => t.status === "done").length; + const overdue = tasks.filter( + (t) => t.status !== "done" && t.ended_at && isBefore(new Date(t.ended_at), today) + ).length; + return { total, inprogress, done, overdue }; + }, [tasks, today]); + + const urgentTasks = useMemo(() => { + const overdue = tasks + .filter((t) => t.status !== "done" && t.ended_at && isBefore(new Date(t.ended_at), today)) + .slice(0, 5); + + const todayDue = tasks + .filter((t) => t.status !== "done" && t.ended_at && isToday(new Date(t.ended_at))) + .slice(0, 5); + + const upcoming = tasks + .filter( + (t) => + t.status !== "done" && + t.ended_at && + !isBefore(new Date(t.ended_at), today) && + !isToday(new Date(t.ended_at)) && + isBefore(new Date(t.ended_at), threeDaysLater) + ) + .slice(0, 5); + + return { overdue, todayDue, upcoming }; + }, [tasks, today, threeDaysLater]); + + const tabCounts = { + overdue: urgentTasks.overdue.length, + today: urgentTasks.todayDue.length, + upcoming: urgentTasks.upcoming.length, + }; + + const currentList = + activeTab === "overdue" + ? urgentTasks.overdue + : activeTab === "today" + ? urgentTasks.todayDue + : urgentTasks.upcoming; + + const hasAnyUrgent = + tabCounts.overdue + tabCounts.today + tabCounts.upcoming > 0; + + const todayStr = format(new Date(), "yyyy년 M월 d일 (E)", { locale: ko }); + + return ( +
+ {/* 인사 헤더 */} +
+

+ 안녕하세요, {userName ?? "사용자"}님 +

+

{todayStr}

+
+ + {/* 통계 카드 */} +
+ } + iconBg="bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400" + /> + } + iconBg="bg-blue-100 dark:bg-blue-900/30 text-blue-500 dark:text-blue-400" + valueColor="text-blue-600 dark:text-blue-400" + /> + } + iconBg="bg-green-100 dark:bg-green-900/30 text-green-500 dark:text-green-400" + valueColor="text-green-600 dark:text-green-400" + /> + } + iconBg="bg-red-100 dark:bg-red-900/30 text-red-500 dark:text-red-400" + valueColor={stats.overdue > 0 ? "text-red-600 dark:text-red-400" : undefined} + /> +
+ + {/* 긴급 작업 */} +
+ {/* 탭 헤더 */} +
+ {( + [ + { key: "overdue", label: "지연", icon: }, + { key: "today", label: "오늘 마감", icon: }, + { key: "upcoming", label: "D-3 이내", icon: }, + ] as const + ).map((tab) => ( + + ))} +
+ + {/* 작업 목록 */} +
+ {isLoading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ) : !hasAnyUrgent ? ( +
+ 모든 작업이 기한 내에 있어요 🎉 +
+ ) : currentList.length === 0 ? ( +
+ 해당하는 작업이 없어요 +
+ ) : ( +
    + {currentList.map((task) => ( +
  • router.push(`/project/workspace/${task.project_id}`)} + className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-muted/50 cursor-pointer transition-colors group" + > + {task.priority && ( + + {PRIORITY_LABEL[task.priority]} + + )} + + {task.title} + + + {task.project_name} + + {task.ended_at && ( + + {format(new Date(task.ended_at), "M/d")} + + )} +
  • + ))} +
+ )} +
+
+
+ ); +} + +interface StatCardProps { + label: string; + value: string; + icon: React.ReactNode; + iconBg: string; + valueColor?: string; +} + +function StatCard({ label, value, icon, iconBg, valueColor }: StatCardProps) { + return ( +
+
+ {icon} +
+
+

{label}

+

{value}

+
+
+ ); +} diff --git a/src/lib/constants/queryKeys.ts b/src/lib/constants/queryKeys.ts index 2c57122..cb970b9 100644 --- a/src/lib/constants/queryKeys.ts +++ b/src/lib/constants/queryKeys.ts @@ -32,4 +32,8 @@ export const queryKeys = { role: (projectId: string, userId: string | undefined) => ["workspace-role", projectId, userId] as const, }, + dashboard: { + myTasks: (userId: string | undefined) => + ["dashboard", "my-tasks", userId] as const, + }, } as const; diff --git a/src/middleware.ts b/src/middleware.ts index c10da88..b9d82e2 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -58,13 +58,13 @@ export default withAuth( // 2) 로그인 한 유저 → 로그인 페이지 접근 불가 if (isAuthenticated && pathname === "/login") { - return NextResponse.redirect(new URL("/", req.url)); + return NextResponse.redirect(new URL("/dashboard", req.url)); } if (pathname.startsWith("/admin")) { // 관리자 라우트는 NextAuth role 기반으로만 허용 if (!hasNextAuthToken || role !== "admin") { - return NextResponse.redirect(new URL("/", req.url)); + return NextResponse.redirect(new URL("/dashboard", req.url)); } } From 321b724404e37f2fe9560490c26d38e1e81b4d8e Mon Sep 17 00:00:00 2001 From: nelee Date: Mon, 18 May 2026 01:22:43 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(data):=20=EB=AA=A9=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EB=B0=8F=20=EC=8B=9C=EB=93=9C=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=B6=94=EA=B0=80,=20=ED=83=9C=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=20=EC=A1=B0=ED=9A=8C=20RLS=20=EC=9A=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 페이지별 목 데이터 생성: mockUsers, mockProjects, mockKanbanBoards, mockTasks(21개), mockProjectMembers, mockProjectMemos, mockNotices(8개) - API 레이어 목 데이터 폴백 추가: projects, notices, adminUsers - 어드민 프로젝트 페이지 목 데이터 기반 동적 렌더링으로 전환 - getTasksByBoardId를 /api/kanban/tasks 서버 라우트로 전환 (RLS 우회) - 워크스페이스 역할 쿼리를 /api/projectMembers 서버 라우트로 전환 - README 업데이트: 신규 기능 및 기술 스택 반영 - supabase 시드 스크립트 gitignore 추가 --- .gitignore | 5 +- README.md | 74 +++-- src/app/(admin)/admin/projects/page.tsx | 101 ++++-- .../(main)/project/workspace/[id]/page.tsx | 17 +- src/app/api/tasks/tasks.ts | 28 +- src/app/data/index.ts | 7 + src/app/data/mockKanbanBoards.ts | 31 ++ src/app/data/mockNotices.ts | 137 ++++++++- src/app/data/mockProjectMembers.ts | 126 ++++++++ src/app/data/mockProjectMemos.ts | 167 ++++++++++ src/app/data/mockProjects.ts | 54 ++-- src/app/data/mockTasks.ts | 288 +++++++++++------- src/app/data/mockUsers.ts | 92 ++++++ src/features/task/ui/card/TaskCard.tsx | 258 ++++++++-------- src/features/task/ui/fields/DateInfo.tsx | 16 +- src/lib/api/adminUsers.ts | 25 +- src/lib/api/notices.ts | 13 +- src/lib/api/projects.ts | 30 +- 18 files changed, 1075 insertions(+), 394 deletions(-) create mode 100644 src/app/data/index.ts create mode 100644 src/app/data/mockKanbanBoards.ts create mode 100644 src/app/data/mockProjectMembers.ts create mode 100644 src/app/data/mockProjectMemos.ts create mode 100644 src/app/data/mockUsers.ts diff --git a/.gitignore b/.gitignore index c5a0b70..4f3891c 100644 --- a/.gitignore +++ b/.gitignore @@ -140,4 +140,7 @@ vite.config.ts.timestamp-* # Markdown documentation files (except README) *.md -!README.md \ No newline at end of file +!README.md +# Supabase seed scripts (contain service role key) +supabase/run-seed*.mjs +supabase/seed.sql diff --git a/README.md b/README.md index 395db96..6f813e5 100644 --- a/README.md +++ b/README.md @@ -11,20 +11,46 @@ **Taskry**는 복잡한 절차 없이 누구나 쉽게 업무 흐름을 관리할 수 있는 웹 기반 협업 도구입니다. 직관적인 UI와 실시간 동기화를 통해 팀의 생산성을 극대화합니다. -업데이트일: 2026-05-10 +업데이트일: 2026-05-17 ## 📸 Screen Shots -| 메인 대시보드 | 칸반보드 | 캘린더 | + ## 💡 주요 기능 (Key Features) - **📊 업무 진행 현황의 시각화** - 업무 상태(To Do, In Progress, Done)를 단계별로 구분하여 시각적으로 표현합니다. - - 드래그 앤 드롭 등을 통해 상태를 손쉽게 변경할 수 있습니다. (사용 라이브러리: @dnd-kit) + - 드래그 앤 드롭으로 상태를 손쉽게 변경할 수 있습니다. (사용 라이브러리: @dnd-kit) + +- **🗂️ 대시보드** + + - 진행 중인 프로젝트 현황과 주요 지표를 한눈에 확인할 수 있습니다. + +- **📅 유연하고 확장 가능한 일정 관리** + + - 월/주/일/어젠다 4가지 뷰로 일정을 확인할 수 있습니다. + - 일정 추가/수정/삭제(CRUD), 팀원 지정, 중요도 설정, 검색 및 필터링을 지원합니다. + +- **📝 프로젝트 메모** + + - 워크스페이스별 메모를 작성하고 고정/해제, 이모지 반응, 색상 레이블로 관리할 수 있습니다. + +- **📢 공지사항** + + - Lexical 리치 텍스트 에디터와 GitHub Flavored Markdown을 지원합니다. + - 중요도 플래그, 페이지네이션 기능이 포함됩니다. + +- **🔐 팀원 초대 및 권한 관리** + + - 워크스페이스에 팀원을 초대하고 역할(Role)을 지정할 수 있습니다. + +- **🌙 다크모드** + + - 라이트/다크 테마를 헤더에서 즉시 전환할 수 있습니다. (next-themes) - **🎨 직관적이고 간결한 UX** @@ -36,24 +62,30 @@ - 팀원의 작업 내역과 상태 변경이 실시간으로 보드에 반영됩니다. - Supabase를 활용하여 데이터의 정합성을 유지합니다. -- **📅 유연하고 확장 가능한 일정 관리** - - 일정 추가/수정/삭제(CRUD) 기능. - - 팀원 지정, 중요도 설정, 검색 및 필터링을 통해 원하는 정보를 빠르게 찾을 수 있습니다. +- **🛡️ 관리자 패널** + + - 사용자 역할 관리, 전체 프로젝트 조회, 공지사항 관리 기능을 제공합니다. ## 🛠️ 기술 스택 (Tech Stack) -| 분류 | 기술 | 버전 | 비고 | -| :-------------: | :----------------------- | :-------: | :---------------------------- | -| **Framework** | **Next.js** (App Router) | `16.0.1` | React 기반 풀스택 프레임워크 | -| **Runtime** | **React** | `19.2.0` | 최신 React 19 사용 | -| **Language** | **TypeScript** | `^5` | 안정적인 타입 시스템 | -| **Styling** | **Tailwind CSS** | `^4` | 유틸리티 퍼스트 CSS | -| **DnD** | **@dnd-kit** | `^6.3.1` | 드래그 앤 드롭 기능 | -| **Auth / DB** | **Supabase** | `^2.81.1` | Google OAuth 및 PostgreSQL DB | -| **Editor** | **Lexical** | `^0.38.2` | 리치 텍스트 에디터 | -| **Date** | **date-fns** | `^4.1.0` | 날짜 유틸리티 | -| **Linting** | ESLint | - | 코드 품질 관리 | -| **Package Mgr** | npm | - | 패키지 관리 | +| 분류 | 기술 | 버전 | 비고 | +| :-------------: | :--------------------------- | :--------: | :-------------------------------- | +| **Framework** | **Next.js** (App Router) | `16.0.1` | React 기반 풀스택 프레임워크 | +| **Runtime** | **React** | `19.2.0` | 최신 React 19 사용 | +| **Language** | **TypeScript** | `^5` | 안정적인 타입 시스템 | +| **Styling** | **Tailwind CSS** | `^4` | 유틸리티 퍼스트 CSS | +| **DnD** | **@dnd-kit** | `^6.3.1` | 드래그 앤 드롭 기능 | +| **Auth / DB** | **Supabase** | `^2.81.1` | Google·GitHub OAuth, PostgreSQL | +| **Auth** | **NextAuth** | `^4` | 세션 관리, Google·GitHub OAuth | +| **Fetching** | **TanStack Query** | `^5` | 서버 상태 관리 및 데이터 페칭 | +| **Editor** | **Lexical** | `^0.38.2` | 리치 텍스트 에디터 | +| **Calendar** | **react-big-calendar** | `^1.19.4` | 월/주/일/어젠다 캘린더 뷰 | +| **Markdown** | **react-markdown + remark-gfm** | `^10.1.0` | GitHub Flavored Markdown 렌더링 | +| **Toast** | **react-hot-toast** | `^2.6.0` | 토스트 알림 | +| **Theming** | **next-themes** | `^0` | 다크/라이트 모드 전환 | +| **Date** | **date-fns** | `^4.1.0` | 날짜 유틸리티 | +| **Linting** | ESLint | - | 코드 품질 관리 | +| **Package Mgr** | npm | - | 패키지 관리 | ## 📦 시작 가이드 (Getting Started) @@ -73,6 +105,8 @@ NEXTAUTH_URL=your_url NEXTAUTH_SECRET=your_secret GOOGLE_CLIENT_ID=your_client_id GOOGLE_CLIENT_SECRET=your_client_secret +GITHUB_CLIENT_ID=your_client_id +GITHUB_CLIENT_SECRET=your_client_secret # Database URLs DATABASE_URL=your_database_url @@ -87,7 +121,7 @@ SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key ### 3. 인증 운영 방식 - 현재 인증은 NextAuth와 Supabase Auth를 병행 운영합니다. -- 로그인 페이지에서 NextAuth Google 또는 Supabase Google 로그인으로 진입할 수 있습니다. +- 로그인 페이지에서 **Google** 또는 **GitHub** 소셜 로그인으로 진입할 수 있습니다. - 보호 라우트는 NextAuth 토큰과 Supabase 세션을 함께 확인합니다. - 서버 API는 통합 인증 유틸을 통해 사용자 컨텍스트를 추출합니다. - 관리자 API는 role === "admin" 기준으로만 허용합니다. diff --git a/src/app/(admin)/admin/projects/page.tsx b/src/app/(admin)/admin/projects/page.tsx index 3d3abea..69b10ee 100644 --- a/src/app/(admin)/admin/projects/page.tsx +++ b/src/app/(admin)/admin/projects/page.tsx @@ -3,9 +3,35 @@ import Button from "@/components/ui/Button"; import AdminPageWrapper from "@/components/features/admin/AdminPageWrapper"; import { primaryBgColor, primaryBorderColor } from "@/app/sample/color/page"; import { Icon } from "@/components/shared/Icon"; +import { mockProjects } from "@/app/data/mockProjects"; +import { mockProjectMembers } from "@/app/data/mockProjectMembers"; +import { mockTasks } from "@/app/data/mockTasks"; + +function getProgress(projectId: string): number { + const tasks = mockTasks.filter((t) => t.project_id === projectId); + if (tasks.length === 0) return 0; + const done = tasks.filter((t) => t.status === "done").length; + return Math.round((done / tasks.length) * 100); +} + +function getLeaderName(projectId: string): string { + const leader = mockProjectMembers.find( + (m) => m.project_id === projectId && m.role === "leader" + ); + return leader?.users.user_name ?? "-"; +} + +function getMemberCount(projectId: string): number { + return mockProjectMembers.filter((m) => m.project_id === projectId).length; +} + +const STATUS_BADGE: Record = { + active: "inProgress", + completed: "done", + archived: "dueSoon", +}; export default function AdminProjectsPage() { - const progress = 65; return ( } > -
-
-
-

- 프로젝트명 -

-
    -
  • 리더: 김이름
  • -
  • 멤버: 5명
  • -
-
-
- {/* 진행률 */} -
+ {mockProjects.map((project) => { + const progress = getProgress(project.project_id); + const leaderName = getLeaderName(project.project_id); + const memberCount = getMemberCount(project.project_id); + const badgeType = STATUS_BADGE[project.status] ?? "dueSoon"; + + return (
-
-
+ key={project.project_id} + className={`border ${primaryBorderColor.Color2[0]} py-7 px-5 rounded-xl mb-4`} + > +
+
+

+ {project.project_name} +

+
    +
  • 리더: {leaderName}
  • +
  • 멤버: {memberCount}명
  • + {project.tech_stack && ( +
  • + {project.tech_stack} +
  • + )} +
+
+
+
+
+
+

+ 태스크 완료율 {progress}% +

+
+ ); + })} ); } diff --git a/src/app/(main)/project/workspace/[id]/page.tsx b/src/app/(main)/project/workspace/[id]/page.tsx index fe56b47..f0ff72d 100644 --- a/src/app/(main)/project/workspace/[id]/page.tsx +++ b/src/app/(main)/project/workspace/[id]/page.tsx @@ -71,17 +71,12 @@ export default function ProjectPage() { 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; + const res = await fetch(`/api/projectMembers?id=${projectId}&userId=${userId}`); + if (!res.ok) return null; + const json = await res.json(); + const members = Array.isArray(json.data) ? json.data : Array.isArray(json) ? json : []; + const me = members.find((m: { user_id: string; role: string }) => m.user_id === userId); + return (me?.role as ProjectRole) ?? null; }, enabled: !!projectId && !!userId, staleTime: 1000 * 60 * 5, diff --git a/src/app/api/tasks/tasks.ts b/src/app/api/tasks/tasks.ts index 11fc3f1..ed38d69 100644 --- a/src/app/api/tasks/tasks.ts +++ b/src/app/api/tasks/tasks.ts @@ -93,7 +93,7 @@ export async function createTask( /** * 특정 프로젝트의 모든 Task 조회 - * ✅ JOIN을 사용해 한 번의 쿼리로 처리 + * ✅ /api/kanban/tasks 서버 라우트를 통해 supabaseAdmin으로 RLS 우회 */ export async function getTasksByBoardId( projectId: string @@ -103,29 +103,11 @@ export async function getTasksByBoardId( throw new Error("프로젝트 ID가 필요합니다."); } - const { data, error } = await supabase - .from("tasks") - .select( - ` - *, - kanban_boards!inner(project_id) - ` - ) - .eq("kanban_boards.project_id", projectId) - .order("created_at", { ascending: false }); - - if (error) throw error; - - // project_id를 Task 객체에 포함시키기 - const tasksWithProjectId = (data || []).map((task: any) => { - const { kanban_boards, ...taskData } = task; - return { - ...taskData, - project_id: kanban_boards.project_id, - }; - }); + const res = await fetch(`/api/kanban/tasks?boardId=${projectId}`); + if (!res.ok) throw new Error(`태스크 조회 실패: ${res.status}`); - return { data: tasksWithProjectId as Task[], error: null }; + const json = await res.json(); + return { data: (json.data ?? []) as Task[], error: null }; } catch (error) { return handleApiError("Task 조회", error); } diff --git a/src/app/data/index.ts b/src/app/data/index.ts new file mode 100644 index 0000000..a851b5f --- /dev/null +++ b/src/app/data/index.ts @@ -0,0 +1,7 @@ +export { mockUsers } from "./mockUsers"; +export { mockProjects } from "./mockProjects"; +export { mockKanbanBoards } from "./mockKanbanBoards"; +export { mockTasks } from "./mockTasks"; +export { mockProjectMembers } from "./mockProjectMembers"; +export { mockProjectMemos } from "./mockProjectMemos"; +export { mockNotices, STORAGE_KEY } from "./mockNotices"; diff --git a/src/app/data/mockKanbanBoards.ts b/src/app/data/mockKanbanBoards.ts new file mode 100644 index 0000000..611f063 --- /dev/null +++ b/src/app/data/mockKanbanBoards.ts @@ -0,0 +1,31 @@ +import { KanbanBoardType } from "@/types/kanban"; + +export const mockKanbanBoards: KanbanBoardType[] = [ + { + id: "board-001", + name: "웹 서비스 리뉴얼 보드", + description: "웹 서비스 리뉴얼 프로젝트 칸반 보드", + project_id: "project-001", + columns: "todo,inprogress,done", + created_at: "2026-02-20T09:00:00Z", + updated_at: "2026-05-10T09:00:00Z", + }, + { + id: "board-002", + name: "모바일 앱 개발 보드", + description: "모바일 앱 개발 프로젝트 칸반 보드", + project_id: "project-002", + columns: "todo,inprogress,done", + created_at: "2026-03-15T09:00:00Z", + updated_at: "2026-05-12T09:00:00Z", + }, + { + id: "board-003", + name: "백엔드 API 개선 보드", + description: "백엔드 API 개선 프로젝트 칸반 보드", + project_id: "project-003", + columns: "todo,inprogress,done", + created_at: "2026-01-10T09:00:00Z", + updated_at: "2026-04-30T17:00:00Z", + }, +]; diff --git a/src/app/data/mockNotices.ts b/src/app/data/mockNotices.ts index afe3e1a..a98394e 100644 --- a/src/app/data/mockNotices.ts +++ b/src/app/data/mockNotices.ts @@ -1,27 +1,132 @@ import { Notice } from "@/types/notice"; -// ------------------------------------------------------ -// 테스트용 mock 데이터 -// ------------------------------------------------------ - export const mockNotices: Notice[] = [ { - announcement_id: "1", - user_id: "a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d", - title: "테스트를 위한 중요 공지(중요)", - content: "이거는 중요 공지입니다.", + announcement_id: "notice-001", + user_id: "user-001", + title: "[필독] 서비스 점검 안내 (2026-05-25 02:00–04:00)", + content: `안녕하세요, Taskry 팀입니다. + +정기 서버 점검으로 인해 아래 시간 동안 서비스를 일시 중단합니다. + +- 점검 일시: 2026년 5월 25일 (월) 오전 2:00 ~ 4:00 +- 점검 내용: DB 마이그레이션, 보안 패치 적용 + +점검 중에는 로그인 및 데이터 저장이 불가합니다. +작업 중인 내용은 미리 저장해두시기 바랍니다. + +불편을 드려 죄송합니다.`, + is_important: true, + created_at: "2026-05-15T10:00:00Z", + updated_at: "2026-05-15T10:00:00Z", + }, + { + announcement_id: "notice-002", + user_id: "user-001", + title: "[업데이트] 칸반 보드 드래그 앤 드롭 개선", + content: `칸반 보드의 드래그 앤 드롭 기능이 업데이트되었습니다. + +**주요 변경사항:** +- 태스크를 컬럼 간 이동 시 애니메이션 추가 +- 여러 태스크 동시 선택 기능 지원 +- 모바일 터치 드래그 안정성 개선 + +피드백은 하단 문의 채널로 남겨주세요.`, + is_important: false, + created_at: "2026-05-12T09:00:00Z", + updated_at: "2026-05-12T09:00:00Z", + }, + { + announcement_id: "notice-003", + user_id: "user-001", + title: "[공지] 5월 정기 팀 회의 일정 안내", + content: `5월 정기 팀 회의 일정을 안내드립니다. + +- 일시: 2026년 5월 20일 (수) 오후 2:00 +- 장소: 화상 회의 (링크는 채널에 공유) +- 안건: 1분기 성과 리뷰, 2분기 로드맵 논의 + +참석 여부를 5월 18일까지 회신해주세요.`, + is_important: false, + created_at: "2026-05-10T14:00:00Z", + updated_at: "2026-05-10T14:00:00Z", + }, + { + announcement_id: "notice-004", + user_id: "user-001", + title: "[필독] 개인정보 처리방침 개정 안내", + content: `개인정보 처리방침이 2026년 6월 1일부로 개정됩니다. + +주요 변경사항: +1. 수집 항목에 '서비스 이용 기록' 추가 +2. 제3자 제공 조항 명확화 +3. 보관 기간 세분화 + +개정된 처리방침은 공지일로부터 14일 후 적용됩니다. +자세한 내용은 개인정보 처리방침 페이지를 확인해주세요.`, is_important: true, - created_at: "2025-11-18T14:00:00Z", - updated_at: "2025-11-18T14:00:00Z", + created_at: "2026-05-05T10:00:00Z", + updated_at: "2026-05-05T10:00:00Z", + }, + { + announcement_id: "notice-005", + user_id: "user-001", + title: "[업데이트] 캘린더 멀티뷰 기능 출시", + content: `요청이 많았던 캘린더 멀티뷰 기능이 출시되었습니다. + +지원 뷰: +- 월간 뷰 (기존) +- 주간 뷰 (신규) +- 일간 뷰 (신규) +- 어젠다 뷰 (신규) + +프로젝트 워크스페이스 내 캘린더 탭에서 바로 이용하실 수 있습니다.`, + is_important: false, + created_at: "2026-04-28T09:00:00Z", + updated_at: "2026-04-28T09:00:00Z", + }, + { + announcement_id: "notice-006", + user_id: "user-001", + title: "[공지] 다크모드 정식 지원 시작", + content: `Taskry에서 다크모드를 정식으로 지원합니다. + +GNB 우측 상단의 테마 토글 버튼을 통해 라이트/다크 모드를 전환할 수 있습니다. +선택한 테마는 브라우저에 저장되어 다음 방문 시에도 유지됩니다.`, + is_important: false, + created_at: "2026-04-20T10:00:00Z", + updated_at: "2026-04-20T10:00:00Z", }, { - announcement_id: "2", - user_id: "b2c3d4e5-f6a7-8b9c-0d1e-2f3a4b5c6d7e", - title: "테스트를 위한 일반 공지", - content: "이거는 일반 공지입니다.", + announcement_id: "notice-007", + user_id: "user-001", + title: "[업데이트] 프로젝트 메모 기능 추가", + content: `프로젝트 워크스페이스에 메모 기능이 추가되었습니다. + +주요 기능: +- 메모 작성 및 수정/삭제 +- 메모 고정(핀) 기능 +- 색상 레이블 (빨강/주황/노랑/초록/파랑/보라) +- 이모지 반응 추가 + +팀원들과 빠르게 메모를 공유해보세요!`, is_important: false, - created_at: "2025-11-15T09:30:00Z", - updated_at: "2025-11-15T09:30:00Z", + created_at: "2026-04-10T09:00:00Z", + updated_at: "2026-04-10T09:00:00Z", + }, + { + announcement_id: "notice-008", + user_id: "user-001", + title: "[필독] GitHub OAuth 로그인 추가 안내", + content: `이제 GitHub 계정으로도 Taskry에 로그인할 수 있습니다. + +로그인 페이지에서 'GitHub으로 로그인' 버튼을 통해 이용해주세요. +기존 Google 계정 로그인도 계속 지원됩니다. + +같은 이메일로 여러 OAuth를 연결하는 기능은 추후 지원 예정입니다.`, + is_important: true, + created_at: "2026-03-15T10:00:00Z", + updated_at: "2026-03-15T10:00:00Z", }, ]; diff --git a/src/app/data/mockProjectMembers.ts b/src/app/data/mockProjectMembers.ts new file mode 100644 index 0000000..1cba802 --- /dev/null +++ b/src/app/data/mockProjectMembers.ts @@ -0,0 +1,126 @@ +import { ProjectMemberWithUser } from "@/types/projectMember"; + +export const mockProjectMembers: ProjectMemberWithUser[] = [ + // ─── project-001 ────────────────────────────────────── + { + member_id: "member-001", + project_id: "project-001", + user_id: "user-001", + role: "leader", + joined_at: "2026-02-20T09:00:00Z", + users: { + user_id: "user-001", + user_name: "이남은", + email: "namune.lee@taskry.dev", + profile_image: "https://avatars.githubusercontent.com/u/10000001", + }, + }, + { + member_id: "member-002", + project_id: "project-001", + user_id: "user-002", + role: "member", + joined_at: "2026-02-21T09:00:00Z", + users: { + user_id: "user-002", + user_name: "김민중", + email: "minjung.kim@taskry.dev", + profile_image: "https://avatars.githubusercontent.com/u/10000002", + }, + }, + { + member_id: "member-003", + project_id: "project-001", + user_id: "user-003", + role: "member", + joined_at: "2026-02-21T10:00:00Z", + users: { + user_id: "user-003", + user_name: "이원찬", + email: "wonchan.lee@taskry.dev", + profile_image: "https://avatars.githubusercontent.com/u/10000003", + }, + }, + + // ─── project-002 ────────────────────────────────────── + { + member_id: "member-004", + project_id: "project-002", + user_id: "user-002", + role: "leader", + joined_at: "2026-03-15T09:00:00Z", + users: { + user_id: "user-002", + user_name: "김민중", + email: "minjung.kim@taskry.dev", + profile_image: "https://avatars.githubusercontent.com/u/10000002", + }, + }, + { + member_id: "member-005", + project_id: "project-002", + user_id: "user-003", + role: "member", + joined_at: "2026-03-16T09:00:00Z", + users: { + user_id: "user-003", + user_name: "이원찬", + email: "wonchan.lee@taskry.dev", + profile_image: "https://avatars.githubusercontent.com/u/10000003", + }, + }, + { + member_id: "member-006", + project_id: "project-002", + user_id: "user-004", + role: "member", + joined_at: "2026-03-16T10:00:00Z", + users: { + user_id: "user-004", + user_name: "이현수", + email: "hyunsu.lee@taskry.dev", + profile_image: "https://avatars.githubusercontent.com/u/10000004", + }, + }, + + // ─── project-003 ────────────────────────────────────── + { + member_id: "member-007", + project_id: "project-003", + user_id: "user-003", + role: "leader", + joined_at: "2026-01-10T09:00:00Z", + users: { + user_id: "user-003", + user_name: "이원찬", + email: "wonchan.lee@taskry.dev", + profile_image: "https://avatars.githubusercontent.com/u/10000003", + }, + }, + { + member_id: "member-008", + project_id: "project-003", + user_id: "user-001", + role: "member", + joined_at: "2026-01-11T09:00:00Z", + users: { + user_id: "user-001", + user_name: "이남은", + email: "namune.lee@taskry.dev", + profile_image: "https://avatars.githubusercontent.com/u/10000001", + }, + }, + { + member_id: "member-009", + project_id: "project-003", + user_id: "user-004", + role: "member", + joined_at: "2026-01-11T10:00:00Z", + users: { + user_id: "user-004", + user_name: "이현수", + email: "hyunsu.lee@taskry.dev", + profile_image: "https://avatars.githubusercontent.com/u/10000004", + }, + }, +]; diff --git a/src/app/data/mockProjectMemos.ts b/src/app/data/mockProjectMemos.ts new file mode 100644 index 0000000..897462f --- /dev/null +++ b/src/app/data/mockProjectMemos.ts @@ -0,0 +1,167 @@ +import { ProjectMemo } from "@/types/projectMemo"; + +export const mockProjectMemos: ProjectMemo[] = [ + // ─── project-001 ────────────────────────────────────── + { + memo_id: "memo-001", + project_id: "project-001", + user_id: "user-001", + content: + "5월 스프린트 목표: 메인 페이지 반응형 완성 + 성능 최적화 착수. 이번 주 금요일까지 UI 컴포넌트 PR 머지 필요.", + is_pinned: true, + pinned_at: "2026-05-01T09:00:00Z", + is_deleted: false, + deleted_at: null, + created_at: "2026-05-01T09:00:00Z", + updated_at: "2026-05-01T09:00:00Z", + reactions: { "👍": ["user-002", "user-003"], "🔥": ["user-002"] }, + label_color: "blue", + author: { + user_id: "user-001", + user_name: "이남은", + email: "namune.lee@taskry.dev", + }, + }, + { + memo_id: "memo-002", + project_id: "project-001", + user_id: "user-002", + content: + "Lighthouse 점수 현재 72점 → 목표 90점 이상. 이미지 lazy loading, 폰트 최적화 우선 진행 예정.", + is_pinned: false, + pinned_at: null, + is_deleted: false, + deleted_at: null, + created_at: "2026-05-08T14:00:00Z", + updated_at: "2026-05-08T14:00:00Z", + reactions: { "💡": ["user-001"] }, + label_color: "yellow", + author: { + user_id: "user-002", + user_name: "김민중", + email: "minjung.kim@taskry.dev", + }, + }, + { + memo_id: "memo-003", + project_id: "project-001", + user_id: "user-003", + content: "디자인 시스템 컬러 토큰 정리 완료. Figma 링크 팀 채널에 공유함.", + is_pinned: false, + pinned_at: null, + is_deleted: false, + deleted_at: null, + created_at: "2026-05-12T11:30:00Z", + updated_at: "2026-05-12T11:30:00Z", + reactions: {}, + label_color: "green", + author: { + user_id: "user-003", + user_name: "이원찬", + email: "wonchan.lee@taskry.dev", + }, + }, + + // ─── project-002 ────────────────────────────────────── + { + memo_id: "memo-004", + project_id: "project-002", + user_id: "user-002", + content: + "Expo SDK 51로 업그레이드 완료. 네이티브 모듈 의존성 충돌 없음. 로그인 화면 개발 다음 주 완료 예상.", + is_pinned: true, + pinned_at: "2026-05-10T09:00:00Z", + is_deleted: false, + deleted_at: null, + created_at: "2026-05-10T09:00:00Z", + updated_at: "2026-05-10T09:00:00Z", + reactions: { "👍": ["user-003", "user-004"] }, + label_color: "purple", + author: { + user_id: "user-002", + user_name: "김민중", + email: "minjung.kim@taskry.dev", + }, + }, + { + memo_id: "memo-005", + project_id: "project-002", + user_id: "user-004", + content: + "FCM 설정 문서 확인 필요. Android target SDK 34 이상에서 알림 권한 요청 방식이 변경됨.", + is_pinned: false, + pinned_at: null, + is_deleted: false, + deleted_at: null, + created_at: "2026-05-13T15:00:00Z", + updated_at: "2026-05-13T15:00:00Z", + reactions: { "❗": ["user-002"] }, + label_color: "red", + author: { + user_id: "user-004", + user_name: "이현수", + email: "hyunsu.lee@taskry.dev", + }, + }, + { + memo_id: "memo-006", + project_id: "project-002", + user_id: "user-003", + content: "디자인 리뷰 회의 2026-05-22 오후 2시. 온보딩 플로우 시안 준비해주세요.", + is_pinned: false, + pinned_at: null, + is_deleted: false, + deleted_at: null, + created_at: "2026-05-14T10:00:00Z", + updated_at: "2026-05-14T10:00:00Z", + reactions: { "✅": ["user-002", "user-004"] }, + label_color: "orange", + author: { + user_id: "user-003", + user_name: "이원찬", + email: "wonchan.lee@taskry.dev", + }, + }, + + // ─── project-003 ────────────────────────────────────── + { + memo_id: "memo-007", + project_id: "project-003", + user_id: "user-003", + content: + "프로젝트 완료 회고 정리. 주요 성과: API 응답 속도 평균 43% 개선, Swagger 문서화 100% 달성.", + is_pinned: true, + pinned_at: "2026-04-30T17:00:00Z", + is_deleted: false, + deleted_at: null, + created_at: "2026-04-30T17:00:00Z", + updated_at: "2026-04-30T17:00:00Z", + reactions: { "🎉": ["user-001", "user-004"], "👍": ["user-001"] }, + label_color: "green", + author: { + user_id: "user-003", + user_name: "이원찬", + email: "wonchan.lee@taskry.dev", + }, + }, + { + memo_id: "memo-008", + project_id: "project-003", + user_id: "user-004", + content: + "인덱스 추가 후 주문 목록 조회 쿼리 1.2s → 80ms로 개선. 복합 인덱스 설계 문서 Wiki에 업로드.", + is_pinned: false, + pinned_at: null, + is_deleted: false, + deleted_at: null, + created_at: "2026-04-12T11:00:00Z", + updated_at: "2026-04-12T11:00:00Z", + reactions: { "🚀": ["user-001", "user-003"] }, + label_color: "blue", + author: { + user_id: "user-004", + user_name: "이현수", + email: "hyunsu.lee@taskry.dev", + }, + }, +]; diff --git a/src/app/data/mockProjects.ts b/src/app/data/mockProjects.ts index 1866eb8..47c84a7 100644 --- a/src/app/data/mockProjects.ts +++ b/src/app/data/mockProjects.ts @@ -1,33 +1,43 @@ -// Mock 프로젝트 데이터 - -export interface Project { - id: string; - name: string; - description?: string; - created_at: string; - updated_at: string; -} +import { Project } from "@/types/project"; export const mockProjects: Project[] = [ { - id: "project-1", - name: "웹 서비스 리뉴얼", + project_id: "project-001", + user_id: "user-001", + project_name: "웹 서비스 리뉴얼", description: "메인 서비스 UI/UX 개선 및 성능 최적화", - created_at: "2025-11-01T00:00:00Z", - updated_at: "2025-11-13T00:00:00Z", + type: "웹", + status: "active", + tech_stack: "Next.js, TypeScript, Tailwind CSS", + started_at: "2026-03-01", + ended_at: "2026-06-30", + created_at: "2026-02-20T09:00:00Z", + updated_at: "2026-05-10T09:00:00Z", }, { - id: "project-2", - name: "모바일 앱 개발", + project_id: "project-002", + user_id: "user-002", + project_name: "모바일 앱 개발", description: "iOS/Android 크로스 플랫폼 앱 개발", - created_at: "2025-11-05T00:00:00Z", - updated_at: "2025-11-13T00:00:00Z", + type: "모바일", + status: "active", + tech_stack: "React Native, Expo, TypeScript", + started_at: "2026-04-01", + ended_at: "2026-08-31", + created_at: "2026-03-15T09:00:00Z", + updated_at: "2026-05-12T09:00:00Z", }, { - id: "project-3", - name: "백엔드 API 개선", - description: "REST API 성능 개선 및 문서화", - created_at: "2025-11-10T00:00:00Z", - updated_at: "2025-11-13T00:00:00Z", + project_id: "project-003", + user_id: "user-003", + project_name: "백엔드 API 개선", + description: "REST API 성능 개선 및 Swagger 문서화", + type: "백엔드", + status: "completed", + tech_stack: "Node.js, PostgreSQL, Supabase", + started_at: "2026-01-15", + ended_at: "2026-04-30", + created_at: "2026-01-10T09:00:00Z", + updated_at: "2026-04-30T17:00:00Z", }, ]; diff --git a/src/app/data/mockTasks.ts b/src/app/data/mockTasks.ts index 44d64a3..eb40d7d 100644 --- a/src/app/data/mockTasks.ts +++ b/src/app/data/mockTasks.ts @@ -1,172 +1,238 @@ -// Mock 데이터 - 실제 DB 없이 테스트용 -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const mockTasks: any[] = [ - // 프로젝트 1: project01 +import { Task } from "@/types/kanban"; + +export const mockTasks: Task[] = [ + // ─── project-001 / board-001 ─────────────────────────── { - id: "1", - kanban_board_id: "project01", + id: "task-001", + kanban_board_id: "board-001", + project_id: "project-001", title: "메인 페이지 UI 개선", description: "메인 페이지 레이아웃 및 반응형 디자인 적용", status: "inprogress", priority: "high", - assigned_user_id: "김철수", + assigned_user_id: "user-001", subtasks: [ - { id: "1-1", title: "와이어프레임 작성", completed: true }, - { id: "1-2", title: "컴포넌트 개발", completed: false }, - { id: "1-3", title: "반응형 테스트", completed: false }, + { id: "task-001-sub-1", title: "와이어프레임 작성", completed: true }, + { id: "task-001-sub-2", title: "컴포넌트 개발", completed: false }, + { id: "task-001-sub-3", title: "반응형 테스트", completed: false }, ], memo: "모바일 우선 디자인 적용", - started_at: "2025-11-10", - ended_at: "2025-11-20", - created_at: "2025-11-08T09:00:00Z", - updated_at: "2025-11-13T09:00:00Z", + started_at: "2026-05-01", + ended_at: "2026-05-20", + created_at: "2026-04-28T09:00:00Z", + updated_at: "2026-05-10T09:00:00Z", + assignee: { + user_id: "user-001", + name: "이남은", + email: "namune.lee@taskry.dev", + avatar_url: "https://avatars.githubusercontent.com/u/10000001", + }, }, { - id: "2", - kanban_board_id: "project01", + id: "task-002", + kanban_board_id: "board-001", + project_id: "project-001", title: "성능 최적화", description: "번들 사이즈 축소 및 로딩 속도 개선", status: "todo", priority: "high", - assigned_user_id: "이영희", + assigned_user_id: "user-002", subtasks: [ - { id: "2-1", title: "번들 분석", completed: false }, - { id: "2-2", title: "코드 스플리팅", completed: false }, - { id: "2-3", title: "이미지 최적화", completed: false }, + { id: "task-002-sub-1", title: "번들 분석", completed: false }, + { id: "task-002-sub-2", title: "코드 스플리팅 적용", completed: false }, + { id: "task-002-sub-3", title: "이미지 최적화", completed: false }, ], - memo: undefined, - started_at: undefined, - ended_at: "2025-11-25", - created_at: "2025-11-08T10:00:00Z", - updated_at: "2025-11-08T10:00:00Z", + memo: null, + started_at: null, + ended_at: "2026-05-28", + created_at: "2026-04-28T10:00:00Z", + updated_at: "2026-04-28T10:00:00Z", + assignee: { + user_id: "user-002", + name: "김민중", + email: "minjung.kim@taskry.dev", + avatar_url: "https://avatars.githubusercontent.com/u/10000002", + }, }, { - id: "3", - kanban_board_id: "project01", + id: "task-003", + kanban_board_id: "board-001", + project_id: "project-001", title: "사용자 피드백 반영", - description: "베타 테스트 피드백 검토 및 수정", + description: "베타 테스트 피드백 검토 및 수정 사항 적용", status: "done", priority: "normal", - assigned_user_id: "박민수", + assigned_user_id: "user-003", subtasks: [ - { id: "3-1", title: "피드백 수집", completed: true }, - { id: "3-2", title: "우선순위 분류", completed: true }, - { id: "3-3", title: "수정 완료", completed: true }, + { id: "task-003-sub-1", title: "피드백 수집 및 정리", completed: true }, + { id: "task-003-sub-2", title: "우선순위 분류", completed: true }, + { id: "task-003-sub-3", title: "수정 사항 반영", completed: true }, ], - memo: "사용자 만족도 상승", - started_at: "2025-11-05", - ended_at: "2025-11-12", - created_at: "2025-11-05T09:00:00Z", - updated_at: "2025-11-12T17:00:00Z", + memo: "사용자 만족도 크게 상승", + started_at: "2026-04-10", + ended_at: "2026-04-25", + created_at: "2026-04-08T09:00:00Z", + updated_at: "2026-04-25T17:00:00Z", + assignee: { + user_id: "user-003", + name: "이원찬", + email: "wonchan.lee@taskry.dev", + avatar_url: "https://avatars.githubusercontent.com/u/10000003", + }, }, - // 프로젝트 2: project02 + // ─── project-002 / board-002 ─────────────────────────── { - id: "4", - kanban_board_id: "project02", - title: "React Native 프로젝트 셋업", - description: "초기 프로젝트 구조 및 환경 설정", + id: "task-004", + kanban_board_id: "board-002", + project_id: "project-002", + title: "React Native 프로젝트 초기 셋업", + description: "초기 프로젝트 구조 및 개발 환경 설정", status: "done", priority: "high", - assigned_user_id: "최지원", + assigned_user_id: "user-002", subtasks: [ - { id: "4-1", title: "프로젝트 생성", completed: true }, - { id: "4-2", title: "라이브러리 설치", completed: true }, - { id: "4-3", title: "폴더 구조 정리", completed: true }, + { id: "task-004-sub-1", title: "프로젝트 생성", completed: true }, + { id: "task-004-sub-2", title: "주요 라이브러리 설치", completed: true }, + { id: "task-004-sub-3", title: "폴더 구조 정리", completed: true }, ], - memo: undefined, - started_at: "2025-11-05", - ended_at: "2025-11-08", - created_at: "2025-11-05T09:00:00Z", - updated_at: "2025-11-08T17:00:00Z", + memo: null, + started_at: "2026-04-01", + ended_at: "2026-04-05", + created_at: "2026-04-01T09:00:00Z", + updated_at: "2026-04-05T17:00:00Z", + assignee: { + user_id: "user-002", + name: "김민중", + email: "minjung.kim@taskry.dev", + avatar_url: "https://avatars.githubusercontent.com/u/10000002", + }, }, { - id: "5", - kanban_board_id: "project02", + id: "task-005", + kanban_board_id: "board-002", + project_id: "project-002", title: "로그인 화면 개발", - description: "소셜 로그인 및 일반 로그인 구현", + description: "Google·GitHub 소셜 로그인 및 이메일 로그인 구현", status: "inprogress", priority: "high", - assigned_user_id: "정수현", + assigned_user_id: "user-003", subtasks: [ - { id: "5-1", title: "UI 디자인", completed: true }, - { id: "5-2", title: "API 연동", completed: false }, - { id: "5-3", title: "유효성 검증", completed: false }, + { id: "task-005-sub-1", title: "UI 디자인 완료", completed: true }, + { id: "task-005-sub-2", title: "소셜 로그인 API 연동", completed: false }, + { id: "task-005-sub-3", title: "유효성 검증 로직", completed: false }, ], - memo: "카카오, 구글 로그인 추가", - started_at: "2025-11-09", - ended_at: "2025-11-16", - created_at: "2025-11-09T09:00:00Z", - updated_at: "2025-11-13T10:00:00Z", + memo: "Google, GitHub 로그인 모두 지원", + started_at: "2026-04-08", + ended_at: "2026-05-16", + created_at: "2026-04-07T09:00:00Z", + updated_at: "2026-05-13T10:00:00Z", + assignee: { + user_id: "user-003", + name: "이원찬", + email: "wonchan.lee@taskry.dev", + avatar_url: "https://avatars.githubusercontent.com/u/10000003", + }, }, { - id: "6", - kanban_board_id: "project02", + id: "task-006", + kanban_board_id: "board-002", + project_id: "project-002", title: "푸시 알림 기능", - description: "FCM을 이용한 푸시 알림 구현", + description: "FCM을 이용한 푸시 알림 수신 및 처리 구현", status: "todo", priority: "normal", - assigned_user_id: "강민지", + assigned_user_id: "user-004", subtasks: [], - memo: undefined, - started_at: undefined, - ended_at: "2025-11-30", - created_at: "2025-11-10T09:00:00Z", - updated_at: "2025-11-10T09:00:00Z", + memo: null, + started_at: null, + ended_at: "2026-06-30", + created_at: "2026-04-10T09:00:00Z", + updated_at: "2026-04-10T09:00:00Z", + assignee: { + user_id: "user-004", + name: "이현수", + email: "hyunsu.lee@taskry.dev", + avatar_url: "https://avatars.githubusercontent.com/u/10000004", + }, }, - // 프로젝트 3: project03 + // ─── project-003 / board-003 ─────────────────────────── { - id: "7", - kanban_board_id: "project03", - title: "API 문서화", - description: "Swagger를 이용한 API 문서 자동화", - status: "inprogress", + id: "task-007", + kanban_board_id: "board-003", + project_id: "project-003", + title: "Swagger API 문서화", + description: "Swagger를 이용한 REST API 문서 자동화", + status: "done", priority: "high", - assigned_user_id: "윤대현", + assigned_user_id: "user-003", subtasks: [ - { id: "7-1", title: "Swagger 설정", completed: true }, - { id: "7-2", title: "엔드포인트 문서화", completed: false }, - { id: "7-3", title: "예제 추가", completed: false }, + { id: "task-007-sub-1", title: "Swagger 설정", completed: true }, + { id: "task-007-sub-2", title: "전체 엔드포인트 문서화", completed: true }, + { id: "task-007-sub-3", title: "요청/응답 예제 추가", completed: true }, ], - memo: "개발자 가이드 포함", - started_at: "2025-11-11", - ended_at: "2025-11-18", - created_at: "2025-11-11T09:00:00Z", - updated_at: "2025-11-13T11:00:00Z", + memo: "개발자 가이드 포함 완료", + started_at: "2026-02-01", + ended_at: "2026-03-15", + created_at: "2026-01-28T09:00:00Z", + updated_at: "2026-03-15T17:00:00Z", + assignee: { + user_id: "user-003", + name: "이원찬", + email: "wonchan.lee@taskry.dev", + avatar_url: "https://avatars.githubusercontent.com/u/10000003", + }, }, { - id: "8", - kanban_board_id: "project03", + id: "task-008", + kanban_board_id: "board-003", + project_id: "project-003", title: "데이터베이스 쿼리 최적화", - description: "느린 쿼리 개선 및 인덱스 추가", - status: "todo", + description: "느린 쿼리 분석 및 인덱스 추가로 성능 개선", + status: "done", priority: "high", - assigned_user_id: "송하늘", + assigned_user_id: "user-004", subtasks: [ - { id: "8-1", title: "슬로우 쿼리 분석", completed: false }, - { id: "8-2", title: "인덱스 설계", completed: false }, - { id: "8-3", title: "성능 테스트", completed: false }, + { id: "task-008-sub-1", title: "슬로우 쿼리 분석", completed: true }, + { id: "task-008-sub-2", title: "인덱스 설계 및 적용", completed: true }, + { id: "task-008-sub-3", title: "성능 비교 테스트", completed: true }, ], - memo: undefined, - started_at: undefined, - ended_at: "2025-11-22", - created_at: "2025-11-10T10:00:00Z", - updated_at: "2025-11-10T10:00:00Z", + memo: null, + started_at: "2026-02-15", + ended_at: "2026-04-10", + created_at: "2026-02-10T10:00:00Z", + updated_at: "2026-04-10T15:00:00Z", + assignee: { + user_id: "user-004", + name: "이현수", + email: "hyunsu.lee@taskry.dev", + avatar_url: "https://avatars.githubusercontent.com/u/10000004", + }, }, { - id: "9", - kanban_board_id: "project03", + id: "task-009", + kanban_board_id: "board-003", + project_id: "project-003", title: "에러 핸들링 개선", - description: "통일된 에러 응답 형식 및 로깅 시스템 구축", - status: "todo", + description: "통일된 에러 응답 형식 및 구조화된 로깅 시스템 구축", + status: "done", priority: "normal", - assigned_user_id: "임서연", - subtasks: [], - memo: undefined, - started_at: undefined, - ended_at: "2025-11-28", - created_at: "2025-11-11T11:00:00Z", - updated_at: "2025-11-11T11:00:00Z", + assigned_user_id: "user-001", + subtasks: [ + { id: "task-009-sub-1", title: "에러 코드 체계 설계", completed: true }, + { id: "task-009-sub-2", title: "글로벌 에러 핸들러 구현", completed: true }, + ], + memo: null, + started_at: "2026-03-01", + ended_at: "2026-04-25", + created_at: "2026-02-25T11:00:00Z", + updated_at: "2026-04-25T16:00:00Z", + assignee: { + user_id: "user-001", + name: "이남은", + email: "namune.lee@taskry.dev", + avatar_url: "https://avatars.githubusercontent.com/u/10000001", + }, }, ]; diff --git a/src/app/data/mockUsers.ts b/src/app/data/mockUsers.ts new file mode 100644 index 0000000..45650ed --- /dev/null +++ b/src/app/data/mockUsers.ts @@ -0,0 +1,92 @@ +import { User } from "@/types/user"; + +export const mockUsers: User[] = [ + { + user_id: "542f30a4-f316-41c4-a7e9-723b3a29ddf6", + email: "lnu8926@gmail.com", + user_name: "이남은", + profile_image: "https://avatars.githubusercontent.com/u/60370886", + global_role: "admin", + auth_provider: "github", + is_active: true, + created_at: "2026-01-01T09:00:00Z", + updated_at: "2026-01-01T09:00:00Z", + }, + { + user_id: "2e2fea2d-3034-4e38-9c81-84c4b3b918b7", + email: "muni22kim@gmail.com", + user_name: "LUNA Lee", + profile_image: "https://avatars.githubusercontent.com/u/10000000", + global_role: "user", + auth_provider: "google", + is_active: true, + created_at: "2026-01-02T09:00:00Z", + updated_at: "2026-01-02T09:00:00Z", + }, + { + user_id: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeee0001", + email: "wonchan.lee@taskry.dev", + user_name: "이원찬", + profile_image: "https://avatars.githubusercontent.com/u/10000003", + global_role: "user", + auth_provider: "github", + is_active: true, + created_at: "2026-01-03T09:00:00Z", + updated_at: "2026-01-03T09:00:00Z", + }, + { + user_id: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeee0002", + email: "hyunsu.lee@taskry.dev", + user_name: "이현수", + profile_image: "https://avatars.githubusercontent.com/u/10000004", + global_role: "user", + auth_provider: "google", + is_active: true, + created_at: "2026-01-04T09:00:00Z", + updated_at: "2026-01-04T09:00:00Z", + }, + { + user_id: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeee0003", + email: "minjung.kim@taskry.dev", + user_name: "김민중", + profile_image: "https://avatars.githubusercontent.com/u/10000002", + global_role: "user", + auth_provider: "google", + is_active: true, + created_at: "2026-01-05T09:00:00Z", + updated_at: "2026-01-05T09:00:00Z", + }, + { + user_id: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeee0004", + email: "jieun.park@taskry.dev", + user_name: "박지은", + profile_image: "https://avatars.githubusercontent.com/u/10000005", + global_role: "user", + auth_provider: "github", + is_active: false, + created_at: "2026-01-06T09:00:00Z", + updated_at: "2026-03-01T09:00:00Z", + }, + { + user_id: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeee0005", + email: "seoyeon.choi@taskry.dev", + user_name: "최서연", + profile_image: "https://avatars.githubusercontent.com/u/10000006", + global_role: "user", + auth_provider: "google", + is_active: true, + created_at: "2026-02-01T09:00:00Z", + updated_at: "2026-02-01T09:00:00Z", + }, + { + user_id: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeee0006", + email: "junhyuk.park@taskry.dev", + user_name: "박준혁", + profile_image: "https://avatars.githubusercontent.com/u/10000007", + global_role: "user", + auth_provider: "github", + is_active: true, + created_at: "2026-02-10T09:00:00Z", + updated_at: "2026-02-10T09:00:00Z", + }, +]; diff --git a/src/features/task/ui/card/TaskCard.tsx b/src/features/task/ui/card/TaskCard.tsx index 5f17b3b..3ebbdd7 100644 --- a/src/features/task/ui/card/TaskCard.tsx +++ b/src/features/task/ui/card/TaskCard.tsx @@ -2,7 +2,7 @@ import { useMemo, useEffect, useState, useRef, useCallback } from "react"; import { useSortable } from "@dnd-kit/sortable"; import { Task } from "@/types"; import { CSS } from "@dnd-kit/utilities"; -import { Check } from "lucide-react"; +import { Check, FileText } from "lucide-react"; import PriorityBadge from "@/features/task/ui/fields/PriorityBadge"; import DateInfo from "@/features/task/ui/fields/DateInfo"; @@ -14,6 +14,7 @@ interface TaskCardProps { onTitleUpdate?: (title: string) => void; } + const TaskCard = ({ task, projectId: _projectId, @@ -30,21 +31,23 @@ const TaskCard = ({ if (!task.ended_at || task.status === "done") return false; const now = new Date(); if (task.use_time && task.end_time) { - const endDateStr = task.ended_at.includes("T") - ? task.ended_at.split("T")[0] - : task.ended_at; - return now > new Date(`${endDateStr}T${task.end_time}`); + const dateStr = task.ended_at.includes("T") ? task.ended_at.split("T")[0] : task.ended_at; + return now > new Date(`${dateStr}T${task.end_time}`); } const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const endDateStr = task.ended_at.includes("T") - ? task.ended_at.split("T")[0] - : task.ended_at; - const [year, month, day] = endDateStr.split("-").map(Number); - return new Date(year, month - 1, day) < today; + const dateStr = task.ended_at.includes("T") ? task.ended_at.split("T")[0] : task.ended_at; + const [y, m, d] = dateStr.split("-").map(Number); + return new Date(y, m - 1, d) < today; }, [task.ended_at, task.end_time, task.use_time, task.status]); const isCompleted = task.status === "done"; + const completedSubtasks = Array.isArray(task.subtasks) + ? task.subtasks.filter((s) => s.completed).length + : 0; + const totalSubtasks = Array.isArray(task.subtasks) ? task.subtasks.length : 0; + const progressPct = totalSubtasks > 0 ? Math.round((completedSubtasks / totalSubtasks) * 100) : 0; + useEffect(() => { const timer = setTimeout(() => setIsNew(false), 500); return () => clearTimeout(timer); @@ -59,9 +62,7 @@ const TaskCard = ({ const handleTitleSave = useCallback(() => { const trimmed = editedTitle.trim(); - if (trimmed && trimmed !== task.title) { - onTitleUpdate?.(trimmed); - } + if (trimmed && trimmed !== task.title) onTitleUpdate?.(trimmed); setIsEditingTitle(false); }, [editedTitle, task.title, onTitleUpdate]); @@ -77,24 +78,17 @@ const TaskCard = ({ const handleTitleKeyDown = useCallback( (e: React.KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleTitleSave(); - } - if (e.key === "Escape") { - setIsEditingTitle(false); - setEditedTitle(task.title); - } + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleTitleSave(); } + if (e.key === "Escape") { setIsEditingTitle(false); setEditedTitle(task.title); } }, [handleTitleSave, task.title] ); - const { attributes, listeners, setNodeRef, transform, isDragging } = - useSortable({ - id: task.id, - animateLayoutChanges: () => false, - disabled: isEditingTitle, - }); + const { attributes, listeners, setNodeRef, transform, isDragging } = useSortable({ + id: task.id, + animateLayoutChanges: () => false, + disabled: isEditingTitle, + }); const dragStyle = useMemo( () => ({ @@ -113,113 +107,117 @@ const TaskCard = ({ {...(!isOverlay ? attributes : {})} {...(!isOverlay ? listeners : {})} onClick={onClick} - className={` - bg-card text-foreground - p-4 rounded-[10px] border shadow-[0_1px_3px_rgba(0,0,0,0.08)] - cursor-grab active:cursor-grabbing - ${ - isCompleted - ? "border-l-[3px] border-l-emerald-500 border-border opacity-70" - : isOverdue - ? "border-l-[3px] border-l-red-500 border-border" - : "border-border" - } - ${!isDragging ? "hover:shadow-md hover:border-main-300 dark:hover:border-main-500" : ""} - ${isOverlay ? "shadow-2xl scale-[1.02]" : ""} - ${isNew && !isOverlay ? "animate-slide-in-down" : ""} - transition-shadow duration-150 - `} + className={[ + "group relative bg-card text-foreground", + "rounded-xl border border-border shadow-sm", + "cursor-grab active:cursor-grabbing select-none", + isCompleted ? "border-l-[3px] border-l-emerald-500" : "", + isOverdue ? "border-l-[3px] border-l-red-500" : "", + !isDragging ? "hover:shadow-md hover:border-main-300 dark:hover:border-main-500" : "", + isOverlay ? "shadow-2xl scale-[1.02]" : "", + isNew && !isOverlay ? "animate-slide-in-down" : "", + "transition-all duration-150", + ].filter(Boolean).join(" ")} > - {/* 제목 행 */} -
-
- {isCompleted && !isEditingTitle && ( -
- -
- )} - {isEditingTitle ? ( -