284 lines
12 KiB
TypeScript
284 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { useEffect, useState } from "react";
|
||
|
||
interface PlaceRow {
|
||
id: string;
|
||
name: string;
|
||
description: string | null;
|
||
type: string | null;
|
||
city: string | null;
|
||
province: string | null;
|
||
country: string | null;
|
||
status: string | null;
|
||
image1: string | null;
|
||
contact: string | null;
|
||
address: string | null;
|
||
}
|
||
|
||
function getToken() {
|
||
if (typeof window === "undefined") return "";
|
||
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
|
||
}
|
||
|
||
function statusBadge(status: string | null) {
|
||
switch (status) {
|
||
case "APPROVED":
|
||
return "bg-tertiary-fixed text-on-tertiary-fixed-variant";
|
||
case "PENDING":
|
||
return "bg-primary-fixed text-on-primary-fixed-variant";
|
||
case "REJECTED":
|
||
return "bg-error-container text-on-error-container";
|
||
default:
|
||
return "bg-slate-200 text-slate-600";
|
||
}
|
||
}
|
||
|
||
export default function AdminPlacesPage() {
|
||
const [rows, setRows] = useState<PlaceRow[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState("");
|
||
const [page, setPage] = useState(0);
|
||
const [totalItem, setTotalItem] = useState(0);
|
||
const [totalPage, setTotalPage] = useState(1);
|
||
const pageSize = 20;
|
||
|
||
useEffect(() => {
|
||
async function load() {
|
||
setLoading(true);
|
||
setError("");
|
||
try {
|
||
const token = getToken();
|
||
const res = await fetch(`/api/admin/places?page=${page}&size=${pageSize}`, {
|
||
headers: { "x-auth-token": token },
|
||
});
|
||
const data = await res.json();
|
||
setRows(Array.isArray(data?.rows) ? data.rows : []);
|
||
setTotalItem(data?.totalItem ?? 0);
|
||
setTotalPage(data?.totalPage ?? 1);
|
||
} catch {
|
||
setError("Gagal memuat data lokasi");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
load();
|
||
}, [page]);
|
||
|
||
const startEntry = page * pageSize + 1;
|
||
const endEntry = Math.min((page + 1) * pageSize, totalItem);
|
||
|
||
return (
|
||
<>
|
||
{/* Header */}
|
||
<section className="flex justify-between items-end mb-12">
|
||
<div>
|
||
<nav className="flex items-center space-x-2 text-[10px] font-bold uppercase tracking-widest text-primary mb-3">
|
||
<span>Curator Dashboard</span>
|
||
<span className="material-symbols-outlined text-[10px]">chevron_right</span>
|
||
<span className="text-slate-400">Global Places</span>
|
||
</nav>
|
||
<h2 className="text-4xl font-extrabold tracking-tight text-on-surface leading-none">Places Intelligence</h2>
|
||
<p className="mt-4 text-secondary max-w-xl text-base leading-relaxed">
|
||
Manage locations, tourist destinations, and business spots across Ina Trading's global network.
|
||
</p>
|
||
</div>
|
||
<Link
|
||
href="/admin/places/new"
|
||
className="bg-gradient-to-r from-primary to-primary-container text-white px-8 py-3.5 rounded-lg font-bold flex items-center space-x-3 shadow-lg shadow-primary/20 hover:scale-[1.02] transition-transform"
|
||
>
|
||
<span className="material-symbols-outlined text-lg">add</span>
|
||
<span className="tracking-tight">Add Place</span>
|
||
</Link>
|
||
</section>
|
||
|
||
{/* Stats */}
|
||
<section className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-12">
|
||
<div className="bg-surface-container-lowest p-8 shadow-[0px_20px_40px_rgba(25,28,30,0.06)] rounded-xl border-l-4 border-primary">
|
||
<span className="text-[10px] font-black uppercase tracking-widest text-slate-400 block mb-2">Total Locations</span>
|
||
<div className="flex items-baseline space-x-2">
|
||
<span className="text-4xl font-extrabold tracking-tighter">{totalItem.toLocaleString()}</span>
|
||
<span className="text-tertiary text-xs font-bold">Entries</span>
|
||
</div>
|
||
</div>
|
||
<div className="bg-surface-container-lowest p-8 shadow-[0px_20px_40px_rgba(25,28,30,0.06)] rounded-xl">
|
||
<span className="text-[10px] font-black uppercase tracking-widest text-slate-400 block mb-2">Approved</span>
|
||
<div className="flex items-baseline space-x-2">
|
||
<span className="text-4xl font-extrabold tracking-tighter">
|
||
{rows.filter((r) => r.status === "APPROVED").length}
|
||
</span>
|
||
<span className="text-tertiary text-xs font-bold">Active</span>
|
||
</div>
|
||
</div>
|
||
<div className="bg-surface-container-lowest p-8 shadow-[0px_20px_40px_rgba(25,28,30,0.06)] rounded-xl">
|
||
<span className="text-[10px] font-black uppercase tracking-widest text-slate-400 block mb-2">Showing Page</span>
|
||
<div className="flex items-baseline space-x-2">
|
||
<span className="text-4xl font-extrabold tracking-tighter">{page + 1}</span>
|
||
<span className="text-slate-400 text-xs font-medium">/ {totalPage}</span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{/* Table */}
|
||
<div className="bg-surface-container-lowest shadow-[0px_20px_40px_rgba(25,28,30,0.06)] rounded-2xl overflow-hidden">
|
||
<div className="p-8 border-b border-surface-container">
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="text-lg font-bold tracking-tight">Location Registry</h3>
|
||
<div className="flex items-center space-x-4">
|
||
<button className="flex items-center space-x-2 text-xs font-bold uppercase tracking-widest text-secondary hover:text-primary transition-colors">
|
||
<span className="material-symbols-outlined text-sm">filter_list</span>
|
||
<span>Filter</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{loading ? (
|
||
<div className="p-16 text-center text-slate-400">
|
||
<span className="material-symbols-outlined text-4xl mb-4 block animate-spin">progress_activity</span>
|
||
<p className="text-sm font-medium">Memuat data...</p>
|
||
</div>
|
||
) : error ? (
|
||
<div className="p-16 text-center text-error">
|
||
<span className="material-symbols-outlined text-4xl mb-4 block">error</span>
|
||
<p className="text-sm font-medium">{error}</p>
|
||
</div>
|
||
) : rows.length === 0 ? (
|
||
<div className="p-16 text-center text-slate-400">
|
||
<span className="material-symbols-outlined text-4xl mb-4 block">map</span>
|
||
<p className="text-sm font-medium">Belum ada lokasi</p>
|
||
</div>
|
||
) : (
|
||
<table className="w-full text-left border-collapse">
|
||
<thead>
|
||
<tr className="bg-surface-container-low/50">
|
||
{["Place", "Type", "Location", "Status", "Actions"].map((h) => (
|
||
<th
|
||
key={h}
|
||
className={`px-8 py-5 text-[10px] font-black uppercase tracking-[0.15em] text-slate-500 ${h === "Actions" ? "text-right" : ""}`}
|
||
>
|
||
{h}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-surface-container">
|
||
{rows.map((item) => (
|
||
<tr key={item.id} className="hover:bg-surface-container-low transition-colors group">
|
||
{/* Place */}
|
||
<td className="px-8 py-6">
|
||
<div className="flex items-center space-x-4">
|
||
{item.image1 ? (
|
||
/* eslint-disable-next-line @next/next/no-img-element */
|
||
<img
|
||
src={item.image1}
|
||
alt={item.name}
|
||
className="w-12 h-12 rounded-lg object-cover flex-shrink-0 bg-slate-100"
|
||
/>
|
||
) : (
|
||
<div className="w-12 h-12 rounded-lg bg-slate-100 flex items-center justify-center flex-shrink-0">
|
||
<span className="material-symbols-outlined text-slate-300 text-xl">place</span>
|
||
</div>
|
||
)}
|
||
<div className="min-w-0">
|
||
<p className="font-bold text-on-surface tracking-tight leading-snug truncate max-w-xs">{item.name}</p>
|
||
{item.description && (
|
||
<p className="text-[10px] text-slate-400 mt-0.5 truncate max-w-xs">{item.description}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</td>
|
||
|
||
{/* Type */}
|
||
<td className="px-8 py-6">
|
||
{item.type ? (
|
||
<span className="px-3 py-1 rounded-full bg-secondary-fixed text-on-secondary-fixed-variant text-[10px] font-black uppercase tracking-widest">
|
||
{item.type}
|
||
</span>
|
||
) : (
|
||
<span className="text-slate-300 text-xs">—</span>
|
||
)}
|
||
</td>
|
||
|
||
{/* Location */}
|
||
<td className="px-8 py-6">
|
||
<div className="flex flex-col">
|
||
<span className="text-sm font-semibold text-on-surface">
|
||
{[item.city, item.province].filter(Boolean).join(", ") || "—"}
|
||
</span>
|
||
{item.country && (
|
||
<span className="text-[10px] text-slate-400 mt-0.5">{item.country}</span>
|
||
)}
|
||
</div>
|
||
</td>
|
||
|
||
{/* Status */}
|
||
<td className="px-8 py-6">
|
||
<span className={`px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest ${statusBadge(item.status)}`}>
|
||
{item.status || "—"}
|
||
</span>
|
||
</td>
|
||
|
||
{/* Actions */}
|
||
<td className="px-8 py-6 text-right">
|
||
<Link
|
||
href={`/admin/places/${item.id}/edit`}
|
||
onClick={() => sessionStorage.setItem("editPlaceCache", JSON.stringify(item))}
|
||
className="inline-flex items-center gap-1.5 bg-surface-container px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest text-on-surface hover:bg-primary hover:text-white transition-all"
|
||
>
|
||
<span className="material-symbols-outlined text-sm">edit</span>
|
||
Edit
|
||
</Link>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
|
||
{/* Pagination */}
|
||
{!loading && rows.length > 0 && (
|
||
<div className="p-6 bg-surface-container-low/30 flex items-center justify-between">
|
||
<span className="text-xs text-slate-500">
|
||
Showing{" "}
|
||
<span className="font-bold text-on-surface">{startEntry}–{endEntry}</span>{" "}
|
||
of{" "}
|
||
<span className="font-bold text-on-surface">{totalItem.toLocaleString()}</span> entries
|
||
</span>
|
||
<div className="flex items-center space-x-2">
|
||
<button
|
||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||
disabled={page === 0}
|
||
className="w-8 h-8 flex items-center justify-center rounded border border-surface-container hover:bg-white transition-colors text-slate-400 disabled:opacity-40"
|
||
>
|
||
<span className="material-symbols-outlined text-sm">chevron_left</span>
|
||
</button>
|
||
{Array.from({ length: Math.min(totalPage, 5) }, (_, i) => i).map((i) => (
|
||
<button
|
||
key={i}
|
||
onClick={() => setPage(i)}
|
||
className={`w-8 h-8 flex items-center justify-center rounded text-xs font-bold transition-colors ${
|
||
i === page
|
||
? "bg-primary text-white"
|
||
: "border border-surface-container hover:bg-white text-on-surface"
|
||
}`}
|
||
>
|
||
{i + 1}
|
||
</button>
|
||
))}
|
||
<button
|
||
onClick={() => setPage((p) => Math.min(totalPage - 1, p + 1))}
|
||
disabled={page >= totalPage - 1}
|
||
className="w-8 h-8 flex items-center justify-center rounded border border-surface-container hover:bg-white transition-colors text-slate-400 disabled:opacity-40"
|
||
>
|
||
<span className="material-symbols-outlined text-sm">chevron_right</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
);
|
||
}
|