Update admin review and product flows
This commit is contained in:
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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) : [],
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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]">
|
||||||
|
|||||||
@ -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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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("");
|
||||||
|
|||||||
58
src/app/api/admin/categories/[categoryId]/route.ts
Normal file
58
src/app/api/admin/categories/[categoryId]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
54
src/app/api/admin/review/[productId]/original/route.ts
Normal file
54
src/app/api/admin/review/[productId]/original/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
58
src/app/api/admin/subcategories/[subCategoryId]/route.ts
Normal file
58
src/app/api/admin/subcategories/[subCategoryId]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
92
src/app/api/dashboard/admin/route.ts
Normal file
92
src/app/api/dashboard/admin/route.ts
Normal 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
143
src/app/api/dashboard/seller/route.ts
Normal file
143
src/app/api/dashboard/seller/route.ts
Normal 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user