Add Login and Dashboard
This commit is contained in:
495
src/app/(protected)/dashboard/page.tsx
Normal file
495
src/app/(protected)/dashboard/page.tsx
Normal file
@@ -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<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">
|
||||
{/* Header / Filters */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user