대시보드, GNB 추가
This commit is contained in:
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -10,7 +10,7 @@ export const metadata = {
|
|||||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="ko" suppressHydrationWarning>
|
<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}
|
{children}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export default function LoginPage() {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch("/api/auth/me");
|
const res = await fetch("/api/auth/me");
|
||||||
if (!cancelled && res.ok) {
|
if (!cancelled && res.ok) {
|
||||||
router.push("/projects");
|
router.push("/dashboard");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
@@ -61,8 +61,8 @@ export default function LoginPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 성공 시에는 /projects 로 이동한다.
|
// 성공 시에는 /dashboard 로 이동한다.
|
||||||
router.push("/projects");
|
router.push("/dashboard");
|
||||||
} catch {
|
} catch {
|
||||||
setError("네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
setError("네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+13
-6
@@ -1,12 +1,19 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 메인 페이지 진입 시 대시보드로 안내한다.
|
||||||
|
router.push("/dashboard");
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen items-center justify-center">
|
<main className="flex min-h-screen items-center justify-center">
|
||||||
<div className="text-center space-y-4">
|
<p className="text-sm text-slate-400">대시보드로 이동 중입니다...</p>
|
||||||
<h1 className="text-3xl font-bold">Page Builder MVP</h1>
|
|
||||||
<p className="text-sm text-slate-400">
|
|
||||||
에디터와 블록 시스템을 이 위에서 TDD로 구현합니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+103
-40
@@ -11,6 +11,9 @@ import {
|
|||||||
ListChecks,
|
ListChecks,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
LayoutDashboard,
|
||||||
|
FolderKanban,
|
||||||
|
SunMoon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
interface ProjectListItem {
|
interface ProjectListItem {
|
||||||
@@ -29,6 +32,7 @@ export default function ProjectsPage() {
|
|||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const [selectedSlugs, setSelectedSlugs] = useState<string[]>([]);
|
const [selectedSlugs, setSelectedSlugs] = useState<string[]>([]);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
const pageSize = 10;
|
const pageSize = 10;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -179,7 +183,7 @@ export default function ProjectsPage() {
|
|||||||
window.localStorage.removeItem(`pb:autosave:${slug}`);
|
window.localStorage.removeItem(`pb:autosave:${slug}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (okSlugs.length !== slugs.length) {
|
if (okSlugs.length !== slugs.length) {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
setErrorMessage(
|
setErrorMessage(
|
||||||
@@ -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 (
|
return (
|
||||||
<main className="min-h-screen flex flex-col bg-slate-950 text-slate-50">
|
<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-800 px-6 py-4 flex items-center justify-between bg-slate-950/80 backdrop-blur">
|
<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>
|
<div>
|
||||||
<h1 className="text-xl font-semibold">프로젝트 목록</h1>
|
<h1 className="text-2xl font-bold tracking-tight">프로젝트 목록</h1>
|
||||||
<p className="text-xs text-slate-400">저장된 프로젝트들을 한 눈에 볼 수 있는 목록</p>
|
<p className="text-sm text-slate-500 dark:text-slate-400">저장된 프로젝트들을 한 눈에 볼 수 있는 목록</p>
|
||||||
</div>
|
</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
|
<button
|
||||||
type="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"
|
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={() => {
|
onClick={handleToggleTheme}
|
||||||
void handleLogout();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
로그아웃
|
<SunMoon className="w-4 h-4" aria-hidden="true" />
|
||||||
|
<span>테마 전환</span>
|
||||||
</button>
|
</button>
|
||||||
<Link
|
<div className="relative">
|
||||||
href="/projects/submissions"
|
<button
|
||||||
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"
|
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"
|
||||||
<ListChecks className="w-4 h-4" aria-hidden="true" />
|
onClick={() => {
|
||||||
<span>전체 제출 내역</span>
|
setIsMenuOpen((prev) => !prev);
|
||||||
</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"
|
</button>
|
||||||
>
|
{isMenuOpen && (
|
||||||
<FilePlus2 className="w-4 h-4" aria-hidden="true" />
|
<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">
|
||||||
<span>새 프로젝트 만들기</span>
|
<button
|
||||||
</Link>
|
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>
|
</div>
|
||||||
</header>
|
</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" && (
|
{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 ?? "프로젝트 목록을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."}
|
{errorMessage ?? "프로젝트 목록을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{projects.length === 0 && status === "idle" && (
|
{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 && (
|
{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">
|
<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">
|
<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" />
|
<ListChecks className="w-3 h-3 text-slate-400" aria-hidden="true" />
|
||||||
@@ -248,17 +302,26 @@ export default function ProjectsPage() {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
type="button"
|
<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"
|
type="button"
|
||||||
disabled={selectedSlugs.length === 0}
|
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"
|
||||||
onClick={() => {
|
disabled={selectedSlugs.length === 0}
|
||||||
void handleBulkDelete();
|
onClick={() => {
|
||||||
}}
|
void handleBulkDelete();
|
||||||
>
|
}}
|
||||||
<Trash2 className="w-3 h-3" aria-hidden="true" />
|
>
|
||||||
<span>선택 삭제</span>
|
<Trash2 className="w-3 h-3" aria-hidden="true" />
|
||||||
</button>
|
<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>
|
</div>
|
||||||
<table className="w-full text-xs text-left border-collapse mt-2">
|
<table className="w-full text-xs text-left border-collapse mt-2">
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { FolderKanban, LayoutDashboard, ListChecks, SunMoon } from "lucide-react";
|
||||||
|
|
||||||
interface SubmissionItem {
|
interface SubmissionItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,6 +20,7 @@ export default function AllProjectSubmissionsPage() {
|
|||||||
const [submissions, setSubmissions] = useState<SubmissionItem[]>([]);
|
const [submissions, setSubmissions] = useState<SubmissionItem[]>([]);
|
||||||
const [status, setStatus] = useState<PageStatus>("idle");
|
const [status, setStatus] = useState<PageStatus>("idle");
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -82,39 +85,113 @@ export default function AllProjectSubmissionsPage() {
|
|||||||
.join("\n");
|
.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 (
|
return (
|
||||||
<main className="min-h-screen flex flex-col bg-slate-950 text-slate-50">
|
<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-800 px-6 py-4 flex items-center justify-between bg-slate-950/80 backdrop-blur">
|
<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>
|
<div>
|
||||||
<h1 className="text-xl font-semibold">전체 폼 제출 내역</h1>
|
<h1 className="text-2xl font-bold tracking-tight">전체 폼 제출 내역</h1>
|
||||||
<p className="text-xs text-slate-400">로그인한 계정의 모든 프로젝트에 대한 폼 제출 데이터를 확인할 수 있습니다.</p>
|
<p className="text-sm text-slate-500 dark:text-slate-400">로그인한 계정의 모든 프로젝트에 대한 폼 제출 데이터를 확인할 수 있습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-slate-300 flex flex-col items-end gap-1">
|
<div className="flex items-center gap-3 text-sm">
|
||||||
<a
|
<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">
|
||||||
href="/projects"
|
<Link
|
||||||
role="link"
|
href="/dashboard"
|
||||||
className="text-[11px] text-sky-400 hover:text-sky-300 underline-offset-2 hover:underline"
|
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"
|
||||||
onClick={(event) => {
|
>
|
||||||
event.preventDefault();
|
<LayoutDashboard className="w-4 h-4" aria-hidden="true" />
|
||||||
router.push("/projects");
|
<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");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
프로젝트 목록
|
<SunMoon className="w-4 h-4" aria-hidden="true" />
|
||||||
</a>
|
<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>
|
</div>
|
||||||
</header>
|
</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 && (
|
{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" && (
|
{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 && (
|
{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 && (
|
{submissions.length > 0 && (
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export default function SignupPage() {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch("/api/auth/me");
|
const res = await fetch("/api/auth/me");
|
||||||
if (!cancelled && res.ok) {
|
if (!cancelled && res.ok) {
|
||||||
router.push("/projects");
|
router.push("/dashboard");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
@@ -61,7 +61,7 @@ export default function SignupPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
router.push("/projects");
|
router.push("/dashboard");
|
||||||
} catch {
|
} catch {
|
||||||
setError("네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
setError("네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
darkMode: "class",
|
||||||
content: [
|
content: [
|
||||||
"./src/app/**/*.{ts,tsx}",
|
"./src/app/**/*.{ts,tsx}",
|
||||||
"./src/components/**/*.{ts,tsx}",
|
"./src/components/**/*.{ts,tsx}",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -48,7 +48,7 @@ test("비로그인 사용자가 /preview 에 접근하면 /login 으로 리다
|
|||||||
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
|
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 email = `e2e+${Date.now()}@example.com`;
|
||||||
const password = "securePass1";
|
const password = "securePass1";
|
||||||
|
|
||||||
@@ -98,9 +98,9 @@ test("회원가입 후에는 /projects, /editor, /preview 에 정상 접근할
|
|||||||
await page.getByLabel("비밀번호").fill(password);
|
await page.getByLabel("비밀번호").fill(password);
|
||||||
await page.getByRole("button", { name: "회원가입" }).click();
|
await page.getByRole("button", { name: "회원가입" }).click();
|
||||||
|
|
||||||
// /projects 로 이동했는지, 헤더가 보이는지 확인
|
// /dashboard 로 이동했는지, 헤더가 보이는지 확인
|
||||||
await expect(page).toHaveURL(/\/projects/);
|
await expect(page).toHaveURL(/\/dashboard/);
|
||||||
await expect(page.getByRole("heading", { name: "프로젝트 목록" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "대시보드" })).toBeVisible();
|
||||||
|
|
||||||
// 2) /editor 접근 확인 – 로그인 페이지로 리다이렉트되면 안 된다.
|
// 2) /editor 접근 확인 – 로그인 페이지로 리다이렉트되면 안 된다.
|
||||||
await page.goto("/editor");
|
await page.goto("/editor");
|
||||||
|
|||||||
@@ -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
@@ -219,7 +219,110 @@ test("프로젝트 목록에서 '폼 제출 내역' 링크를 클릭하면 해
|
|||||||
|
|
||||||
await expect(page.getByText("홍길동")).toBeVisible();
|
await expect(page.getByText("홍길동")).toBeVisible();
|
||||||
await expect(page.getByText("user@example.com")).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("1990-01-01")).toBeVisible();
|
||||||
await expect(page.getByText("message: 안녕하세요")).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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -146,11 +146,96 @@ describe("AllProjectSubmissionsPage - 전체 폼 제출 내역", () => {
|
|||||||
|
|
||||||
const backLink = await screen.findByRole("link", { name: "프로젝트 목록" });
|
const backLink = await screen.findByRole("link", { name: "프로젝트 목록" });
|
||||||
expect(backLink).toBeTruthy();
|
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(() => {
|
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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,7 +24,7 @@ describe("LoginPage", () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("올바른 이메일/비밀번호로 로그인하면 /api/auth/login 으로 요청을 보내고 /projects 로 이동해야 한다", async () => {
|
it("올바른 이메일/비밀번호로 로그인하면 /api/auth/login 으로 요청을 보내고 /dashboard 로 이동해야 한다", async () => {
|
||||||
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||||
const url = typeof input === "string" ? input : input.url;
|
const url = typeof input === "string" ? input : input.url;
|
||||||
const method = init?.method ?? "GET";
|
const method = init?.method ?? "GET";
|
||||||
@@ -82,7 +82,7 @@ describe("LoginPage", () => {
|
|||||||
expect(body.password).toBe("securePass1");
|
expect(body.password).toBe("securePass1");
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(pushMock).toHaveBeenCalledWith("/projects");
|
expect(pushMock).toHaveBeenCalledWith("/dashboard");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ describe("LoginPage", () => {
|
|||||||
expect(errorText).toBeTruthy();
|
expect(errorText).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("이미 로그인된 상태에서 /login 에 접근하면 /projects 로 리다이렉트해야 한다", async () => {
|
it("이미 로그인된 상태에서 /login 에 접근하면 /dashboard 로 리다이렉트해야 한다", async () => {
|
||||||
const fetchMock = vi.fn().mockResolvedValue(
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
new Response(JSON.stringify({ id: "1", email: "user@example.com", tokenVersion: 1 }), {
|
new Response(JSON.stringify({ id: "1", email: "user@example.com", tokenVersion: 1 }), {
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -144,7 +144,7 @@ describe("LoginPage", () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
expect(fetchMock.mock.calls[0][0]).toBe("/api/auth/me");
|
expect(fetchMock.mock.calls[0][0]).toBe("/api/auth/me");
|
||||||
expect(pushMock).toHaveBeenCalledWith("/projects");
|
expect(pushMock).toHaveBeenCalledWith("/dashboard");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -112,6 +112,119 @@ describe("ProjectsPage - 프로젝트 목록", () => {
|
|||||||
expect(link.closest("a")?.getAttribute("href")).toBe("/projects/submissions");
|
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 () => {
|
it("각 프로젝트 행에는 폼 제출 내역 페이지(/projects/[slug]/submissions)로 이동하는 링크가 있어야 한다", async () => {
|
||||||
const projects = [
|
const projects = [
|
||||||
{
|
{
|
||||||
@@ -227,6 +340,44 @@ describe("ProjectsPage - 프로젝트 목록", () => {
|
|||||||
confirmSpy.mockRestore();
|
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 () => {
|
it("체크박스로 여러 프로젝트를 선택한 뒤 '선택 삭제' 버튼으로 일괄 삭제할 수 있어야 한다", async () => {
|
||||||
const projects = [
|
const projects = [
|
||||||
{
|
{
|
||||||
@@ -336,7 +487,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("헤더의 '로그아웃' 버튼을 클릭하면 /api/auth/logout 으로 POST 요청을 보내고 /login 으로 이동해야 한다", async () => {
|
it("헤더의 메뉴 안 '로그아웃' 버튼을 클릭하면 /api/auth/logout 으로 POST 요청을 보내고 /login 으로 이동해야 한다", async () => {
|
||||||
const projects = [
|
const projects = [
|
||||||
{
|
{
|
||||||
id: "1",
|
id: "1",
|
||||||
@@ -376,6 +527,9 @@ describe("ProjectsPage - 프로젝트 목록", () => {
|
|||||||
expect(fetchMock).toHaveBeenCalled();
|
expect(fetchMock).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const menuButton = await screen.findByRole("button", { name: "메뉴" });
|
||||||
|
fireEvent.click(menuButton);
|
||||||
|
|
||||||
const logoutButton = await screen.findByRole("button", { name: "로그아웃" });
|
const logoutButton = await screen.findByRole("button", { name: "로그아웃" });
|
||||||
fireEvent.click(logoutButton);
|
fireEvent.click(logoutButton);
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ describe("SignupPage", () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("새 이메일/비밀번호로 회원가입하면 /api/auth/signup 으로 요청을 보내고 /projects 로 이동해야 한다", async () => {
|
it("새 이메일/비밀번호로 회원가입하면 /api/auth/signup 으로 요청을 보내고 /dashboard 로 이동해야 한다", async () => {
|
||||||
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||||
const url = typeof input === "string" ? input : input.url;
|
const url = typeof input === "string" ? input : input.url;
|
||||||
const method = init?.method ?? "GET";
|
const method = init?.method ?? "GET";
|
||||||
@@ -82,7 +82,7 @@ describe("SignupPage", () => {
|
|||||||
expect(body.password).toBe("securePass1");
|
expect(body.password).toBe("securePass1");
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(pushMock).toHaveBeenCalledWith("/projects");
|
expect(pushMock).toHaveBeenCalledWith("/dashboard");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ describe("SignupPage", () => {
|
|||||||
expect(errorText).toBeTruthy();
|
expect(errorText).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("이미 로그인된 상태에서 /signup 에 접근하면 /projects 로 리다이렉트해야 한다", async () => {
|
it("이미 로그인된 상태에서 /signup 에 접근하면 /dashboard 로 리다이렉트해야 한다", async () => {
|
||||||
const fetchMock = vi.fn().mockResolvedValue(
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
new Response(JSON.stringify({ id: "1", email: "user@example.com", tokenVersion: 1 }), {
|
new Response(JSON.stringify({ id: "1", email: "user@example.com", tokenVersion: 1 }), {
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -144,7 +144,7 @@ describe("SignupPage", () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
expect(fetchMock.mock.calls[0][0]).toBe("/api/auth/me");
|
expect(fetchMock.mock.calls[0][0]).toBe("/api/auth/me");
|
||||||
expect(pushMock).toHaveBeenCalledWith("/projects");
|
expect(pushMock).toHaveBeenCalledWith("/dashboard");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user