Update admin review and product flows

This commit is contained in:
2026-05-08 22:32:41 +07:00
parent 006799f872
commit 37466d42e1
13 changed files with 1160 additions and 371 deletions

View File

@ -1,52 +1,94 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import { useLanguage } from "@/lib/i18n-context"; import { useLanguage } from "@/lib/i18n-context";
const recentOrders = [ interface AnalyticsPoint {
{ label: string;
id: "ORD-001", value: number;
product: "Titan Minimalist V2", }
sku: "TM-9920",
customer: "Sarah Jenkins",
location: "London, UK",
date: "Oct 24, 2023",
amount: "$249.00",
status: "Processing",
statusColor: "text-primary bg-primary/10",
},
{
id: "ORD-002",
product: "Sonic Wave Pro",
sku: "SW-1021",
customer: "Marcus Thorne",
location: "Berlin, DE",
date: "Oct 23, 2023",
amount: "$499.00",
status: "Shipped",
statusColor: "text-secondary bg-secondary/10",
},
{
id: "ORD-003",
product: "Pulse Runner X",
sku: "PR-8821",
customer: "Elena Rodriguez",
location: "Madrid, ES",
date: "Oct 23, 2023",
amount: "$125.00",
status: "Delivered",
statusColor: "text-tertiary bg-tertiary/10",
},
];
const barHeights = [40, 65, 50, 85, 70, 60, 95, 45, 55, 80]; interface DashboardOrder {
id: string;
product: string;
sku: string;
customer: string;
location: string;
date: string;
amount: number;
status: string;
}
interface SellerDashboardPayload {
metrics: {
totalProducts: number;
soldProducts: number;
refundProducts: number;
internationalProducts: number;
localProducts: number;
};
analytics: AnalyticsPoint[];
recentOrders: DashboardOrder[];
}
function getToken() {
if (typeof window === "undefined") return "";
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
}
function formatCurrency(value: number) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 2,
}).format(value || 0);
}
function statusColor(status: string) {
const normalized = status.toLowerCase();
if (normalized.includes("ship")) return "text-secondary bg-secondary/10";
if (normalized.includes("deliver")) return "text-tertiary bg-tertiary/10";
if (normalized.includes("cancel") || normalized.includes("reject")) return "text-error bg-error/10";
return "text-primary bg-primary/10";
}
export default function DashboardPage() { export default function DashboardPage() {
const { t } = useLanguage(); const { t } = useLanguage();
const d = t.dashboard.overview; const d = t.dashboard.overview;
const [data, setData] = useState<SellerDashboardPayload | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
async function load() {
setLoading(true);
setError("");
try {
const res = await fetch("/api/dashboard/seller", {
headers: { "x-auth-token": getToken() },
});
const result = await res.json();
if (!res.ok) {
throw new Error(result?.responseDesc || result?.error || "Failed to load dashboard");
}
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load dashboard");
} finally {
setLoading(false);
}
}
load();
}, []);
const analytics = data?.analytics || [];
const maxAnalytics = Math.max(...analytics.map((item) => item.value), 1);
const totalRevenue = (data?.recentOrders || []).reduce((sum, item) => sum + item.amount, 0);
const totalOrders = data?.recentOrders.length || 0;
return ( return (
<div className="p-8"> <div className="p-8">
{/* Hero Title */}
<div className="mb-10"> <div className="mb-10">
<h1 className="text-5xl font-black font-headline tracking-tighter text-on-surface mb-2"> <h1 className="text-5xl font-black font-headline tracking-tighter text-on-surface mb-2">
{d.title} {d.title}
@ -54,88 +96,93 @@ export default function DashboardPage() {
<p className="text-on-surface-variant font-medium">{d.subtitle}</p> <p className="text-on-surface-variant font-medium">{d.subtitle}</p>
</div> </div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-10"> <div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-10">
{/* Total Products */}
<div className="bg-surface-container-lowest p-8 rounded-xl magazine-shadow relative overflow-hidden"> <div className="bg-surface-container-lowest p-8 rounded-xl magazine-shadow relative overflow-hidden">
<div className="absolute top-0 left-0 w-1 h-full bg-primary"></div> <div className="absolute top-0 left-0 w-1 h-full bg-primary"></div>
<p className="text-xs font-bold text-on-surface-variant uppercase tracking-widest mb-2"> <p className="text-xs font-bold text-on-surface-variant uppercase tracking-widest mb-2">
{d.totalProducts} {d.totalProducts}
</p> </p>
<h3 className="text-5xl font-black font-headline text-primary">1,284</h3> <h3 className="text-5xl font-black font-headline text-primary">
{loading ? "—" : (data?.metrics.totalProducts ?? 0).toLocaleString("en-US")}
</h3>
<div className="flex items-center gap-2 mt-4 text-secondary font-bold text-sm"> <div className="flex items-center gap-2 mt-4 text-secondary font-bold text-sm">
<span className="material-symbols-outlined text-sm">trending_up</span> <span className="material-symbols-outlined text-sm">trending_up</span>
<span>+12.5% {d.vsLastMonth}</span> <span>{loading ? "Loading..." : `${data?.metrics.internationalProducts ?? 0} international`}</span>
</div> </div>
</div> </div>
{/* Total Buyers */}
<div className="bg-surface-container-lowest p-8 rounded-xl magazine-shadow relative overflow-hidden"> <div className="bg-surface-container-lowest p-8 rounded-xl magazine-shadow relative overflow-hidden">
<div className="absolute top-0 left-0 w-1 h-full bg-secondary"></div> <div className="absolute top-0 left-0 w-1 h-full bg-secondary"></div>
<p className="text-xs font-bold text-on-surface-variant uppercase tracking-widest mb-2"> <p className="text-xs font-bold text-on-surface-variant uppercase tracking-widest mb-2">
{d.totalBuyers} {d.totalBuyers}
</p> </p>
<h3 className="text-5xl font-black font-headline text-secondary">42,502</h3> <h3 className="text-5xl font-black font-headline text-secondary">
{loading ? "—" : (data?.metrics.soldProducts ?? 0).toLocaleString("en-US")}
</h3>
<div className="flex items-center gap-2 mt-4 text-tertiary font-bold text-sm"> <div className="flex items-center gap-2 mt-4 text-tertiary font-bold text-sm">
<span className="material-symbols-outlined text-sm">group</span> <span className="material-symbols-outlined text-sm">group</span>
<span>{d.globalReach}</span> <span>{loading ? "Loading..." : `${data?.metrics.localProducts ?? 0} local`}</span>
</div> </div>
</div> </div>
{/* Refunds */}
<div className="bg-surface-container-lowest p-8 rounded-xl magazine-shadow relative overflow-hidden"> <div className="bg-surface-container-lowest p-8 rounded-xl magazine-shadow relative overflow-hidden">
<div className="absolute top-0 left-0 w-1 h-full bg-tertiary"></div> <div className="absolute top-0 left-0 w-1 h-full bg-tertiary"></div>
<p className="text-xs font-bold text-on-surface-variant uppercase tracking-widest mb-2"> <p className="text-xs font-bold text-on-surface-variant uppercase tracking-widest mb-2">
{d.refunds} {d.refunds}
</p> </p>
<h3 className="text-5xl font-black font-headline text-tertiary">142</h3> <h3 className="text-5xl font-black font-headline text-tertiary">
{loading ? "—" : (data?.metrics.refundProducts ?? 0).toLocaleString("en-US")}
</h3>
<div className="flex items-center gap-2 mt-4 text-error font-bold text-sm"> <div className="flex items-center gap-2 mt-4 text-error font-bold text-sm">
<span className="material-symbols-outlined text-sm">history</span> <span className="material-symbols-outlined text-sm">history</span>
<span>0.3% {d.returnRate}</span> <span>{loading ? "Loading..." : `${totalOrders} recent orders`}</span>
</div> </div>
</div> </div>
</div> </div>
{/* Analytics Row */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-10"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-10">
{/* Orders Analytics Chart */}
<div className="lg:col-span-2 bg-surface-container-lowest p-8 rounded-xl magazine-shadow"> <div className="lg:col-span-2 bg-surface-container-lowest p-8 rounded-xl magazine-shadow">
<div className="flex items-center justify-between mb-8"> <div className="flex items-center justify-between mb-8">
<div> <div>
<h4 className="text-xl font-black font-headline tracking-tight">{d.ordersAnalytics}</h4> <h4 className="text-xl font-black font-headline tracking-tight">{d.ordersAnalytics}</h4>
<p className="text-sm text-on-surface-variant font-medium">{d.ordersSubtitle}</p> <p className="text-sm text-on-surface-variant font-medium">{d.ordersSubtitle}</p>
</div> </div>
<select className="bg-surface-container-low border border-surface-container-high rounded-xl text-sm font-bold text-on-surface px-3 py-2 outline-none focus:border-primary"> <div className="bg-surface-container-low border border-surface-container-high rounded-xl text-sm font-bold text-on-surface px-3 py-2">
<option>{d.last30Days}</option> {d.last30Days}
<option>{d.lastQuarter}</option> </div>
</select>
</div> </div>
{/* Bar Chart */}
<div className="h-64 flex items-end justify-between gap-2 px-2"> <div className="h-64 flex items-end justify-between gap-2 px-2">
{barHeights.map((height, i) => ( {loading ? (
<div <div className="w-full flex items-center justify-center text-on-surface-variant text-sm font-semibold">
key={i} Loading analytics...
className={`w-full rounded-t-lg transition-all ${ </div>
i === 4 ? "bg-primary" : "bg-surface-container hover:bg-primary/20" ) : analytics.length === 0 ? (
}`} <div className="w-full flex items-center justify-center text-on-surface-variant text-sm font-semibold">
style={{ height: `${height}%` }} No analytics available
/> </div>
))} ) : (
analytics.map((item, i) => (
<div
key={`${item.label}-${i}`}
className={`w-full rounded-t-lg transition-all ${
i === analytics.length - 1 ? "bg-primary" : "bg-surface-container hover:bg-primary/20"
}`}
style={{ height: `${Math.max((item.value / maxAnalytics) * 100, 8)}%` }}
title={`${item.label}: ${item.value}`}
/>
))
)}
</div> </div>
<div className="flex justify-between mt-4 px-2 text-[10px] font-bold text-on-surface-variant uppercase tracking-widest"> <div className="flex justify-between mt-4 px-2 text-[10px] font-bold text-on-surface-variant uppercase tracking-widest">
<span>{d.wk} 1</span> {(analytics.length ? analytics : [{ label: `${d.wk} 1`, value: 0 }]).slice(0, 4).map((item) => (
<span>{d.wk} 2</span> <span key={item.label}>{item.label}</span>
<span>{d.wk} 3</span> ))}
<span>{d.wk} 4</span>
</div> </div>
</div> </div>
{/* Earnings */}
<div className="bg-surface-container-lowest p-8 rounded-xl magazine-shadow flex flex-col"> <div className="bg-surface-container-lowest p-8 rounded-xl magazine-shadow flex flex-col">
<h4 className="text-xl font-black font-headline tracking-tight mb-6">{d.earnings}</h4> <h4 className="text-xl font-black font-headline tracking-tight mb-6">{d.earnings}</h4>
{/* Donut Chart Placeholder */}
<div className="flex-1 flex items-center justify-center"> <div className="flex-1 flex items-center justify-center">
<div className="w-44 h-44 rounded-full border-[16px] border-surface-container flex items-center justify-center relative"> <div className="w-44 h-44 rounded-full border-[16px] border-surface-container flex items-center justify-center relative">
<div <div
@ -143,7 +190,9 @@ export default function DashboardPage() {
style={{ clipPath: "polygon(50% 50%, 50% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 50%)" }} style={{ clipPath: "polygon(50% 50%, 50% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 50%)" }}
/> />
<div className="text-center"> <div className="text-center">
<span className="block text-3xl font-black font-headline">$84.2k</span> <span className="block text-3xl font-black font-headline">
{loading ? "—" : formatCurrency(totalRevenue)}
</span>
<span className="text-[10px] font-bold text-on-surface-variant uppercase tracking-widest"> <span className="text-[10px] font-bold text-on-surface-variant uppercase tracking-widest">
{d.grossRevenue} {d.grossRevenue}
</span> </span>
@ -151,31 +200,29 @@ export default function DashboardPage() {
</div> </div>
</div> </div>
{/* Legend */}
<div className="space-y-4 mt-6"> <div className="space-y-4 mt-6">
{[ {[
{ color: "bg-primary", label: d.directSales, pct: "65%" }, { color: "bg-primary", label: d.directSales, value: `${data?.metrics.totalProducts ?? 0}` },
{ color: "bg-secondary", label: d.retailPartners, pct: "25%" }, { color: "bg-secondary", label: d.retailPartners, value: `${data?.metrics.soldProducts ?? 0}` },
{ color: "bg-tertiary", label: d.affiliates, pct: "10%" }, { color: "bg-tertiary", label: d.affiliates, value: `${data?.metrics.refundProducts ?? 0}` },
].map((item) => ( ].map((item) => (
<div key={item.label} className="flex items-center justify-between text-sm"> <div key={item.label} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={`w-3 h-3 ${item.color} rounded-full`}></span> <span className={`w-3 h-3 ${item.color} rounded-full`}></span>
<span className="font-semibold">{item.label}</span> <span className="font-semibold">{item.label}</span>
</div> </div>
<span className="font-bold text-on-surface-variant">{item.pct}</span> <span className="font-bold text-on-surface-variant">{item.value}</span>
</div> </div>
))} ))}
</div> </div>
</div> </div>
</div> </div>
{/* Recent Orders Table */}
<div className="bg-surface-container-lowest rounded-xl magazine-shadow overflow-hidden"> <div className="bg-surface-container-lowest rounded-xl magazine-shadow overflow-hidden">
<div className="p-8 flex items-center justify-between border-b border-surface-container"> <div className="p-8 flex items-center justify-between border-b border-surface-container">
<h4 className="text-xl font-black font-headline tracking-tight">{d.recentOrders}</h4> <h4 className="text-xl font-black font-headline tracking-tight">{d.recentOrders}</h4>
<button className="text-primary font-bold text-sm hover:underline flex items-center gap-1"> <button className="text-primary font-bold text-sm hover:underline flex items-center gap-1">
{d.viewAll}{" "} {d.viewAll}
<span className="material-symbols-outlined text-sm">arrow_forward</span> <span className="material-symbols-outlined text-sm">arrow_forward</span>
</button> </button>
</div> </div>
@ -192,7 +239,7 @@ export default function DashboardPage() {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-surface-container"> <tbody className="divide-y divide-surface-container">
{recentOrders.map((order) => ( {(data?.recentOrders || []).map((order) => (
<tr key={order.id} className="hover:bg-surface-container-low/50 transition-colors"> <tr key={order.id} className="hover:bg-surface-container-low/50 transition-colors">
<td className="px-8 py-5"> <td className="px-8 py-5">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@ -201,7 +248,7 @@ export default function DashboardPage() {
</div> </div>
<div> <div>
<p className="font-bold text-sm">{order.product}</p> <p className="font-bold text-sm">{order.product}</p>
<p className="text-xs text-on-surface-variant">SKU: {order.sku}</p> <p className="text-xs text-on-surface-variant">SKU: {order.sku || "—"}</p>
</div> </div>
</div> </div>
</td> </td>
@ -210,9 +257,9 @@ export default function DashboardPage() {
<p className="text-xs text-on-surface-variant">{order.location}</p> <p className="text-xs text-on-surface-variant">{order.location}</p>
</td> </td>
<td className="px-8 py-5 text-sm font-medium text-on-surface-variant">{order.date}</td> <td className="px-8 py-5 text-sm font-medium text-on-surface-variant">{order.date}</td>
<td className="px-8 py-5 text-sm font-bold">{order.amount}</td> <td className="px-8 py-5 text-sm font-bold">{formatCurrency(order.amount)}</td>
<td className="px-8 py-5"> <td className="px-8 py-5">
<span className={`px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-tighter ${order.statusColor}`}> <span className={`px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-tighter ${statusColor(order.status)}`}>
{order.status} {order.status}
</span> </span>
</td> </td>
@ -223,10 +270,23 @@ export default function DashboardPage() {
</td> </td>
</tr> </tr>
))} ))}
{!loading && !error && (data?.recentOrders || []).length === 0 ? (
<tr>
<td colSpan={6} className="px-8 py-10 text-center text-sm font-semibold text-on-surface-variant">
No recent orders available.
</td>
</tr>
) : null}
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
{error ? (
<div className="mt-6 rounded-xl bg-error-container px-6 py-4 text-sm font-semibold text-on-error-container">
{error}
</div>
) : null}
</div> </div>
); );
} }

View File

@ -130,6 +130,7 @@ function ProductDetailPageInner() {
const params = useParams<{ productId: string }>(); const params = useParams<{ productId: string }>();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const isDraft = searchParams.get("draft") === "1"; const isDraft = searchParams.get("draft") === "1";
const isReview = searchParams.get("review") === "1";
const [product, setProduct] = useState<ProductDetail | null>(null); const [product, setProduct] = useState<ProductDetail | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
@ -137,7 +138,11 @@ function ProductDetailPageInner() {
useEffect(() => { useEffect(() => {
if (!params.productId) return; if (!params.productId) return;
const url = `/api/products/${params.productId}${isDraft ? "?draft=1" : ""}`; const requestParams = new URLSearchParams();
if (isDraft) requestParams.set("draft", "1");
if (isReview) requestParams.set("review", "1");
const query = requestParams.toString();
const url = `/api/products/${params.productId}${query ? `?${query}` : ""}`;
fetch(url, { headers: { "x-auth-token": getToken() } }) fetch(url, { headers: { "x-auth-token": getToken() } })
.then((r) => r.json()) .then((r) => r.json())
.then((j) => { .then((j) => {
@ -146,7 +151,7 @@ function ProductDetailPageInner() {
}) })
.catch(() => setError(errorLoadText)) .catch(() => setError(errorLoadText))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [errorLoadText, params.productId, isDraft]); }, [errorLoadText, params.productId, isDraft, isReview]);
if (loading) { if (loading) {
return ( return (
@ -185,6 +190,7 @@ function ProductDetailPageInner() {
.filter(isNonEmptyString) .filter(isNonEmptyString)
: []), : []),
]; ];
const isReviewProduct = isReview || product.state === "REVIEW";
return ( return (
<div className="w-full px-6 md:px-10 py-8 pb-36 space-y-8"> <div className="w-full px-6 md:px-10 py-8 pb-36 space-y-8">
@ -202,13 +208,15 @@ function ProductDetailPageInner() {
<h1 className="text-4xl font-black font-headline tracking-tighter text-on-surface">{product.name || d.title}</h1> <h1 className="text-4xl font-black font-headline tracking-tighter text-on-surface">{product.name || d.title}</h1>
<p className="mt-2 text-on-surface-variant font-medium">{product.state || "DRAFT"}</p> <p className="mt-2 text-on-surface-variant font-medium">{product.state || "DRAFT"}</p>
</div> </div>
<Link {!isReviewProduct ? (
href={`/products/${params.productId}/edit${isDraft ? "?draft=1" : ""}`} <Link
className="editorial-gradient text-white px-6 py-3 rounded-xl font-black text-sm uppercase tracking-[0.18em] shadow-md shadow-primary/20 hover:-translate-y-0.5 transition-all flex items-center gap-2" href={`/products/${params.productId}/edit${isDraft ? "?draft=1" : ""}`}
> className="editorial-gradient text-white px-6 py-3 rounded-xl font-black text-sm uppercase tracking-[0.18em] shadow-md shadow-primary/20 hover:-translate-y-0.5 transition-all flex items-center gap-2"
<span className="material-symbols-outlined text-[18px]">edit</span> >
{d.editProduct} <span className="material-symbols-outlined text-[18px]">edit</span>
</Link> {d.editProduct}
</Link>
) : null}
</div> </div>
</div> </div>
@ -493,13 +501,15 @@ function ProductDetailPageInner() {
<span className="material-symbols-outlined text-[18px] group-hover:-translate-x-1 transition-transform">arrow_back</span> <span className="material-symbols-outlined text-[18px] group-hover:-translate-x-1 transition-transform">arrow_back</span>
Kembali Kembali
</Link> </Link>
<Link {!isReviewProduct ? (
href={`/products/${params.productId}/edit${isDraft ? "?draft=1" : ""}`} <Link
className="editorial-gradient text-white px-8 py-3.5 rounded-xl font-black text-sm uppercase tracking-[0.18em] shadow-lg shadow-primary/20 hover:-translate-y-0.5 transition-all flex items-center gap-2" href={`/products/${params.productId}/edit${isDraft ? "?draft=1" : ""}`}
> className="editorial-gradient text-white px-8 py-3.5 rounded-xl font-black text-sm uppercase tracking-[0.18em] shadow-lg shadow-primary/20 hover:-translate-y-0.5 transition-all flex items-center gap-2"
<span className="material-symbols-outlined text-[18px]">edit</span> >
Edit Produk <span className="material-symbols-outlined text-[18px]">edit</span>
</Link> Edit Produk
</Link>
) : null}
</div> </div>
</div> </div>
</div> </div>

View File

@ -150,8 +150,10 @@ interface ApiModel extends ApiMeasurement {
} }
interface ApiProductImage { interface ApiProductImage {
id?: string | number | null;
sequence?: number | null; sequence?: number | null;
imageId?: string | number | null; imageId?: string | number | null;
image?: string | number | null;
} }
interface ApiProductFile { interface ApiProductFile {
@ -415,7 +417,7 @@ function apiToEditState(data: ApiProduct): EditState {
(a: ApiProductImage, b: ApiProductImage) => (a: ApiProductImage, b: ApiProductImage) =>
(a.sequence ?? 0) - (b.sequence ?? 0) (a.sequence ?? 0) - (b.sequence ?? 0)
) )
.map((img: ApiProductImage) => toStr(img.imageId)) .map((img: ApiProductImage) => toStr(img.imageId ?? img.image))
: [], : [],
keywords: Array.isArray(data?.productKeyWords) ? data.productKeyWords.filter(Boolean) : [], keywords: Array.isArray(data?.productKeyWords) ? data.productKeyWords.filter(Boolean) : [],
features: Array.isArray(data?.productFeatures) ? data.productFeatures.filter(Boolean) : [], features: Array.isArray(data?.productFeatures) ? data.productFeatures.filter(Boolean) : [],

View File

@ -148,6 +148,7 @@ function ProductsPageInner() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const tab = searchParams.get("tab"); const tab = searchParams.get("tab");
const activeTab = tabFromQuery(tab); const activeTab = tabFromQuery(tab);
const isInReviewTab = activeTab === "In Review";
const [rows, setRows] = useState<ProductRow[]>([]); const [rows, setRows] = useState<ProductRow[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -442,14 +443,16 @@ function ProductsPageInner() {
</td> </td>
<td className="px-6 py-4 text-center"> <td className="px-6 py-4 text-center">
<div className="flex items-center justify-center gap-3"> <div className="flex items-center justify-center gap-3">
{!isInReviewTab ? (
<Link
href={`/products/${product.id}/edit${activeTab === "Draft" ? "?draft=1" : ""}`}
className="rounded-lg bg-primary px-3 py-1 text-[10px] font-bold text-white transition-colors hover:bg-primary/90"
>
{p.edit}
</Link>
) : null}
<Link <Link
href={`/products/${product.id}/edit${activeTab === "Draft" ? "?draft=1" : ""}`} href={`/products/${product.id}/detail${activeTab === "Draft" ? "?draft=1" : isInReviewTab ? "?review=1" : ""}`}
className="rounded-lg bg-primary px-3 py-1 text-[10px] font-bold text-white transition-colors hover:bg-primary/90"
>
{p.edit}
</Link>
<Link
href={`/products/${product.id}/detail${activeTab === "Draft" ? "?draft=1" : ""}`}
className="text-[10px] font-bold text-primary transition-colors hover:underline" className="text-[10px] font-bold text-primary transition-colors hover:underline"
> >
{p.detail} {p.detail}

View File

@ -15,6 +15,18 @@ interface SubCategoryRow {
subCategoryAttributes: string[]; subCategoryAttributes: string[];
} }
type CategoryFormState = {
name: string;
description: string;
};
type SubCategoryFormState = {
name: string;
description: string;
attributes: string[];
attributeInput: string;
};
function getToken() { function getToken() {
if (typeof window === "undefined") return ""; if (typeof window === "undefined") return "";
return sessionStorage.getItem("token") || localStorage.getItem("token") || ""; return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
@ -26,10 +38,13 @@ function toText(value: unknown) {
function parseCategories(payload: unknown): CategoryRow[] { function parseCategories(payload: unknown): CategoryRow[] {
const rows = const rows =
Array.isArray((payload as { rows?: unknown[] })?.rows) ? (payload as { rows: unknown[] }).rows : Array.isArray((payload as { rows?: unknown[] })?.rows)
Array.isArray((payload as { data?: unknown[] })?.data) ? (payload as { data: unknown[] }).data : ? (payload as { rows: unknown[] }).rows
Array.isArray(payload) ? payload : : Array.isArray((payload as { data?: unknown[] })?.data)
[]; ? (payload as { data: unknown[] }).data
: Array.isArray(payload)
? payload
: [];
return rows return rows
.map((item) => { .map((item) => {
@ -48,10 +63,13 @@ function parseCategories(payload: unknown): CategoryRow[] {
function parseSubCategories(payload: unknown): SubCategoryRow[] { function parseSubCategories(payload: unknown): SubCategoryRow[] {
const rows = const rows =
Array.isArray((payload as { rows?: unknown[] })?.rows) ? (payload as { rows: unknown[] }).rows : Array.isArray((payload as { rows?: unknown[] })?.rows)
Array.isArray((payload as { data?: unknown[] })?.data) ? (payload as { data: unknown[] }).data : ? (payload as { rows: unknown[] }).rows
Array.isArray(payload) ? payload : : Array.isArray((payload as { data?: unknown[] })?.data)
[]; ? (payload as { data: unknown[] }).data
: Array.isArray(payload)
? payload
: [];
return rows return rows
.map((item) => { .map((item) => {
@ -61,7 +79,18 @@ function parseSubCategories(payload: unknown): SubCategoryRow[] {
if (!id || !name) return null; if (!id || !name) return null;
const attributes = Array.isArray(row.subCategoryAttributes) const attributes = Array.isArray(row.subCategoryAttributes)
? row.subCategoryAttributes.map((attr) => toText(attr)).filter(Boolean) ? row.subCategoryAttributes
.map((attr) => {
if (typeof attr === "string" || typeof attr === "number") {
return toText(attr);
}
if (attr && typeof attr === "object") {
const record = attr as Record<string, unknown>;
return toText(record.paramName) || toText(record.name) || toText(record.value);
}
return "";
})
.filter(Boolean)
: []; : [];
return { return {
@ -74,6 +103,14 @@ function parseSubCategories(payload: unknown): SubCategoryRow[] {
.filter((item): item is SubCategoryRow => Boolean(item)); .filter((item): item is SubCategoryRow => Boolean(item));
} }
function emptyCategoryForm(): CategoryFormState {
return { name: "", description: "" };
}
function emptySubCategoryForm(): SubCategoryFormState {
return { name: "", description: "", attributes: [], attributeInput: "" };
}
export default function AdminCategoriesPage() { export default function AdminCategoriesPage() {
const [categories, setCategories] = useState<CategoryRow[]>([]); const [categories, setCategories] = useState<CategoryRow[]>([]);
const [selectedCategoryId, setSelectedCategoryId] = useState(""); const [selectedCategoryId, setSelectedCategoryId] = useState("");
@ -82,165 +119,269 @@ export default function AdminCategoriesPage() {
const [loadingSubcategories, setLoadingSubcategories] = useState(false); const [loadingSubcategories, setLoadingSubcategories] = useState(false);
const [categoryError, setCategoryError] = useState(""); const [categoryError, setCategoryError] = useState("");
const [subcategoryError, setSubcategoryError] = useState(""); const [subcategoryError, setSubcategoryError] = useState("");
const [categoryName, setCategoryName] = useState(""); const [categoryForm, setCategoryForm] = useState<CategoryFormState>(emptyCategoryForm);
const [categoryDescription, setCategoryDescription] = useState(""); const [subCategoryForm, setSubCategoryForm] = useState<SubCategoryFormState>(emptySubCategoryForm);
const [subCategoryName, setSubCategoryName] = useState(""); const [editingCategoryId, setEditingCategoryId] = useState("");
const [subCategoryDescription, setSubCategoryDescription] = useState(""); const [editingSubCategoryId, setEditingSubCategoryId] = useState("");
const [attributeInput, setAttributeInput] = useState("");
const [subCategoryAttributes, setSubCategoryAttributes] = useState<string[]>([]);
const [savingCategory, setSavingCategory] = useState(false); const [savingCategory, setSavingCategory] = useState(false);
const [savingSubcategory, setSavingSubcategory] = useState(false); const [savingSubcategory, setSavingSubcategory] = useState(false);
const [deletingCategoryId, setDeletingCategoryId] = useState("");
const [deletingSubCategoryId, setDeletingSubCategoryId] = useState("");
const selectedCategory = const selectedCategory =
categories.find((category) => category.id === selectedCategoryId) || null; categories.find((category) => category.id === selectedCategoryId) || null;
useEffect(() => { async function loadCategories(preferredCategoryId?: string) {
async function loadCategories() { setLoadingCategories(true);
setLoadingCategories(true); setCategoryError("");
setCategoryError(""); try {
try { const res = await fetch("/api/admin/categories?page=0&size=100", {
const res = await fetch("/api/admin/categories?page=0&size=100", { headers: { "x-auth-token": getToken() },
headers: { "x-auth-token": getToken() }, });
}); const data = await res.json();
const data = await res.json(); if (!res.ok) {
if (!res.ok) { throw new Error(data?.responseDesc || data?.error || "Gagal memuat category");
throw new Error(data?.responseDesc || data?.error || "Gagal memuat category");
}
const rows = parseCategories(data);
setCategories(rows);
setSelectedCategoryId((current) => current || rows[0]?.id || "");
} catch (error) {
setCategoryError(error instanceof Error ? error.message : "Gagal memuat category");
} finally {
setLoadingCategories(false);
} }
const rows = parseCategories(data);
setCategories(rows);
setSelectedCategoryId((current) => {
if (preferredCategoryId && rows.some((item) => item.id === preferredCategoryId)) {
return preferredCategoryId;
}
if (current && rows.some((item) => item.id === current)) {
return current;
}
return rows[0]?.id || "";
});
} catch (error) {
setCategoryError(error instanceof Error ? error.message : "Gagal memuat category");
} finally {
setLoadingCategories(false);
}
}
async function loadSubcategories(categoryId: string) {
if (!categoryId) {
setSubcategories([]);
return;
} }
setLoadingSubcategories(true);
setSubcategoryError("");
try {
const res = await fetch(`/api/admin/categories/${categoryId}/subcategories?page=0&size=100`, {
headers: { "x-auth-token": getToken() },
});
const data = await res.json();
if (!res.ok) {
throw new Error(data?.responseDesc || data?.error || "Gagal memuat sub-category");
}
setSubcategories(parseSubCategories(data));
} catch (error) {
setSubcategoryError(error instanceof Error ? error.message : "Gagal memuat sub-category");
} finally {
setLoadingSubcategories(false);
}
}
useEffect(() => {
loadCategories(); loadCategories();
}, []); }, []);
useEffect(() => { useEffect(() => {
async function loadSubcategories() { if (!selectedCategoryId) {
if (!selectedCategoryId) { setSubcategories([]);
setSubcategories([]); return;
return;
}
setLoadingSubcategories(true);
setSubcategoryError("");
try {
const res = await fetch(`/api/admin/categories/${selectedCategoryId}/subcategories?page=0&size=100`, {
headers: { "x-auth-token": getToken() },
});
const data = await res.json();
if (!res.ok) {
throw new Error(data?.responseDesc || data?.error || "Gagal memuat sub-category");
}
setSubcategories(parseSubCategories(data));
} catch (error) {
setSubcategoryError(error instanceof Error ? error.message : "Gagal memuat sub-category");
} finally {
setLoadingSubcategories(false);
}
} }
loadSubcategories(selectedCategoryId);
loadSubcategories();
}, [selectedCategoryId]); }, [selectedCategoryId]);
function addAttribute() { function resetCategoryEditor() {
const normalized = attributeInput.trim(); setEditingCategoryId("");
if (!normalized) return; setCategoryForm(emptyCategoryForm);
setSubCategoryAttributes((current) =>
current.includes(normalized) ? current : [...current, normalized]
);
setAttributeInput("");
} }
async function handleCreateCategory() { function resetSubCategoryEditor() {
if (!categoryName.trim()) return; setEditingSubCategoryId("");
setSubCategoryForm(emptySubCategoryForm);
}
function startEditCategory(category: CategoryRow) {
setEditingCategoryId(category.id);
setCategoryForm({
name: category.name,
description: category.description || "",
});
}
function startEditSubCategory(subcategory: SubCategoryRow) {
setEditingSubCategoryId(subcategory.id);
setSubCategoryForm({
name: subcategory.name,
description: subcategory.description || "",
attributes: [...subcategory.subCategoryAttributes],
attributeInput: "",
});
}
function addAttribute() {
const normalized = subCategoryForm.attributeInput.trim();
if (!normalized) return;
setSubCategoryForm((current) => ({
...current,
attributes: current.attributes.includes(normalized)
? current.attributes
: [...current.attributes, normalized],
attributeInput: "",
}));
}
function removeAttribute(attribute: string) {
setSubCategoryForm((current) => ({
...current,
attributes: current.attributes.filter((item) => item !== attribute),
}));
}
async function handleSaveCategory() {
if (!categoryForm.name.trim()) return;
setSavingCategory(true); setSavingCategory(true);
setCategoryError(""); setCategoryError("");
try { try {
const res = await fetch("/api/admin/categories", { const isEditing = Boolean(editingCategoryId);
method: "POST", const url = isEditing
? `/api/admin/categories/${editingCategoryId}`
: "/api/admin/categories";
const method = isEditing ? "PUT" : "POST";
const res = await fetch(url, {
method,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"x-auth-token": getToken(), "x-auth-token": getToken(),
}, },
body: JSON.stringify({ body: JSON.stringify({
name: categoryName.trim(), name: categoryForm.name.trim(),
description: categoryDescription.trim() || null, description: categoryForm.description.trim() || null,
}), }),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
throw new Error(data?.responseDesc || data?.error || "Gagal menambah category"); throw new Error(
data?.responseDesc ||
data?.error ||
(isEditing ? "Gagal mengubah category" : "Gagal menambah category")
);
} }
const newRows = parseCategories(data); await loadCategories(isEditing ? editingCategoryId : undefined);
if (newRows[0]) { resetCategoryEditor();
setCategories((current) => [newRows[0], ...current]);
setSelectedCategoryId(newRows[0].id);
} else {
const refreshRes = await fetch("/api/admin/categories?page=0&size=100", {
headers: { "x-auth-token": getToken() },
});
const refreshData = await refreshRes.json();
const rows = parseCategories(refreshData);
setCategories(rows);
setSelectedCategoryId(rows[0]?.id || "");
}
setCategoryName("");
setCategoryDescription("");
} catch (error) { } catch (error) {
setCategoryError(error instanceof Error ? error.message : "Gagal menambah category"); setCategoryError(error instanceof Error ? error.message : "Gagal menyimpan category");
} finally { } finally {
setSavingCategory(false); setSavingCategory(false);
} }
} }
async function handleCreateSubcategory() { async function handleDeleteCategory(category: CategoryRow) {
if (!selectedCategoryId || !subCategoryName.trim()) return; const confirmed = window.confirm(`Hapus category "${category.name}"?`);
if (!confirmed) return;
setDeletingCategoryId(category.id);
setCategoryError("");
try {
const res = await fetch(`/api/admin/categories/${category.id}`, {
method: "DELETE",
headers: { "x-auth-token": getToken() },
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data?.responseDesc || data?.error || "Gagal menghapus category");
}
if (editingCategoryId === category.id) {
resetCategoryEditor();
}
await loadCategories(category.id === selectedCategoryId ? undefined : selectedCategoryId);
} catch (error) {
setCategoryError(error instanceof Error ? error.message : "Gagal menghapus category");
} finally {
setDeletingCategoryId("");
}
}
async function handleSaveSubCategory() {
if (!selectedCategoryId || !subCategoryForm.name.trim()) return;
setSavingSubcategory(true); setSavingSubcategory(true);
setSubcategoryError(""); setSubcategoryError("");
try { try {
const res = await fetch(`/api/admin/categories/${selectedCategoryId}/subcategories`, { const isEditing = Boolean(editingSubCategoryId);
method: "POST", const url = isEditing
? `/api/admin/subcategories/${editingSubCategoryId}`
: `/api/admin/categories/${selectedCategoryId}/subcategories`;
const method = isEditing ? "PUT" : "POST";
const res = await fetch(url, {
method,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"x-auth-token": getToken(), "x-auth-token": getToken(),
}, },
body: JSON.stringify({ body: JSON.stringify({
name: subCategoryName.trim(), name: subCategoryForm.name.trim(),
description: subCategoryDescription.trim() || null, description: subCategoryForm.description.trim() || null,
subCategoryAttributes, subCategoryAttributes: subCategoryForm.attributes,
}), }),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
throw new Error(data?.responseDesc || data?.error || "Gagal menambah sub-category"); throw new Error(
data?.responseDesc ||
data?.error ||
(isEditing ? "Gagal mengubah sub-category" : "Gagal menambah sub-category")
);
} }
const refreshRes = await fetch(`/api/admin/categories/${selectedCategoryId}/subcategories?page=0&size=100`, { await loadSubcategories(selectedCategoryId);
headers: { "x-auth-token": getToken() }, resetSubCategoryEditor();
});
const refreshData = await refreshRes.json();
setSubcategories(parseSubCategories(refreshData));
setSubCategoryName("");
setSubCategoryDescription("");
setSubCategoryAttributes([]);
setAttributeInput("");
} catch (error) { } catch (error) {
setSubcategoryError(error instanceof Error ? error.message : "Gagal menambah sub-category"); setSubcategoryError(error instanceof Error ? error.message : "Gagal menyimpan sub-category");
} finally { } finally {
setSavingSubcategory(false); setSavingSubcategory(false);
} }
} }
async function handleDeleteSubCategory(subcategory: SubCategoryRow) {
const confirmed = window.confirm(`Hapus sub-category "${subcategory.name}"?`);
if (!confirmed) return;
setDeletingSubCategoryId(subcategory.id);
setSubcategoryError("");
try {
const res = await fetch(`/api/admin/subcategories/${subcategory.id}`, {
method: "DELETE",
headers: { "x-auth-token": getToken() },
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data?.responseDesc || data?.error || "Gagal menghapus sub-category");
}
if (editingSubCategoryId === subcategory.id) {
resetSubCategoryEditor();
}
await loadSubcategories(selectedCategoryId);
} catch (error) {
setSubcategoryError(error instanceof Error ? error.message : "Gagal menghapus sub-category");
} finally {
setDeletingSubCategoryId("");
}
}
return ( return (
<> <>
<section className="mb-10 flex items-end justify-between gap-6"> <section className="mb-10 flex items-end justify-between gap-6">
@ -252,7 +393,7 @@ export default function AdminCategoriesPage() {
Category Management Category Management
</h2> </h2>
<p className="mt-3 text-base text-slate-500"> <p className="mt-3 text-base text-slate-500">
Kelola category dan sub-category produk untuk admin panel menggunakan endpoint category dari backend. Kelola category dan sub-category produk langsung dari admin panel dengan data real dari backend.
</p> </p>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
@ -283,35 +424,57 @@ export default function AdminCategoriesPage() {
</div> </div>
<div className="mb-6 space-y-3 rounded-xl bg-white p-4"> <div className="mb-6 space-y-3 rounded-xl bg-white p-4">
<div className="flex items-center justify-between">
<p className="text-xs font-black uppercase tracking-widest text-slate-400">
{editingCategoryId ? "Edit Category" : "Add Category"}
</p>
{editingCategoryId ? (
<button
type="button"
onClick={resetCategoryEditor}
className="text-[10px] font-black uppercase tracking-widest text-slate-400 hover:text-primary"
>
Cancel
</button>
) : null}
</div>
<input <input
value={categoryName} value={categoryForm.name}
onChange={(event) => setCategoryName(event.target.value)} onChange={(event) => setCategoryForm((current) => ({ ...current, name: event.target.value }))}
placeholder="Nama category" placeholder="Nama category"
className="w-full rounded-lg border border-surface-container px-4 py-3 text-sm focus:border-primary focus:outline-none" className="w-full rounded-lg border border-surface-container px-4 py-3 text-sm focus:border-primary focus:outline-none"
/> />
<textarea <textarea
value={categoryDescription} value={categoryForm.description}
onChange={(event) => setCategoryDescription(event.target.value)} onChange={(event) =>
setCategoryForm((current) => ({ ...current, description: event.target.value }))
}
placeholder="Deskripsi category" placeholder="Deskripsi category"
rows={3} rows={3}
className="w-full rounded-lg border border-surface-container px-4 py-3 text-sm focus:border-primary focus:outline-none" className="w-full rounded-lg border border-surface-container px-4 py-3 text-sm focus:border-primary focus:outline-none"
/> />
<button <button
type="button" type="button"
onClick={handleCreateCategory} onClick={handleSaveCategory}
disabled={savingCategory || !categoryName.trim()} disabled={savingCategory || !categoryForm.name.trim()}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-primary-container px-4 py-3 text-sm font-bold text-white transition hover:bg-primary disabled:opacity-60" className="flex w-full items-center justify-center gap-2 rounded-xl bg-primary-container px-4 py-3 text-sm font-bold text-white transition hover:bg-primary disabled:opacity-60"
> >
<span className="material-symbols-outlined text-base">add</span> <span className="material-symbols-outlined text-base">
{savingCategory ? "Menyimpan..." : "Add New Category"} {editingCategoryId ? "save" : "add"}
</span>
{savingCategory
? "Menyimpan..."
: editingCategoryId
? "Save Category"
: "Add New Category"}
</button> </button>
</div> </div>
{categoryError && ( {categoryError ? (
<div className="mb-4 rounded-xl bg-error-container px-4 py-3 text-sm font-semibold text-on-error-container"> <div className="mb-4 rounded-xl bg-error-container px-4 py-3 text-sm font-semibold text-on-error-container">
{categoryError} {categoryError}
</div> </div>
)} ) : null}
<div className="space-y-3"> <div className="space-y-3">
{loadingCategories ? ( {loadingCategories ? (
@ -326,31 +489,53 @@ export default function AdminCategoriesPage() {
categories.map((category, index) => { categories.map((category, index) => {
const isActive = category.id === selectedCategoryId; const isActive = category.id === selectedCategoryId;
return ( return (
<button <div
key={category.id} key={category.id}
type="button" className={`group border-l-4 px-5 py-4 transition ${
onClick={() => setSelectedCategoryId(category.id)}
className={`group flex w-full items-center justify-between border-l-4 px-5 py-4 text-left transition ${
isActive isActive
? "border-primary bg-white" ? "border-primary bg-white"
: "border-transparent bg-white/50 hover:border-slate-300 hover:bg-white" : "border-transparent bg-white/50 hover:border-slate-300 hover:bg-white"
}`} }`}
> >
<div> <div className="flex items-start justify-between gap-3">
<span className={`mb-1 block text-xs font-black uppercase tracking-widest ${isActive ? "text-secondary" : "text-slate-400"}`}> <button
CAT-{String(index + 1).padStart(3, "0")} type="button"
</span> onClick={() => setSelectedCategoryId(category.id)}
<h4 className={`text-xl font-black tracking-tight ${isActive ? "text-on-surface" : "text-on-surface/70"}`}> className="flex-1 text-left"
{category.name} >
</h4> <span className={`mb-1 block text-xs font-black uppercase tracking-widest ${isActive ? "text-secondary" : "text-slate-400"}`}>
{category.description && ( CAT-{String(index + 1).padStart(3, "0")}
<p className="mt-1 text-xs text-slate-500">{category.description}</p> </span>
)} <h4 className={`text-xl font-black tracking-tight ${isActive ? "text-on-surface" : "text-on-surface/70"}`}>
{category.name}
</h4>
{category.description ? (
<p className="mt-1 text-xs text-slate-500">{category.description}</p>
) : null}
</button>
<div className="flex items-center gap-1 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition">
<button
type="button"
onClick={() => startEditCategory(category)}
className="rounded-lg p-2 text-slate-400 hover:bg-slate-100 hover:text-primary"
title="Edit category"
>
<span className="material-symbols-outlined text-sm">edit</span>
</button>
<button
type="button"
onClick={() => handleDeleteCategory(category)}
disabled={deletingCategoryId === category.id}
className="rounded-lg p-2 text-slate-400 hover:bg-red-50 hover:text-error disabled:opacity-60"
title="Delete category"
>
<span className="material-symbols-outlined text-sm">
{deletingCategoryId === category.id ? "progress_activity" : "delete"}
</span>
</button>
</div>
</div> </div>
<span className={`material-symbols-outlined transition ${isActive ? "text-primary" : "text-slate-300 group-hover:text-slate-500"}`}> </div>
chevron_right
</span>
</button>
); );
}) })
)} )}
@ -372,17 +557,36 @@ export default function AdminCategoriesPage() {
</div> </div>
<div className="mb-8 rounded-2xl bg-surface-container-low p-5"> <div className="mb-8 rounded-2xl bg-surface-container-low p-5">
<div className="mb-4 flex items-center justify-between">
<p className="text-xs font-black uppercase tracking-widest text-slate-400">
{editingSubCategoryId ? "Edit Sub-Category" : "Add Sub-Category"}
</p>
{editingSubCategoryId ? (
<button
type="button"
onClick={resetSubCategoryEditor}
className="text-[10px] font-black uppercase tracking-widest text-slate-400 hover:text-primary"
>
Cancel
</button>
) : null}
</div>
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
<input <input
value={subCategoryName} value={subCategoryForm.name}
onChange={(event) => setSubCategoryName(event.target.value)} onChange={(event) =>
setSubCategoryForm((current) => ({ ...current, name: event.target.value }))
}
placeholder="Nama sub-category" placeholder="Nama sub-category"
disabled={!selectedCategoryId} disabled={!selectedCategoryId}
className="w-full rounded-lg border border-surface-container bg-white px-4 py-3 text-sm focus:border-primary focus:outline-none disabled:opacity-60" className="w-full rounded-lg border border-surface-container bg-white px-4 py-3 text-sm focus:border-primary focus:outline-none disabled:opacity-60"
/> />
<input <input
value={subCategoryDescription} value={subCategoryForm.description}
onChange={(event) => setSubCategoryDescription(event.target.value)} onChange={(event) =>
setSubCategoryForm((current) => ({ ...current, description: event.target.value }))
}
placeholder="Deskripsi sub-category" placeholder="Deskripsi sub-category"
disabled={!selectedCategoryId} disabled={!selectedCategoryId}
className="w-full rounded-lg border border-surface-container bg-white px-4 py-3 text-sm focus:border-primary focus:outline-none disabled:opacity-60" className="w-full rounded-lg border border-surface-container bg-white px-4 py-3 text-sm focus:border-primary focus:outline-none disabled:opacity-60"
@ -391,8 +595,10 @@ export default function AdminCategoriesPage() {
<div className="mt-3 flex gap-3"> <div className="mt-3 flex gap-3">
<input <input
value={attributeInput} value={subCategoryForm.attributeInput}
onChange={(event) => setAttributeInput(event.target.value)} onChange={(event) =>
setSubCategoryForm((current) => ({ ...current, attributeInput: event.target.value }))
}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === "Enter") { if (event.key === "Enter") {
event.preventDefault(); event.preventDefault();
@ -406,24 +612,20 @@ export default function AdminCategoriesPage() {
<button <button
type="button" type="button"
onClick={addAttribute} onClick={addAttribute}
disabled={!selectedCategoryId || !attributeInput.trim()} disabled={!selectedCategoryId || !subCategoryForm.attributeInput.trim()}
className="rounded-xl border-2 border-primary px-4 py-3 text-xs font-black uppercase tracking-widest text-primary transition hover:bg-primary hover:text-white disabled:opacity-60" className="rounded-xl border-2 border-primary px-4 py-3 text-xs font-black uppercase tracking-widest text-primary transition hover:bg-primary hover:text-white disabled:opacity-60"
> >
Add Attribute Add Attribute
</button> </button>
</div> </div>
{subCategoryAttributes.length > 0 && ( {subCategoryForm.attributes.length > 0 ? (
<div className="mt-4 flex flex-wrap gap-2"> <div className="mt-4 flex flex-wrap gap-2">
{subCategoryAttributes.map((attribute) => ( {subCategoryForm.attributes.map((attribute) => (
<button <button
key={attribute} key={attribute}
type="button" type="button"
onClick={() => onClick={() => removeAttribute(attribute)}
setSubCategoryAttributes((current) =>
current.filter((item) => item !== attribute)
)
}
className="flex items-center gap-1 rounded-full bg-surface-container-high px-3 py-1 text-[10px] font-black uppercase tracking-wider text-tertiary" className="flex items-center gap-1 rounded-full bg-surface-container-high px-3 py-1 text-[10px] font-black uppercase tracking-wider text-tertiary"
> >
<span className="material-symbols-outlined text-xs">label</span> <span className="material-symbols-outlined text-xs">label</span>
@ -431,24 +633,30 @@ export default function AdminCategoriesPage() {
</button> </button>
))} ))}
</div> </div>
)} ) : null}
<button <button
type="button" type="button"
onClick={handleCreateSubcategory} onClick={handleSaveSubCategory}
disabled={savingSubcategory || !selectedCategoryId || !subCategoryName.trim()} disabled={savingSubcategory || !selectedCategoryId || !subCategoryForm.name.trim()}
className="mt-4 flex items-center gap-2 rounded-xl bg-primary px-5 py-3 text-xs font-black uppercase tracking-widest text-white transition hover:bg-primary-container disabled:opacity-60" className="mt-4 flex items-center gap-2 rounded-xl bg-primary px-5 py-3 text-xs font-black uppercase tracking-widest text-white transition hover:bg-primary-container disabled:opacity-60"
> >
<span className="material-symbols-outlined text-sm">add_circle</span> <span className="material-symbols-outlined text-sm">
{savingSubcategory ? "Menyimpan..." : "Add Sub-Category"} {editingSubCategoryId ? "save" : "add_circle"}
</span>
{savingSubcategory
? "Menyimpan..."
: editingSubCategoryId
? "Save Sub-Category"
: "Add Sub-Category"}
</button> </button>
</div> </div>
{subcategoryError && ( {subcategoryError ? (
<div className="mb-4 rounded-xl bg-error-container px-4 py-3 text-sm font-semibold text-on-error-container"> <div className="mb-4 rounded-xl bg-error-container px-4 py-3 text-sm font-semibold text-on-error-container">
{subcategoryError} {subcategoryError}
</div> </div>
)} ) : null}
{!selectedCategoryId ? ( {!selectedCategoryId ? (
<div className="rounded-xl border border-dashed border-surface-container px-6 py-12 text-center text-slate-400"> <div className="rounded-xl border border-dashed border-surface-container px-6 py-12 text-center text-slate-400">
@ -480,9 +688,32 @@ export default function AdminCategoriesPage() {
</p> </p>
</div> </div>
</div> </div>
<span className="rounded-full bg-surface-container px-3 py-1 text-[10px] font-black uppercase tracking-widest text-slate-500"> <div className="flex items-center gap-3">
{subcategory.subCategoryAttributes.length} attributes <span className="rounded-full bg-surface-container px-3 py-1 text-[10px] font-black uppercase tracking-widest text-slate-500">
</span> {subcategory.subCategoryAttributes.length} attributes
</span>
<div className="flex items-center gap-1 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition">
<button
type="button"
onClick={() => startEditSubCategory(subcategory)}
className="rounded-lg p-2 text-slate-400 hover:bg-slate-100 hover:text-primary"
title="Edit sub-category"
>
<span className="material-symbols-outlined text-sm">edit</span>
</button>
<button
type="button"
onClick={() => handleDeleteSubCategory(subcategory)}
disabled={deletingSubCategoryId === subcategory.id}
className="rounded-lg p-2 text-slate-400 hover:bg-red-50 hover:text-error disabled:opacity-60"
title="Delete sub-category"
>
<span className="material-symbols-outlined text-sm">
{deletingSubCategoryId === subcategory.id ? "progress_activity" : "delete"}
</span>
</button>
</div>
</div>
</div> </div>
<div className="mt-4 flex flex-wrap gap-2 pl-[3.75rem]"> <div className="mt-4 flex flex-wrap gap-2 pl-[3.75rem]">

View File

@ -1,52 +1,74 @@
"use client"; "use client";
const reviewQueue = [ import Link from "next/link";
{ icon: "inventory_2", name: "Artisan Ceramic Vase", vendor: "Kyoto Craft Guild", date: "Oct 24, 2023" }, import { useEffect, useState } from "react";
{ icon: "watch", name: "Chronos Obsidian X", vendor: "Titan Horology", date: "Oct 24, 2023" },
{ icon: "stroller", name: "MetroGlider S2", vendor: "Urban Mobility Co.", date: "Oct 23, 2023" },
{ icon: "coffee", name: "Sumatra Single Origin", vendor: "Emerald Roast House", date: "Oct 23, 2023" },
];
const updateQueue = [ interface AdminQueueItem {
{ id: string;
tag: "Price Revision", name: string;
tagColor: "text-tertiary bg-tertiary-fixed", seller: string;
borderColor: "border-tertiary", date: string;
name: "Nordic Wool Sweater", note: string;
vendor: "Oslo Textiles", market: string;
date: "Oct 24", totalStock: number;
note: null, minPrice: number;
}, maxPrice: number;
{ }
tag: "Spec Change",
tagColor: "text-primary bg-primary-fixed",
borderColor: "border-primary",
name: "Lumix Ultra Drone",
vendor: "AeroDynamics Gmbh",
date: "Oct 23",
note: "Battery capacity updated (+15%)",
},
];
const curatorActions = [ interface AdminDashboardPayload {
{ metrics: {
color: "bg-tertiary-fixed", totalPending: number;
dot: "bg-tertiary", inReview: number;
text: 'Listing Approved: "Organic Silk Scarf"', categoriesTotal: number;
by: "By Sarah J. • 12 mins ago", newsTotal: number;
}, placesTotal: number;
{ };
color: "bg-primary-fixed", reviewQueue: AdminQueueItem[];
dot: "bg-primary", updateQueue: AdminQueueItem[];
text: 'Update Rejected: "Smart Lock Gen 3"', }
by: "By System Audit • 45 mins ago",
}, function getToken() {
]; if (typeof window === "undefined") return "";
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
}
function formatPrice(min: number, max: number) {
const formatter = new Intl.NumberFormat("id-ID");
if (!min && !max) return "—";
if (min === max || !max) return `Rp ${formatter.format(min || max)}`;
return `Rp ${formatter.format(min)} - Rp ${formatter.format(max)}`;
}
export default function AdminDashboardPage() { export default function AdminDashboardPage() {
const [data, setData] = useState<AdminDashboardPayload | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
async function load() {
setLoading(true);
setError("");
try {
const res = await fetch("/api/dashboard/admin", {
headers: { "x-auth-token": getToken() },
});
const result = await res.json();
if (!res.ok) {
throw new Error(result?.responseDesc || result?.error || "Failed to load admin dashboard");
}
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load admin dashboard");
} finally {
setLoading(false);
}
}
load();
}, []);
return ( return (
<> <>
{/* Header */}
<div className="mb-12 flex flex-col md:flex-row md:items-end justify-between gap-6"> <div className="mb-12 flex flex-col md:flex-row md:items-end justify-between gap-6">
<div className="max-w-2xl"> <div className="max-w-2xl">
<h2 className="text-4xl font-extrabold tracking-tight text-on-surface mb-2">Curator Intelligence</h2> <h2 className="text-4xl font-extrabold tracking-tight text-on-surface mb-2">Curator Intelligence</h2>
@ -57,39 +79,38 @@ export default function AdminDashboardPage() {
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="bg-primary-container text-on-primary-container px-4 py-2 rounded-lg flex items-center gap-2"> <div className="bg-primary-container text-on-primary-container px-4 py-2 rounded-lg flex items-center gap-2">
<span className="material-symbols-outlined text-sm">bolt</span> <span className="material-symbols-outlined text-sm">bolt</span>
<span className="text-xs font-bold uppercase tracking-widest">Live Updates Active</span> <span className="text-xs font-bold uppercase tracking-widest">
{loading ? "Loading..." : `${data?.metrics.categoriesTotal ?? 0} Categories Active`}
</span>
</div> </div>
</div> </div>
</div> </div>
{/* Metric Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12">
{[ {[
{ label: "Total Pending", value: "142", sub: "+12% vs yesterday", subColor: "text-primary" }, { label: "Total Pending", value: String(data?.metrics.totalPending ?? 0), sub: `${data?.metrics.newsTotal ?? 0} news`, subColor: "text-primary" },
{ label: "In Review", value: "28", sub: "4 teams active", subColor: "text-tertiary" }, { label: "In Review", value: String(data?.metrics.inReview ?? 0), sub: `${data?.metrics.placesTotal ?? 0} places`, subColor: "text-tertiary" },
{ label: "Approved Today", value: "382", sub: "94% acceptance", subColor: "text-slate-400", valueColor: "text-tertiary" }, { label: "Categories", value: String(data?.metrics.categoriesTotal ?? 0), sub: "taxonomy live", subColor: "text-slate-400", valueColor: "text-tertiary" },
].map((metric) => ( ].map((metric) => (
<div key={metric.label} className="bg-surface-container-lowest p-8 rounded-xl relative overflow-hidden shadow-[0px_20px_40px_rgba(25,28,30,0.06)] group"> <div key={metric.label} className="bg-surface-container-lowest p-8 rounded-xl relative overflow-hidden shadow-[0px_20px_40px_rgba(25,28,30,0.06)] group">
<div className="absolute top-0 right-0 w-32 h-32 bg-slate-50 rounded-bl-full -mr-8 -mt-8 transition-transform group-hover:scale-110" /> <div className="absolute top-0 right-0 w-32 h-32 bg-slate-50 rounded-bl-full -mr-8 -mt-8 transition-transform group-hover:scale-110" />
<p className="text-[10px] uppercase tracking-widest text-slate-400 mb-2 relative z-10">{metric.label}</p> <p className="text-[10px] uppercase tracking-widest text-slate-400 mb-2 relative z-10">{metric.label}</p>
<div className="flex items-baseline gap-2 relative z-10"> <div className="flex items-baseline gap-2 relative z-10">
<h3 className={`text-5xl font-black ${metric.valueColor || "text-on-surface"}`}>{metric.value}</h3> <h3 className={`text-5xl font-black ${metric.valueColor || "text-on-surface"}`}>{loading ? "—" : metric.value}</h3>
<span className={`text-sm font-bold ${metric.subColor}`}>{metric.sub}</span> <span className={`text-sm font-bold ${metric.subColor}`}>{metric.sub}</span>
</div> </div>
</div> </div>
))} ))}
</div> </div>
{/* Two-column content */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
{/* Product Review Queue */}
<div className="lg:col-span-7"> <div className="lg:col-span-7">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div> <div>
<h4 className="text-2xl font-bold tracking-tight">New Product Review Queue</h4> <h4 className="text-2xl font-bold tracking-tight">New Product Review Queue</h4>
<p className="text-sm text-slate-400 uppercase tracking-widest mt-1">Initial Listing Requests</p> <p className="text-sm text-slate-400 uppercase tracking-widest mt-1">Initial Listing Requests</p>
</div> </div>
<button className="text-sm font-semibold text-primary hover:underline">View All</button> <Link href="/admin/review" className="text-sm font-semibold text-primary hover:underline">View All</Link>
</div> </div>
<div className="bg-surface-container-low rounded-xl overflow-hidden p-1"> <div className="bg-surface-container-low rounded-xl overflow-hidden p-1">
<div className="bg-surface-container-lowest rounded-lg"> <div className="bg-surface-container-lowest rounded-lg">
@ -104,32 +125,38 @@ export default function AdminDashboardPage() {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-50"> <tbody className="divide-y divide-slate-50">
{reviewQueue.map((item) => ( {(data?.reviewQueue || []).map((item) => (
<tr key={item.name} className="group hover:bg-slate-50 transition-colors"> <tr key={item.id} className="group hover:bg-slate-50 transition-colors">
<td className="px-6 py-5"> <td className="px-6 py-5">
<div className="flex items-center"> <div className="flex items-center">
<div className="w-10 h-10 bg-slate-100 rounded-lg flex items-center justify-center mr-4 flex-shrink-0"> <div className="w-10 h-10 bg-slate-100 rounded-lg flex items-center justify-center mr-4 flex-shrink-0">
<span className="material-symbols-outlined text-slate-400">{item.icon}</span> <span className="material-symbols-outlined text-slate-400">inventory_2</span>
</div> </div>
<span className="font-bold text-on-surface">{item.name}</span> <span className="font-bold text-on-surface">{item.name}</span>
</div> </div>
</td> </td>
<td className="px-6 py-5 text-slate-600 font-medium">{item.vendor}</td> <td className="px-6 py-5 text-slate-600 font-medium">{item.seller}</td>
<td className="px-6 py-5 text-slate-500 font-medium">{item.date}</td> <td className="px-6 py-5 text-slate-500 font-medium">{item.date}</td>
<td className="px-6 py-5 text-right"> <td className="px-6 py-5 text-right">
<button className="bg-gradient-to-r from-primary to-primary-container text-white px-5 py-2 rounded-lg text-xs font-bold uppercase tracking-widest shadow-lg shadow-primary/20 transform group-hover:scale-105 transition-all"> <Link href={`/admin/review/${item.id}`} className="inline-block bg-gradient-to-r from-primary to-primary-container text-white px-5 py-2 rounded-lg text-xs font-bold uppercase tracking-widest shadow-lg shadow-primary/20 transform group-hover:scale-105 transition-all">
Take Review Take Review
</button> </Link>
</td> </td>
</tr> </tr>
))} ))}
{!loading && !error && (data?.reviewQueue || []).length === 0 ? (
<tr>
<td colSpan={4} className="px-6 py-8 text-center text-sm font-semibold text-slate-400">
No pending review items.
</td>
</tr>
) : null}
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
</div> </div>
{/* Update Queue */}
<div className="lg:col-span-5"> <div className="lg:col-span-5">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div> <div>
@ -138,36 +165,50 @@ export default function AdminDashboardPage() {
</div> </div>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
{updateQueue.map((item) => ( {(data?.updateQueue || []).map((item) => (
<div key={item.name} className={`bg-surface-container-lowest p-6 rounded-xl shadow-[0px_20px_40px_rgba(25,28,30,0.06)] border-l-4 ${item.borderColor}`}> <div key={item.id} className={`bg-surface-container-lowest p-6 rounded-xl shadow-[0px_20px_40px_rgba(25,28,30,0.06)] border-l-4 ${item.note ? "border-tertiary" : "border-primary"}`}>
<div className="flex justify-between items-start mb-4"> <div className="flex justify-between items-start mb-4">
<div> <div>
<span className={`text-[10px] font-black uppercase tracking-widest px-2 py-1 rounded mb-2 inline-block ${item.tagColor}`}> <span className={`text-[10px] font-black uppercase tracking-widest px-2 py-1 rounded mb-2 inline-block ${item.note ? "text-tertiary bg-tertiary-fixed" : "text-primary bg-primary-fixed"}`}>
{item.tag} {item.market || "Update"}
</span> </span>
<h5 className="text-lg font-bold">{item.name}</h5> <h5 className="text-lg font-bold">{item.name}</h5>
<p className="text-xs text-slate-400 font-medium">Vendor: {item.vendor}</p> <p className="text-xs text-slate-400 font-medium">Vendor: {item.seller}</p>
</div> </div>
<p className="text-[10px] font-bold text-slate-400 uppercase">{item.date}</p> <p className="text-[10px] font-bold text-slate-400 uppercase">{item.date}</p>
</div> </div>
<div className="flex items-center justify-between pt-4 border-t border-slate-50"> <div className="flex items-center justify-between pt-4 border-t border-slate-50">
{item.note <p className="text-xs text-slate-500 font-medium">{item.note || formatPrice(item.minPrice, item.maxPrice)}</p>
? <p className="text-xs text-slate-500 font-medium">{item.note}</p> <Link href={`/admin/review/${item.id}`} className="text-xs font-black uppercase tracking-[0.1em] text-primary hover:text-primary-container transition-colors">
: <div />
}
<button className="text-xs font-black uppercase tracking-[0.1em] text-primary hover:text-primary-container transition-colors">
Review Changes Review Changes
</button> </Link>
</div> </div>
</div> </div>
))} ))}
{!loading && !error && (data?.updateQueue || []).length === 0 ? (
<div className="bg-surface-container-lowest p-6 rounded-xl shadow-[0px_20px_40px_rgba(25,28,30,0.06)] text-sm font-semibold text-slate-400">
No update queue items available.
</div>
) : null}
{/* Recent Curator Actions */}
<div className="mt-4 bg-surface-container-low p-6 rounded-xl"> <div className="mt-4 bg-surface-container-low p-6 rounded-xl">
<h6 className="text-xs font-black uppercase tracking-[0.2em] text-slate-500 mb-6">Recent Curator Actions</h6> <h6 className="text-xs font-black uppercase tracking-[0.2em] text-slate-500 mb-6">Recent Curator Actions</h6>
<div className="space-y-6 relative"> <div className="space-y-6 relative">
<div className="absolute left-[11px] top-2 bottom-2 w-[2px] bg-slate-200" /> <div className="absolute left-[11px] top-2 bottom-2 w-[2px] bg-slate-200" />
{curatorActions.map((action) => ( {[
{
color: "bg-tertiary-fixed",
dot: "bg-tertiary",
text: `${data?.metrics.newsTotal ?? 0} news articles currently published`,
by: "Editorial data",
},
{
color: "bg-primary-fixed",
dot: "bg-primary",
text: `${data?.metrics.placesTotal ?? 0} places indexed for admin`,
by: "Location registry",
},
].map((action) => (
<div key={action.text} className="relative pl-8"> <div key={action.text} className="relative pl-8">
<div className={`absolute left-0 top-1 w-6 h-6 rounded-full ${action.color} flex items-center justify-center border-4 border-surface-container-low`}> <div className={`absolute left-0 top-1 w-6 h-6 rounded-full ${action.color} flex items-center justify-center border-4 border-surface-container-low`}>
<div className={`w-2 h-2 ${action.dot} rounded-full`} /> <div className={`w-2 h-2 ${action.dot} rounded-full`} />
@ -182,31 +223,27 @@ export default function AdminDashboardPage() {
</div> </div>
</div> </div>
{/* Market Saturation Report Banner */} {error ? (
<div className="mt-6 rounded-xl bg-error-container px-6 py-4 text-sm font-semibold text-on-error-container">
{error}
</div>
) : null}
<div className="mt-20 bg-slate-900 text-white rounded-2xl p-10 flex flex-col md:flex-row items-center justify-between gap-8 relative overflow-hidden"> <div className="mt-20 bg-slate-900 text-white rounded-2xl p-10 flex flex-col md:flex-row items-center justify-between gap-8 relative overflow-hidden">
<div className="absolute top-0 right-0 w-64 h-64 bg-primary/20 blur-[100px] -mr-32 -mt-32" /> <div className="absolute top-0 right-0 w-64 h-64 bg-primary/20 blur-[100px] -mr-32 -mt-32" />
<div className="relative z-10 max-w-xl"> <div className="relative z-10 max-w-xl">
<h4 className="text-3xl font-black mb-4">Market Saturation Report</h4> <h4 className="text-3xl font-black mb-4">Market Saturation Report</h4>
<p className="text-slate-400 font-medium"> <p className="text-slate-400 font-medium">
Your current review velocity is{" "} Current pending review load is{" "}
<span className="text-white">15% higher</span> than the monthly average. <span className="text-white">{data?.metrics.totalPending ?? 0}</span> items across products, places, and content workflows.
Consider delegating price-based updates to the automated filter.
</p> </p>
</div> </div>
<div className="relative z-10"> <div className="relative z-10">
<button className="bg-white text-slate-950 font-black px-8 py-4 rounded-lg uppercase tracking-widest text-sm hover:bg-primary hover:text-white transition-all"> <Link href="/admin/review" className="bg-white text-slate-950 font-black px-8 py-4 rounded-lg uppercase tracking-widest text-sm hover:bg-primary hover:text-white transition-all">
Download PDF Analysis Open Review Queue
</button> </Link>
</div> </div>
</div> </div>
{/* FAB */}
<button className="fixed bottom-8 right-8 w-16 h-16 bg-primary text-white rounded-full flex items-center justify-center shadow-2xl hover:scale-110 transition-transform z-50 group">
<span className="material-symbols-outlined text-3xl group-hover:rotate-90 transition-transform">add</span>
<span className="absolute right-20 bg-slate-900 text-white text-[10px] font-black uppercase tracking-widest px-4 py-2 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none">
Create Manual Entry
</span>
</button>
</> </>
); );
} }

View File

@ -40,6 +40,27 @@ function SectionCard({ title, accent, children }: { title: string; accent?: bool
); );
} }
function extractIdChange(payload: unknown) {
const rows = Array.isArray((payload as { data?: unknown })?.data)
? ((payload as { data?: unknown[] }).data ?? [])
: [];
return rows.find(
(row) =>
row &&
typeof row === "object" &&
(row as { field?: unknown }).field === "id" &&
typeof (row as { oldValue?: unknown }).oldValue === "string" &&
typeof (row as { newValue?: unknown }).newValue === "string"
) as
| {
oldValue?: string;
newValue?: string;
isUpdate?: boolean;
}
| undefined;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
function ProductColumn({ product, label, accent }: { product: any; label: string; accent?: boolean }) { function ProductColumn({ product, label, accent }: { product: any; label: string; accent?: boolean }) {
if (!product) return ( if (!product) return (
@ -183,6 +204,7 @@ export default function AdminReviewDetailPage() {
const [product, setProduct] = useState<any>(null); // updated (review) const [product, setProduct] = useState<any>(null); // updated (review)
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const [oldProduct, setOldProduct] = useState<any>(null); // original (compare) const [oldProduct, setOldProduct] = useState<any>(null); // original (compare)
const [isComparison, setIsComparison] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(""); const [loadError, setLoadError] = useState("");
@ -195,42 +217,58 @@ export default function AdminReviewDetailPage() {
useEffect(() => { useEffect(() => {
if (!params.productId) return; if (!params.productId) return;
setLoading(true); setLoading(true);
setLoadError("");
setOldProduct(null);
setIsComparison(false);
// Always fetch updated (review) product
const reviewFetch = fetch(`/api/admin/review/${params.productId}`, { const reviewFetch = fetch(`/api/admin/review/${params.productId}`, {
headers: { "x-auth-token": getToken() }, headers: { "x-auth-token": getToken() },
}).then((r) => r.json()); }).then((r) => r.json());
// Fetch compare (old vs new) — used when isNew=false
const compareFetch = fetch(`/api/admin/review/${params.productId}/compare`, { const compareFetch = fetch(`/api/admin/review/${params.productId}/compare`, {
headers: { "x-auth-token": getToken() }, headers: { "x-auth-token": getToken() },
}).then((r) => r.json()).catch(() => null); })
.then((r) => r.json())
.catch(() => null);
Promise.all([reviewFetch, compareFetch]) Promise.all([reviewFetch, compareFetch])
.then(([reviewData, compareData]) => { .then(async ([reviewData, compareData]) => {
if (!reviewData?.data) throw new Error(reviewData?.responseDesc || "Data tidak ditemukan"); if (!reviewData?.data) throw new Error(reviewData?.responseDesc || "Data tidak ditemukan");
const updated = reviewData.data; const idChange = extractIdChange(compareData);
const reviewId = idChange?.newValue || params.productId;
let updated = reviewData.data;
if (reviewId && reviewId !== params.productId) {
const reviewRes = await fetch(`/api/admin/review/${reviewId}`, {
headers: { "x-auth-token": getToken() },
});
const reviewOverride = await reviewRes.json().catch(() => ({}));
if (!reviewRes.ok || !reviewOverride?.data) {
throw new Error(reviewOverride?.responseDesc || "Gagal memuat versi produk review");
}
updated = reviewOverride.data;
}
setProduct(updated); setProduct(updated);
// If isNew=false, extract old product from compare response const shouldCompare = idChange?.isUpdate === true && Boolean(idChange.oldValue) && Boolean(idChange.newValue);
if (!updated.isNew) { setIsComparison(shouldCompare);
// compare response may have data.original or data.currentProduct etc.
const old = if (shouldCompare) {
compareData?.data?.original || const originalRes = await fetch(`/api/admin/review/${reviewId}/original`, {
compareData?.data?.currentProduct || headers: { "x-auth-token": getToken() },
compareData?.data?.oldProduct || });
compareData?.original || const originalData = await originalRes.json().catch(() => ({}));
compareData?.currentProduct || if (!originalRes.ok || !originalData?.data) {
null; throw new Error(originalData?.responseDesc || "Gagal memuat versi produk saat ini");
setOldProduct(old); }
setOldProduct(originalData.data);
} }
}) })
.catch((e) => setLoadError(e.message || "Gagal memuat data")) .catch((e) => setLoadError(e.message || "Gagal memuat data"))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [params.productId]); }, [params.productId]);
const isComparison = product && !product.isNew;
async function submitReview(action: "accept" | "reject", reason?: string) { async function submitReview(action: "accept" | "reject", reason?: string) {
setActing(true); setActing(true);
setActionError(""); setActionError("");

View File

@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from "next/server";
import { API_URL, makeHeaders } from "@/lib/api";
function normalizeBearerToken(rawToken: string) {
if (!rawToken) return "";
return rawToken.startsWith("Bearer ") ? rawToken : `Bearer ${rawToken}`;
}
export async function GET(
req: NextRequest,
context: { params: Promise<{ categoryId: string }> }
) {
const token = normalizeBearerToken(req.headers.get("x-auth-token") || "");
const { categoryId } = await context.params;
const res = await fetch(`${API_URL}/api/v1.0/categories/${categoryId}`, {
method: "GET",
headers: makeHeaders(token, { includeTenantId: true }),
cache: "no-store",
});
const data = await res.json().catch(() => ({}));
return NextResponse.json(data, { status: res.status });
}
export async function PUT(
req: NextRequest,
context: { params: Promise<{ categoryId: string }> }
) {
const token = normalizeBearerToken(req.headers.get("x-auth-token") || "");
const { categoryId } = await context.params;
const body = await req.json();
const res = await fetch(`${API_URL}/api/v1.0/categories/${categoryId}`, {
method: "PUT",
headers: makeHeaders(token, { includeTenantId: true }),
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
return NextResponse.json(data, { status: res.status });
}
export async function DELETE(
req: NextRequest,
context: { params: Promise<{ categoryId: string }> }
) {
const token = normalizeBearerToken(req.headers.get("x-auth-token") || "");
const { categoryId } = await context.params;
const res = await fetch(`${API_URL}/api/v1.0/categories/${categoryId}`, {
method: "DELETE",
headers: makeHeaders(token, { includeTenantId: true }),
});
const data = await res.json().catch(() => ({}));
return NextResponse.json(data, { status: res.status });
}

View File

@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from "next/server";
import { API_URL, makeHeaders } from "@/lib/api";
function extractOriginalProductId(payload: unknown) {
const data = (payload as { data?: unknown })?.data;
if (data && typeof data === "object" && !Array.isArray(data)) {
const directId =
(data as { original?: { id?: unknown } })?.original?.id ||
(data as { currentProduct?: { id?: unknown } })?.currentProduct?.id ||
(data as { oldProduct?: { id?: unknown } })?.oldProduct?.id;
if (typeof directId === "string" && directId) return directId;
}
const rows = Array.isArray(data) ? data : Array.isArray(payload) ? payload : [];
const idRow = rows.find(
(row) =>
row &&
typeof row === "object" &&
(row as { field?: unknown }).field === "id" &&
typeof (row as { oldValue?: unknown }).oldValue === "string"
) as { oldValue?: string } | undefined;
return idRow?.oldValue || "";
}
export async function GET(
req: NextRequest,
context: { params: Promise<{ productId: string }> }
) {
const token = req.headers.get("x-auth-token") || "";
const { productId } = await context.params;
const compareRes = await fetch(`${API_URL}/api/v1.0/product/compare/${productId}`, {
headers: makeHeaders(token),
cache: "no-store",
});
const compareData = await compareRes.json().catch(() => ({}));
const originalProductId = extractOriginalProductId(compareData);
if (!originalProductId) {
return NextResponse.json(
{ responseDesc: "Original product id not found" },
{ status: 404 }
);
}
const productRes = await fetch(`${API_URL}/api/v1.0/product/${originalProductId}`, {
headers: makeHeaders(token),
cache: "no-store",
});
const productData = await productRes.json().catch(() => ({}));
return NextResponse.json(productData, { status: productRes.status });
}

View File

@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from "next/server";
import { API_URL, makeHeaders } from "@/lib/api";
function normalizeBearerToken(rawToken: string) {
if (!rawToken) return "";
return rawToken.startsWith("Bearer ") ? rawToken : `Bearer ${rawToken}`;
}
export async function GET(
req: NextRequest,
context: { params: Promise<{ subCategoryId: string }> }
) {
const token = normalizeBearerToken(req.headers.get("x-auth-token") || "");
const { subCategoryId } = await context.params;
const res = await fetch(`${API_URL}/api/v1.0/subcategories/${subCategoryId}`, {
method: "GET",
headers: makeHeaders(token, { includeTenantId: true }),
cache: "no-store",
});
const data = await res.json().catch(() => ({}));
return NextResponse.json(data, { status: res.status });
}
export async function PUT(
req: NextRequest,
context: { params: Promise<{ subCategoryId: string }> }
) {
const token = normalizeBearerToken(req.headers.get("x-auth-token") || "");
const { subCategoryId } = await context.params;
const body = await req.json();
const res = await fetch(`${API_URL}/api/v1.0/subcategories/${subCategoryId}`, {
method: "PUT",
headers: makeHeaders(token, { includeTenantId: true }),
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
return NextResponse.json(data, { status: res.status });
}
export async function DELETE(
req: NextRequest,
context: { params: Promise<{ subCategoryId: string }> }
) {
const token = normalizeBearerToken(req.headers.get("x-auth-token") || "");
const { subCategoryId } = await context.params;
const res = await fetch(`${API_URL}/api/v1.0/subcategories/${subCategoryId}`, {
method: "DELETE",
headers: makeHeaders(token, { includeTenantId: true }),
});
const data = await res.json().catch(() => ({}));
return NextResponse.json(data, { status: res.status });
}

View File

@ -0,0 +1,92 @@
import { NextRequest, NextResponse } from "next/server";
import { API_URL, makeHeaders } from "@/lib/api";
function normalizeBearerToken(rawToken: string) {
if (!rawToken) return "";
return rawToken.startsWith("Bearer ") ? rawToken : `Bearer ${rawToken}`;
}
function toArray<T>(value: unknown): T[] {
return Array.isArray(value) ? (value as T[]) : [];
}
function pickNumber(value: unknown): number {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const normalized = Number(value.replace(/[^0-9.-]/g, ""));
return Number.isFinite(normalized) ? normalized : 0;
}
return 0;
}
function pickText(value: unknown): string {
if (typeof value === "string") return value;
if (typeof value === "number") return String(value);
return "";
}
function parseReviewRows(payload: unknown) {
const rows = toArray<Record<string, unknown>>(
(payload as { rows?: unknown[] })?.rows ||
(payload as { data?: { rows?: unknown[] } })?.data?.rows
);
return rows.map((item) => ({
id: pickText(item.id),
name: pickText(item.name) || "Product",
seller: pickText(item.sellerName) || pickText(item.vendor) || "Seller",
date: pickText(item.createdDate) || pickText(item.createdAt) || pickText(item.updatedAt) || "—",
isNew: typeof item.isNew === "boolean" ? item.isNew : !pickText(item.rejectReason) && !pickText(item.oldProductId),
note: pickText(item.rejectReason) || pickText(item.reviewStatus) || pickText(item.state),
market: pickText(item.market),
totalStock: pickNumber(item.totalStock),
minPrice: pickNumber(item.minPrice),
maxPrice: pickNumber(item.maxPrice),
}));
}
export async function GET(req: NextRequest) {
const token = normalizeBearerToken(req.headers.get("x-auth-token") || "");
const headers = makeHeaders(token, { includeTenantId: true });
const requests = await Promise.allSettled([
fetch(`${API_URL}/api/v1.0/product/review?page=1&size=20`, { headers, cache: "no-store" }),
fetch(`${API_URL}/api/v1.0/newsarticles?page=1&size=5`, { headers, cache: "no-store" }),
fetch(`${API_URL}/api/v1.0/locations?page=1&size=5`, { headers, cache: "no-store" }),
fetch(`${API_URL}/api/v1.0/categories?page=1&size=100`, { headers, cache: "no-store" }),
]);
async function jsonAt(index: number) {
const result = requests[index];
if (result.status !== "fulfilled") return null;
return result.value.json().catch(() => null);
}
const [reviewPayload, newsPayload, placesPayload, categoriesPayload] =
await Promise.all([jsonAt(0), jsonAt(1), jsonAt(2), jsonAt(3)]);
const reviewRows = parseReviewRows(reviewPayload);
return NextResponse.json({
metrics: {
totalPending:
pickNumber((reviewPayload as { totalItem?: unknown })?.totalItem) ||
pickNumber((reviewPayload as { data?: { totalItem?: unknown } })?.data?.totalItem) ||
reviewRows.length,
inReview: reviewRows.length,
categoriesTotal:
pickNumber((categoriesPayload as { totalItem?: unknown })?.totalItem) ||
pickNumber((categoriesPayload as { data?: { totalItem?: unknown } })?.data?.totalItem) ||
toArray((categoriesPayload as { rows?: unknown[] })?.rows).length ||
toArray((categoriesPayload as { data?: unknown[] })?.data).length,
newsTotal:
pickNumber((newsPayload as { totalItem?: unknown })?.totalItem) ||
pickNumber((newsPayload as { data?: { totalItem?: unknown } })?.data?.totalItem),
placesTotal:
pickNumber((placesPayload as { totalItem?: unknown })?.totalItem) ||
pickNumber((placesPayload as { data?: { totalItem?: unknown } })?.data?.totalItem),
},
reviewQueue: reviewRows.filter((item) => item.isNew).slice(0, 5),
updateQueue: reviewRows.filter((item) => !item.isNew).slice(0, 5),
});
}

View File

@ -0,0 +1,143 @@
import { NextRequest, NextResponse } from "next/server";
import { API_URL, makeHeaders } from "@/lib/api";
function normalizeBearerToken(rawToken: string) {
if (!rawToken) return "";
return rawToken.startsWith("Bearer ") ? rawToken : `Bearer ${rawToken}`;
}
function toArray<T>(value: unknown): T[] {
return Array.isArray(value) ? (value as T[]) : [];
}
function pickNumber(value: unknown): number {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const normalized = Number(value.replace(/[^0-9.-]/g, ""));
return Number.isFinite(normalized) ? normalized : 0;
}
return 0;
}
function pickText(value: unknown): string {
if (typeof value === "string") return value;
if (typeof value === "number") return String(value);
return "";
}
function extractScalar(payload: unknown) {
const candidates = [
payload,
(payload as { data?: unknown })?.data,
(payload as { rows?: unknown[] })?.rows?.[0],
];
for (const item of candidates) {
if (typeof item === "number" || typeof item === "string") return item;
if (item && typeof item === "object") {
const record = item as Record<string, unknown>;
for (const key of ["total", "count", "value", "data", "totalProduct", "totalItem"]) {
if (record[key] != null) return record[key];
}
}
}
return 0;
}
function parseAnalytics(payload: unknown) {
const rows = [
...toArray<Record<string, unknown>>((payload as { rows?: unknown[] })?.rows),
...toArray<Record<string, unknown>>((payload as { data?: unknown[] })?.data),
...(Array.isArray(payload) ? (payload as Record<string, unknown>[]) : []),
];
return rows
.map((item, index) => ({
label:
pickText(item.label) ||
pickText(item.name) ||
pickText(item.month) ||
pickText(item.week) ||
pickText(item.period) ||
`P${index + 1}`,
value:
pickNumber(item.total) ||
pickNumber(item.value) ||
pickNumber(item.amount) ||
pickNumber(item.count) ||
pickNumber(item.orderTotal) ||
pickNumber(item.transactionTotal),
}))
.filter((item) => item.label)
.slice(0, 10);
}
function parseOrders(payload: unknown) {
const rows = [
...toArray<Record<string, unknown>>((payload as { rows?: unknown[] })?.rows),
...toArray<Record<string, unknown>>((payload as { data?: unknown[] })?.data),
...(Array.isArray(payload) ? (payload as Record<string, unknown>[]) : []),
];
return rows.slice(0, 5).map((item, index) => ({
id: pickText(item.id) || pickText(item.orderId) || `ORD-${index + 1}`,
product: pickText(item.productName) || pickText(item.name) || pickText(item.title) || "Product",
sku: pickText(item.sku),
customer: pickText(item.customerName) || pickText(item.buyerName) || pickText(item.customer) || "Customer",
location: pickText(item.country) || pickText(item.location) || pickText(item.city) || "—",
date: pickText(item.createdDate) || pickText(item.createdAt) || pickText(item.orderDate) || pickText(item.date) || "—",
amount: pickNumber(item.totalAmount) || pickNumber(item.amount) || pickNumber(item.totalPrice),
status: pickText(item.status) || pickText(item.state) || "Pending",
}));
}
export async function GET(req: NextRequest) {
const token = normalizeBearerToken(req.headers.get("x-auth-token") || "");
const headers = makeHeaders(token, { includeTenantId: true });
const requests = await Promise.allSettled([
fetch(`${API_URL}/api/v1.0/seller/total/product`, { headers, cache: "no-store" }),
fetch(`${API_URL}/api/v1.0/seller/sold/product`, { headers, cache: "no-store" }),
fetch(`${API_URL}/api/v1.0/seller/refund/product`, { headers, cache: "no-store" }),
fetch(`${API_URL}/api/v1.0/order/seller/analytics`, { headers, cache: "no-store" }),
fetch(`${API_URL}/api/v1.0/order/seller/top5`, { headers, cache: "no-store" }),
fetch(`${API_URL}/api/v1.0/seller/product?page=1&size=5`, { headers, cache: "no-store" }),
]);
async function jsonAt(index: number) {
const result = requests[index];
if (result.status !== "fulfilled") return null;
return result.value.json().catch(() => null);
}
const [totalPayload, soldPayload, refundPayload, analyticsPayload, topOrdersPayload, productsPayload] =
await Promise.all([jsonAt(0), jsonAt(1), jsonAt(2), jsonAt(3), jsonAt(4), jsonAt(5)]);
const productsRows = toArray<Record<string, unknown>>(
(productsPayload as { rows?: unknown[] })?.rows ||
(productsPayload as { data?: { rows?: unknown[] } })?.data?.rows
);
const marketCounts = productsRows.reduce<{ international: number; local: number }>(
(acc, item) => {
const market = pickText(item.market).toLowerCase();
if (market.includes("international")) acc.international += 1;
if (market.includes("local")) acc.local += 1;
return acc;
},
{ international: 0, local: 0 }
);
return NextResponse.json({
metrics: {
totalProducts: pickNumber(extractScalar(totalPayload)),
soldProducts: pickNumber(extractScalar(soldPayload)),
refundProducts: pickNumber(extractScalar(refundPayload)),
internationalProducts: marketCounts.international,
localProducts: marketCounts.local,
},
analytics: parseAnalytics(analyticsPayload),
recentOrders: parseOrders(topOrdersPayload),
});
}

View File

@ -13,10 +13,13 @@ export async function GET(
const token = normalizeBearerToken(req.headers.get("x-auth-token") || ""); const token = normalizeBearerToken(req.headers.get("x-auth-token") || "");
const { productId } = await context.params; const { productId } = await context.params;
const isDraft = req.nextUrl.searchParams.get("draft") === "1"; const isDraft = req.nextUrl.searchParams.get("draft") === "1";
const isReview = req.nextUrl.searchParams.get("review") === "1";
const endpoint = isDraft const endpoint = isDraft
? `${API_URL}/api/v1.0/seller/draft/product/${productId}` ? `${API_URL}/api/v1.0/seller/draft/product/${productId}`
: `${API_URL}/api/v1.0/product/${productId}`; : isReview
? `${API_URL}/api/v1.0/product/review/${productId}`
: `${API_URL}/api/v1.0/product/${productId}`;
const res = await fetch(endpoint, { const res = await fetch(endpoint, {
method: "GET", method: "GET",