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)); } }