Files
amrez-nova-eop-services-app/src/app/(protected)/dashboard/page.tsx
Thanakarn Klangkasame 1df1338cb8 Fix Layout
2025-10-08 15:28:08 +07:00

495 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// File: src/app/(protected)/dashboard/page.tsx
"use client";
/**
* EOP Dashboard — Commerce + Marketplace + Chat + IoT + AI Orchestration
* - สรุปภาพรวมยอดขาย (GMV/Orders/AOV/Refunds)
* - Channel Mix (Marketplace / D2C / Social/Chat)
* - Best Sellers & Low Stock
* - Live Ops: Fulfillment SLA, IoT Device Health
* - AI Flows: ล่าสุด/สถานะรัน, Queue/Latency
* - Alerts/Tasks
*
* NOTE: ตอนนี้ใช้ mock data เพื่อโชว์ design/ UX.
* เชื่อม API ได้โดยแทนที่ hooks ด้านล่าง (fetch/*) ให้ดึงจริงจาก proxy ของคุณ
*/
import { useMemo, useState } from "react";
/* ===========================
Types (ภายในไฟล์)
=========================== */
type Range = "today" | "7d" | "30d";
type Channel = "all" | "shopee" | "lazada" | "tiktok" | "d2c" | "chat";
type Kpi = { label: string; value: number | string; delta?: number; hint?: string; suffix?: string };
type MixItem = { label: string; value: number; key: Channel };
type Sku = { sku: string; name: string; units: number; gmv: number; stock?: number; thumbnail?: string };
type Fulfillment = { metric: string; value: string; delta?: number; hint?: string };
type Device = { id: string; site: string; role: "scanner" | "labeler" | "sorter" | "gateway"; status: "ok" | "warn" | "down"; lastSeen: string };
type Flow = { id: string; name: string; runsToday: number; successRate: number; avgLatencyMs: number; lastRun: string; status: "ok" | "warn" | "error" };
type Alert = { id: string; title: string; detail: string; when: string; level: "info" | "warn" | "error" };
/* ===========================
Mock Data
=========================== */
const KPI_BY_RANGE: Record<Range, Kpi[]> = {
today: [
{ label: "GMV", value: 186_420, delta: +6.8, suffix: "฿", hint: "รวมทุกช่องทาง" },
{ label: "Orders", value: 942, delta: +4.3 },
{ label: "AOV", value: 198, delta: +2.1, suffix: "฿" },
{ label: "Refunds", value: 1.2, delta: -0.2, suffix: "%", hint: "อัตราคืนสินค้า" },
],
"7d": [
{ label: "GMV", value: 1_126_500, delta: +8.9, suffix: "฿" },
{ label: "Orders", value: 5_814, delta: +5.1 },
{ label: "AOV", value: 194, delta: +1.4, suffix: "฿" },
{ label: "Refunds", value: 1.6, delta: -0.1, suffix: "%" },
],
"30d": [
{ label: "GMV", value: 4_812_900, delta: +12.3, suffix: "฿" },
{ label: "Orders", value: 24_310, delta: +7.7 },
{ label: "AOV", value: 198, delta: +2.0, suffix: "฿" },
{ label: "Refunds", value: 1.4, delta: -0.4, suffix: "%" },
],
};
const MIX_ALL: MixItem[] = [
{ label: "Shopee", value: 34, key: "shopee" },
{ label: "Lazada", value: 28, key: "lazada" },
{ label: "TikTok", value: 16, key: "tiktok" },
{ label: "Direct (D2C)", value: 12, key: "d2c" },
{ label: "Social/Chat", value: 10, key: "chat" },
];
const BEST_SELLERS: Sku[] = [
{ sku: "CDIOR-50ML", name: "Dior Lip Glow 001 Pink", units: 382, gmv: 145_000, stock: 128 },
{ sku: "CDIOR-999", name: "Dior Rouge 999 Velvet", units: 274, gmv: 118_500, stock: 42 },
{ sku: "CDIOR-FOUND", name: "Forever Skin Glow 1N", units: 190, gmv: 86_200, stock: 18 },
{ sku: "CDIOR-MASC", name: "Diorshow Iconic Overcurl", units: 156, gmv: 62_700, stock: 9 },
{ sku: "CDIOR-SET", name: "Holiday Gift Set 2025", units: 120, gmv: 96_000, stock: 6 },
];
const LOW_STOCK: Sku[] = [
{ sku: "CDIOR-999", name: "Dior Rouge 999 Velvet", units: 274, gmv: 118_500, stock: 42 },
{ sku: "CDIOR-FOUND", name: "Forever Skin Glow 1N", units: 190, gmv: 86_200, stock: 18 },
{ sku: "CDIOR-MASC", name: "Diorshow Iconic Overcurl", units: 156, gmv: 62_700, stock: 9 },
{ sku: "CDIOR-SET", name: "Holiday Gift Set 2025", units: 120, gmv: 96_000, stock: 6 },
];
const SLA: Fulfillment[] = [
{ metric: "SLA Ship-on-time", value: "97.2%", delta: +0.4, hint: "ภายใน 24ชม." },
{ metric: "Picked in 2h", value: "92.5%", delta: -1.1 },
{ metric: "Packaging Defects", value: "0.7%", delta: -0.2 },
{ metric: "Delivery < 48h", value: "88.9%", delta: +2.8 },
];
const DEVICES: Device[] = [
{ id: "GW-01", site: "BKK-DC", role: "gateway", status: "ok", lastSeen: "just now" },
{ id: "SC-12", site: "BKK-DC", role: "scanner", status: "ok", lastSeen: "12s ago" },
{ id: "LB-03", site: "BKK-DC", role: "labeler", status: "warn", lastSeen: "1m ago" },
{ id: "ST-07", site: "BKK-DC", role: "sorter", status: "ok", lastSeen: "5s ago" },
{ id: "SC-02", site: "CNX-HUB", role: "scanner", status: "down", lastSeen: "14m ago" },
];
const FLOWS: Flow[] = [
{ id: "F-CHAT-01", name: "Chat → Quote → Order", runsToday: 482, successRate: 96.8, avgLatencyMs: 740, lastRun: "1m", status: "ok" },
{ id: "F-ROUTER-02", name: "Marketplace Router", runsToday: 3120, successRate: 99.2, avgLatencyMs: 210, lastRun: "15s", status: "ok" },
{ id: "F-VISION-01", name: "IoT Vision QC", runsToday: 260, successRate: 92.4, avgLatencyMs: 980, lastRun: "2m", status: "warn" },
{ id: "F-RMA-01", name: "Returns Classifier", runsToday: 58, successRate: 88.3, avgLatencyMs: 1320, lastRun: "4m", status: "error" },
];
const ALERTS: Alert[] = [
{ id: "al1", title: "Low stock: Holiday Gift Set", detail: "Stock: 6 units • risk OOS in 2 days", when: "just now", level: "warn" },
{ id: "al2", title: "AI Flow degraded: IoT Vision QC", detail: "Latency > 1s for last 15 mins", when: "5m ago", level: "error" },
{ id: "al3", title: "Channel API quota nearing limit", detail: "TikTok Shop at 83% for today", when: "18m ago", level: "warn" },
];
/* ===========================
Helpers
=========================== */
const fmt = (n: number | string) =>
typeof n === "number" ? n.toLocaleString() : n;
const THB = (n: number) => `฿${n.toLocaleString()}`;
const sign = (d?: number) => (d && d > 0 ? "+" : d && d < 0 ? "" : "");
const barWidth = (n: number) => Math.max(6, Math.min(100, n));
function chipStatus(ok: boolean | "warn" | "error") {
if (ok === "warn") return "bg-amber-50 text-amber-700 border border-amber-200";
if (ok === "error") return "bg-red-50 text-red-700 border border-red-200";
return "bg-emerald-50 text-emerald-700 border border-emerald-200";
}
function deviceDot(s: Device["status"]) {
return s === "ok" ? "bg-emerald-500" : s === "warn" ? "bg-amber-500" : "bg-red-500";
}
/* ===========================
Component
=========================== */
export default function DashboardPage() {
const [range, setRange] = useState<Range>("7d");
const [channel, setChannel] = useState<Channel>("all");
const kpis = useMemo(() => KPI_BY_RANGE[range], [range]);
const mix = useMemo(() => MIX_ALL, []);
const topSkus = useMemo(() => BEST_SELLERS, []);
const lowStock = useMemo(() => LOW_STOCK, []);
const sla = useMemo(() => SLA, []);
const devices = useMemo(() => DEVICES, []);
const flows = useMemo(() => FLOWS, []);
const alerts = useMemo(() => ALERTS, []);
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-3xl font-extrabold tracking-tight">EOP Dashboard</h1>
<p className="mt-1 text-sm text-neutral-500">
Commerce overview marketplaces, social/chat, IoT & AI orchestration
</p>
</div>
<div className="flex items-center gap-2">
<div className="rounded-2xl border border-neutral-200/70 bg-white/70 p-1 shadow-sm backdrop-blur">
<div className="flex">
{(["today", "7d", "30d"] as const).map((r) => (
<button
key={r}
onClick={() => setRange(r)}
className={[
"rounded-xl px-3 py-1.5 text-sm transition",
range === r ? "bg-neutral-900 text-white" : "text-neutral-700 hover:bg-neutral-100/80",
].join(" ")}
>
{r.toUpperCase()}
</button>
))}
</div>
</div>
<div className="rounded-2xl border border-neutral-200/70 bg-white/70 p-1 shadow-sm backdrop-blur">
<div className="flex">
{(["all", "shopee", "lazada", "tiktok", "d2c", "chat"] as const).map((c) => (
<button
key={c}
onClick={() => setChannel(c)}
className={[
"rounded-xl px-3 py-1.5 text-sm transition",
channel === c ? "bg-neutral-900 text-white" : "text-neutral-700 hover:bg-neutral-100/80",
].join(" ")}
>
{c.toUpperCase()}
</button>
))}
</div>
</div>
</div>
</div>
{/* KPI Cards */}
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{kpis.map((k) => (
<article
key={k.label}
className="rounded-3xl border border-neutral-200/70 bg-white/80 p-5 shadow-[0_10px_30px_-12px_rgba(0,0,0,0.08)]"
>
<div className="text-sm text-neutral-500">{k.label}{k.hint ? `${k.hint}` : ""}</div>
<div className="mt-1 flex items-baseline gap-2">
<div className="text-2xl font-bold tracking-tight">
{k.suffix === "฿" ? THB(Number(k.value)) : `${fmt(k.value)}${k.suffix ?? ""}`}
</div>
{typeof k.delta === "number" && (
<div
className={[
"rounded-full px-2 py-0.5 text-[11px]",
k.delta >= 0
? "bg-emerald-50 text-emerald-700 border border-emerald-200"
: "bg-red-50 text-red-700 border border-red-200",
].join(" ")}
>
{sign(k.delta)}
{Math.abs(k.delta).toFixed(1)}%
</div>
)}
</div>
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-neutral-100">
<div
className="h-full bg-neutral-900"
style={{ width: `${barWidth(30 + Math.random() * 50)}%` }}
/>
</div>
</article>
))}
</section>
{/* Channel Mix + Top SKUs */}
<section className="grid gap-6 lg:grid-cols-12">
{/* Channel Mix */}
<div className="lg:col-span-5">
<div className="rounded-3xl border border-neutral-200/70 bg-white/80 p-5 shadow-[0_10px_30px_-12px_rgba(0,0,0,0.08)]">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold tracking-tight">Channel mix</h2>
<span className="text-xs text-neutral-500">by GMV</span>
</div>
<ul className="space-y-3">
{mix.map((m) => (
<li key={m.key} className="rounded-2xl border border-neutral-200/70 bg-white/60 p-3">
<div className="flex items-center justify-between">
<div className="text-sm font-medium">{m.label}</div>
<div className="text-sm">{m.value}%</div>
</div>
<div className="mt-2 h-2 w-full overflow-hidden rounded-full bg-neutral-100">
<div className="h-full bg-neutral-900" style={{ width: `${barWidth(m.value)}%` }} />
</div>
</li>
))}
</ul>
</div>
</div>
{/* Best Sellers */}
<div className="lg:col-span-7">
<div className="rounded-3xl border border-neutral-200/70 bg-white/80 p-5 shadow-[0_10px_30px_-12px_rgba(0,0,0,0.08)]">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold tracking-tight">Best sellers</h2>
<button className="rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-1.5 text-sm shadow-sm hover:bg-neutral-100/80">
Catalog
</button>
</div>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="text-left text-neutral-500">
<tr>
<th className="pb-2 pr-4">SKU</th>
<th className="pb-2 pr-4">Name</th>
<th className="pb-2 pr-4 text-right">Units</th>
<th className="pb-2 pr-4 text-right">GMV</th>
<th className="pb-2 text-right">Stock</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200/70">
{topSkus.map((s) => (
<tr key={s.sku} className="align-middle">
<td className="py-2 pr-4 font-mono">{s.sku}</td>
<td className="py-2 pr-4">{s.name}</td>
<td className="py-2 pr-4 text-right">{fmt(s.units)}</td>
<td className="py-2 pr-4 text-right">{THB(s.gmv)}</td>
<td className="py-2 text-right">
<span
className={[
"rounded-full px-2 py-0.5 text-[11px]",
(s.stock ?? 0) < 10
? "bg-amber-50 text-amber-700 border border-amber-200"
: "bg-neutral-50 text-neutral-700 border border-neutral-200",
].join(" ")}
>
{fmt(s.stock ?? 0)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Low stock banner */}
<div className="mt-4 rounded-2xl border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
Low stock:{" "}
{lowStock
.filter((x) => (x.stock ?? 0) <= 20)
.slice(0, 3)
.map((x) => `${x.name} (${x.stock})`)
.join(", ")}{" "}
plan replenishment & marketplace buffers
</div>
</div>
</div>
</section>
{/* Fulfillment & IoT */}
<section className="grid gap-6 lg:grid-cols-12">
{/* Fulfillment SLA */}
<div className="lg:col-span-6">
<div className="rounded-3xl border border-neutral-200/70 bg-white/80 p-5 shadow-[0_10px_30px_-12px_rgba(0,0,0,0.08)]">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold tracking-tight">Fulfillment SLA</h2>
<span className="text-xs text-neutral-500">warehouse & delivery</span>
</div>
<ul className="space-y-3">
{sla.map((f) => (
<li key={f.metric} className="rounded-2xl border border-neutral-200/70 bg-white/60 p-3">
<div className="flex items-center justify-between">
<div className="text-sm">{f.metric}</div>
<div className="flex items-center gap-2">
{typeof f.delta === "number" && (
<span
className={[
"rounded-full px-2 py-0.5 text-[11px]",
f.delta >= 0
? "bg-emerald-50 text-emerald-700 border border-emerald-200"
: "bg-red-50 text-red-700 border border-red-200",
].join(" ")}
>
{sign(f.delta)}
{Math.abs(f.delta).toFixed(1)}%
</span>
)}
<span className="text-sm font-semibold">{f.value}</span>
</div>
</div>
<div className="mt-2 h-2 w-full overflow-hidden rounded-full bg-neutral-100">
<div className="h-full bg-neutral-900" style={{ width: `${barWidth(parseFloat(f.value))}%` }} />
</div>
{f.hint && <div className="mt-1 text-xs text-neutral-500">{f.hint}</div>}
</li>
))}
</ul>
</div>
</div>
{/* IoT Device Health */}
<div className="lg:col-span-6">
<div className="rounded-3xl border border-neutral-200/70 bg-white/80 p-5 shadow-[0_10px_30px_-12px_rgba(0,0,0,0.08)]">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold tracking-tight">IoT device health</h2>
<button className="rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-1.5 text-sm shadow-sm hover:bg-neutral-100/80">
Device console
</button>
</div>
<ul className="grid gap-3 sm:grid-cols-2">
{devices.map((d) => (
<li
key={d.id}
className="flex items-center justify-between rounded-2xl border border-neutral-200/70 bg-white/60 px-3 py-2"
>
<div className="flex items-center gap-3">
<span className={`h-2.5 w-2.5 rounded-full ${deviceDot(d.status)}`} />
<div className="leading-tight">
<div className="text-sm font-medium">{d.id} {d.role}</div>
<div className="text-xs text-neutral-500">{d.site} {d.lastSeen}</div>
</div>
</div>
<span className={`rounded-full px-2 py-0.5 text-[11px] ${chipStatus(d.status === "ok" ? true : d.status === "warn" ? "warn" : "error")}`}>
{d.status}
</span>
</li>
))}
</ul>
</div>
</div>
</section>
{/* AI Orchestration & Alerts */}
<section className="grid gap-6 lg:grid-cols-12">
{/* AI Flows */}
<div className="lg:col-span-8">
<div className="rounded-3xl border border-neutral-200/70 bg-white/80 p-5 shadow-[0_10px_30px_-12px_rgba(0,0,0,0.08)]">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold tracking-tight">AI flows</h2>
<div className="flex items-center gap-2">
<button className="rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-1.5 text-sm shadow-sm hover:bg-neutral-100/80">
Orchestrator
</button>
<button className="rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-1.5 text-sm shadow-sm hover:bg-neutral-100/80">
New flow
</button>
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="text-left text-neutral-500">
<tr>
<th className="pb-2 pr-4">Flow</th>
<th className="pb-2 pr-4 text-right">Runs (today)</th>
<th className="pb-2 pr-4 text-right">Success</th>
<th className="pb-2 pr-4 text-right">Avg Latency</th>
<th className="pb-2 text-right">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200/70">
{flows.map((f) => (
<tr key={f.id}>
<td className="py-2 pr-4">
<div className="font-medium">{f.name}</div>
<div className="text-xs text-neutral-500">last: {f.lastRun} ago</div>
</td>
<td className="py-2 pr-4 text-right">{fmt(f.runsToday)}</td>
<td className="py-2 pr-4 text-right">{f.successRate.toFixed(1)}%</td>
<td className="py-2 pr-4 text-right">{f.avgLatencyMs} ms</td>
<td className="py-2 text-right">
<span
className={[
"rounded-full px-2 py-0.5 text-[11px]",
f.status === "ok"
? "bg-emerald-50 text-emerald-700 border border-emerald-200"
: f.status === "warn"
? "bg-amber-50 text-amber-700 border border-amber-200"
: "bg-red-50 text-red-700 border border-red-200",
].join(" ")}
>
{f.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
{/* Alerts / Tasks */}
<div className="lg:col-span-4">
<div className="rounded-3xl border border-neutral-200/70 bg-white/80 p-5 shadow-[0_10px_30px_-12px_rgba(0,0,0,0.08)]">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold tracking-tight">Alerts</h2>
<button className="rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-1.5 text-sm shadow-sm hover:bg-neutral-100/80">
Rules
</button>
</div>
<ul className="space-y-3">
{alerts.map((a) => (
<li key={a.id} className="rounded-2xl border border-neutral-200/70 bg-white/60 p-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-medium">{a.title}</div>
<div className="text-xs text-neutral-500">{a.detail}</div>
</div>
<span
className={[
"shrink-0 rounded-full px-2 py-0.5 text-[11px]",
a.level === "info"
? "bg-neutral-50 text-neutral-700 border border-neutral-200"
: a.level === "warn"
? "bg-amber-50 text-amber-700 border border-amber-200"
: "bg-red-50 text-red-700 border border-red-200",
].join(" ")}
>
{a.when}
</span>
</div>
</li>
))}
</ul>
<div className="mt-4 text-right">
<button className="rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-1.5 text-sm shadow-sm hover:bg-neutral-100/80">
View all
</button>
</div>
</div>
</div>
</section>
{/* Footer action row */}
<section className="flex flex-wrap items-center justify-end gap-2">
<button className="rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-1.5 text-sm shadow-sm hover:bg-neutral-100/80">
Export report
</button>
<button className="rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-1.5 text-sm shadow-sm hover:bg-neutral-100/80">
Manage channels
</button>
<button className="rounded-xl bg-neutral-900 px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-neutral-800">
Create campaign
</button>
</section>
</div>
);
}