forked from UKSOURCE/ipv6
842 lines
40 KiB
TypeScript
842 lines
40 KiB
TypeScript
"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();
|
||
|
||
// 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 active = pathname === item.href;
|
||
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>
|
||
<button
|
||
type="button"
|
||
className="group flex flex-col items-center gap-1 text-on-surface-variant hover:text-primary mt-auto"
|
||
>
|
||
<span className="material-symbols-outlined text-2xl transition-all duration-300 group-hover:rotate-45">settings</span>
|
||
<span className="font-[var(--font-label-caps)] text-[8px] uppercase tracking-widest">{lang === "vi" ? "Cài đặt" : "Settings"}</span>
|
||
</button>
|
||
</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="flex flex-col items-end">
|
||
<span className="font-[var(--font-label-caps)] text-[10px] text-primary uppercase tracking-widest">
|
||
{t.meta.user.role}
|
||
</span>
|
||
</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>
|
||
);
|
||
}
|