Files
ipv6-sims/app/components/admin/AdminConsole.tsx
T

871 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { setRequestStatusAction } from "@/app/(internal)/admin/actions";
import Image from "next/image";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useMemo, useState, useTransition } from "react";
import { useLanguage } from "@/app/context/LanguageContext";
import { useTranslation } from "@/app/hooks/useTranslation";
import type { AdminRequestRow, SubmissionStatus } from "@/types/admin-submission";
type AdminRequest = AdminRequestRow;
type StatusKey = SubmissionStatus;
export type AdminConsoleView = "all" | "pending" | "approved" | "rejected";
export type AdminData = {
meta: {
title: string;
subtitle: string;
user: { name: string; role: string; avatarUrl: string };
footerTag: string;
};
stats: Array<{
id: string;
label: string;
value: string;
hint: string;
icon: string;
accent: "none" | "pending" | "confirmed" | "progress";
progressPercent?: number;
}>;
filters: {
status: { label: string; options: { value: string; label: string }[] };
companyGroup: { label: string; options: { value: string; label: string }[] };
searchPlaceholder: string;
exportLabel: string;
};
table: {
columns: Record<string, string>;
pageSize: number;
};
statusMap: Record<
StatusKey,
{
label: string;
borderClass: string;
dotClass: string;
}
>;
requests: AdminRequest[];
footer: { legal: string[] };
};
function InfoItem({
label,
value,
isEmail = false,
isStatus = false,
status = "",
statusMap = {} as any
}: {
label: string;
value?: string;
isEmail?: boolean;
isStatus?: boolean;
status?: string;
statusMap?: any;
}) {
if (!value && !isStatus) return (
<div className="flex flex-col gap-1">
<span className="text-[10px] font-[var(--font-label-caps)] text-outline uppercase tracking-widest">{label}</span>
<span className="text-sm text-on-surface-variant/40 italic"></span>
</div>
);
return (
<div className="flex flex-col gap-1">
<span className="text-[10px] font-[var(--font-label-caps)] text-outline uppercase tracking-widest">{label}</span>
{isStatus ? (
<span className={`px-2 py-0.5 rounded text-[10px] font-bold border w-fit uppercase tracking-wider ${statusMap[status]?.borderClass || ""}`}>
{value}
</span>
) : (
<span className={`text-sm font-medium ${isEmail ? "text-primary hover:underline cursor-pointer" : "text-on-surface"}`}>
{value}
</span>
)}
</div>
);
}
function StatCard({
item,
}: {
item: AdminData["stats"][0];
}) {
const accent =
item.accent === "pending"
? "border-l-4 border-secondary"
: item.accent === "confirmed"
? "border-l-4 border-primary"
: "";
return (
<div className={`glass-panel p-6 rounded-xl relative overflow-hidden group ${accent}`}>
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity pointer-events-none">
<span className="material-symbols-outlined text-6xl">{item.icon}</span>
</div>
<p
className={`font-[var(--font-label-caps)] text-[11px] uppercase tracking-widest mb-2 flex items-center gap-2 ${
item.accent === "pending"
? "text-secondary"
: item.accent === "confirmed"
? "text-primary"
: "text-outline"
}`}
>
{item.accent === "pending" || item.accent === "confirmed" ? (
<span
className={`status-led w-2 h-2 rounded-full ${
item.accent === "pending" ? "bg-secondary" : "bg-primary"
}`}
/>
) : null}
{item.label}
</p>
<h3 className="font-[var(--font-display-lg)] text-2xl md:text-3xl text-on-surface">{item.value}</h3>
{item.accent === "progress" && typeof item.progressPercent === "number" ? (
<div className="mt-4 w-full bg-surface-container-high h-1.5 rounded-full overflow-hidden">
<div
className="h-full gold-gradient-bg transition-all"
style={{ width: `${item.progressPercent}%` }}
/>
</div>
) : (
<div className="mt-4 flex items-center gap-2 min-h-[20px]">
{item.hint ? (
<span
className={`text-xs ${
item.accent === "pending"
? "text-secondary"
: item.accent === "confirmed"
? "text-primary"
: "text-primary"
}`}
>
{item.hint}
</span>
) : null}
</div>
)}
</div>
);
}
const PENDING_THRESHOLD = 50;
function formatCount(n: number) {
return n.toLocaleString("en-US");
}
function buildLiveStats(data: AdminData, lang: string) {
const req = data.requests;
const total = req.length;
const pending = req.filter((r) => r.status === "pending").length;
const approved = req.filter((r) => r.status === "approved").length;
const rejected = req.filter((r) => r.status === "rejected").length;
const conversionPct = total > 0 ? Math.round((approved / total) * 100) : 0;
const isVi = lang === "vi";
return data.stats.map((s) => {
switch (s.id) {
case "total":
return {
...s,
value: formatCount(total),
hint: isVi
? `${formatCount(approved)} đã duyệt · ${formatCount(pending)} chờ duyệt · ${formatCount(rejected)} từ chối`
: `${formatCount(approved)} approved · ${formatCount(pending)} pending · ${formatCount(rejected)} rejected`,
};
case "pending":
return {
...s,
value: formatCount(pending),
hint: isVi
? (pending >= PENDING_THRESHOLD
? `Đạt hoặc vượt ngưỡng (${PENDING_THRESHOLD})`
: `Dưới ngưỡng (${PENDING_THRESHOLD})`)
: (pending >= PENDING_THRESHOLD
? `At or above threshold (${PENDING_THRESHOLD})`
: `Below threshold (${PENDING_THRESHOLD})`),
};
case "confirmed":
return {
...s,
value: formatCount(approved),
hint: total > 0
? (isVi ? `${Math.round((approved / total) * 100)}% trên tổng số yêu cầu` : `${Math.round((approved / total) * 100)}% of all requests`)
: (isVi ? "Chưa có yêu cầu nào" : "No requests yet"),
};
case "conversion":
return {
...s,
value: `${conversionPct}%`,
hint: total > 0
? (isVi ? `Tỷ lệ đã duyệt: ${approved} / ${total}` : `Approved share: ${approved} / ${total}`)
: "",
progressPercent: conversionPct,
};
default:
return s;
}
});
}
export default function AdminConsole({ data, view = "all" }: { data: AdminData; view?: AdminConsoleView }) {
const router = useRouter();
const pathname = usePathname();
const [isPending, startTransition] = useTransition();
const [search, setSearch] = useState("");
const [status, setStatus] = useState("all");
const [companyGroup, setCompanyGroup] = useState("all");
const [afterDate, setAfterDate] = useState("");
const [page, setPage] = useState(1);
const [detail, setDetail] = useState<AdminRequest | null>(null);
const { lang, setLang } = useLanguage();
const { admin: t } = useTranslation();
const [userDropdown, setUserDropdown] = useState(false);
const handleLogout = async () => {
try {
await fetch("/ipv6/api/auth/logout", { method: "POST" });
router.push("/login");
} catch (error) {
console.error("Logout failed", error);
}
};
// Merge real requests from server prop with translated UI labels
const tData = {
...t,
requests: data.requests,
table: { ...t.table, pageSize: data.table.pageSize },
stats: t.stats as AdminData["stats"],
} as AdminData;
const liveStats = useMemo(() => buildLiveStats(tData, lang), [tData, lang]);
const statusFilter = view === "all" ? status : view;
const filtered = useMemo(() => {
let q = search.trim().toLowerCase();
// Remove leading # if searching for ID
if (q.startsWith("#")) {
q = q.substring(1);
}
return data.requests.filter((r) => {
if (statusFilter !== "all" && r.status !== statusFilter) return false;
if (companyGroup !== "all" && r.segment !== companyGroup) return false;
if (afterDate && r.submittedAt < afterDate) return false;
if (!q) return true;
const hay = `${r.id} ${r.fullName} ${r.company} ${r.email} ${r.phone} ${r.jobTitle} ${r.status}`.toLowerCase();
return hay.includes(q);
});
}, [data.requests, search, statusFilter, companyGroup, afterDate]);
async function commitStatus(publicId: string, next: SubmissionStatus) {
try {
await setRequestStatusAction(publicId, next);
startTransition(() => router.refresh());
} catch (e) {
alert(e instanceof Error ? e.message : "Could not update status");
}
}
const pageSize = data.table.pageSize;
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
const safePage = Math.min(page, totalPages);
const sliceStart = (safePage - 1) * pageSize;
const pageRows = filtered.slice(sliceStart, sliceStart + pageSize);
const showingFrom = filtered.length === 0 ? 0 : sliceStart + 1;
const showingTo = sliceStart + pageRows.length;
return (
<div className="bg-background text-on-surface font-[var(--font-body-base)] min-h-screen flex relative">
<aside className="fixed left-0 top-0 h-full w-20 md:w-24 bg-surface-container-low border-r border-outline/20 flex flex-col items-center py-8 z-50">
<div className="mb-12">
<div className="w-12 h-12 gold-gradient-bg flex items-center justify-center rounded-lg shadow-lg">
<span className="material-symbols-outlined text-on-primary text-3xl" style={{ fontVariationSettings: "'FILL' 1" }}>
cloud_done
</span>
</div>
</div>
<nav className="flex flex-col gap-8 flex-1" aria-label="Admin sections">
{(
[
{ href: "/admin", icon: "dashboard", label: lang === "vi" ? "Bảng điều khiển" : "Panel" },
{ href: "/admin/pending", icon: "hourglass_empty", label: lang === "vi" ? "Chờ duyệt" : "Pending" },
{ href: "/admin/approved", icon: "check_circle", label: lang === "vi" ? "Đã duyệt" : "Approved" },
{ href: "/admin/rejected", icon: "cancel", label: lang === "vi" ? "Từ chối" : "Rejected" },
] as const
).map((item) => {
const cleanPathname = pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
const cleanHref = item.href.endsWith("/") ? item.href.slice(0, -1) : item.href;
const active = cleanPathname === cleanHref;
return (
<Link
key={item.href}
href={item.href}
className={`group flex flex-col items-center gap-1 ${
active ? "text-primary" : "text-on-surface-variant hover:text-primary"
}`}
>
<span
className="material-symbols-outlined text-2xl transition-all duration-300 group-hover:scale-110"
style={active ? { fontVariationSettings: "'FILL' 1" } : undefined}
>
{item.icon}
</span>
<span className="font-[var(--font-label-caps)] text-[8px] uppercase tracking-widest opacity-0 group-hover:opacity-100 transition-opacity">
{item.label}
</span>
</Link>
);
})}
</nav>
{/* Settings button removed as per request */}
</aside>
<div className="flex-1 ml-20 md:ml-24 flex flex-col min-w-0">
<header className="sticky top-0 w-full z-40 glass-panel py-4 px-[var(--spacing-gutter)] flex flex-col lg:flex-row items-stretch lg:items-center justify-between gap-4 border-b border-outline/10">
<div className="hidden md:block min-w-0">
<h1 className="font-[var(--font-display-lg)] text-xl text-primary uppercase tracking-tighter truncate">
{view === "all"
? t.meta.title
: view === "pending"
? (lang === "vi" ? "Chờ Duyệt" : "Pending Approval")
: view === "approved"
? (lang === "vi" ? "Đã Duyệt" : "Approved Requests")
: (lang === "vi" ? "Từ Chối" : "Rejected Requests")}
</h1>
<p className="font-[var(--font-label-caps)] text-on-surface-variant text-[10px] uppercase tracking-widest">
{t.meta.subtitle}
</p>
</div>
<div className="flex-1 max-w-xl mx-0 lg:mx-8 min-w-0">
<div className="relative group">
<span className="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-outline pointer-events-none">
search
</span>
<input
className="w-full bg-surface-container-low border-none rounded-full py-2.5 pl-12 pr-4 text-on-surface placeholder:text-outline/50 focus:outline-none focus:ring-1 focus:ring-primary/40 transition-all"
placeholder={t.filters.searchPlaceholder}
type="search"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
/>
</div>
</div>
<div className="flex items-center gap-4 shrink-0 justify-end">
{/* Language Switcher — Admin Header */}
<div className="flex items-center gap-1 font-[var(--font-label-caps)] text-[10px]">
<button
type="button"
onClick={() => setLang("en")}
className={`flex items-center gap-1 px-2 py-1 rounded transition-all duration-200 ${
lang === "en" ? "text-primary border-b border-primary" : "text-on-surface-variant/50 hover:text-primary"
}`}
>
<span>EN</span>
</button>
<span className="text-on-surface-variant/30">|</span>
<button
type="button"
onClick={() => setLang("vi")}
className={`flex items-center gap-1 px-2 py-1 rounded transition-all duration-200 ${
lang === "vi" ? "text-primary border-b border-primary" : "text-on-surface-variant/50 hover:text-primary"
}`}
>
<span>VIE</span>
</button>
</div>
<div className="relative">
<button
type="button"
onClick={() => setUserDropdown(!userDropdown)}
className="flex flex-col items-end group"
>
<span className="font-[var(--font-label-caps)] text-[10px] text-primary uppercase tracking-widest group-hover:text-primary/80 transition-colors">
{t.meta.user.role}
</span>
<span className="material-symbols-outlined text-outline group-hover:text-primary transition-colors text-xs">
{userDropdown ? "expand_less" : "expand_more"}
</span>
</button>
{userDropdown && (
<div className="absolute right-0 mt-2 w-48 glass-panel rounded-xl shadow-2xl border border-white/10 z-[60] overflow-hidden">
<button
onClick={handleLogout}
className="w-full px-4 py-3 flex items-center gap-3 text-left hover:bg-white/5 text-on-surface transition-colors border-b border-white/5"
>
<span className="material-symbols-outlined text-red-400 text-lg">logout</span>
<span className="text-xs font-[var(--font-label-caps)] uppercase tracking-wider">
{lang === "vi" ? "Đăng xuất" : "Log Out"}
</span>
</button>
</div>
)}
</div>
</div>
</header>
<main className="p-4 md:p-6 w-full flex flex-col gap-6 flex-1">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-[var(--spacing-gutter)]">
{liveStats.map((s) => (
<StatCard key={s.id} item={s} />
))}
</div>
<section className="glass-panel px-6 py-4 rounded-xl flex flex-col lg:flex-row items-stretch lg:items-end justify-between gap-4">
<div className="flex flex-wrap items-end gap-4 w-full lg:w-auto">
{view === "all" ? (
<div className="flex flex-col gap-1 min-w-[140px]">
<label className="font-[var(--font-label-caps)] text-[10px] text-outline uppercase tracking-wider">
{t.filters.status.label}
</label>
<select
className="bg-surface-container-high border border-outline/30 text-on-surface text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
value={status}
onChange={(e) => {
setStatus(e.target.value);
setPage(1);
}}
>
{t.filters.status.options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
) : (
<div className="flex flex-col gap-1 min-w-[140px]">
<span className="font-[var(--font-label-caps)] text-[10px] text-outline uppercase tracking-wider">
{t.filters.status.label}
</span>
<p className="text-sm text-on-surface capitalize py-2 border border-outline/20 rounded-lg px-3 bg-surface-container-high/50">
{t.statusMap[view as StatusKey]?.label ?? view}
</p>
</div>
)}
<div className="flex flex-col gap-1 min-w-[160px]">
<label className="font-[var(--font-label-caps)] text-[10px] text-outline uppercase tracking-wider">
{t.filters.companyGroup.label}
</label>
<select
className="bg-surface-container-high border border-outline/30 text-on-surface text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
value={companyGroup}
onChange={(e) => {
setCompanyGroup(e.target.value);
setPage(1);
}}
>
{t.filters.companyGroup.options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1 min-w-[160px]">
<label className="font-[var(--font-label-caps)] text-[10px] text-outline uppercase tracking-wider">
{lang === "vi" ? "Nộp sau ngày" : "Submitted after"}
</label>
<input
className="bg-surface-container-high border border-outline/30 text-on-surface text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
type="date"
value={afterDate}
onChange={(e) => {
setAfterDate(e.target.value);
setPage(1);
}}
/>
</div>
</div>
<button
type="button"
className="w-full lg:w-auto border border-primary text-primary font-[var(--font-label-caps)] px-6 py-2 rounded-lg transition-all hover:bg-primary hover:text-on-primary flex items-center justify-center gap-2 uppercase tracking-wider text-xs"
onClick={() => alert("Export is not wired (frontend-only demo).")}
>
<span className="material-symbols-outlined text-sm">download</span>
{t.filters.exportLabel}
</button>
</section>
<div className="glass-panel rounded-xl overflow-hidden shadow-2xl">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse min-w-[880px]">
<thead>
<tr className="bg-surface-container-high border-b border-outline/20">
<th className="px-4 py-3 font-[var(--font-label-caps)] text-outline text-[10px] uppercase tracking-widest w-[80px]">
{t.table.columns.id}
</th>
<th className="px-4 py-3 font-[var(--font-label-caps)] text-outline text-[10px] uppercase tracking-widest w-[100px]">
{t.table.columns.submitted}
</th>
<th className="px-4 py-3 font-[var(--font-label-caps)] text-outline text-[10px] uppercase tracking-widest min-w-[160px]">
{t.table.columns.name}
</th>
<th className="px-4 py-3 font-[var(--font-label-caps)] text-outline text-[10px] uppercase tracking-widest min-w-[140px]">
{t.table.columns.company}
</th>
<th className="px-4 py-3 font-[var(--font-label-caps)] text-outline text-[10px] uppercase tracking-widest min-w-[160px]">
{t.table.columns.contact}
</th>
<th className="px-4 py-3 font-[var(--font-label-caps)] text-outline text-[10px] uppercase tracking-widest w-[110px]">
{t.table.columns.status}
</th>
<th className="px-4 py-3 font-[var(--font-label-caps)] text-outline text-[10px] uppercase tracking-widest text-right w-[160px]">
{t.table.columns.actions}
</th>
</tr>
</thead>
<tbody className="divide-y divide-outline/10">
{pageRows.map((row) => {
const sm = t.statusMap[row.status as StatusKey];
return (
<tr key={row.id} className="hover:bg-surface-container-high/40 transition-colors border-b border-outline/5 last:border-0">
<td className="px-4 py-3 font-[var(--font-mono)] text-[13px] text-primary whitespace-nowrap">
#{row.id}
</td>
<td className="px-4 py-3 text-[13px] text-on-surface-variant whitespace-nowrap">
{row.displayDate}
</td>
<td className="px-4 py-3">
<div className="font-[var(--font-body-base)] text-[13px] font-semibold text-on-surface truncate max-w-[200px]" title={row.fullName}>
{row.fullName}
</div>
<div className="text-[11px] text-outline truncate max-w-[200px]" title={row.jobTitle}>
{row.jobTitle}
</div>
</td>
<td className="px-4 py-3 text-[13px] text-on-surface-variant truncate max-w-[150px]" title={row.company}>
{row.company || "—"}
</td>
<td className="px-4 py-3">
<div className="text-[13px] truncate max-w-[180px]" title={row.email}>{row.email || "—"}</div>
<div className="text-[11px] text-outline truncate max-w-[180px]" title={row.phone}>{row.phone || "—"}</div>
</td>
<td className="px-4 py-3">
<span
className={`px-2 py-0.5 rounded-full text-[9px] font-[var(--font-label-caps)] border uppercase tracking-wider flex items-center gap-1.5 w-fit ${sm.borderClass}`}
>
<span className={`w-1 h-1 rounded-full status-led ${sm.dotClass}`} />
{sm.label}
</span>
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-0.5">
<button
type="button"
className="p-1.5 rounded-lg hover:bg-surface-container-highest text-on-surface-variant hover:text-on-surface transition-all disabled:opacity-40"
title={lang === "vi" ? "Xem" : "View"}
disabled={isPending}
onClick={() => setDetail(row)}
>
<span className="material-symbols-outlined text-lg">visibility</span>
</button>
{row.status === "pending" ? (
<>
<button
type="button"
className="p-1.5 rounded-lg hover:bg-primary/20 text-primary transition-all disabled:opacity-40"
title={lang === "vi" ? "Sửa" : "Edit"}
disabled={isPending}
onClick={() => alert(lang === "vi" ? `Sửa ${row.id} (chưa hỗ trợ).` : `Edit ${row.id} (not implemented).`)}
>
<span className="material-symbols-outlined text-lg">edit</span>
</button>
<button
type="button"
className="p-1.5 rounded-lg text-white hover:bg-primary hover:text-white transition-all disabled:opacity-40"
title={lang === "vi" ? "Duyệt" : "Approve"}
disabled={isPending}
onClick={() => commitStatus(row.id, "approved")}
>
<span className="material-symbols-outlined text-lg" style={{ fontVariationSettings: "'FILL' 1" }}>
check
</span>
</button>
<button
type="button"
className="p-1.5 rounded-lg hover:bg-red-500/15 text-red-300 transition-all disabled:opacity-40"
title={lang === "vi" ? "Từ chối" : "Reject"}
disabled={isPending}
onClick={() => commitStatus(row.id, "rejected")}
>
<span className="material-symbols-outlined text-lg">close</span>
</button>
</>
) : (
<>
<button
type="button"
className="p-1.5 rounded-lg hover:bg-surface-container-highest text-on-surface-variant disabled:opacity-40"
title={lang === "vi" ? "Lịch sử" : "History"}
disabled={isPending}
onClick={() => alert(lang === "vi" ? `Lịch sử cho ${row.id} (đang cập nhật).` : `History for ${row.id} (placeholder).`)}
>
<span className="material-symbols-outlined text-lg">history</span>
</button>
{row.status === "approved" ? (
<>
<button
type="button"
className="p-1.5 rounded-lg hover:bg-surface-container-high text-on-surface-variant disabled:opacity-40"
title={lang === "vi" ? "Hoàn tác" : "Mark pending"}
disabled={isPending}
onClick={() => commitStatus(row.id, "pending")}
>
<span className="material-symbols-outlined text-lg">undo</span>
</button>
<button
type="button"
className="p-1.5 rounded-lg hover:bg-red-500/15 text-red-300 transition-all disabled:opacity-40"
title={lang === "vi" ? "Từ chối" : "Reject"}
disabled={isPending}
onClick={() => commitStatus(row.id, "rejected")}
>
<span className="material-symbols-outlined text-lg">close</span>
</button>
</>
) : (
<>
<button
type="button"
className="p-1.5 rounded-lg text-white hover:bg-primary hover:text-white transition-all disabled:opacity-40"
title={lang === "vi" ? "Duyệt" : "Approve"}
disabled={isPending}
onClick={() => commitStatus(row.id, "approved")}
>
<span className="material-symbols-outlined text-lg" style={{ fontVariationSettings: "'FILL' 1" }}>
check
</span>
</button>
<button
type="button"
className="p-2 rounded-lg hover:bg-surface-container-high text-on-surface-variant disabled:opacity-40"
title={lang === "vi" ? "Hoàn tác" : "Mark pending"}
disabled={isPending}
onClick={() => commitStatus(row.id, "pending")}
>
<span className="material-symbols-outlined text-lg">undo</span>
</button>
</>
)}
</>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="px-6 py-4 border-t border-outline/10 flex flex-col sm:flex-row items-center justify-between gap-4">
<p className="text-xs text-outline">
{lang === "vi"
? `Hiển thị ${showingFrom}${showingTo} / ${filtered.length} mục`
: `Showing ${showingFrom} to ${showingTo} of ${filtered.length} entries`}
</p>
<div className="flex items-center gap-3">
<span className="text-xs text-on-surface-variant hidden sm:inline">
{lang === "vi" ? `Trang ${safePage} / ${totalPages}` : `Page ${safePage} of ${totalPages}`}
</span>
<button
type="button"
className="w-8 h-8 flex items-center justify-center rounded border border-outline/30 text-outline hover:border-primary hover:text-primary transition-all disabled:opacity-40"
disabled={safePage <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<span className="material-symbols-outlined text-sm">chevron_left</span>
</button>
<button
type="button"
className="w-8 h-8 flex items-center justify-center rounded border border-outline/30 text-outline hover:border-primary hover:text-primary transition-all disabled:opacity-40"
disabled={safePage >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
<span className="material-symbols-outlined text-sm">chevron_right</span>
</button>
</div>
</div>
</div>
</main>
<footer className="mt-auto border-t border-outline/20 py-8 px-[var(--spacing-gutter)] flex flex-col md:flex-row items-center justify-between gap-4">
<div className="flex flex-wrap items-center gap-4 justify-center md:justify-start">
<span className="font-[var(--font-display-lg)] text-sm text-primary uppercase tracking-tighter">IPv6 SUMMIT 2026</span>
<span className="text-xs text-outline">| {t.meta.footerTag}</span>
</div>
<p className="text-[11px] font-[var(--font-label-caps)] text-outline tracking-widest text-center">
{lang === "vi"
? "© 2026 Hội Nghị IPv6 cho AI & Trung Tâm Dữ Liệu. Hạ Tầng Cho Tương Lai."
: "© 2026 IPv6 for AI & Data Centre Summit. Infrastructure for the Future."}
</p>
<div className="flex items-center gap-6 justify-center">
{t.footer.legal.map((label) => (
<button
key={label}
type="button"
className="text-[10px] font-[var(--font-label-caps)] text-on-surface-variant hover:text-primary transition-colors uppercase tracking-widest"
onClick={() => alert(`${label} (placeholder).`)}
>
{label}
</button>
))}
</div>
</footer>
</div>
<div className="fixed top-[-10%] left-[-10%] w-[40%] h-[40%] bg-primary/5 blur-[120px] rounded-full -z-10 pointer-events-none" />
<div className="fixed bottom-[-10%] right-[-10%] w-[30%] h-[30%] bg-secondary/5 blur-[100px] rounded-full -z-10 pointer-events-none" />
{detail ? (
<div
className="fixed inset-0 z-[100] flex items-end sm:items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-labelledby="admin-detail-title"
>
<button
type="button"
className="absolute inset-0 cursor-default"
aria-label="Close"
onClick={() => setDetail(null)}
/>
<div className="relative w-full max-w-2xl glass-panel rounded-2xl border border-white/10 shadow-[0_20px_50px_rgba(0,0,0,0.5)] z-10 overflow-hidden flex flex-col max-h-[90vh]">
{/* Modal Header */}
<div className="p-6 border-b border-white/5 bg-white/5 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 gold-gradient-bg rounded-lg flex items-center justify-center shadow-lg">
<span className="material-symbols-outlined text-on-primary">
{detail.source === "feedback" ? "rate_review" : "person_add"}
</span>
</div>
<div>
<h2 id="admin-detail-title" className="font-[var(--font-display-lg)] text-xl text-primary leading-tight">
{detail.fullName || (lang === "vi" ? "Yêu cầu mới" : "New Request")}
</h2>
<p className="text-[10px] font-[var(--font-label-caps)] text-outline uppercase tracking-widest mt-0.5">
ID: #{detail.id} {detail.displayDate}
</p>
</div>
</div>
<button
type="button"
className="w-10 h-10 rounded-full hover:bg-white/10 text-on-surface-variant flex items-center justify-center transition-colors"
onClick={() => setDetail(null)}
>
<span className="material-symbols-outlined text-2xl">close</span>
</button>
</div>
{/* Modal Body */}
<div className="flex-1 overflow-y-auto p-8 space-y-8 custom-scrollbar">
{/* Primary Info Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-6">
<h3 className="text-xs font-[var(--font-label-caps)] text-primary uppercase tracking-[0.2em] border-b border-primary/20 pb-2">
{lang === "vi" ? "Thông tin cá nhân" : "Identity & Professional"}
</h3>
<div className="space-y-4">
<InfoItem label={lang === "vi" ? "Họ và tên" : "Full Name"} value={detail.fullName} />
<InfoItem label={lang === "vi" ? "Chức vụ" : "Job Title"} value={detail.jobTitle} />
<InfoItem label={lang === "vi" ? "Công ty / Tổ chức" : "Company"} value={detail.company} />
<InfoItem label={lang === "vi" ? "Phân khúc" : "Segment"} value={detail.segment} />
</div>
</div>
<div className="space-y-6">
<h3 className="text-xs font-[var(--font-label-caps)] text-primary uppercase tracking-[0.2em] border-b border-primary/20 pb-2">
{lang === "vi" ? "Thông tin liên hệ" : "Contact Details"}
</h3>
<div className="space-y-4">
<InfoItem label="Email" value={detail.email} isEmail />
<InfoItem label={lang === "vi" ? "Số điện thoại" : "Phone Number"} value={detail.phone} />
<InfoItem
label={lang === "vi" ? "Trạng thái hồ sơ" : "Application Status"}
value={lang === "vi" ? (t.statusMap[detail.status as StatusKey]?.label || detail.status) : detail.status}
isStatus
status={detail.status}
statusMap={t.statusMap}
/>
</div>
</div>
</div>
{/* Form Content / Notes Section */}
{detail.notes && (
<div className="space-y-6">
<h3 className="text-xs font-[var(--font-label-caps)] text-primary uppercase tracking-[0.2em] border-b border-primary/20 pb-2">
{lang === "vi" ? "Nội dung phản hồi" : "Form Submission Content"}
</h3>
<div className="glass-panel p-6 rounded-xl bg-white/5 border border-white/5">
{detail.notes.startsWith("Source:") ? (
<div className="grid grid-cols-1 gap-4">
{detail.notes.split(/\s(?=[A-Z][a-z\s]+:)/).map((part, idx) => {
const [key, ...val] = part.split(":");
if (!val.length) return <p key={idx} className="text-sm text-on-surface-variant leading-relaxed">{part}</p>;
return (
<div key={idx} className="flex flex-col sm:flex-row sm:items-baseline gap-1 sm:gap-4 border-b border-white/5 pb-3 last:border-0 last:pb-0">
<span className="text-[11px] font-[var(--font-label-caps)] text-outline uppercase tracking-wider min-w-[140px] shrink-0">
{key.replace("Source:", "").trim()}
</span>
<span className="text-sm text-on-surface font-medium">
{val.join(":").trim()}
</span>
</div>
);
})}
</div>
) : (
<p className="text-sm text-on-surface-variant leading-relaxed whitespace-pre-wrap italic">
{detail.notes}
</p>
)}
</div>
</div>
)}
</div>
{/* Modal Footer Actions */}
<div className="p-6 border-t border-white/5 bg-white/5 flex items-center justify-end gap-4">
<button
type="button"
className="px-6 py-2.5 rounded-lg border border-white/10 text-on-surface-variant text-xs font-bold uppercase tracking-wider hover:bg-white/5 transition-all"
onClick={() => setDetail(null)}
>
{lang === "vi" ? "Đóng" : "Close"}
</button>
{detail.status === "pending" && (
<button
type="button"
className="px-6 py-2.5 rounded-lg gold-gradient-bg text-on-primary text-xs font-bold uppercase tracking-wider shadow-lg shadow-primary/20 active:scale-95 transition-all"
onClick={() => {
commitStatus(detail.id, "approved");
setDetail(null);
}}
>
{lang === "vi" ? "Phê duyệt hồ sơ" : "Approve Application"}
</button>
)}
</div>
</div>
</div>
) : null}
</div>
);
}