Files
page-builder/src/app/projects/submissions/page.tsx
T
jaybe f71207aeb5
CI / test (push) Failing after 11m12s
CI / e2e (push) Has been cancelled
CI / pr_and_merge (push) Has been cancelled
i18n 적용
2025-12-10 15:56:51 +09:00

258 lines
10 KiB
TypeScript

"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";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getDashboardMessages } from "@/features/i18n/messages/dashboard";
import { getSubmissionsMessages } from "@/features/i18n/messages/submissions";
interface SubmissionItem {
id: string;
createdAt: string;
projectSlug: string;
projectTitle: string | null;
payload: Record<string, unknown>;
}
type PageStatus = "idle" | "loading" | "error";
export default function AllProjectSubmissionsPage() {
const router = useRouter();
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);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
setStatus("loading");
setErrorMessage(null);
const res = await fetch("/api/projects/submissions");
if (!res.ok) {
if (cancelled) return;
if (res.status === 401) {
setStatus("error");
setErrorMessage(m.errorUnauthorized);
router.push("/login");
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;
};
}, []);
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);
}
};
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-sm text-slate-500 dark:text-slate-400">{m.headerDescription}</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"
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>{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={() => {
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" />
<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>
)}
{status === "idle" && !errorMessage && submissions.length === 0 && (
<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.tableHeaderProject}</th>
<th className="py-2 pr-4">{m.tableHeaderSlug}</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">{item.projectTitle ?? "-"}</td>
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300">{item.projectSlug}</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>
);
}