520 lines
21 KiB
TypeScript
520 lines
21 KiB
TypeScript
"use client";
|
|
|
|
import { Suspense, useEffect, useState } from "react";
|
|
import { useSearchParams } from "next/navigation";
|
|
import { useLanguage } from "@/lib/i18n-context";
|
|
|
|
interface AnalyticsPoint {
|
|
label: string;
|
|
value: number;
|
|
}
|
|
|
|
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[];
|
|
}
|
|
|
|
interface ProductSearchRow {
|
|
id: string;
|
|
name: string;
|
|
market?: string | null;
|
|
state?: string | null;
|
|
status?: string | null;
|
|
totalStock?: number | null;
|
|
}
|
|
|
|
interface WarehouseSearchRow {
|
|
id: string;
|
|
name: string | null;
|
|
address: string | null;
|
|
city: string | null;
|
|
province: string | null;
|
|
country: string | null;
|
|
warehouseType: string | null;
|
|
}
|
|
|
|
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";
|
|
}
|
|
|
|
function DashboardContent() {
|
|
const searchParams = useSearchParams();
|
|
const { t } = useLanguage();
|
|
const d = t.dashboard.overview;
|
|
const [data, setData] = useState<SellerDashboardPayload | null>(null);
|
|
const [productResults, setProductResults] = useState<ProductSearchRow[]>([]);
|
|
const [warehouseResults, setWarehouseResults] = useState<WarehouseSearchRow[]>([]);
|
|
const [searchLoading, setSearchLoading] = useState(false);
|
|
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 rawDashboardQuery = (searchParams.get("q") || "").trim();
|
|
const dashboardQuery = rawDashboardQuery.toLowerCase();
|
|
const filteredOrders = (data?.recentOrders || []).filter((order) => {
|
|
if (!dashboardQuery) {
|
|
return true;
|
|
}
|
|
|
|
return [
|
|
order.product,
|
|
order.sku,
|
|
order.customer,
|
|
order.location,
|
|
order.date,
|
|
order.status,
|
|
String(order.amount),
|
|
]
|
|
.join(" ")
|
|
.toLowerCase()
|
|
.includes(dashboardQuery);
|
|
});
|
|
const totalRevenue = filteredOrders.reduce((sum, item) => sum + item.amount, 0);
|
|
const totalOrders = filteredOrders.length;
|
|
|
|
useEffect(() => {
|
|
async function loadSearchResults() {
|
|
if (!dashboardQuery) {
|
|
setProductResults([]);
|
|
setWarehouseResults([]);
|
|
setSearchLoading(false);
|
|
return;
|
|
}
|
|
|
|
setSearchLoading(true);
|
|
|
|
try {
|
|
const [productsRes, warehousesRes] = await Promise.all([
|
|
fetch("/api/products?page=1&size=100", {
|
|
headers: { "x-auth-token": getToken() },
|
|
}),
|
|
fetch("/api/products/warehouses?page=1&size=100", {
|
|
headers: { "x-auth-token": getToken() },
|
|
}),
|
|
]);
|
|
|
|
const [productsResult, warehousesResult] = await Promise.all([
|
|
productsRes.json().catch(() => ({})),
|
|
warehousesRes.json().catch(() => ({})),
|
|
]);
|
|
|
|
const products: ProductSearchRow[] = Array.isArray(productsResult?.rows)
|
|
? productsResult.rows
|
|
: Array.isArray(productsResult?.data?.rows)
|
|
? productsResult.data.rows
|
|
: [];
|
|
|
|
const warehouses: WarehouseSearchRow[] = Array.isArray(warehousesResult?.rows)
|
|
? warehousesResult.rows
|
|
: Array.isArray(warehousesResult?.data)
|
|
? warehousesResult.data
|
|
: [];
|
|
|
|
setProductResults(
|
|
products.filter((item) =>
|
|
[item.name, item.market, item.state, item.status, String(item.totalStock ?? "")]
|
|
.join(" ")
|
|
.toLowerCase()
|
|
.includes(dashboardQuery)
|
|
)
|
|
);
|
|
|
|
setWarehouseResults(
|
|
warehouses.filter((item) =>
|
|
[
|
|
item.name || "",
|
|
item.address || "",
|
|
item.city || "",
|
|
item.province || "",
|
|
item.country || "",
|
|
item.warehouseType || "",
|
|
]
|
|
.join(" ")
|
|
.toLowerCase()
|
|
.includes(dashboardQuery)
|
|
)
|
|
);
|
|
} catch {
|
|
setProductResults([]);
|
|
setWarehouseResults([]);
|
|
} finally {
|
|
setSearchLoading(false);
|
|
}
|
|
}
|
|
|
|
loadSearchResults();
|
|
}, [dashboardQuery]);
|
|
|
|
return (
|
|
<div className="p-8">
|
|
<div className="mb-10">
|
|
<h1 className="text-5xl font-black font-headline tracking-tighter text-on-surface mb-2">
|
|
{d.title}
|
|
</h1>
|
|
<p className="text-on-surface-variant font-medium">{d.subtitle}</p>
|
|
</div>
|
|
|
|
{dashboardQuery ? (
|
|
<div className="mb-10 rounded-2xl border border-surface-container bg-surface-container-lowest p-8 magazine-shadow">
|
|
<div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
|
|
<div>
|
|
<p className="text-[10px] font-black uppercase tracking-[0.22em] text-primary">
|
|
Dashboard Search
|
|
</p>
|
|
<h2 className="mt-2 text-2xl font-black tracking-tight text-on-surface">
|
|
Hasil pencarian untuk "{rawDashboardQuery}"
|
|
</h2>
|
|
</div>
|
|
<p className="text-sm font-medium text-on-surface-variant">
|
|
{searchLoading
|
|
? "Mencari data..."
|
|
: `${filteredOrders.length} order, ${productResults.length} produk, ${warehouseResults.length} warehouse`}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="mt-8 grid gap-6 lg:grid-cols-3">
|
|
<div className="rounded-2xl bg-surface p-5">
|
|
<h3 className="text-sm font-black uppercase tracking-[0.18em] text-outline">
|
|
Orders
|
|
</h3>
|
|
<div className="mt-4 space-y-3">
|
|
{filteredOrders.slice(0, 4).map((order) => (
|
|
<div key={order.id} className="rounded-xl border border-surface-container p-4">
|
|
<p className="font-bold text-on-surface">{order.product}</p>
|
|
<p className="mt-1 text-sm text-on-surface-variant">{order.customer} · {order.location}</p>
|
|
</div>
|
|
))}
|
|
{!searchLoading && filteredOrders.length === 0 ? (
|
|
<p className="text-sm font-medium text-on-surface-variant">Tidak ada order yang cocok.</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-2xl bg-surface p-5">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<h3 className="text-sm font-black uppercase tracking-[0.18em] text-outline">
|
|
Products
|
|
</h3>
|
|
<a href="/products" className="text-xs font-bold text-primary hover:underline">
|
|
Lihat semua
|
|
</a>
|
|
</div>
|
|
<div className="mt-4 space-y-3">
|
|
{productResults.slice(0, 4).map((product) => (
|
|
<a
|
|
key={product.id}
|
|
href="/products"
|
|
className="block rounded-xl border border-surface-container p-4 transition-colors hover:bg-surface-container-low"
|
|
>
|
|
<p className="font-bold text-on-surface">{product.name}</p>
|
|
<p className="mt-1 text-sm text-on-surface-variant">
|
|
{product.market || product.state || product.status || "Produk"}
|
|
</p>
|
|
</a>
|
|
))}
|
|
{!searchLoading && productResults.length === 0 ? (
|
|
<p className="text-sm font-medium text-on-surface-variant">Tidak ada produk yang cocok.</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-2xl bg-surface p-5">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<h3 className="text-sm font-black uppercase tracking-[0.18em] text-outline">
|
|
Warehouses
|
|
</h3>
|
|
<a href="/dashboard/warehouse" className="text-xs font-bold text-primary hover:underline">
|
|
Lihat semua
|
|
</a>
|
|
</div>
|
|
<div className="mt-4 space-y-3">
|
|
{warehouseResults.slice(0, 4).map((warehouse) => (
|
|
<a
|
|
key={warehouse.id}
|
|
href="/dashboard/warehouse"
|
|
className="block rounded-xl border border-surface-container p-4 transition-colors hover:bg-surface-container-low"
|
|
>
|
|
<p className="font-bold text-on-surface">{warehouse.name || "Tanpa nama"}</p>
|
|
<p className="mt-1 text-sm text-on-surface-variant">
|
|
{[warehouse.city, warehouse.province, warehouse.country].filter(Boolean).join(", ") || warehouse.address || "Warehouse"}
|
|
</p>
|
|
</a>
|
|
))}
|
|
{!searchLoading && warehouseResults.length === 0 ? (
|
|
<p className="text-sm font-medium text-on-surface-variant">Tidak ada warehouse yang cocok.</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-10">
|
|
<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>
|
|
<p className="text-xs font-bold text-on-surface-variant uppercase tracking-widest mb-2">
|
|
{d.totalProducts}
|
|
</p>
|
|
<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">
|
|
<span className="material-symbols-outlined text-sm">trending_up</span>
|
|
<span>{loading ? "Loading..." : `${data?.metrics.internationalProducts ?? 0} international`}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<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>
|
|
<p className="text-xs font-bold text-on-surface-variant uppercase tracking-widest mb-2">
|
|
{d.totalBuyers}
|
|
</p>
|
|
<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">
|
|
<span className="material-symbols-outlined text-sm">group</span>
|
|
<span>{loading ? "Loading..." : `${data?.metrics.localProducts ?? 0} local`}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<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>
|
|
<p className="text-xs font-bold text-on-surface-variant uppercase tracking-widest mb-2">
|
|
{d.refunds}
|
|
</p>
|
|
<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">
|
|
<span className="material-symbols-outlined text-sm">history</span>
|
|
<span>{loading ? "Loading..." : `${totalOrders} recent orders`}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-10">
|
|
<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>
|
|
<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>
|
|
</div>
|
|
<div className="bg-surface-container-low border border-surface-container-high rounded-xl text-sm font-bold text-on-surface px-3 py-2">
|
|
{d.last30Days}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="h-64 flex items-end justify-between gap-2 px-2">
|
|
{loading ? (
|
|
<div className="w-full flex items-center justify-center text-on-surface-variant text-sm font-semibold">
|
|
Loading analytics...
|
|
</div>
|
|
) : analytics.length === 0 ? (
|
|
<div className="w-full flex items-center justify-center text-on-surface-variant text-sm font-semibold">
|
|
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 className="flex justify-between mt-4 px-2 text-[10px] font-bold text-on-surface-variant uppercase tracking-widest">
|
|
{(analytics.length ? analytics : [{ label: `${d.wk} 1`, value: 0 }]).slice(0, 4).map((item) => (
|
|
<span key={item.label}>{item.label}</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<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>
|
|
<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="absolute inset-[-16px] rounded-full border-[16px] border-primary"
|
|
style={{ clipPath: "polygon(50% 50%, 50% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 50%)" }}
|
|
/>
|
|
<div className="text-center">
|
|
<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">
|
|
{d.grossRevenue}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4 mt-6">
|
|
{[
|
|
{ color: "bg-primary", label: d.directSales, value: `${data?.metrics.totalProducts ?? 0}` },
|
|
{ color: "bg-secondary", label: d.retailPartners, value: `${data?.metrics.soldProducts ?? 0}` },
|
|
{ color: "bg-tertiary", label: d.affiliates, value: `${data?.metrics.refundProducts ?? 0}` },
|
|
].map((item) => (
|
|
<div key={item.label} className="flex items-center justify-between text-sm">
|
|
<div className="flex items-center gap-2">
|
|
<span className={`w-3 h-3 ${item.color} rounded-full`}></span>
|
|
<span className="font-semibold">{item.label}</span>
|
|
</div>
|
|
<span className="font-bold text-on-surface-variant">{item.value}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<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>
|
|
<h4 className="text-xl font-black font-headline tracking-tight">{d.recentOrders}</h4>
|
|
{dashboardQuery ? (
|
|
<p className="mt-2 text-sm font-medium text-on-surface-variant">
|
|
Menampilkan hasil pencarian untuk <span className="font-bold text-on-surface">"{searchParams.get("q")}"</span>
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
<button className="text-primary font-bold text-sm hover:underline flex items-center gap-1">
|
|
{d.viewAll}
|
|
<span className="material-symbols-outlined text-sm">arrow_forward</span>
|
|
</button>
|
|
</div>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left">
|
|
<thead>
|
|
<tr className="bg-surface-container-low text-[10px] font-black text-on-surface-variant uppercase tracking-widest">
|
|
<th className="px-8 py-4">{d.productDetails}</th>
|
|
<th className="px-8 py-4">{d.customer}</th>
|
|
<th className="px-8 py-4">{d.transactionDate}</th>
|
|
<th className="px-8 py-4">{d.amount}</th>
|
|
<th className="px-8 py-4">{d.status}</th>
|
|
<th className="px-8 py-4 text-right">{d.action}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-surface-container">
|
|
{filteredOrders.map((order) => (
|
|
<tr key={order.id} className="hover:bg-surface-container-low/50 transition-colors">
|
|
<td className="px-8 py-5">
|
|
<div className="flex items-center gap-4">
|
|
<div className="w-12 h-12 rounded-xl bg-surface-container flex items-center justify-center">
|
|
<span className="material-symbols-outlined text-on-surface-variant">inventory_2</span>
|
|
</div>
|
|
<div>
|
|
<p className="font-bold text-sm">{order.product}</p>
|
|
<p className="text-xs text-on-surface-variant">SKU: {order.sku || "—"}</p>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-8 py-5">
|
|
<p className="text-sm font-semibold">{order.customer}</p>
|
|
<p className="text-xs text-on-surface-variant">{order.location}</p>
|
|
</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">{formatCurrency(order.amount)}</td>
|
|
<td className="px-8 py-5">
|
|
<span className={`px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-tighter ${statusColor(order.status)}`}>
|
|
{order.status}
|
|
</span>
|
|
</td>
|
|
<td className="px-8 py-5 text-right">
|
|
<button className="material-symbols-outlined text-on-surface-variant hover:text-primary transition-colors">
|
|
more_horiz
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{!loading && !error && filteredOrders.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={6} className="px-8 py-10 text-center text-sm font-semibold text-on-surface-variant">
|
|
{dashboardQuery ? "Tidak ada order yang cocok dengan pencarian Anda." : "No recent orders available."}
|
|
</td>
|
|
</tr>
|
|
) : null}
|
|
</tbody>
|
|
</table>
|
|
</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>
|
|
);
|
|
}
|
|
|
|
export default function DashboardPage() {
|
|
return (
|
|
<Suspense>
|
|
<DashboardContent />
|
|
</Suspense>
|
|
);
|
|
}
|