ADI576 commited on
Commit
7f25ddf
·
1 Parent(s): 2a2655b

Deploy ZippMart admin dashboard (Docker Space)

Browse files
apps/admin-web/app/admin/dashboard/page.tsx CHANGED
@@ -2,6 +2,8 @@
2
 
3
  import { useRouter } from "next/navigation";
4
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
 
 
5
  import { BarcodeCameraScanner } from "../../components/BarcodeCameraScanner";
6
  import { OrderReceiptModal } from "../../components/OrderReceiptModal";
7
  import { StockAdjustModal } from "../../components/StockAdjustModal";
@@ -138,6 +140,7 @@ export default function AdminDashboardPage() {
138
  const [authed, setAuthed] = useState(false);
139
  const [stockModalProduct, setStockModalProduct] = useState<Pick<AdminProduct, "id" | "name" | "barcode" | "inStock"> | null>(null);
140
  const [stockModalBusy, setStockModalBusy] = useState(false);
 
141
 
142
  const formProfit = useMemo(() => {
143
  const sell = Number(sellingPrice);
@@ -297,6 +300,8 @@ export default function AdminDashboardPage() {
297
  if (resp.ok) setAuditEntries(await resp.json());
298
  }, [router]);
299
 
 
 
300
  useEffect(() => {
301
  if (!authed) return;
302
  void loadProducts();
@@ -305,13 +310,21 @@ export default function AdminDashboardPage() {
305
  void loadOrders();
306
  }, [authed, loadProducts, loadMetrics, loadDiscounts, loadOrders]);
307
 
 
 
 
 
 
 
 
 
 
 
308
  function logout() {
309
  clearAdminToken();
310
  router.replace("/admin");
311
  }
312
 
313
- const isManager = adminRole === "manager";
314
-
315
  async function voidOrder(orderId: string) {
316
  if (!confirm("Void this unpaid order? It will disappear from open totals.")) return;
317
  const resp = await fetch(`${apiBase}/v1/admin/orders/${encodeURIComponent(orderId)}/void`, {
@@ -604,7 +617,7 @@ export default function AdminDashboardPage() {
604
  <div>
605
  <p className="adminHeader__eyebrow">ZippMart HQ</p>
606
  <h1 className="adminHeader__title">Operations dashboard</h1>
607
- <p className="adminHeader__sub">Catalogue economics, promotions, and store KPIs.</p>
608
  </div>
609
  <div className="adminHeader__actions">
610
  <select
@@ -626,7 +639,14 @@ export default function AdminDashboardPage() {
626
  <button type="button" className="adminBtn adminBtn--ghost" onClick={() => void downloadOrdersCsv()}>
627
  Orders CSV
628
  </button>
629
- <button type="button" className="adminBtn adminBtn--ghost" onClick={() => void loadAudit()}>
 
 
 
 
 
 
 
630
  Audit
631
  </button>
632
  </>
@@ -649,61 +669,13 @@ export default function AdminDashboardPage() {
649
  </div>
650
  </header>
651
 
652
- {metrics ? (
653
- <section className="metricsGrid" aria-label="Store KPIs">
654
- <div className="metricCard">
655
- <p className="metricCard__label">Paid orders</p>
656
- <p className="metricCard__value">{metrics.paidOrderCount}</p>
657
- <p className="metricCard__hint">All time (this server)</p>
658
- </div>
659
- <div className="metricCard metricCard--accent">
660
- <p className="metricCard__label">Net revenue</p>
661
- <p className="metricCard__value">{money(metrics.totalRevenue)}</p>
662
- <p className="metricCard__hint">Completed payments</p>
663
- </div>
664
- <div className="metricCard">
665
- <p className="metricCard__label">Open orders</p>
666
- <p className="metricCard__value">{metrics.openOrderCount}</p>
667
- <p className="metricCard__hint">{money(metrics.pendingOrderValue)} at counter / pending</p>
668
- </div>
669
- <div className="metricCard">
670
- <p className="metricCard__label">Avg paid ticket</p>
671
- <p className="metricCard__value">{money(metrics.averagePaidOrderValue)}</p>
672
- <p className="metricCard__hint">Per settled order</p>
673
- </div>
674
- <div className="metricCard">
675
- <p className="metricCard__label">SKU count</p>
676
- <p className="metricCard__value">{metrics.productCount}</p>
677
- <p className="metricCard__hint">{metrics.lowStockSkuCount} low stock (&lt; 15 units)</p>
678
- </div>
679
- <div className="metricCard">
680
- <p className="metricCard__label">Inventory @ cost</p>
681
- <p className="metricCard__value">{money(metrics.inventoryValueAtCost)}</p>
682
- <p className="metricCard__hint">Stock × unit cost</p>
683
- </div>
684
- <div className="metricCard">
685
- <p className="metricCard__label">Inventory @ list</p>
686
- <p className="metricCard__value">{money(metrics.inventoryValueAtList)}</p>
687
- <p className="metricCard__hint">Stock × list sell</p>
688
- </div>
689
- <div className="metricCard">
690
- <p className="metricCard__label">Active promos</p>
691
- <p className="metricCard__value">{metrics.activeDiscountCount}</p>
692
- <p className="metricCard__hint">Discount rules live on shop</p>
693
- </div>
694
- </section>
695
- ) : (
696
- <p className="muted">Loading metrics…</p>
697
- )}
698
 
699
- {metrics && metrics.lowStockSkuCount > 0 ? (
700
- <div className="alertBanner" role="status">
701
- <strong>{metrics.lowStockSkuCount}</strong> SKU{metrics.lowStockSkuCount === 1 ? "" : "s"} below 15 units —
702
- use <strong>Low stock only</strong> under inventory to focus the table.
703
- </div>
704
  ) : null}
705
 
706
- {orders.length > 0 ? (
707
  <section className="panel adminPanel adminPanel--orders" aria-label="Recent orders">
708
  <h2 className="adminPanel__title">Recent orders</h2>
709
  <p className="adminPanel__lede">
@@ -794,40 +766,54 @@ export default function AdminDashboardPage() {
794
  </table>
795
  </div>
796
  </section>
 
 
797
  ) : null}
798
 
799
- {isManager && auditEntries.length > 0 ? (
800
- <section className="panel adminPanel" aria-label="Audit log">
801
- <h2 className="adminPanel__title">Audit log</h2>
802
- <p className="adminPanel__lede">Last security and inventory events (newest first).</p>
803
- <div className="tableWrap">
804
- <table className="adminTable adminTable--compact">
805
- <thead>
806
- <tr>
807
- <th>When</th>
808
- <th>Actor</th>
809
- <th>Role</th>
810
- <th>Action</th>
811
- <th>Detail</th>
812
- </tr>
813
- </thead>
814
- <tbody>
815
- {auditEntries.map((a) => (
816
- <tr key={a.id}>
817
- <td className="muted">{new Date(a.at).toLocaleString()}</td>
818
- <td>{a.actor}</td>
819
- <td>{a.role}</td>
820
- <td className="mono">{a.action}</td>
821
- <td className="muted">{a.detail}</td>
822
  </tr>
823
- ))}
824
- </tbody>
825
- </table>
826
- </div>
827
- </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
828
  ) : null}
829
 
830
- {products.length === 0 && !loading ? (
 
 
 
 
 
 
 
 
831
  <div className="alertBanner" role="status">
832
  Your catalogue is empty. Use <strong>Add product</strong> on the left to create your first SKU — customers will see it on the shop once saved.
833
  </div>
@@ -1101,8 +1087,13 @@ export default function AdminDashboardPage() {
1101
  </table>
1102
  </div>
1103
  </section>
 
 
 
 
1104
 
1105
- <section className="panel adminPanel adminPanel--promo">
 
1106
  <h2 className="adminPanel__title">Discounts</h2>
1107
  <p className="adminPanel__lede">Percentage off list price for the customer shop. Max 90%. Removes instantly when deleted.</p>
1108
  <div className="promoRow">
@@ -1145,9 +1136,8 @@ export default function AdminDashboardPage() {
1145
  </div>
1146
  ))}
1147
  </div>
1148
- </section>
1149
- </div>
1150
- </div>
1151
 
1152
  {message ? <div className="adminToast">{message}</div> : null}
1153
 
 
2
 
3
  import { useRouter } from "next/navigation";
4
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
+ import { AdminNav, type AdminSection } from "../../components/AdminNav";
6
+ import { OverviewSection } from "../../components/admin/OverviewSection";
7
  import { BarcodeCameraScanner } from "../../components/BarcodeCameraScanner";
8
  import { OrderReceiptModal } from "../../components/OrderReceiptModal";
9
  import { StockAdjustModal } from "../../components/StockAdjustModal";
 
140
  const [authed, setAuthed] = useState(false);
141
  const [stockModalProduct, setStockModalProduct] = useState<Pick<AdminProduct, "id" | "name" | "barcode" | "inStock"> | null>(null);
142
  const [stockModalBusy, setStockModalBusy] = useState(false);
143
+ const [activeSection, setActiveSection] = useState<AdminSection>("overview");
144
 
145
  const formProfit = useMemo(() => {
146
  const sell = Number(sellingPrice);
 
300
  if (resp.ok) setAuditEntries(await resp.json());
301
  }, [router]);
302
 
303
+ const isManager = adminRole === "manager";
304
+
305
  useEffect(() => {
306
  if (!authed) return;
307
  void loadProducts();
 
310
  void loadOrders();
311
  }, [authed, loadProducts, loadMetrics, loadDiscounts, loadOrders]);
312
 
313
+ useEffect(() => {
314
+ if (!authed || activeSection !== "audit" || !isManager) return;
315
+ if (auditEntries.length === 0) void loadAudit();
316
+ }, [activeSection, authed, isManager, auditEntries.length, loadAudit]);
317
+
318
+ function onSectionChange(section: AdminSection) {
319
+ setActiveSection(section);
320
+ if (section === "audit" && isManager) void loadAudit();
321
+ }
322
+
323
  function logout() {
324
  clearAdminToken();
325
  router.replace("/admin");
326
  }
327
 
 
 
328
  async function voidOrder(orderId: string) {
329
  if (!confirm("Void this unpaid order? It will disappear from open totals.")) return;
330
  const resp = await fetch(`${apiBase}/v1/admin/orders/${encodeURIComponent(orderId)}/void`, {
 
617
  <div>
618
  <p className="adminHeader__eyebrow">ZippMart HQ</p>
619
  <h1 className="adminHeader__title">Operations dashboard</h1>
620
+ <p className="adminHeader__sub">Inventory, orders, promotions, and store analytics in one place.</p>
621
  </div>
622
  <div className="adminHeader__actions">
623
  <select
 
639
  <button type="button" className="adminBtn adminBtn--ghost" onClick={() => void downloadOrdersCsv()}>
640
  Orders CSV
641
  </button>
642
+ <button
643
+ type="button"
644
+ className="adminBtn adminBtn--ghost"
645
+ onClick={() => {
646
+ onSectionChange("audit");
647
+ void loadAudit();
648
+ }}
649
+ >
650
  Audit
651
  </button>
652
  </>
 
669
  </div>
670
  </header>
671
 
672
+ <AdminNav active={activeSection} isManager={isManager} onChange={onSectionChange} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
673
 
674
+ {activeSection === "overview" ? (
675
+ <OverviewSection metrics={metrics} products={products} orders={orders} money={money} />
 
 
 
676
  ) : null}
677
 
678
+ {activeSection === "orders" && orders.length > 0 ? (
679
  <section className="panel adminPanel adminPanel--orders" aria-label="Recent orders">
680
  <h2 className="adminPanel__title">Recent orders</h2>
681
  <p className="adminPanel__lede">
 
766
  </table>
767
  </div>
768
  </section>
769
+ ) : activeSection === "orders" ? (
770
+ <p className="muted adminEmptyHint">No orders yet for this store filter.</p>
771
  ) : null}
772
 
773
+ {activeSection === "audit" && isManager ? (
774
+ auditEntries.length > 0 ? (
775
+ <section className="panel adminPanel" aria-label="Audit log">
776
+ <h2 className="adminPanel__title">Audit log</h2>
777
+ <p className="adminPanel__lede">Last security and inventory events (newest first).</p>
778
+ <div className="tableWrap">
779
+ <table className="adminTable adminTable--compact">
780
+ <thead>
781
+ <tr>
782
+ <th>When</th>
783
+ <th>Actor</th>
784
+ <th>Role</th>
785
+ <th>Action</th>
786
+ <th>Detail</th>
 
 
 
 
 
 
 
 
 
787
  </tr>
788
+ </thead>
789
+ <tbody>
790
+ {auditEntries.map((a) => (
791
+ <tr key={a.id}>
792
+ <td className="muted">{new Date(a.at).toLocaleString()}</td>
793
+ <td>{a.actor}</td>
794
+ <td>{a.role}</td>
795
+ <td className="mono">{a.action}</td>
796
+ <td className="muted">{a.detail}</td>
797
+ </tr>
798
+ ))}
799
+ </tbody>
800
+ </table>
801
+ </div>
802
+ </section>
803
+ ) : (
804
+ <p className="muted adminEmptyHint">No audit events yet. Actions like stock changes and voids appear here.</p>
805
+ )
806
  ) : null}
807
 
808
+ {activeSection === "inventory" ? (
809
+ <>
810
+ {metrics && metrics.lowStockSkuCount > 0 ? (
811
+ <div className="alertBanner" role="status">
812
+ <strong>{metrics.lowStockSkuCount}</strong> SKU{metrics.lowStockSkuCount === 1 ? "" : "s"} below 15 units —
813
+ use <strong>Low stock only</strong> in the table below.
814
+ </div>
815
+ ) : null}
816
+ {products.length === 0 && !loading ? (
817
  <div className="alertBanner" role="status">
818
  Your catalogue is empty. Use <strong>Add product</strong> on the left to create your first SKU — customers will see it on the shop once saved.
819
  </div>
 
1087
  </table>
1088
  </div>
1089
  </section>
1090
+ </div>
1091
+ </div>
1092
+ </>
1093
+ ) : null}
1094
 
1095
+ {activeSection === "promotions" ? (
1096
+ <section className="panel adminPanel adminPanel--promo">
1097
  <h2 className="adminPanel__title">Discounts</h2>
1098
  <p className="adminPanel__lede">Percentage off list price for the customer shop. Max 90%. Removes instantly when deleted.</p>
1099
  <div className="promoRow">
 
1136
  </div>
1137
  ))}
1138
  </div>
1139
+ </section>
1140
+ ) : null}
 
1141
 
1142
  {message ? <div className="adminToast">{message}</div> : null}
1143
 
apps/admin-web/app/components/AdminNav.tsx ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ export type AdminSection = "overview" | "inventory" | "orders" | "promotions" | "audit";
4
+
5
+ const ITEMS: {
6
+ id: AdminSection;
7
+ label: string;
8
+ hint: string;
9
+ managerOnly?: boolean;
10
+ }[] = [
11
+ { id: "overview", label: "Overview", hint: "KPIs & analysis" },
12
+ { id: "inventory", label: "Inventory", hint: "Catalogue & stock" },
13
+ { id: "orders", label: "Orders", hint: "Checkouts & receipts" },
14
+ { id: "promotions", label: "Promotions", hint: "Discount rules" },
15
+ { id: "audit", label: "Audit log", hint: "Manager only", managerOnly: true }
16
+ ];
17
+
18
+ type Props = {
19
+ active: AdminSection;
20
+ isManager: boolean;
21
+ onChange: (section: AdminSection) => void;
22
+ };
23
+
24
+ export function AdminNav({ active, isManager, onChange }: Props) {
25
+ const visible = ITEMS.filter((item) => !item.managerOnly || isManager);
26
+
27
+ return (
28
+ <nav className="adminNav" aria-label="Dashboard sections">
29
+ {visible.map((item) => (
30
+ <button
31
+ key={item.id}
32
+ type="button"
33
+ className={`adminNav__item${active === item.id ? " adminNav__item--active" : ""}`}
34
+ onClick={() => onChange(item.id)}
35
+ aria-current={active === item.id ? "page" : undefined}
36
+ >
37
+ <span className="adminNav__label">{item.label}</span>
38
+ <span className="adminNav__hint">{item.hint}</span>
39
+ </button>
40
+ ))}
41
+ </nav>
42
+ );
43
+ }
apps/admin-web/app/components/admin/OverviewSection.tsx ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useMemo } from "react";
4
+
5
+ type Metrics = {
6
+ orderCount: number;
7
+ paidOrderCount: number;
8
+ openOrderCount: number;
9
+ totalRevenue: number;
10
+ pendingOrderValue: number;
11
+ averagePaidOrderValue: number;
12
+ productCount: number;
13
+ lowStockSkuCount: number;
14
+ inventoryValueAtCost: number;
15
+ inventoryValueAtList: number;
16
+ activeDiscountCount: number;
17
+ };
18
+
19
+ type Product = {
20
+ id: string;
21
+ name: string;
22
+ category: string;
23
+ inStock: number;
24
+ demandScore: number;
25
+ unitPrice: number;
26
+ costPrice: number;
27
+ profitPerUnitAtSale: number;
28
+ };
29
+
30
+ type OrderRow = {
31
+ orderId: string;
32
+ createdAt: string;
33
+ grandTotal: number;
34
+ paid: boolean;
35
+ voided: boolean;
36
+ refunded: boolean;
37
+ paymentMode: string;
38
+ };
39
+
40
+ type Props = {
41
+ metrics: Metrics | null;
42
+ products: Product[];
43
+ orders: OrderRow[];
44
+ money: (n: number) => string;
45
+ };
46
+
47
+ function pct(part: number, total: number) {
48
+ if (total <= 0) return 0;
49
+ return Math.round((part / total) * 100);
50
+ }
51
+
52
+ export function OverviewSection({ metrics, products, orders, money }: Props) {
53
+ const stockHealth = useMemo(() => {
54
+ let out = 0;
55
+ let low = 0;
56
+ let ok = 0;
57
+ for (const p of products) {
58
+ if (p.inStock <= 0) out += 1;
59
+ else if (p.inStock < 15) low += 1;
60
+ else ok += 1;
61
+ }
62
+ return { out, low, ok, total: products.length };
63
+ }, [products]);
64
+
65
+ const topDemand = useMemo(
66
+ () => [...products].sort((a, b) => b.demandScore - a.demandScore).slice(0, 5),
67
+ [products]
68
+ );
69
+
70
+ const categoryRows = useMemo(() => {
71
+ const map = new Map<string, { count: number; stock: number; listValue: number }>();
72
+ for (const p of products) {
73
+ const cur = map.get(p.category) ?? { count: 0, stock: 0, listValue: 0 };
74
+ cur.count += 1;
75
+ cur.stock += p.inStock;
76
+ cur.listValue += p.inStock * p.unitPrice;
77
+ map.set(p.category, cur);
78
+ }
79
+ return [...map.entries()]
80
+ .map(([category, v]) => ({ category, ...v }))
81
+ .sort((a, b) => b.listValue - a.listValue);
82
+ }, [products]);
83
+
84
+ const orderInsights = useMemo(() => {
85
+ const paid = orders.filter((o) => o.paid && !o.voided && !o.refunded);
86
+ const counter = paid.filter((o) => o.paymentMode === "COUNTER").length;
87
+ const online = paid.filter((o) => o.paymentMode === "ONLINE").length;
88
+ const last24h = orders.filter((o) => Date.now() - new Date(o.createdAt).getTime() < 86_400_000).length;
89
+ return { paid: paid.length, counter, online, last24h, sample: orders.length };
90
+ }, [orders]);
91
+
92
+ const marginAtSale = useMemo(
93
+ () => products.reduce((sum, p) => sum + Math.max(0, p.inStock) * p.profitPerUnitAtSale, 0),
94
+ [products]
95
+ );
96
+
97
+ if (!metrics) {
98
+ return <p className="muted">Loading analytics…</p>;
99
+ }
100
+
101
+ const revenueTotal = metrics.totalRevenue + metrics.pendingOrderValue;
102
+ const paidShare = pct(metrics.totalRevenue, revenueTotal);
103
+
104
+ return (
105
+ <>
106
+ <section className="metricsGrid" aria-label="Store KPIs">
107
+ <div className="metricCard">
108
+ <p className="metricCard__label">Paid orders</p>
109
+ <p className="metricCard__value">{metrics.paidOrderCount}</p>
110
+ <p className="metricCard__hint">All time (filtered store)</p>
111
+ </div>
112
+ <div className="metricCard metricCard--accent">
113
+ <p className="metricCard__label">Net revenue</p>
114
+ <p className="metricCard__value">{money(metrics.totalRevenue)}</p>
115
+ <p className="metricCard__hint">Completed payments</p>
116
+ </div>
117
+ <div className="metricCard">
118
+ <p className="metricCard__label">Open orders</p>
119
+ <p className="metricCard__value">{metrics.openOrderCount}</p>
120
+ <p className="metricCard__hint">{money(metrics.pendingOrderValue)} pending</p>
121
+ </div>
122
+ <div className="metricCard">
123
+ <p className="metricCard__label">Avg paid ticket</p>
124
+ <p className="metricCard__value">{money(metrics.averagePaidOrderValue)}</p>
125
+ <p className="metricCard__hint">Per settled order</p>
126
+ </div>
127
+ <div className="metricCard">
128
+ <p className="metricCard__label">SKU count</p>
129
+ <p className="metricCard__value">{metrics.productCount}</p>
130
+ <p className="metricCard__hint">{metrics.lowStockSkuCount} low stock (&lt; 15)</p>
131
+ </div>
132
+ <div className="metricCard">
133
+ <p className="metricCard__label">Inventory @ cost</p>
134
+ <p className="metricCard__value">{money(metrics.inventoryValueAtCost)}</p>
135
+ <p className="metricCard__hint">On-hand × unit cost</p>
136
+ </div>
137
+ <div className="metricCard">
138
+ <p className="metricCard__label">Inventory @ list</p>
139
+ <p className="metricCard__value">{money(metrics.inventoryValueAtList)}</p>
140
+ <p className="metricCard__hint">On-hand × list price</p>
141
+ </div>
142
+ <div className="metricCard">
143
+ <p className="metricCard__label">Active promos</p>
144
+ <p className="metricCard__value">{metrics.activeDiscountCount}</p>
145
+ <p className="metricCard__hint">Live discount rules</p>
146
+ </div>
147
+ </section>
148
+
149
+ {metrics.lowStockSkuCount > 0 ? (
150
+ <div className="alertBanner" role="status">
151
+ <strong>{metrics.lowStockSkuCount}</strong> SKU{metrics.lowStockSkuCount === 1 ? "" : "s"} below 15 units —
152
+ open <strong>Inventory</strong> and use low-stock filter to replenish.
153
+ </div>
154
+ ) : null}
155
+
156
+ <div className="analysisGrid">
157
+ <section className="panel adminPanel analysisCard">
158
+ <h2 className="adminPanel__title">Revenue mix</h2>
159
+ <p className="adminPanel__lede">Settled vs still-open order value in the current filter.</p>
160
+ <div className="analysisBar" aria-hidden>
161
+ <span className="analysisBar__paid" style={{ width: `${paidShare}%` }} />
162
+ </div>
163
+ <div className="analysisLegend">
164
+ <span>
165
+ <i className="analysisDot analysisDot--paid" /> Paid {money(metrics.totalRevenue)} ({paidShare}%)
166
+ </span>
167
+ <span>
168
+ <i className="analysisDot analysisDot--pending" /> Pending {money(metrics.pendingOrderValue)} (
169
+ {100 - paidShare}%)
170
+ </span>
171
+ </div>
172
+ <p className="analysisFoot">
173
+ Est. margin on current stock (at shelf promo prices): <strong>{money(marginAtSale)}</strong>
174
+ </p>
175
+ </section>
176
+
177
+ <section className="panel adminPanel analysisCard">
178
+ <h2 className="adminPanel__title">Stock health</h2>
179
+ <p className="adminPanel__lede">{stockHealth.total} SKUs in catalogue.</p>
180
+ <ul className="analysisList">
181
+ <li>
182
+ <span>Healthy (15+ units)</span>
183
+ <strong>{stockHealth.ok}</strong>
184
+ </li>
185
+ <li>
186
+ <span>Low stock</span>
187
+ <strong className="textWarn">{stockHealth.low}</strong>
188
+ </li>
189
+ <li>
190
+ <span>Out of stock</span>
191
+ <strong className="textDanger">{stockHealth.out}</strong>
192
+ </li>
193
+ </ul>
194
+ </section>
195
+
196
+ <section className="panel adminPanel analysisCard">
197
+ <h2 className="adminPanel__title">Orders snapshot</h2>
198
+ <p className="adminPanel__lede">From the latest {orderInsights.sample} orders loaded.</p>
199
+ <ul className="analysisList">
200
+ <li>
201
+ <span>Paid (in sample)</span>
202
+ <strong>{orderInsights.paid}</strong>
203
+ </li>
204
+ <li>
205
+ <span>Last 24 hours</span>
206
+ <strong>{orderInsights.last24h}</strong>
207
+ </li>
208
+ <li>
209
+ <span>Counter / online paid</span>
210
+ <strong>
211
+ {orderInsights.counter} / {orderInsights.online}
212
+ </strong>
213
+ </li>
214
+ </ul>
215
+ </section>
216
+
217
+ <section className="panel adminPanel analysisCard analysisCard--wide">
218
+ <h2 className="adminPanel__title">Top demand SKUs</h2>
219
+ <p className="adminPanel__lede">Higher demand score surfaces first on the customer shop.</p>
220
+ {topDemand.length === 0 ? (
221
+ <p className="emptyHint">No products yet.</p>
222
+ ) : (
223
+ <div className="tableWrap">
224
+ <table className="adminTable adminTable--compact">
225
+ <thead>
226
+ <tr>
227
+ <th>Product</th>
228
+ <th>Category</th>
229
+ <th>Demand</th>
230
+ <th>Stock</th>
231
+ <th>List</th>
232
+ </tr>
233
+ </thead>
234
+ <tbody>
235
+ {topDemand.map((p) => (
236
+ <tr key={p.id}>
237
+ <td>{p.name}</td>
238
+ <td className="muted">{p.category}</td>
239
+ <td>{p.demandScore}</td>
240
+ <td>{p.inStock}</td>
241
+ <td>{money(p.unitPrice)}</td>
242
+ </tr>
243
+ ))}
244
+ </tbody>
245
+ </table>
246
+ </div>
247
+ )}
248
+ </section>
249
+
250
+ <section className="panel adminPanel analysisCard analysisCard--wide">
251
+ <h2 className="adminPanel__title">Category breakdown</h2>
252
+ <p className="adminPanel__lede">SKU count and list-value on hand by aisle.</p>
253
+ {categoryRows.length === 0 ? (
254
+ <p className="emptyHint">No categories yet.</p>
255
+ ) : (
256
+ <div className="tableWrap">
257
+ <table className="adminTable adminTable--compact">
258
+ <thead>
259
+ <tr>
260
+ <th>Category</th>
261
+ <th>SKUs</th>
262
+ <th>Units on hand</th>
263
+ <th>List value</th>
264
+ </tr>
265
+ </thead>
266
+ <tbody>
267
+ {categoryRows.map((row) => (
268
+ <tr key={row.category}>
269
+ <td>{row.category}</td>
270
+ <td>{row.count}</td>
271
+ <td>{row.stock}</td>
272
+ <td>{money(row.listValue)}</td>
273
+ </tr>
274
+ ))}
275
+ </tbody>
276
+ </table>
277
+ </div>
278
+ )}
279
+ </section>
280
+ </div>
281
+ </>
282
+ );
283
+ }
284
+
apps/admin-web/app/globals.css CHANGED
@@ -480,6 +480,149 @@ button.secondary {
480
  font-size: 12px;
481
  }
482
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
483
  .metricsGrid {
484
  display: grid;
485
  grid-template-columns: repeat(4, minmax(0, 1fr));
 
480
  font-size: 12px;
481
  }
482
 
483
+ .adminNav {
484
+ display: flex;
485
+ flex-wrap: wrap;
486
+ gap: 10px;
487
+ margin-bottom: 24px;
488
+ }
489
+
490
+ .adminNav__item {
491
+ display: flex;
492
+ flex-direction: column;
493
+ align-items: flex-start;
494
+ gap: 2px;
495
+ min-width: 120px;
496
+ padding: 12px 16px;
497
+ border: 1px solid var(--line);
498
+ border-radius: var(--radius);
499
+ background: var(--surface);
500
+ color: var(--olive-dark);
501
+ cursor: pointer;
502
+ text-align: left;
503
+ box-shadow: var(--shadow);
504
+ transition: border-color 0.15s ease, background 0.15s ease;
505
+ }
506
+
507
+ .adminNav__item:hover {
508
+ border-color: rgba(69, 78, 56, 0.35);
509
+ }
510
+
511
+ .adminNav__item--active {
512
+ border-color: var(--accent);
513
+ background: linear-gradient(160deg, #fffdfb, var(--surface));
514
+ }
515
+
516
+ .adminNav__label {
517
+ font-size: 14px;
518
+ font-weight: 800;
519
+ }
520
+
521
+ .adminNav__hint {
522
+ font-size: 11px;
523
+ color: var(--muted);
524
+ }
525
+
526
+ .adminEmptyHint {
527
+ margin: 0 0 24px;
528
+ padding: 20px;
529
+ border: 1px dashed var(--line);
530
+ border-radius: var(--radius);
531
+ text-align: center;
532
+ }
533
+
534
+ .analysisGrid {
535
+ display: grid;
536
+ grid-template-columns: repeat(3, minmax(0, 1fr));
537
+ gap: 18px;
538
+ margin-bottom: 28px;
539
+ }
540
+
541
+ @media (max-width: 1100px) {
542
+ .analysisGrid {
543
+ grid-template-columns: 1fr;
544
+ }
545
+ }
546
+
547
+ .analysisCard--wide {
548
+ grid-column: 1 / -1;
549
+ }
550
+
551
+ .analysisBar {
552
+ height: 12px;
553
+ border-radius: 999px;
554
+ background: var(--line);
555
+ overflow: hidden;
556
+ margin-bottom: 12px;
557
+ }
558
+
559
+ .analysisBar__paid {
560
+ display: block;
561
+ height: 100%;
562
+ min-width: 4px;
563
+ background: linear-gradient(90deg, var(--olive), var(--accent));
564
+ border-radius: 999px;
565
+ }
566
+
567
+ .analysisLegend {
568
+ display: flex;
569
+ flex-wrap: wrap;
570
+ gap: 14px 20px;
571
+ font-size: 13px;
572
+ color: var(--muted);
573
+ }
574
+
575
+ .analysisDot {
576
+ display: inline-block;
577
+ width: 10px;
578
+ height: 10px;
579
+ border-radius: 50%;
580
+ margin-right: 6px;
581
+ vertical-align: middle;
582
+ }
583
+
584
+ .analysisDot--paid {
585
+ background: var(--accent);
586
+ }
587
+
588
+ .analysisDot--pending {
589
+ background: var(--line);
590
+ }
591
+
592
+ .analysisFoot {
593
+ margin: 14px 0 0;
594
+ font-size: 13px;
595
+ color: var(--muted);
596
+ }
597
+
598
+ .analysisList {
599
+ list-style: none;
600
+ margin: 0;
601
+ padding: 0;
602
+ }
603
+
604
+ .analysisList li {
605
+ display: flex;
606
+ justify-content: space-between;
607
+ align-items: center;
608
+ gap: 12px;
609
+ padding: 10px 0;
610
+ border-bottom: 1px solid var(--line);
611
+ font-size: 14px;
612
+ }
613
+
614
+ .analysisList li:last-child {
615
+ border-bottom: none;
616
+ }
617
+
618
+ .textWarn {
619
+ color: #b45309;
620
+ }
621
+
622
+ .textDanger {
623
+ color: var(--danger);
624
+ }
625
+
626
  .metricsGrid {
627
  display: grid;
628
  grid-template-columns: repeat(4, minmax(0, 1fr));