275 lines
11 KiB
TypeScript
275 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import Link from "next/link";
|
|
import { useRouter, useParams } from "next/navigation";
|
|
import { FolderKanban, LayoutDashboard, ListChecks, SunMoon } from "lucide-react";
|
|
import { useAppLocale } from "@/features/i18n/LocaleProvider";
|
|
import { getDashboardMessages } from "@/features/i18n/messages/dashboard";
|
|
import { getSubmissionsMessages } from "@/features/i18n/messages/submissions";
|
|
|
|
// 프로젝트별 폼 제출 내역을 조회해서 보여주는 페이지 컴포넌트.
|
|
// - URL 의 slug 파라미터를 기준으로 /api/projects/[slug]/submissions 에 요청을 보낸다.
|
|
// - 401 이면 로그인 페이지로 이동시키고, 404/500 계열 에러는 한국어 에러 메시지로 안내한다.
|
|
// - 응답 payload 의 name/email/phone/birthdate 는 개별 컬럼으로, 나머지는 "기타 필드" 텍스트로 렌더링한다.
|
|
|
|
interface SubmissionItem {
|
|
id: string;
|
|
createdAt: string;
|
|
projectSlug: string;
|
|
// 서버에서 복호화된 폼 필드 전체가 payload 로 내려온다.
|
|
payload: Record<string, unknown>;
|
|
}
|
|
|
|
type PageStatus = "idle" | "loading" | "error";
|
|
|
|
export default function ProjectSubmissionsPage() {
|
|
const router = useRouter();
|
|
const params = useParams() as { slug?: string } | null;
|
|
const slug = (params?.slug ?? "").toString();
|
|
|
|
const locale = useAppLocale();
|
|
const t = getDashboardMessages(locale);
|
|
const m = getSubmissionsMessages(locale);
|
|
|
|
const [submissions, setSubmissions] = useState<SubmissionItem[]>([]);
|
|
const [status, setStatus] = useState<PageStatus>("idle");
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
|
|
|
// 마운트 시 현재 slug 기준으로 폼 제출 내역을 불러온다.
|
|
useEffect(() => {
|
|
if (!slug) return;
|
|
|
|
let cancelled = false;
|
|
|
|
const load = async () => {
|
|
try {
|
|
setStatus("loading");
|
|
setErrorMessage(null);
|
|
|
|
const res = await fetch(`/api/projects/${encodeURIComponent(slug)}/submissions`);
|
|
|
|
if (!res.ok) {
|
|
if (cancelled) return;
|
|
|
|
if (res.status === 401) {
|
|
setStatus("error");
|
|
setErrorMessage(m.errorUnauthorized);
|
|
router.push("/login");
|
|
return;
|
|
}
|
|
|
|
if (res.status === 404) {
|
|
setStatus("error");
|
|
setErrorMessage(m.errorGeneric);
|
|
return;
|
|
}
|
|
|
|
setStatus("error");
|
|
setErrorMessage(m.errorGeneric);
|
|
return;
|
|
}
|
|
|
|
const data = (await res.json()) as SubmissionItem[];
|
|
|
|
if (!cancelled) {
|
|
setSubmissions(Array.isArray(data) ? data : []);
|
|
setStatus("idle");
|
|
setErrorMessage(null);
|
|
}
|
|
} catch {
|
|
if (!cancelled) {
|
|
setStatus("error");
|
|
setErrorMessage(m.errorGeneric);
|
|
}
|
|
}
|
|
};
|
|
|
|
void load();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [slug]);
|
|
|
|
// payload 에서 name/email/phone/birthdate 를 제외한 나머지 필드를 "기타" 영역으로 모아 보여준다.
|
|
const renderOtherFields = (payload: Record<string, unknown>): string => {
|
|
const knownKeys = new Set(["name", "email", "phone", "birthdate"]);
|
|
const entries = Object.entries(payload).filter(([key]) => !knownKeys.has(key));
|
|
|
|
if (entries.length === 0) {
|
|
return "-";
|
|
}
|
|
|
|
return entries
|
|
.map(([key, value]) => {
|
|
const stringValue = typeof value === "string" ? value : JSON.stringify(value);
|
|
return `${key}: ${stringValue}`;
|
|
})
|
|
.join("\n");
|
|
};
|
|
|
|
const handleLogout = async () => {
|
|
try {
|
|
const res = await fetch("/api/auth/logout", {
|
|
method: "POST",
|
|
});
|
|
|
|
if (!res.ok) {
|
|
setErrorMessage(m.errorLogout);
|
|
return;
|
|
}
|
|
|
|
router.push("/login");
|
|
} catch {
|
|
setErrorMessage(m.errorLogout);
|
|
}
|
|
};
|
|
|
|
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">{m.headerTitle}</h1>
|
|
<p className="text-xs text-slate-500 dark:text-slate-400">{m.headerDescription}</p>
|
|
<p className="mt-1 text-[11px] font-mono text-slate-500 dark:text-slate-400">{slug}</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"
|
|
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>{t.navDashboard}</span>
|
|
</Link>
|
|
<Link
|
|
href="/projects"
|
|
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium text-slate-700 hover:bg-slate-100 hover:text-sky-700 dark:text-slate-200 dark:hover:bg-slate-800 dark:hover:text-sky-200"
|
|
>
|
|
<FolderKanban className="w-4 h-4" aria-hidden="true" />
|
|
<span>{t.navProjects}</span>
|
|
</Link>
|
|
<Link
|
|
href="/projects/submissions"
|
|
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium text-emerald-700 hover:bg-emerald-50 hover:text-emerald-800 dark:text-emerald-200 dark:hover:bg-emerald-900/60 dark:hover:text-emerald-100"
|
|
>
|
|
<ListChecks className="w-4 h-4" aria-hidden="true" />
|
|
<span>{t.navSubmissions}</span>
|
|
</Link>
|
|
</nav>
|
|
<button
|
|
type="button"
|
|
className="inline-flex items-center gap-1 rounded-full border border-slate-300 bg-white/80 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800"
|
|
onClick={handleToggleTheme}
|
|
>
|
|
<SunMoon className="w-4 h-4" aria-hidden="true" />
|
|
<span>{t.themeToggleLabel}</span>
|
|
</button>
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
className="inline-flex items-center gap-1 rounded-full border border-slate-300 bg-white/80 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800"
|
|
onClick={() => {
|
|
setIsMenuOpen((prev) => !prev);
|
|
}}
|
|
>
|
|
{t.menuLabel}
|
|
</button>
|
|
{isMenuOpen && (
|
|
<div className="absolute right-0 mt-1 w-32 rounded border border-slate-200 bg-white shadow-lg z-20 dark:border-slate-700 dark:bg-slate-900/95">
|
|
<button
|
|
type="button"
|
|
className="w-full px-3 py-2 text-left text-xs text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800"
|
|
onClick={() => {
|
|
setIsMenuOpen(false);
|
|
void handleLogout();
|
|
}}
|
|
>
|
|
{t.logoutLabel}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<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-500 mb-3 dark:text-red-300">{errorMessage}</p>
|
|
)}
|
|
|
|
{status === "loading" && (
|
|
<p className="text-xs text-slate-500 mb-3 dark:text-slate-400">{m.loadingText}</p>
|
|
)}
|
|
|
|
{submissions.length === 0 && status === "idle" && !errorMessage && (
|
|
<p className="text-xs text-slate-500 dark:text-slate-400">{m.emptyText}</p>
|
|
)}
|
|
|
|
{submissions.length > 0 && (
|
|
<table className="w-full text-xs text-left border-collapse mt-2">
|
|
<thead>
|
|
<tr className="border-b border-slate-200 text-slate-600 dark:border-slate-800 dark:text-slate-400">
|
|
<th className="py-2 pr-4">{m.tableHeaderCreatedAt}</th>
|
|
<th className="py-2 pr-4">{m.tableHeaderName}</th>
|
|
<th className="py-2 pr-4">{m.tableHeaderEmail}</th>
|
|
<th className="py-2 pr-4">{m.tableHeaderPhone}</th>
|
|
<th className="py-2 pr-4">{m.tableHeaderBirthdate}</th>
|
|
<th className="py-2 pr-4">{m.tableHeaderOtherFields}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{submissions.map((item) => {
|
|
const payload = (item.payload ?? {}) as Record<string, unknown>;
|
|
|
|
const nameValue = payload["name"];
|
|
const emailValue = payload["email"];
|
|
const phoneValue = payload["phone"];
|
|
const birthdateValue = payload["birthdate"];
|
|
|
|
const name = typeof nameValue === "string" ? nameValue : "";
|
|
const email = typeof emailValue === "string" ? emailValue : "";
|
|
const phone = typeof phoneValue === "string" ? phoneValue : "";
|
|
const birthdate = typeof birthdateValue === "string" ? birthdateValue : "";
|
|
|
|
return (
|
|
<tr
|
|
key={item.id}
|
|
className="border-b border-slate-200 hover:bg-slate-50 dark:border-slate-900 dark:hover:bg-slate-900/60"
|
|
>
|
|
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300 text-[11px]">
|
|
{new Date(item.createdAt).toLocaleString()}
|
|
</td>
|
|
<td className="py-2 pr-4 text-slate-900 dark:text-slate-100">{name}</td>
|
|
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300">{email}</td>
|
|
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300">{phone}</td>
|
|
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300">{birthdate}</td>
|
|
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300 whitespace-pre-wrap">
|
|
{renderOtherFields(payload)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|