411 lines
16 KiB
TypeScript
411 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import Link from "next/link";
|
|
import { BarChart2, FolderKanban, LayoutDashboard, ListChecks, SunMoon } from "lucide-react";
|
|
import { useAppLocale } from "@/features/i18n/LocaleProvider";
|
|
import { getDashboardMessages } from "@/features/i18n/messages/dashboard";
|
|
|
|
// 대시보드 페이지 (/dashboard)
|
|
// - /api/dashboard/overview 에서 요약/프로젝트별 통계를 가져와 카드 형태로 렌더링한다.
|
|
// - 비로그인(401) 응답 시 로그인 페이지로 리다이렉트하고 에러 메시지를 표시한다.
|
|
|
|
interface SummaryStats {
|
|
totalProjects: number;
|
|
totalSubmissions: number;
|
|
todaySubmissions: number;
|
|
last7DaysSubmissions: number;
|
|
}
|
|
|
|
interface DailySubmissionItem {
|
|
// 일별 제출 수 그래프를 위한 집계 데이터
|
|
date: string; // YYYY-MM-DD
|
|
count: number;
|
|
}
|
|
|
|
interface ProjectStatsItem {
|
|
projectId: string;
|
|
title: string;
|
|
slug: string;
|
|
status: string;
|
|
totalSubmissions: number;
|
|
lastSubmissionAt: string | null;
|
|
}
|
|
|
|
type PageStatus = "idle" | "loading" | "error";
|
|
|
|
export default function DashboardPage() {
|
|
const router = useRouter();
|
|
const locale = useAppLocale();
|
|
const t = getDashboardMessages(locale);
|
|
|
|
const [summary, setSummary] = useState<SummaryStats | null>(null);
|
|
const [projects, setProjects] = useState<ProjectStatsItem[]>([]);
|
|
const [dailySubmissions, setDailySubmissions] = useState<DailySubmissionItem[]>([]);
|
|
const [status, setStatus] = useState<PageStatus>("idle");
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
|
const [emailVerified, setEmailVerified] = useState<boolean | null>(null);
|
|
|
|
// 마운트 시 대시보드 개요 데이터를 불러온다.
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
const load = async () => {
|
|
try {
|
|
setStatus("loading");
|
|
setErrorMessage(null);
|
|
|
|
const res = await fetch("/api/dashboard/overview");
|
|
|
|
if (!res.ok) {
|
|
if (cancelled) return;
|
|
|
|
if (res.status === 401) {
|
|
setStatus("error");
|
|
setErrorMessage(t.errorUnauthorized);
|
|
router.push("/login");
|
|
return;
|
|
}
|
|
|
|
setStatus("error");
|
|
setErrorMessage(t.errorGeneric);
|
|
return;
|
|
}
|
|
|
|
const data = (await res.json()) as {
|
|
summaryStats: SummaryStats;
|
|
projectStats: ProjectStatsItem[];
|
|
dailySubmissions?: DailySubmissionItem[];
|
|
};
|
|
|
|
if (cancelled) return;
|
|
|
|
setSummary(data.summaryStats ?? null);
|
|
setProjects(Array.isArray(data.projectStats) ? data.projectStats : []);
|
|
setDailySubmissions(
|
|
Array.isArray(data.dailySubmissions) ? data.dailySubmissions : [],
|
|
);
|
|
setStatus("idle");
|
|
setErrorMessage(null);
|
|
} catch {
|
|
if (!cancelled) {
|
|
setStatus("error");
|
|
setErrorMessage(t.errorGeneric);
|
|
}
|
|
}
|
|
};
|
|
|
|
void load();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [router]);
|
|
|
|
// 마운트 시 현재 사용자 이메일 인증 여부를 확인한다.
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
const loadMe = async () => {
|
|
try {
|
|
const res = await fetch("/api/auth/me");
|
|
if (cancelled || !res.ok) return;
|
|
|
|
const data = (await res.json().catch(() => null)) as any;
|
|
if (!cancelled && data) {
|
|
setEmailVerified(Boolean(data.emailVerified));
|
|
}
|
|
} catch {
|
|
// ignore – 인증 여부를 알 수 없으면 배너만 표시하지 않는다.
|
|
}
|
|
};
|
|
|
|
void loadMe();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
const safeSummary: SummaryStats =
|
|
summary ?? {
|
|
totalProjects: 0,
|
|
totalSubmissions: 0,
|
|
todaySubmissions: 0,
|
|
last7DaysSubmissions: 0,
|
|
};
|
|
|
|
const handleLogout = async () => {
|
|
try {
|
|
const res = await fetch("/api/auth/logout", {
|
|
method: "POST",
|
|
});
|
|
|
|
if (!res.ok) {
|
|
return;
|
|
}
|
|
|
|
router.push("/login");
|
|
} catch {
|
|
}
|
|
};
|
|
|
|
const handleToggleTheme = () => {
|
|
if (typeof document === "undefined") {
|
|
return;
|
|
}
|
|
|
|
const root = document.documentElement;
|
|
if (!root) {
|
|
return;
|
|
}
|
|
|
|
root.classList.toggle("dark");
|
|
};
|
|
|
|
return (
|
|
<main className="min-h-screen flex flex-col bg-slate-100 text-slate-900 dark:bg-slate-950 dark:text-slate-50">
|
|
<header className="border-b border-slate-200 px-6 py-4 flex items-center justify-between bg-white/80 backdrop-blur dark:border-slate-800 dark:bg-slate-950/80">
|
|
<div>
|
|
<h1 className="text-2xl font-bold tracking-tight">{t.title}</h1>
|
|
<p className="text-sm text-slate-500 mt-1 dark:text-slate-400">{t.description}</p>
|
|
</div>
|
|
<div className="flex items-center gap-3 text-sm">
|
|
<nav className="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-white/80 px-1.5 py-1 shadow-sm dark:border-slate-700 dark:bg-slate-900/80">
|
|
<Link
|
|
href="/dashboard"
|
|
aria-current="page"
|
|
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-semibold bg-sky-600 text-white shadow-sm hover:bg-sky-700"
|
|
>
|
|
<LayoutDashboard className="w-4 h-4" aria-hidden="true" />
|
|
<span>{t.navDashboard}</span>
|
|
</Link>
|
|
<Link
|
|
href="/projects"
|
|
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium text-slate-700 hover:bg-slate-100 hover:text-sky-700 dark:text-slate-200 dark:hover:bg-slate-800 dark:hover:text-sky-200"
|
|
>
|
|
<FolderKanban className="w-4 h-4" aria-hidden="true" />
|
|
<span>{t.navProjects}</span>
|
|
</Link>
|
|
<Link
|
|
href="/projects/submissions"
|
|
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium text-emerald-700 hover:bg-emerald-50 hover:text-emerald-800 dark:text-emerald-200 dark:hover:bg-emerald-900/60 dark:hover:text-emerald-100"
|
|
>
|
|
<ListChecks className="w-4 h-4" aria-hidden="true" />
|
|
<span>{t.navSubmissions}</span>
|
|
</Link>
|
|
<Link
|
|
href="/reports"
|
|
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium text-slate-700 hover:bg-slate-100 hover:text-sky-700 dark:text-slate-200 dark:hover:bg-slate-800 dark:hover:text-sky-200"
|
|
>
|
|
<BarChart2 className="w-4 h-4" aria-hidden="true" />
|
|
<span>{t.navReports ?? "Reports"}</span>
|
|
</Link>
|
|
</nav>
|
|
<button
|
|
type="button"
|
|
className="inline-flex items-center gap-1 rounded-full border border-slate-300 bg-white/80 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800"
|
|
onClick={handleToggleTheme}
|
|
>
|
|
<SunMoon className="w-4 h-4" aria-hidden="true" />
|
|
<span>{t.themeToggleLabel}</span>
|
|
</button>
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
className="inline-flex items-center gap-1 rounded-full border border-slate-300 bg-white/80 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800"
|
|
onClick={() => {
|
|
setIsMenuOpen((prev) => !prev);
|
|
}}
|
|
>
|
|
{t.menuLabel}
|
|
</button>
|
|
{isMenuOpen && (
|
|
<div className="absolute right-0 mt-1 w-32 rounded border border-slate-200 bg-white shadow-lg z-20 dark:border-slate-700 dark:bg-slate-900/95">
|
|
<button
|
|
type="button"
|
|
className="w-full px-3 py-2 text-left text-xs text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800"
|
|
onClick={() => {
|
|
setIsMenuOpen(false);
|
|
router.push("/mypage");
|
|
}}
|
|
>
|
|
{t.myPageLabel}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="w-full px-3 py-2 text-left text-xs text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800"
|
|
onClick={() => {
|
|
setIsMenuOpen(false);
|
|
void handleLogout();
|
|
}}
|
|
>
|
|
{t.logoutLabel}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<section className="flex-1 px-6 py-4 space-y-4 overflow-auto bg-slate-50/60 dark:bg-transparent">
|
|
{status === "error" && errorMessage && (
|
|
<p className="text-xs text-red-500 dark:text-red-300">{errorMessage}</p>
|
|
)}
|
|
|
|
{emailVerified === false && (
|
|
<p className="text-xs text-amber-700 mb-1 dark:text-amber-300">
|
|
{t.emailNotVerifiedBanner}
|
|
</p>
|
|
)}
|
|
|
|
{status === "loading" && (
|
|
<p className="text-xs text-slate-400">{t.loadingText}</p>
|
|
)}
|
|
|
|
{/* 상단 요약 위젯 영역 */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
|
|
<div
|
|
data-testid="dashboard-summary-total-projects"
|
|
className="rounded-xl border border-slate-200 bg-white/80 px-4 py-3 flex flex-col gap-1 shadow-sm dark:border-slate-800 dark:bg-slate-900/70"
|
|
>
|
|
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400">
|
|
{t.summaryTotalProjectsLabel}
|
|
</span>
|
|
<span className="text-2xl font-bold tracking-tight">{safeSummary.totalProjects}</span>
|
|
</div>
|
|
|
|
<div
|
|
data-testid="dashboard-summary-total-submissions"
|
|
className="rounded-xl border border-slate-200 bg-white/80 px-4 py-3 flex flex-col gap-1 shadow-sm dark:border-slate-800 dark:bg-slate-900/70"
|
|
>
|
|
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400">
|
|
{t.summaryTotalSubmissionsLabel}
|
|
</span>
|
|
<span className="text-2xl font-bold tracking-tight">{safeSummary.totalSubmissions}</span>
|
|
</div>
|
|
|
|
<div
|
|
data-testid="dashboard-summary-today-submissions"
|
|
className="rounded-xl border border-slate-200 bg-white/80 px-4 py-3 flex flex-col gap-1 shadow-sm dark:border-slate-800 dark:bg-slate-900/70"
|
|
>
|
|
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400">
|
|
{t.summaryTodaySubmissionsLabel}
|
|
</span>
|
|
<span className="text-2xl font-bold tracking-tight">{safeSummary.todaySubmissions}</span>
|
|
</div>
|
|
|
|
<div
|
|
data-testid="dashboard-summary-last7days-submissions"
|
|
className="rounded-xl border border-slate-200 bg-white/80 px-4 py-3 flex flex-col gap-1 shadow-sm dark:border-slate-800 dark:bg-slate-900/70"
|
|
>
|
|
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400">
|
|
{t.summaryLast7DaysSubmissionsLabel}
|
|
</span>
|
|
<span className="text-2xl font-bold tracking-tight">{safeSummary.last7DaysSubmissions}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 일별 제출 수 간단 그래프 영역 */}
|
|
<div
|
|
data-testid="dashboard-daily-chart"
|
|
className="mt-2 rounded-xl border border-slate-200 bg-white/80 px-4 py-3 shadow-sm dark:border-slate-800 dark:bg-slate-900/70"
|
|
>
|
|
<div className="flex items-baseline justify-between mb-2">
|
|
<h2 className="text-sm font-semibold text-slate-800 dark:text-slate-200">{t.chartTitle}</h2>
|
|
<span className="text-[10px] text-slate-500 dark:text-slate-500">{t.chartSubtitle}</span>
|
|
</div>
|
|
|
|
{dailySubmissions.length === 0 ? (
|
|
<p className="text-[11px] text-slate-500 dark:text-slate-400">{t.chartEmptyLabel}</p>
|
|
) : (
|
|
<div className="flex items-end gap-2 text-[10px]">
|
|
{dailySubmissions.map((item) => (
|
|
<div
|
|
key={item.date}
|
|
data-testid="dashboard-daily-chart-bar"
|
|
className="flex flex-col items-center gap-1"
|
|
>
|
|
<div
|
|
className="w-6 rounded-t bg-emerald-500/80"
|
|
style={{ height: `${Math.max(8, item.count * 12)}px` }}
|
|
/>
|
|
<span className="text-slate-700 font-semibold dark:text-slate-300">{item.count}</span>
|
|
<span className="text-slate-500 dark:text-slate-500">{item.date}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 프로젝트 카드 리스트 영역 */}
|
|
<div className="mt-2">
|
|
<h2 className="text-base font-semibold mb-2 text-slate-800 dark:text-slate-100">
|
|
{t.sectionProjectsTitle}
|
|
</h2>
|
|
|
|
{projects.length === 0 && status === "idle" && !errorMessage && (
|
|
<p className="text-xs text-slate-500 dark:text-slate-400">{t.sectionProjectsEmpty}</p>
|
|
)}
|
|
|
|
{projects.length > 0 && (
|
|
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
|
{projects.map((project) => {
|
|
const lastSubmitted = project.lastSubmissionAt
|
|
? new Date(project.lastSubmissionAt).toLocaleString()
|
|
: t.projectLatestSubmissionEmpty;
|
|
|
|
return (
|
|
<article
|
|
key={project.projectId}
|
|
data-testid="dashboard-project-card"
|
|
className="rounded-xl border border-slate-200 bg-white/80 px-4 py-3 flex flex-col gap-1 shadow-sm dark:border-slate-800 dark:bg-slate-900/70"
|
|
>
|
|
<div className="flex items-baseline justify-between gap-2">
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-50">{project.title}</h3>
|
|
<p className="text-[11px] text-slate-500 font-mono dark:text-slate-400">{project.slug}</p>
|
|
</div>
|
|
<span className="text-[10px] px-2 py-0.5 rounded-full border border-slate-300 bg-slate-50 text-slate-700 dark:border-slate-700 dark:bg-transparent dark:text-slate-300">
|
|
{project.status}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="mt-1 flex items-center justify-between text-[11px] text-slate-700 dark:text-slate-300">
|
|
<span>
|
|
{t.projectTotalSubmissionsPrefix}
|
|
<span className="font-semibold">{project.totalSubmissions}</span>
|
|
{t.projectTotalSubmissionsSuffix}
|
|
</span>
|
|
<span className="text-slate-500 dark:text-slate-400">
|
|
{t.projectLatestSubmissionPrefix} {lastSubmitted}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="mt-2 flex items-center gap-2 text-[11px]">
|
|
<Link
|
|
href={`/projects/${encodeURIComponent(project.slug)}/submissions`}
|
|
className="text-emerald-700 hover:text-emerald-900 underline-offset-2 hover:underline dark:text-emerald-300 dark:hover:text-emerald-100"
|
|
>
|
|
{t.projectViewSubmissionsLink}
|
|
</Link>
|
|
<Link
|
|
href={`/p/${encodeURIComponent(project.slug)}`}
|
|
className="text-sky-700 hover:text-sky-900 underline-offset-2 hover:underline dark:text-sky-300 dark:hover:text-sky-200"
|
|
>
|
|
{t.projectOpenPublicPageLink}
|
|
</Link>
|
|
</div>
|
|
</article>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|