diff --git a/package-lock.json b/package-lock.json
index c7e19a1..630df0c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,7 +8,10 @@
"name": "amrez-eop-app",
"version": "0.1.0",
"dependencies": {
+ "@types/jsonwebtoken": "^9.0.10",
+ "jsonwebtoken": "^9.0.2",
"next": "15.5.4",
+ "next-themes": "^0.4.6",
"react": "19.1.0",
"react-dom": "19.1.0"
},
@@ -1290,11 +1293,26 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/jsonwebtoken": {
+ "version": "9.0.10",
+ "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
+ "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/ms": "*",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/ms": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
+ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
+ "license": "MIT"
+ },
"node_modules/@types/node": {
"version": "20.19.18",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.18.tgz",
"integrity": "sha512-KeYVbfnbsBCyKG8e3gmUqAfyZNcoj/qpEbHRkQkfZdKOBrU7QQ+BsTdfqLSWX9/m1ytYreMhpKvp+EZi3UFYAg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@@ -2204,6 +2222,12 @@
"node": ">=8"
}
},
+ "node_modules/buffer-equal-constant-time": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/call-bind": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
@@ -2526,6 +2550,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
"node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
@@ -4141,6 +4174,28 @@
"json5": "lib/cli.js"
}
},
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
+ "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^3.2.2",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
"node_modules/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -4157,6 +4212,27 @@
"node": ">=4.0"
}
},
+ "node_modules/jwa": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
+ "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jws": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
+ "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^1.4.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -4456,6 +4532,42 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/lodash.includes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isinteger": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnumber": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "license": "MIT"
+ },
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -4463,6 +4575,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/lodash.once": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+ "license": "MIT"
+ },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -4570,7 +4688,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@@ -4666,6 +4783,16 @@
}
}
},
+ "node_modules/next-themes": {
+ "version": "0.4.6",
+ "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
+ "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
+ }
+ },
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@@ -5204,6 +5331,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/safe-push-apply": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
@@ -5249,7 +5396,6 @@
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
- "devOptional": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -5923,7 +6069,6 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/unrs-resolver": {
diff --git a/package.json b/package.json
index fd11d55..377c988 100644
--- a/package.json
+++ b/package.json
@@ -9,19 +9,22 @@
"lint": "eslint"
},
"dependencies": {
+ "@types/jsonwebtoken": "^9.0.10",
+ "jsonwebtoken": "^9.0.2",
+ "next": "15.5.4",
+ "next-themes": "^0.4.6",
"react": "19.1.0",
- "react-dom": "19.1.0",
- "next": "15.5.4"
+ "react-dom": "19.1.0"
},
"devDependencies": {
- "typescript": "^5",
+ "@eslint/eslintrc": "^3",
+ "@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
- "@tailwindcss/postcss": "^4",
- "tailwindcss": "^4",
"eslint": "^9",
"eslint-config-next": "15.5.4",
- "@eslint/eslintrc": "^3"
+ "tailwindcss": "^4",
+ "typescript": "^5"
}
}
diff --git a/src/app/(protected)/dashboard/page.tsx b/src/app/(protected)/dashboard/page.tsx
new file mode 100644
index 0000000..e12978b
--- /dev/null
+++ b/src/app/(protected)/dashboard/page.tsx
@@ -0,0 +1,495 @@
+// 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 = {
+ 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("7d");
+ const [channel, setChannel] = useState("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 (
+
+ {/* Header / Filters */}
+
+
+
EOP Dashboard
+
+ Commerce overview — marketplaces, social/chat, IoT & AI orchestration
+
+
+
+
+
+ {(["today", "7d", "30d"] as const).map((r) => (
+
+ ))}
+
+
+
+
+ {(["all", "shopee", "lazada", "tiktok", "d2c", "chat"] as const).map((c) => (
+
+ ))}
+
+
+
+
+
+ {/* KPI Cards */}
+
+ {kpis.map((k) => (
+
+ {k.label}{k.hint ? ` • ${k.hint}` : ""}
+
+
+ {k.suffix === "฿" ? THB(Number(k.value)) : `${fmt(k.value)}${k.suffix ?? ""}`}
+
+ {typeof k.delta === "number" && (
+
= 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)}%
+
+ )}
+
+
+
+ ))}
+
+
+ {/* Channel Mix + Top SKUs */}
+
+ {/* Channel Mix */}
+
+
+
+
Channel mix
+ by GMV
+
+
+ {mix.map((m) => (
+ -
+
+
{m.label}
+
{m.value}%
+
+
+
+ ))}
+
+
+
+
+ {/* Best Sellers */}
+
+
+
+
Best sellers
+
+
+
+
+
+
+ | SKU |
+ Name |
+ Units |
+ GMV |
+ Stock |
+
+
+
+ {topSkus.map((s) => (
+
+ | {s.sku} |
+ {s.name} |
+ {fmt(s.units)} |
+ {THB(s.gmv)} |
+
+
+ {fmt(s.stock ?? 0)}
+
+ |
+
+ ))}
+
+
+
+ {/* Low stock banner */}
+
+ Low stock:{" "}
+ {lowStock
+ .filter((x) => (x.stock ?? 0) <= 20)
+ .slice(0, 3)
+ .map((x) => `${x.name} (${x.stock})`)
+ .join(", ")}{" "}
+ — plan replenishment & marketplace buffers
+
+
+
+
+
+ {/* Fulfillment & IoT */}
+
+ {/* Fulfillment SLA */}
+
+
+
+
Fulfillment SLA
+ warehouse & delivery
+
+
+ {sla.map((f) => (
+ -
+
+
{f.metric}
+
+ {typeof f.delta === "number" && (
+ = 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)}%
+
+ )}
+ {f.value}
+
+
+
+ {f.hint && {f.hint}
}
+
+ ))}
+
+
+
+
+ {/* IoT Device Health */}
+
+
+
+
IoT device health
+
+
+
+ {devices.map((d) => (
+ -
+
+
+
+
{d.id} • {d.role}
+
{d.site} • {d.lastSeen}
+
+
+
+ {d.status}
+
+
+ ))}
+
+
+
+
+
+ {/* AI Orchestration & Alerts */}
+
+ {/* AI Flows */}
+
+
+
+
AI flows
+
+
+
+
+
+
+
+
+
+ | Flow |
+ Runs (today) |
+ Success |
+ Avg Latency |
+ Status |
+
+
+
+ {flows.map((f) => (
+
+ |
+ {f.name}
+ last: {f.lastRun} ago
+ |
+ {fmt(f.runsToday)} |
+ {f.successRate.toFixed(1)}% |
+ {f.avgLatencyMs} ms |
+
+
+ {f.status}
+
+ |
+
+ ))}
+
+
+
+
+
+
+ {/* Alerts / Tasks */}
+
+
+
+
Alerts
+
+
+
+ {alerts.map((a) => (
+ -
+
+
+
{a.title}
+
{a.detail}
+
+
+ {a.when}
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+ {/* Footer action row */}
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(protected)/layout.tsx b/src/app/(protected)/layout.tsx
new file mode 100644
index 0000000..c731e2c
--- /dev/null
+++ b/src/app/(protected)/layout.tsx
@@ -0,0 +1,51 @@
+import { ReactNode } from "react";
+import { cookies } from "next/headers";
+import { redirect } from "next/navigation";
+import AppShell from "@/app/component/layout/AppShell";
+import { getUserFromToken } from "@/lib/server-auth";
+import { buildNavForRoles } from "@/lib/nav";
+import type { UserSession } from "@/types/auth";
+
+export default async function ProtectedLayout({ children }: { children: ReactNode }) {
+ const cookieStore = await cookies();
+ const access = cookieStore.get("access_token")?.value;
+
+ if (!access) {
+ redirect(`/login?returnUrl=${encodeURIComponent("/dashboard")}`);
+ }
+
+ const jwtUser = await getUserFromToken(access!);
+
+ const infoB64 = cookieStore.get("user_info")?.value;
+ let info: Record | null = null;
+ if (infoB64) {
+ try {
+ info = JSON.parse(Buffer.from(infoB64, "base64").toString("utf8")) as Record;
+ } catch {
+ info = null;
+ }
+ }
+
+ const merged: UserSession = {
+ id: (jwtUser?.id ?? (info?.userId as string | undefined) ?? ""),
+ email: (jwtUser?.email ?? (info?.email as string | undefined)),
+ tenantKey: (jwtUser?.tenantKey ?? (info?.tenantKey as string | undefined)),
+ tenantId: (jwtUser?.tenantId ?? (info?.tenantId as string | undefined)),
+ roles: Array.isArray(jwtUser?.roles) ? jwtUser!.roles : [],
+ _dbg: jwtUser?._dbg,
+ };
+
+ if (!merged.id) {
+ redirect(`/login?returnUrl=${encodeURIComponent("/dashboard")}`);
+ }
+
+ const nav = buildNavForRoles(merged.roles);
+
+ return (
+
+
+ {children}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/app/(public)/layout.tsx b/src/app/(public)/layout.tsx
new file mode 100644
index 0000000..49de0e0
--- /dev/null
+++ b/src/app/(public)/layout.tsx
@@ -0,0 +1,3 @@
+export default function PublicLayout({ children }: { children: React.ReactNode }) {
+ return {children}
;
+}
diff --git a/src/app/(public)/login/page.tsx b/src/app/(public)/login/page.tsx
new file mode 100644
index 0000000..56e6149
--- /dev/null
+++ b/src/app/(public)/login/page.tsx
@@ -0,0 +1,165 @@
+"use client";
+
+import React, { Suspense, useMemo, useState } from "react";
+import { useRouter, useSearchParams } from "next/navigation";
+import BrandLogo from "@/app/component/common/BrandLogo";
+
+// บอก Next ให้บังคับ dynamic เพื่อไม่ให้พยายาม prerender แบบ static แล้วล้ม
+export const dynamic = "force-dynamic";
+
+function LoginFormInner() {
+ const sp = useSearchParams();
+ const returnUrl = sp.get("returnUrl") || "/dashboard";
+
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [message, setMessage] = useState(null);
+ const [error, setError] = useState(null);
+
+ const router = useRouter();
+
+ const emailError = useMemo(() => {
+ if (!email) return null;
+ const ok = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
+ return ok ? null : "กรุณากรอกอีเมลให้ถูกต้อง";
+ }, [email]);
+
+ const canSubmit = Boolean(email && password && !emailError && !loading);
+
+ async function onSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ if (!canSubmit) return;
+
+ setLoading(true);
+ setError(null);
+ setMessage(null);
+
+ try {
+ const res = await fetch("/api/auth/login", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ credentials: "include",
+ body: JSON.stringify({ email, password }),
+ cache: "no-store",
+ });
+
+ if (!res.ok) {
+ const j = (await res.json().catch(() => ({}))) as { message?: string };
+ throw new Error(j?.message ?? `Login failed (${res.status})`);
+ }
+
+ setMessage("เข้าสู่ระบบสำเร็จ กำลังพาไปยังหน้าแดชบอร์ด…");
+ router.replace(returnUrl);
+ } catch (ex: unknown) {
+ const err = ex as Error;
+ setError(err.message ?? "Login error");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+
+ {/* Brand */}
+
+
+
+
+ {/* Heading */}
+ Sign in
+ เข้าสู่ระบบเพื่อใช้งานแพลตฟอร์ม
+
+ {/* Card */}
+
+
+
+ {/* Banners */}
+ {message && (
+
+ {message}
+
+ )}
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ );
+}
+
+function LoginSkeleton() {
+ return (
+
+
+
+ );
+}
+
+export default function LoginPage() {
+ return (
+ }>
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/app/(public)/redeem/[transactionId]/page.tsx b/src/app/(public)/redeem/[transactionId]/page.tsx
index baaaba6..6c8ab56 100644
--- a/src/app/(public)/redeem/[transactionId]/page.tsx
+++ b/src/app/(public)/redeem/[transactionId]/page.tsx
@@ -1,175 +1,102 @@
// File: src/app/(public)/redeem/[transactionId]/page.tsx
"use client";
-import { useState, useMemo } from "react";
+import { useMemo, useRef, useState } from "react";
import { useParams, useSearchParams } from "next/navigation";
import BrandLogo from "@/app/component/common/BrandLogo";
+import { genIdempotencyKey } from "@/lib/idempotency";
+import { resolveTenantKeyFromHost } from "@/lib/tenant";
+import { onlyAsciiDigits, isThaiMobile10, looksLikeCountryCode } from "@/lib/phone";
+import type { RedeemResponse, RedeemRequest } from "@/types/crm";
-/* =======================
- Config
-======================= */
-const API_BASE = process.env.NEXT_PUBLIC_EOP_PUBLIC_API_BASE ?? "";
-
-/* =======================
- Types
-======================= */
-type RedeemResponse = {
- balance?: number;
- voucherCode?: string;
- ledgerEntryId?: string;
- redeemed?: boolean;
- message?: string;
-};
-
-/* =======================
- Utils
-======================= */
-function uuidv4() {
- if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
- return "idem-" + Date.now() + "-" + Math.random().toString(16).slice(2);
-}
-
-function getTenantKeyFromHost() {
- if (typeof window === "undefined") return process.env.NEXT_PUBLIC_DEFAULT_TENANT_KEY ?? "demo";
- const host = window.location.hostname;
- const parts = host.split(".");
- if (parts.length >= 3) return parts[0];
- return process.env.NEXT_PUBLIC_DEFAULT_TENANT_KEY ?? "demo";
-}
-
-// แปลงเลขไทย→อารบิก แล้วเหลือเฉพาะ 0-9 เท่านั้น
-function toAsciiDigitsOnly(input: string) {
- const map: Record = {
- "๐": "0", "๑": "1", "๒": "2", "๓": "3", "๔": "4",
- "๕": "5", "๖": "6", "๗": "7", "๘": "8", "๙": "9",
- };
- const thToEn = input.replace(/[๐-๙]/g, (ch) => map[ch] ?? ch);
- return thToEn.replace(/\D/g, "");
-}
-
-// รับเฉพาะเบอร์มือถือไทย 10 หลัก ที่ขึ้นต้นด้วย 06/08/09
-function isStrictThaiMobile10(digitsOnly: string) {
- return /^0(6|8|9)\d{8}$/.test(digitsOnly);
-}
-
-// รูปแบบที่ดูเหมือน +66/66xxxxx (ไม่อนุญาต)
-function looksLikeCountryCodeFormat(raw: string) {
- const t = raw.trim();
- return t.startsWith("+66") || /^66\d+/.test(t);
-}
-
-/* =======================
- API
-======================= */
-async function redeemPoints(input: {
- tenantKey: string;
- transactionId: string;
- phoneDigits10: string; // 0812345678
- idempotencyKey: string;
-}): Promise {
- const res = await fetch(`${API_BASE}/api/v1/loyalty/redeem`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- "X-Tenant-Key": input.tenantKey,
- "Idempotency-Key": input.idempotencyKey,
- },
- body: JSON.stringify({
- transactionId: input.transactionId,
- contact: { phone: input.phoneDigits10 },
- metadata: { source: "qr-landing" },
- }),
- cache: "no-store",
- });
-
- if (!res.ok) {
- const text = await res.text();
- throw new Error(text || `HTTP ${res.status}`);
- }
- return res.json() as Promise;
-}
-
-/* =======================
- Page
-======================= */
export default function RedeemPage() {
const params = useParams<{ transactionId: string }>();
const search = useSearchParams();
const transactionId = params?.transactionId ?? search?.get("transactionId") ?? "";
- const [phoneDigits, setPhoneDigits] = useState("");
- const [loading, setLoading] = useState(false);
+ const [phoneDigits, setPhoneDigits] = useState("");
+ const [loading, setLoading] = useState(false);
const [message, setMessage] = useState(null);
const [error, setError] = useState(null);
const [result, setResult] = useState(null);
- // Validation message
- const phoneError: string | null = useMemo(() => {
+ const idemRef = useRef(null);
+
+ const phoneError = useMemo(() => {
if (!phoneDigits) return null;
- if (looksLikeCountryCodeFormat(phoneDigits)) {
- return "กรุณาใส่เบอร์รูปแบบไทย 10 หลัก เช่น 0812345678 (ไม่ต้องใส่ +66)";
- }
- if (phoneDigits.length > 0 && phoneDigits.length !== 10) {
- return "กรุณากรอกเป็นตัวเลข 10 หลัก";
- }
- if (phoneDigits.length === 10 && !isStrictThaiMobile10(phoneDigits)) {
+ if (looksLikeCountryCode(phoneDigits)) return "กรุณาใส่รูปแบบไทย 10 หลัก เช่น 0812345678";
+ if (phoneDigits.length > 0 && phoneDigits.length !== 10) return "กรุณากรอกเป็นตัวเลข 10 หลัก";
+ if (phoneDigits.length === 10 && !isThaiMobile10(phoneDigits))
return "รับเฉพาะเบอร์มือถือไทยที่ขึ้นต้นด้วย 06, 08 หรือ 09 เท่านั้น";
- }
return null;
}, [phoneDigits]);
- const phoneValid = phoneDigits.length === 10 && !phoneError && isStrictThaiMobile10(phoneDigits);
+ const phoneValid = phoneDigits.length === 10 && !phoneError && isThaiMobile10(phoneDigits);
- // Guard input — digits only (รองรับเลขไทย)
function handleChange(e: React.ChangeEvent) {
- const onlyDigits = toAsciiDigitsOnly(e.target.value).slice(0, 10);
- setPhoneDigits(onlyDigits);
+ setPhoneDigits(onlyAsciiDigits(e.target.value).slice(0, 10));
}
function handleBeforeInput(e: React.FormEvent) {
- const ie = e.nativeEvent as InputEvent;
- const data = (ie && "data" in ie ? ie.data : undefined) ?? "";
- if (!data) return;
- if (!/^[0-9๐-๙]+$/.test(data)) e.preventDefault();
+ const nativeEvt = e.nativeEvent as unknown;
+ const data =
+ nativeEvt && typeof nativeEvt === "object" && "data" in (nativeEvt as InputEvent)
+ ? (nativeEvt as InputEvent).data
+ : null;
+ if (data && !/^[0-9๐-๙]+$/.test(data)) e.preventDefault();
}
function handleKeyDown(e: React.KeyboardEvent) {
const allow = ["Backspace", "Delete", "Tab", "ArrowLeft", "ArrowRight", "Home", "End"];
- if (allow.includes(e.key)) return;
- if (/^[0-9]$/.test(e.key)) return;
+ if (allow.includes(e.key) || /^[0-9]$/.test(e.key)) return;
e.preventDefault();
}
function handlePaste(e: React.ClipboardEvent) {
e.preventDefault();
- const text = e.clipboardData.getData("text") ?? "";
- const onlyDigits = toAsciiDigitsOnly(text);
+ const onlyDigits = onlyAsciiDigits(e.clipboardData.getData("text") ?? "");
if (!onlyDigits) return;
setPhoneDigits((prev) => (prev + onlyDigits).slice(0, 10));
}
- async function onSubmit(e: React.FormEvent) {
+ async function onSubmit(e: React.FormEvent) {
e.preventDefault();
+ if (!transactionId || !phoneValid || loading) return;
+
setLoading(true);
setError(null);
setMessage(null);
setResult(null);
try {
- const tenantKey = getTenantKeyFromHost();
- const idempotencyKey = uuidv4();
+ const tenantKey = resolveTenantKeyFromHost();
+ idemRef.current = genIdempotencyKey();
- const data = await redeemPoints({
- tenantKey,
+ const payload: RedeemRequest = {
transactionId,
- phoneDigits10: phoneDigits,
- idempotencyKey,
+ contact: { phone: phoneDigits },
+ metadata: { source: "qr-landing" },
+ };
+
+ const res = await fetch(`/api/public/redeem`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Tenant-Key": tenantKey,
+ "Idempotency-Key": idemRef.current!,
+ },
+ body: JSON.stringify(payload),
+ cache: "no-store",
});
+ if (!res.ok) throw new Error((await res.text()) || `HTTP ${res.status}`);
+
+ const data: RedeemResponse = await res.json();
setResult(data);
setMessage("สะสมคะแนนสำเร็จ");
- } catch (err: unknown) {
- const msg = err instanceof Error ? err.message : "เกิดข้อผิดพลาด ไม่สามารถแลกคะแนนได้";
- setError(msg);
+ } catch (ex: unknown) {
+ const err = ex as Error;
+ setError(err.message ?? "เกิดข้อผิดพลาด ไม่สามารถแลกคะแนนได้");
} finally {
setLoading(false);
+ idemRef.current = null;
}
}
@@ -187,7 +114,7 @@ export default function RedeemPage() {
Transaction: {transactionId || "—"}
- {/* Card — เรียบแต่เนี๊ยบขึ้น */}
+ {/* Card */}
{!transactionId && (
@@ -197,14 +124,10 @@ export default function RedeemPage() {