대시보드, GNB 추가
CI / test (push) Successful in 4m58s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Successful in 1m45s

This commit is contained in:
2025-12-08 06:59:52 +09:00
parent b40be693ee
commit 9d8c4538c7
18 changed files with 1595 additions and 86 deletions
+220
View File
@@ -0,0 +1,220 @@
import { NextResponse } from "next/server";
import { PrismaClient } from "@prisma/client";
import { verifyAccessToken } from "@/features/auth/authCrypto";
// 대시보드 개요 API (/api/dashboard/overview)
// - 로그인한 사용자의 프로젝트/폼 제출 통계를 집계해 요약/프로젝트별 지표를 반환한다.
// - DB 스키마: Project, FormSubmission
// - 인증: pb_access 쿠키(JWT)를 검증해 현재 사용자 식별
const prisma = new PrismaClient();
interface AuthUser {
id: string;
email: string;
tokenVersion: number;
}
// 공통: pb_access 쿠키에서 JWT 토큰을 추출한다.
function extractTokenFromCookieHeader(cookieHeader: string | null): string | null {
if (!cookieHeader) return null;
const parts = cookieHeader.split(";");
for (const part of parts) {
const trimmed = part.trim();
if (trimmed.startsWith("pb_access=")) {
const value = trimmed.substring("pb_access=".length);
return decodeURIComponent(value);
}
}
return null;
}
// 공통: Request 헤더의 쿠키에서 인증된 유저 정보를 복원한다.
async function getAuthUserFromRequest(request: Request): Promise<AuthUser | null> {
const cookieHeader = request.headers.get("cookie");
const token = extractTokenFromCookieHeader(cookieHeader);
if (!token) return null;
const payload = await verifyAccessToken(token);
if (!payload) return null;
return {
id: payload.sub,
email: payload.email,
tokenVersion: payload.tokenVersion,
};
}
interface SummaryStats {
totalProjects: number;
totalSubmissions: number;
todaySubmissions: number;
last7DaysSubmissions: number;
}
interface DailySubmissionItem {
// 일별 제출 수 그래프를 위한 집계 데이터
date: string; // YYYY-MM-DD (UTC 기준)
count: number;
}
interface ProjectStatsItem {
projectId: string;
title: string;
slug: string;
status: string;
totalSubmissions: number;
lastSubmissionAt: string | null;
}
// GET /api/dashboard/overview
// - 현재 로그인 사용자의 프로젝트 목록/폼 제출 내역을 조회해 통계를 계산한다.
export async function GET(request: Request) {
const authUser = await getAuthUserFromRequest(request);
if (!authUser) {
return NextResponse.json(
{ message: "대시보드를 조회하려면 로그인이 필요합니다." },
{ status: 401 },
);
}
try {
// 현재 사용자 소유 프로젝트와, 해당 사용자가 소유한 제출 내역을 한 번에 로드한다.
const [projects, submissions] = await Promise.all([
prisma.project.findMany({
where: { userId: authUser.id },
select: {
id: true,
title: true,
slug: true,
status: true,
},
}),
prisma.formSubmission.findMany({
where: { userId: authUser.id },
select: {
id: true,
projectId: true,
projectSlug: true,
createdAt: true,
},
}),
]);
const totalProjects = projects.length;
const totalSubmissions = submissions.length;
// 오늘/최근 7일 기준 시각 계산
const now = new Date();
const startOfToday = new Date(now);
startOfToday.setHours(0, 0, 0, 0);
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
let todaySubmissions = 0;
let last7DaysSubmissions = 0;
for (const submission of submissions) {
const createdAt = submission.createdAt instanceof Date
? submission.createdAt
: new Date(submission.createdAt as unknown as string);
if (createdAt >= startOfToday) {
todaySubmissions += 1;
}
if (createdAt >= sevenDaysAgo) {
last7DaysSubmissions += 1;
}
}
// 프로젝트별 통계 맵을 구성한다.
const statsByProjectId = new Map<
string,
{
projectId: string;
title: string;
slug: string;
status: string;
totalSubmissions: number;
lastSubmissionAt: Date | null;
}
>();
for (const project of projects) {
statsByProjectId.set(project.id, {
projectId: project.id,
title: project.title,
slug: project.slug,
status: project.status,
totalSubmissions: 0,
lastSubmissionAt: null,
});
}
for (const submission of submissions) {
const stat = submission.projectId ? statsByProjectId.get(submission.projectId) : null;
if (!stat) continue;
stat.totalSubmissions += 1;
const createdAt = submission.createdAt instanceof Date
? submission.createdAt
: new Date(submission.createdAt as unknown as string);
if (!stat.lastSubmissionAt || createdAt > stat.lastSubmissionAt) {
stat.lastSubmissionAt = createdAt;
}
}
const projectStats: ProjectStatsItem[] = Array.from(statsByProjectId.values()).map((item) => ({
projectId: item.projectId,
title: item.title,
slug: item.slug,
status: item.status,
totalSubmissions: item.totalSubmissions,
lastSubmissionAt: item.lastSubmissionAt ? item.lastSubmissionAt.toISOString() : null,
}));
const summaryStats: SummaryStats = {
totalProjects,
totalSubmissions,
todaySubmissions,
last7DaysSubmissions,
};
// 일별 전체 제출 수를 집계해 그래프용 데이터로 변환한다.
const countsByDate = new Map<string, number>();
for (const submission of submissions) {
const createdAt = submission.createdAt instanceof Date
? submission.createdAt
: new Date(submission.createdAt as unknown as string);
const dateKey = createdAt.toISOString().slice(0, 10); // YYYY-MM-DD
const prev = countsByDate.get(dateKey) ?? 0;
countsByDate.set(dateKey, prev + 1);
}
const dailySubmissions: DailySubmissionItem[] = Array.from(countsByDate.entries())
.map(([date, count]) => ({ date, count }))
.sort((a, b) => a.date.localeCompare(b.date));
return NextResponse.json(
{
summaryStats,
projectStats,
dailySubmissions,
},
{ status: 200 },
);
} catch (error: any) {
return NextResponse.json(
{
message: "대시보드 데이터를 계산하는 중 오류가 발생했습니다.",
error: error?.message,
},
{ status: 500 },
);
}
}
+345
View File
@@ -0,0 +1,345 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { FolderKanban, LayoutDashboard, ListChecks, SunMoon } from "lucide-react";
// 대시보드 페이지 (/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 [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);
// 마운트 시 대시보드 개요 데이터를 불러온다.
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("대시보드를 보려면 로그인이 필요합니다. 다시 로그인해 주세요.");
router.push("/login");
return;
}
setStatus("error");
setErrorMessage("대시보드 데이터를 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
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(
"대시보드 데이터를 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.",
);
}
}
};
void load();
return () => {
cancelled = true;
};
}, [router]);
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"></h1>
<p className="text-sm text-slate-500 mt-1 dark:text-slate-400"> .</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></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> </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> </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> </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);
}}
>
</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);
void handleLogout();
}}
>
</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>
)}
{status === "loading" && (
<p className="text-xs text-slate-400"> ...</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"> </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"> </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"> </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"> 7 </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"> ()</h2>
<span className="text-[10px] text-slate-500 dark:text-slate-500"> .</span>
</div>
{dailySubmissions.length === 0 ? (
<p className="text-[11px] text-slate-500 dark:text-slate-400"> .</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"> </h2>
{projects.length === 0 && status === "idle" && !errorMessage && (
<p className="text-xs text-slate-500 dark:text-slate-400"> .</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()
: "제출 내역 없음";
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>
<span className="font-semibold">{project.totalSubmissions}</span>
</span>
<span className="text-slate-500 dark:text-slate-400"> : {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"
>
</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"
>
</Link>
</div>
</article>
);
})}
</div>
)}
</div>
</section>
</main>
);
}
+1 -1
View File
@@ -10,7 +10,7 @@ export const metadata = {
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="ko" suppressHydrationWarning>
<body className="min-h-screen bg-slate-950 text-slate-50">
<body className="min-h-screen bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-50">
{children}
</body>
</html>
+3 -3
View File
@@ -23,7 +23,7 @@ export default function LoginPage() {
try {
const res = await fetch("/api/auth/me");
if (!cancelled && res.ok) {
router.push("/projects");
router.push("/dashboard");
}
} catch {
// ignore
@@ -61,8 +61,8 @@ export default function LoginPage() {
return;
}
// 성공 시에는 /projects 로 이동한다.
router.push("/projects");
// 성공 시에는 /dashboard 로 이동한다.
router.push("/dashboard");
} catch {
setError("네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
} finally {
+13 -6
View File
@@ -1,12 +1,19 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
export default function HomePage() {
const router = useRouter();
useEffect(() => {
// 메인 페이지 진입 시 대시보드로 안내한다.
router.push("/dashboard");
}, [router]);
return (
<main className="flex min-h-screen items-center justify-center">
<div className="text-center space-y-4">
<h1 className="text-3xl font-bold">Page Builder MVP</h1>
<p className="text-sm text-slate-400">
TDD로 .
</p>
</div>
<p className="text-sm text-slate-400"> ...</p>
</main>
);
}
+102 -39
View File
@@ -11,6 +11,9 @@ import {
ListChecks,
ChevronLeft,
ChevronRight,
LayoutDashboard,
FolderKanban,
SunMoon,
} from "lucide-react";
interface ProjectListItem {
@@ -29,6 +32,7 @@ export default function ProjectsPage() {
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [selectedSlugs, setSelectedSlugs] = useState<string[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const pageSize = 10;
useEffect(() => {
@@ -195,51 +199,101 @@ export default function ProjectsPage() {
}
};
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-950 text-slate-50">
<header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between bg-slate-950/80 backdrop-blur">
<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-xl font-semibold"> </h1>
<p className="text-xs text-slate-400"> </p>
<h1 className="text-2xl font-bold tracking-tight"> </h1>
<p className="text-sm text-slate-500 dark:text-slate-400"> </p>
</div>
<div className="flex items-center gap-2 text-xs">
<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"
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"
>
<LayoutDashboard className="w-4 h-4" aria-hidden="true" />
<span></span>
</Link>
<Link
href="/projects"
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"
>
<FolderKanban className="w-4 h-4" aria-hidden="true" />
<span> </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> </span>
</Link>
</nav>
<button
type="button"
className="inline-flex items-center gap-1 rounded border border-slate-700 px-3 py-1 text-slate-200 hover:bg-slate-900"
onClick={() => {
void handleLogout();
}}
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> </span>
</button>
<Link
href="/projects/submissions"
className="inline-flex items-center gap-1 rounded border border-emerald-700 bg-emerald-950 px-3 py-1 text-emerald-100 hover:bg-emerald-900 shadow-sm"
>
<ListChecks className="w-4 h-4" aria-hidden="true" />
<span> </span>
</Link>
<Link
href="/editor?new=1"
className="inline-flex items-center gap-1 rounded border border-sky-700 bg-sky-950 px-3 py-1 text-sky-100 hover:bg-sky-900 shadow-sm"
>
<FilePlus2 className="w-4 h-4" aria-hidden="true" />
<span> </span>
</Link>
<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);
}}
>
</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);
void handleLogout();
}}
>
</button>
</div>
)}
</div>
</div>
</header>
<section className="flex-1 px-6 py-4 overflow-auto">
<section className="flex-1 px-6 py-4 overflow-auto bg-slate-50/60 dark:bg-transparent">
{status === "error" && (
<p className="text-xs text-red-300 mb-3">
<p className="text-xs text-red-500 mb-3 dark:text-red-300">
{errorMessage ?? "프로젝트 목록을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."}
</p>
)}
{projects.length === 0 && status === "idle" && (
<p className="text-xs text-slate-400"> . .</p>
<p className="text-xs text-slate-500 dark:text-slate-400"> . .</p>
)}
{projects.length > 0 && (
<>
<div className="flex items-center justify-between mb-2 text-[11px] text-slate-300">
<div
data-testid="projects-toolbar"
className="flex items-center justify-between mb-2 text-[11px] text-slate-300"
>
<div className="inline-flex items-center gap-2">
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-slate-900/70 border border-slate-700">
<ListChecks className="w-3 h-3 text-slate-400" aria-hidden="true" />
@@ -248,17 +302,26 @@ export default function ProjectsPage() {
</span>
</span>
</div>
<button
type="button"
className="inline-flex items-center gap-1 rounded border border-red-700 px-2 py-1 text-red-200 hover:bg-red-900/40 disabled:opacity-40 disabled:cursor-not-allowed"
disabled={selectedSlugs.length === 0}
onClick={() => {
void handleBulkDelete();
}}
>
<Trash2 className="w-3 h-3" aria-hidden="true" />
<span> </span>
</button>
<div className="flex items-center gap-2">
<button
type="button"
className="inline-flex items-center gap-1 rounded border border-red-700 px-2 py-1 text-red-200 hover:bg-red-900/40 disabled:opacity-40 disabled:cursor-not-allowed"
disabled={selectedSlugs.length === 0}
onClick={() => {
void handleBulkDelete();
}}
>
<Trash2 className="w-3 h-3" aria-hidden="true" />
<span> </span>
</button>
<Link
href="/editor?new=1"
className="inline-flex items-center gap-1 rounded border border-sky-700 bg-sky-950 px-2 py-1 text-sky-100 hover:bg-sky-900 shadow-sm"
>
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
<span> </span>
</Link>
</div>
</div>
<table className="w-full text-xs text-left border-collapse mt-2">
<thead>
+95 -18
View File
@@ -2,6 +2,8 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { FolderKanban, LayoutDashboard, ListChecks, SunMoon } from "lucide-react";
interface SubmissionItem {
id: string;
@@ -18,6 +20,7 @@ export default function AllProjectSubmissionsPage() {
const [submissions, setSubmissions] = useState<SubmissionItem[]>([]);
const [status, setStatus] = useState<PageStatus>("idle");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isMenuOpen, setIsMenuOpen] = useState(false);
useEffect(() => {
let cancelled = false;
@@ -82,39 +85,113 @@ export default function AllProjectSubmissionsPage() {
.join("\n");
};
const handleLogout = async () => {
try {
const res = await fetch("/api/auth/logout", {
method: "POST",
});
if (!res.ok) {
setErrorMessage("로그아웃 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
return;
}
router.push("/login");
} catch {
setErrorMessage("로그아웃 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
}
};
return (
<main className="min-h-screen flex flex-col bg-slate-950 text-slate-50">
<header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between bg-slate-950/80 backdrop-blur">
<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-xl font-semibold"> </h1>
<p className="text-xs text-slate-400"> .</p>
<h1 className="text-2xl font-bold tracking-tight"> </h1>
<p className="text-sm text-slate-500 dark:text-slate-400"> .</p>
</div>
<div className="text-xs text-slate-300 flex flex-col items-end gap-1">
<a
href="/projects"
role="link"
className="text-[11px] text-sky-400 hover:text-sky-300 underline-offset-2 hover:underline"
onClick={(event) => {
event.preventDefault();
router.push("/projects");
<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"
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"
>
<LayoutDashboard className="w-4 h-4" aria-hidden="true" />
<span></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> </span>
</Link>
<Link
href="/projects/submissions"
aria-current="page"
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-semibold bg-emerald-600 text-white shadow-sm hover:bg-emerald-700"
>
<ListChecks className="w-4 h-4" aria-hidden="true" />
<span> </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={() => {
if (typeof document === "undefined") {
return;
}
const root = document.documentElement;
if (!root) {
return;
}
root.classList.toggle("dark");
}}
>
</a>
<SunMoon className="w-4 h-4" aria-hidden="true" />
<span> </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);
}}
>
</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);
void handleLogout();
}}
>
</button>
</div>
)}
</div>
</div>
</header>
<section className="flex-1 px-6 py-4 overflow-auto">
<section className="flex-1 px-6 py-4 overflow-auto bg-slate-50/60 dark:bg-transparent">
{status === "error" && errorMessage && (
<p className="text-xs text-red-300 mb-3">{errorMessage}</p>
<p className="text-xs text-red-500 mb-3 dark:text-red-300">{errorMessage}</p>
)}
{status === "loading" && (
<p className="text-xs text-slate-400 mb-3"> ...</p>
<p className="text-xs text-slate-500 mb-3 dark:text-slate-400"> ...</p>
)}
{submissions.length === 0 && status === "idle" && !errorMessage && (
<p className="text-xs text-slate-400"> .</p>
<p className="text-xs text-slate-500 dark:text-slate-400"> .</p>
)}
{submissions.length > 0 && (
+2 -2
View File
@@ -23,7 +23,7 @@ export default function SignupPage() {
try {
const res = await fetch("/api/auth/me");
if (!cancelled && res.ok) {
router.push("/projects");
router.push("/dashboard");
}
} catch {
// ignore
@@ -61,7 +61,7 @@ export default function SignupPage() {
return;
}
router.push("/projects");
router.push("/dashboard");
} catch {
setError("네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
} finally {
+1
View File
@@ -1,5 +1,6 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: "class",
content: [
"./src/app/**/*.{ts,tsx}",
"./src/components/**/*.{ts,tsx}",
+220
View File
@@ -0,0 +1,220 @@
import "dotenv/config";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { signAccessToken } from "@/features/auth/authCrypto";
// /api/dashboard/overview TDD
// - 로그인한 사용자가 자신의 프로젝트/폼 제출 통계를 조회할 수 있어야 한다.
// - 로그인하지 않은 경우 401 을 반환해야 한다.
// - 프로젝트/제출 내역이 없는 경우 0 값과 빈 배열을 반환해야 한다.
const BASE_URL = "http://localhost";
const inMemoryProjects: any[] = [];
const inMemoryFormSubmissions: any[] = [];
vi.mock("@prisma/client", () => {
class PrismaClientMock {
project = {
findMany: async (args: any = {}) => {
const where = args.where ?? {};
let items = [...inMemoryProjects];
if (where.userId) {
items = items.filter((p) => p.userId === where.userId);
}
return items;
},
};
formSubmission = {
findMany: async (args: any = {}) => {
const where = args.where ?? {};
let items = [...inMemoryFormSubmissions];
if (where.userId) {
items = items.filter((s) => s.userId === where.userId);
}
return items;
},
};
}
return { PrismaClient: PrismaClientMock };
});
const TEST_USER = { id: "user-1", email: "dashboard@example.com", tokenVersion: 1 };
const OTHER_USER = { id: "user-2", email: "dashboard2@example.com", tokenVersion: 1 };
async function buildAuthHeadersFor(user: { id: string; email: string; tokenVersion: number }) {
const token = await signAccessToken(user);
return { cookie: `pb_access=${token}` };
}
async function buildAuthHeaders() {
return buildAuthHeadersFor(TEST_USER);
}
beforeEach(() => {
process.env.AUTH_JWT_SECRET = "test-jwt-secret-dashboard";
inMemoryProjects.length = 0;
inMemoryFormSubmissions.length = 0;
});
describe("/api/dashboard/overview", () => {
it("로그인한 사용자가 자신의 프로젝트/폼 제출 통계를 조회할 수 있어야 한다", async () => {
const project1 = {
id: "proj-1",
userId: TEST_USER.id,
title: "테스트 프로젝트 A",
slug: "test-project-a",
status: "DRAFT",
};
const project2 = {
id: "proj-2",
userId: TEST_USER.id,
title: "테스트 프로젝트 B",
slug: "test-project-b",
status: "PUBLISHED",
};
const projectOther = {
id: "proj-3",
userId: OTHER_USER.id,
title: "다른 유저 프로젝트",
slug: "other-project",
status: "DRAFT",
};
inMemoryProjects.push(project1, project2, projectOther);
const now = new Date("2025-01-03T12:00:00.000Z");
const yesterday = new Date("2025-01-02T12:00:00.000Z");
const eightDaysAgo = new Date("2024-12-26T12:00:00.000Z");
inMemoryFormSubmissions.push(
{
id: "sub-1",
projectId: project1.id,
projectSlug: project1.slug,
userId: TEST_USER.id,
createdAt: now,
payloadJson: { message: "A-1" },
sensitiveEnc: undefined,
metaJson: null,
},
{
id: "sub-2",
projectId: project1.id,
projectSlug: project1.slug,
userId: TEST_USER.id,
createdAt: yesterday,
payloadJson: { message: "A-2" },
sensitiveEnc: undefined,
metaJson: null,
},
{
id: "sub-3",
projectId: project2.id,
projectSlug: project2.slug,
userId: TEST_USER.id,
createdAt: eightDaysAgo,
payloadJson: { message: "B-1" },
sensitiveEnc: undefined,
metaJson: null,
},
{
id: "sub-4",
projectId: projectOther.id,
projectSlug: projectOther.slug,
userId: OTHER_USER.id,
createdAt: now,
payloadJson: { message: "OTHER" },
sensitiveEnc: undefined,
metaJson: null,
},
);
const { GET: getDashboardOverview } = await import("@/app/api/dashboard/overview/route");
const headers = await buildAuthHeaders();
const res = await getDashboardOverview(
new Request(`${BASE_URL}/api/dashboard/overview`, {
headers,
}),
);
expect(res.status).toBe(200);
const json = (await res.json()) as any;
expect(json.summaryStats.totalProjects).toBe(2);
expect(json.summaryStats.totalSubmissions).toBe(3);
expect(Array.isArray(json.projectStats)).toBe(true);
expect(json.projectStats.length).toBe(2);
const proj1Stats = json.projectStats.find((p: any) => p.slug === project1.slug);
const proj2Stats = json.projectStats.find((p: any) => p.slug === project2.slug);
expect(proj1Stats).toBeTruthy();
expect(proj1Stats.totalSubmissions).toBe(2);
expect(proj1Stats.lastSubmissionAt).toBe(now.toISOString());
expect(proj2Stats).toBeTruthy();
expect(proj2Stats.totalSubmissions).toBe(1);
expect(proj2Stats.lastSubmissionAt).toBe(eightDaysAgo.toISOString());
const daily = (json as any).dailySubmissions;
expect(Array.isArray(daily)).toBe(true);
expect(daily.length).toBe(3);
const dates = daily.map((item: any) => item.date).sort();
expect(dates).toEqual(["2024-12-26", "2025-01-02", "2025-01-03"]);
const countsByDate = (daily as any[]).reduce((acc: Record<string, number>, item: any) => {
acc[item.date] = item.count;
return acc;
}, {} as Record<string, number>);
expect(countsByDate["2025-01-03"]).toBe(1);
expect(countsByDate["2025-01-02"]).toBe(1);
expect(countsByDate["2024-12-26"]).toBe(1);
});
it("로그인하지 않은 경우 401 을 반환해야 한다", async () => {
const { GET: getDashboardOverview } = await import("@/app/api/dashboard/overview/route");
const res = await getDashboardOverview(
new Request(`${BASE_URL}/api/dashboard/overview`),
);
expect(res.status).toBe(401);
});
it("프로젝트와 제출 내역이 없는 경우 0 값과 빈 배열을 반환해야 한다", async () => {
const { GET: getDashboardOverview } = await import("@/app/api/dashboard/overview/route");
const headers = await buildAuthHeaders();
const res = await getDashboardOverview(
new Request(`${BASE_URL}/api/dashboard/overview`, {
headers,
}),
);
expect(res.status).toBe(200);
const json = (await res.json()) as any;
expect(json.summaryStats.totalProjects).toBe(0);
expect(json.summaryStats.totalSubmissions).toBe(0);
expect(Array.isArray(json.projectStats)).toBe(true);
expect(json.projectStats.length).toBe(0);
});
});
+4 -4
View File
@@ -48,7 +48,7 @@ test("비로그인 사용자가 /preview 에 접근하면 /login 으로 리다
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
});
test("회원가입 후에는 /projects, /editor, /preview 에 정상 접근할 수 있어야 한다", async ({ page }) => {
test("회원가입 후에는 /dashboard, /editor, /preview 에 정상 접근할 수 있어야 한다", async ({ page }) => {
const email = `e2e+${Date.now()}@example.com`;
const password = "securePass1";
@@ -98,9 +98,9 @@ test("회원가입 후에는 /projects, /editor, /preview 에 정상 접근할
await page.getByLabel("비밀번호").fill(password);
await page.getByRole("button", { name: "회원가입" }).click();
// /projects 로 이동했는지, 헤더가 보이는지 확인
await expect(page).toHaveURL(/\/projects/);
await expect(page.getByRole("heading", { name: "프로젝트 목록" })).toBeVisible();
// /dashboard 로 이동했는지, 헤더가 보이는지 확인
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByRole("heading", { name: "대시보드" })).toBeVisible();
// 2) /editor 접근 확인 – 로그인 페이지로 리다이렉트되면 안 된다.
await page.goto("/editor");
+140
View File
@@ -0,0 +1,140 @@
import { test, expect } from "@playwright/test";
// 대시보드 E2E TDD
// - 비로그인 사용자는 /dashboard 접근 시 /login 으로 리다이렉트되어야 한다.
// - 로그인 사용자는 /dashboard 에서 요약 지표 + 프로젝트 카드 리스트를 볼 수 있어야 한다.
// - 백엔드/DB 에 의존하지 않도록 /api/dashboard/overview 를 Playwright 라우팅으로 목킹한다.
test("비로그인 사용자가 /dashboard 에 접근하면 /login 으로 리다이렉트되어야 한다", async ({ page }) => {
await page.route("**/api/dashboard/overview", async (route) => {
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ message: "로그인이 필요합니다." }),
});
});
await page.goto("/dashboard");
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
});
test("로그인 사용자가 /dashboard 에 접근하면 요약 지표와 프로젝트 카드 리스트, 일별 그래프를 볼 수 있어야 한다", async ({ page }) => {
await page.route("**/api/dashboard/overview", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
summaryStats: {
totalProjects: 2,
totalSubmissions: 3,
todaySubmissions: 1,
last7DaysSubmissions: 3,
},
projectStats: [
{
projectId: "proj-1",
title: "테스트 프로젝트 A",
slug: "test-project-a",
status: "DRAFT",
totalSubmissions: 2,
lastSubmissionAt: "2025-01-02T12:00:00.000Z",
},
{
projectId: "proj-2",
title: "테스트 프로젝트 B",
slug: "test-project-b",
status: "PUBLISHED",
totalSubmissions: 1,
lastSubmissionAt: "2025-01-03T09:30:00.000Z",
},
],
dailySubmissions: [
{ date: "2024-12-26", count: 1 },
{ date: "2025-01-02", count: 1 },
{ date: "2025-01-03", count: 1 },
],
}),
});
});
await page.goto("/dashboard");
// 대시보드 헤더가 보여야 한다.
await expect(page.getByRole("heading", { name: "대시보드" })).toBeVisible();
// 상단 헤더 내비게이션이 보여야 한다.
const dashboardLink = page.getByRole("link", { name: "대시보드" });
await expect(dashboardLink).toBeVisible();
const projectsLink = page.getByRole("link", { name: "프로젝트 목록" });
await expect(projectsLink).toBeVisible();
const submissionsLink = page.getByRole("link", { name: "전체 제출 내역" });
await expect(submissionsLink).toBeVisible();
const menuButton = page.getByRole("button", { name: "메뉴" });
await expect(menuButton).toBeVisible();
// 상단 요약 위젯의 지표들이 기대 값으로 렌더되어야 한다.
const totalProjects = page.getByTestId("dashboard-summary-total-projects");
await expect(totalProjects).toContainText("2");
const totalSubmissions = page.getByTestId("dashboard-summary-total-submissions");
await expect(totalSubmissions).toContainText("3");
const todaySubmissions = page.getByTestId("dashboard-summary-today-submissions");
await expect(todaySubmissions).toContainText("1");
const last7DaysSubmissions = page.getByTestId(
"dashboard-summary-last7days-submissions",
);
await expect(last7DaysSubmissions).toContainText("3");
// 프로젝트 카드 리스트가 2개 렌더링되어야 한다.
const projectCards = page.getByTestId("dashboard-project-card");
await expect(projectCards).toHaveCount(2);
await expect(projectCards.nth(0)).toContainText("테스트 프로젝트 A");
await expect(projectCards.nth(0)).toContainText("test-project-a");
await expect(projectCards.nth(0)).toContainText("2");
await expect(projectCards.nth(1)).toContainText("테스트 프로젝트 B");
await expect(projectCards.nth(1)).toContainText("test-project-b");
await expect(projectCards.nth(1)).toContainText("1");
// 일별 제출 수 그래프가 렌더링되어야 한다.
const dailyChart = page.getByTestId("dashboard-daily-chart");
await expect(dailyChart).toBeVisible();
const dailyBars = page.getByTestId("dashboard-daily-chart-bar");
await expect(dailyBars).toHaveCount(3);
// 메뉴 버튼을 클릭하면 로그아웃 항목이 보여야 한다.
await menuButton.click();
await expect(page.getByRole("button", { name: "로그아웃" })).toBeVisible();
});
test("로그인 사용자가 / 에 접속하면 대시보드로 진입해야 한다", async ({ page }) => {
await page.route("**/api/dashboard/overview", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
summaryStats: {
totalProjects: 1,
totalSubmissions: 0,
todaySubmissions: 0,
last7DaysSubmissions: 0,
},
projectStats: [],
dailySubmissions: [],
}),
});
});
await page.goto("/");
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByRole("heading", { name: "대시보드" })).toBeVisible();
});
+104 -1
View File
@@ -219,7 +219,110 @@ test("프로젝트 목록에서 '폼 제출 내역' 링크를 클릭하면 해
await expect(page.getByText("홍길동")).toBeVisible();
await expect(page.getByText("user@example.com")).toBeVisible();
await expect(page.getByText("010-1234-5678")).toBeVisible();
await expect(page.getByText("message: 안녕하세요")).toBeVisible();
await expect(page.getByText("1990-01-01")).toBeVisible();
await expect(page.getByText("message: 안녕하세요")).toBeVisible();
});
test("프로젝트 목록 헤더에서 대시보드로 이동할 수 있어야 한다", async ({ page }) => {
await page.route("**/api/auth/me", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: "user-e2e", email: "e2e@example.com", tokenVersion: 1 }),
});
});
await page.route("**/api/projects", async (route) => {
const request = route.request();
const url = new URL(request.url());
const method = request.method();
if (url.pathname === "/api/projects" && method === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{
id: "1",
title: "테스트 프로젝트 A",
slug: "test-project-a",
status: "DRAFT",
createdAt: new Date("2025-01-01T00:00:00.000Z").toISOString(),
updatedAt: new Date("2025-01-01T00:00:00.000Z").toISOString(),
},
]),
});
return;
}
await route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ message: "Not implemented in E2E mock" }),
});
});
await page.route("**/api/dashboard/overview", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
summaryStats: {
totalProjects: 1,
totalSubmissions: 0,
todaySubmissions: 0,
last7DaysSubmissions: 0,
},
projectStats: [],
dailySubmissions: [],
}),
});
});
await page.goto("/projects");
await expect(page.getByText("테스트 프로젝트 A")).toBeVisible();
await page.getByRole("link", { name: "대시보드" }).click();
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByRole("heading", { name: "대시보드" })).toBeVisible();
});
test("전체 제출 내역 페이지에서 공통 GNB와 메뉴가 보여야 한다", async ({ page }) => {
await page.route("**/api/projects/submissions", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
});
});
await page.route("**/api/auth/logout", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ message: "ok" }),
});
});
await page.goto("/projects/submissions");
await expect(page.getByRole("heading", { name: "전체 폼 제출 내역" })).toBeVisible();
const dashboardLink = page.getByRole("link", { name: "대시보드" });
await expect(dashboardLink).toBeVisible();
const projectsLink = page.getByRole("link", { name: "프로젝트 목록" });
await expect(projectsLink).toBeVisible();
const submissionsLink = page.getByRole("link", { name: "전체 제출 내역" });
await expect(submissionsLink).toBeVisible();
const menuButton = page.getByRole("button", { name: "메뉴" });
await expect(menuButton).toBeVisible();
await menuButton.click();
await expect(page.getByRole("button", { name: "로그아웃" })).toBeVisible();
});
+87 -2
View File
@@ -146,11 +146,96 @@ describe("AllProjectSubmissionsPage - 전체 폼 제출 내역", () => {
const backLink = await screen.findByRole("link", { name: "프로젝트 목록" });
expect(backLink).toBeTruthy();
expect(backLink.closest("a")?.getAttribute("href")).toBe("/projects");
});
backLink.click();
it("상단 GNB 에 대시보드/프로젝트 목록/전체 제출 내역 링크가 있어야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify([]), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<AllProjectSubmissionsPage />);
const dashboardLink = await screen.findByRole("link", { name: "대시보드" });
expect(dashboardLink.closest("a")?.getAttribute("href")).toBe("/dashboard");
const projectsLink = await screen.findByRole("link", { name: "프로젝트 목록" });
expect(projectsLink.closest("a")?.getAttribute("href")).toBe("/projects");
const submissionsLink = await screen.findByRole("link", { name: "전체 제출 내역" });
expect(submissionsLink.closest("a")?.getAttribute("href")).toBe("/projects/submissions");
});
it("GNB에서 현재 페이지인 '전체 제출 내역' 탭에 aria-current=\"page\"가 설정되어야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify([]), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<AllProjectSubmissionsPage />);
await waitFor(() => {
expect(pushMock).toHaveBeenCalledWith("/projects");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
const dashboardLink = await screen.findByRole("link", { name: "대시보드" });
const projectsLink = await screen.findByRole("link", { name: "프로젝트 목록" });
const submissionsLink = await screen.findByRole("link", { name: "전체 제출 내역" });
expect(submissionsLink.getAttribute("href")).toBe("/projects/submissions");
expect(submissionsLink.getAttribute("aria-current")).toBe("page");
expect(dashboardLink.getAttribute("aria-current")).toBeNull();
expect(projectsLink.getAttribute("aria-current")).toBeNull();
});
it("상단 메뉴 안 '로그아웃' 버튼을 클릭하면 /api/auth/logout 으로 POST 요청을 보내고 /login 으로 이동해야 한다", async () => {
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
const url = typeof input === "string" ? input : input.url;
const method = init?.method ?? "GET";
if (url === "/api/projects/submissions" && method === "GET") {
return Promise.resolve(
new Response(JSON.stringify([]), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
}
if (url === "/api/auth/logout" && method === "POST") {
return Promise.resolve(new Response(JSON.stringify({ message: "ok" }), { status: 200 }));
}
return Promise.resolve(new Response(null, { status: 500 }));
});
vi.stubGlobal("fetch", fetchMock as any);
render(<AllProjectSubmissionsPage />);
const menuButton = await screen.findByRole("button", { name: "메뉴" });
menuButton.click();
const logoutButton = await screen.findByRole("button", { name: "로그아웃" });
logoutButton.click();
await waitFor(() => {
const logoutCall = fetchMock.mock.calls.find(([url, options]) => {
return url === "/api/auth/logout" && options?.method === "POST";
});
expect(logoutCall).toBeDefined();
expect(pushMock).toHaveBeenCalledWith("/login");
});
});
});
+94
View File
@@ -0,0 +1,94 @@
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, screen, cleanup, waitFor } from "@testing-library/react";
import DashboardPage from "@/app/dashboard/page";
export const pushMock = vi.fn();
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({ push: pushMock }),
};
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
describe("DashboardPage - GNB와 테마", () => {
it("GNB에서 현재 페이지인 '대시보드' 탭에 aria-current=\"page\"가 설정되어야 한다", async () => {
const overview = {
summaryStats: {
totalProjects: 0,
totalSubmissions: 0,
todaySubmissions: 0,
last7DaysSubmissions: 0,
},
projectStats: [],
dailySubmissions: [],
};
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(overview), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
render(<DashboardPage />);
const dashboardLink = await screen.findByRole("link", { name: "대시보드" });
const projectsLink = await screen.findByRole("link", { name: "프로젝트 목록" });
const submissionsLink = await screen.findByRole("link", { name: "전체 제출 내역" });
expect(dashboardLink.getAttribute("href")).toBe("/dashboard");
expect(dashboardLink.getAttribute("aria-current")).toBe("page");
expect(projectsLink.getAttribute("aria-current")).toBeNull();
expect(submissionsLink.getAttribute("aria-current")).toBeNull();
});
it("헤더에 테마 전환 버튼이 있고 클릭 시 html 요소의 dark 클래스가 토글되어야 한다", async () => {
const overview = {
summaryStats: {
totalProjects: 0,
totalSubmissions: 0,
todaySubmissions: 0,
last7DaysSubmissions: 0,
},
projectStats: [],
dailySubmissions: [],
};
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(overview), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
render(<DashboardPage />);
const html = document.documentElement;
html.classList.remove("dark");
const themeToggleButton = await screen.findByRole("button", { name: "테마 전환" });
expect(html.classList.contains("dark")).toBe(false);
themeToggleButton.click();
expect(html.classList.contains("dark")).toBe(true);
themeToggleButton.click();
expect(html.classList.contains("dark")).toBe(false);
});
});
+4 -4
View File
@@ -24,7 +24,7 @@ describe("LoginPage", () => {
vi.clearAllMocks();
});
it("올바른 이메일/비밀번호로 로그인하면 /api/auth/login 으로 요청을 보내고 /projects 로 이동해야 한다", async () => {
it("올바른 이메일/비밀번호로 로그인하면 /api/auth/login 으로 요청을 보내고 /dashboard 로 이동해야 한다", async () => {
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
const url = typeof input === "string" ? input : input.url;
const method = init?.method ?? "GET";
@@ -82,7 +82,7 @@ describe("LoginPage", () => {
expect(body.password).toBe("securePass1");
await waitFor(() => {
expect(pushMock).toHaveBeenCalledWith("/projects");
expect(pushMock).toHaveBeenCalledWith("/dashboard");
});
});
@@ -129,7 +129,7 @@ describe("LoginPage", () => {
expect(errorText).toBeTruthy();
});
it("이미 로그인된 상태에서 /login 에 접근하면 /projects 로 리다이렉트해야 한다", async () => {
it("이미 로그인된 상태에서 /login 에 접근하면 /dashboard 로 리다이렉트해야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ id: "1", email: "user@example.com", tokenVersion: 1 }), {
status: 200,
@@ -144,7 +144,7 @@ describe("LoginPage", () => {
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe("/api/auth/me");
expect(pushMock).toHaveBeenCalledWith("/projects");
expect(pushMock).toHaveBeenCalledWith("/dashboard");
});
});
});
+155 -1
View File
@@ -112,6 +112,119 @@ describe("ProjectsPage - 프로젝트 목록", () => {
expect(link.closest("a")?.getAttribute("href")).toBe("/projects/submissions");
});
it("헤더에 대시보드 페이지(/dashboard)로 이동하는 링크가 있어야 한다", async () => {
const projects = [
{
id: "1",
title: "테스트 프로젝트 A",
slug: "test-project-a",
status: "DRAFT",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-01T00:00:00.000Z",
},
];
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(projects), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
render(<ProjectsPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
const link = screen.getByText("대시보드");
expect(link).toBeTruthy();
expect(link.closest("a")?.getAttribute("href")).toBe("/dashboard");
});
it("GNB에서 현재 페이지인 '프로젝트 목록' 탭에 aria-current=\"page\"가 설정되어야 한다", async () => {
const projects = [
{
id: "1",
title: "테스트 프로젝트 A",
slug: "test-project-a",
status: "DRAFT",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-01T00:00:00.000Z",
},
];
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(projects), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
render(<ProjectsPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
const dashboardLink = await screen.findByRole("link", { name: "대시보드" });
const projectsLink = await screen.findByRole("link", { name: "프로젝트 목록" });
const submissionsLink = await screen.findByRole("link", { name: "전체 제출 내역" });
expect(projectsLink.closest("a")?.getAttribute("href")).toBe("/projects");
expect(projectsLink.closest("a")?.getAttribute("aria-current")).toBe("page");
expect(dashboardLink.closest("a")?.getAttribute("aria-current")).toBeNull();
expect(submissionsLink.closest("a")?.getAttribute("aria-current")).toBeNull();
});
it("헤더에 테마 전환 버튼이 있고 클릭 시 html 요소의 dark 클래스가 토글되어야 한다", async () => {
const projects = [
{
id: "1",
title: "테스트 프로젝트 A",
slug: "test-project-a",
status: "DRAFT",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-01T00:00:00.000Z",
},
];
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(projects), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
render(<ProjectsPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
const html = document.documentElement;
html.classList.remove("dark");
const themeToggleButton = await screen.findByRole("button", { name: "테마 전환" });
expect(html.classList.contains("dark")).toBe(false);
themeToggleButton.click();
expect(html.classList.contains("dark")).toBe(true);
themeToggleButton.click();
expect(html.classList.contains("dark")).toBe(false);
});
it("각 프로젝트 행에는 폼 제출 내역 페이지(/projects/[slug]/submissions)로 이동하는 링크가 있어야 한다", async () => {
const projects = [
{
@@ -227,6 +340,44 @@ describe("ProjectsPage - 프로젝트 목록", () => {
confirmSpy.mockRestore();
});
it("프로젝트 목록 상단 툴바에 '새 프로젝트 만들기' 버튼과 '선택 삭제' 버튼이 함께 보여야 한다", async () => {
const projects = [
{
id: "1",
title: "테스트 프로젝트 A",
slug: "test-project-a",
status: "DRAFT",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-01T00:00:00.000Z",
},
{
id: "2",
title: "테스트 프로젝트 B",
slug: "test-project-b",
status: "PUBLISHED",
createdAt: "2025-01-02T00:00:00.000Z",
updatedAt: "2025-01-02T00:00:00.000Z",
},
];
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(projects), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
render(<ProjectsPage />);
// 상단 툴바에서 선택 삭제와 새 프로젝트 만들기 버튼이 함께 보여야 한다.
const toolbar = await screen.findByTestId("projects-toolbar");
expect(toolbar.querySelector("button[type='button']")?.textContent).toContain("선택 삭제");
expect(toolbar.querySelector("a[href='/editor?new=1']")?.textContent).toContain("새 프로젝트 만들기");
});
it("체크박스로 여러 프로젝트를 선택한 뒤 '선택 삭제' 버튼으로 일괄 삭제할 수 있어야 한다", async () => {
const projects = [
{
@@ -336,7 +487,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
});
});
it("헤더의 '로그아웃' 버튼을 클릭하면 /api/auth/logout 으로 POST 요청을 보내고 /login 으로 이동해야 한다", async () => {
it("헤더의 메뉴 안 '로그아웃' 버튼을 클릭하면 /api/auth/logout 으로 POST 요청을 보내고 /login 으로 이동해야 한다", async () => {
const projects = [
{
id: "1",
@@ -376,6 +527,9 @@ describe("ProjectsPage - 프로젝트 목록", () => {
expect(fetchMock).toHaveBeenCalled();
});
const menuButton = await screen.findByRole("button", { name: "메뉴" });
fireEvent.click(menuButton);
const logoutButton = await screen.findByRole("button", { name: "로그아웃" });
fireEvent.click(logoutButton);
+4 -4
View File
@@ -24,7 +24,7 @@ describe("SignupPage", () => {
vi.clearAllMocks();
});
it("새 이메일/비밀번호로 회원가입하면 /api/auth/signup 으로 요청을 보내고 /projects 로 이동해야 한다", async () => {
it("새 이메일/비밀번호로 회원가입하면 /api/auth/signup 으로 요청을 보내고 /dashboard 로 이동해야 한다", async () => {
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
const url = typeof input === "string" ? input : input.url;
const method = init?.method ?? "GET";
@@ -82,7 +82,7 @@ describe("SignupPage", () => {
expect(body.password).toBe("securePass1");
await waitFor(() => {
expect(pushMock).toHaveBeenCalledWith("/projects");
expect(pushMock).toHaveBeenCalledWith("/dashboard");
});
});
@@ -129,7 +129,7 @@ describe("SignupPage", () => {
expect(errorText).toBeTruthy();
});
it("이미 로그인된 상태에서 /signup 에 접근하면 /projects 로 리다이렉트해야 한다", async () => {
it("이미 로그인된 상태에서 /signup 에 접근하면 /dashboard 로 리다이렉트해야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ id: "1", email: "user@example.com", tokenVersion: 1 }), {
status: 200,
@@ -144,7 +144,7 @@ describe("SignupPage", () => {
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe("/api/auth/me");
expect(pushMock).toHaveBeenCalledWith("/projects");
expect(pushMock).toHaveBeenCalledWith("/dashboard");
});
});
});