Push the rest
This commit is contained in:
@@ -1,363 +1,145 @@
|
||||
import { trace, SpanStatusCode } from "@opentelemetry/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { startTransition, useDeferredValue } from "react";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { getDashboard } from "./api/client";
|
||||
import { Navigate, NavLink, Route, Routes } from "react-router-dom";
|
||||
import { useAuth } from "./auth/AuthContext";
|
||||
|
||||
const money = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
import SalesDashboard from "./pages/aw/SalesDashboard";
|
||||
import RepScores from "./pages/aw/RepScores";
|
||||
import ProductDemand from "./pages/aw/ProductDemand";
|
||||
import AnomalyDetection from "./pages/aw/AnomalyDetection";
|
||||
import StockDashboard from "./pages/wwi/StockDashboard";
|
||||
import SupplierScores from "./pages/wwi/SupplierScores";
|
||||
import WhatIf from "./pages/wwi/WhatIf";
|
||||
import BusinessEvents from "./pages/wwi/BusinessEvents";
|
||||
import OperationsPage from "./pages/ops/OperationsPage";
|
||||
import AuditPage from "./pages/ops/AuditPage";
|
||||
import ExportsPage from "./pages/ops/ExportsPage";
|
||||
|
||||
const tracer = trace.getTracer("bi-frontend-ui");
|
||||
|
||||
function formatCompactDate(value: string): string {
|
||||
return new Date(value).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
function NavItem({ to, label }: { to: string; label: string }) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className={({ isActive }) => `nav-link${isActive ? " nav-active" : ""}`}
|
||||
>
|
||||
{label}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTooltipMoney(
|
||||
value: string | number | readonly (string | number)[] | undefined,
|
||||
): string {
|
||||
const raw = Array.isArray(value) ? Number(value[0]) : Number(value);
|
||||
return money.format(Number.isFinite(raw) ? raw : 0);
|
||||
}
|
||||
|
||||
function formatTooltipNumber(
|
||||
value: string | number | readonly (string | number)[] | undefined,
|
||||
): string {
|
||||
const raw = Array.isArray(value) ? Number(value[0]) : Number(value);
|
||||
return Number.isFinite(raw) ? raw.toFixed(2) : "0.00";
|
||||
function CenteredShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen grid place-items-center text-center p-4 text-[#d6e7ff]">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const auth = useAuth();
|
||||
const dashboardQuery = useQuery({
|
||||
queryKey: ["dashboard"],
|
||||
queryFn: getDashboard,
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 120_000,
|
||||
enabled: auth.authenticated || !auth.enabled,
|
||||
});
|
||||
|
||||
const deferredRankings = useDeferredValue(
|
||||
dashboardQuery.data?.rankings ?? [],
|
||||
);
|
||||
|
||||
const chartHistory =
|
||||
dashboardQuery.data?.history.slice(-120).map((point) => ({
|
||||
date: point.date,
|
||||
actual: point.revenue,
|
||||
forecast: null as number | null,
|
||||
lower: null as number | null,
|
||||
upper: null as number | null,
|
||||
})) ?? [];
|
||||
const chartForecast =
|
||||
dashboardQuery.data?.forecasts.slice(0, 45).map((point) => ({
|
||||
date: point.date,
|
||||
actual: null as number | null,
|
||||
forecast: point.predicted_revenue,
|
||||
lower: point.lower_bound,
|
||||
upper: point.upper_bound,
|
||||
})) ?? [];
|
||||
const trendData = [...chartHistory, ...chartForecast];
|
||||
|
||||
const refreshData = () => {
|
||||
tracer.startActiveSpan("frontend.refresh_click", async (span) => {
|
||||
try {
|
||||
startTransition(() => {
|
||||
void dashboardQuery.refetch();
|
||||
});
|
||||
span.setStatus({ code: SpanStatusCode.OK });
|
||||
} catch (error) {
|
||||
span.recordException(error as Error);
|
||||
span.setStatus({
|
||||
code: SpanStatusCode.ERROR,
|
||||
message: "Failed to refresh dashboard data.",
|
||||
});
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (auth.loading) {
|
||||
return <div className="loading-shell">Initializing OIDC session...</div>;
|
||||
return <CenteredShell>Initializing OIDC session…</CenteredShell>;
|
||||
}
|
||||
|
||||
if (auth.error) {
|
||||
return (
|
||||
<div className="loading-shell">
|
||||
Authentication setup error.
|
||||
<br />
|
||||
{auth.error}
|
||||
</div>
|
||||
);
|
||||
return <CenteredShell>Authentication error: {auth.error}</CenteredShell>;
|
||||
}
|
||||
|
||||
if (auth.enabled && !auth.authenticated) {
|
||||
return (
|
||||
<div className="loading-shell">
|
||||
Authentication required.
|
||||
<br />
|
||||
<button
|
||||
className="refresh-button"
|
||||
onClick={() => void auth.login()}
|
||||
type="button"
|
||||
>
|
||||
Sign In with OIDC
|
||||
</button>
|
||||
</div>
|
||||
<CenteredShell>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<p className="m-0 text-lg">Authentication required.</p>
|
||||
<button className="btn-primary" onClick={() => void auth.login()} type="button">
|
||||
Sign In with OIDC
|
||||
</button>
|
||||
</div>
|
||||
</CenteredShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (dashboardQuery.isLoading) {
|
||||
return (
|
||||
<div className="loading-shell">
|
||||
Loading telemetry-enabled BI dashboard...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (dashboardQuery.error || !dashboardQuery.data) {
|
||||
return (
|
||||
<div className="loading-shell">
|
||||
Dashboard could not load.
|
||||
<br />
|
||||
{(dashboardQuery.error as Error | undefined)?.message ??
|
||||
"No response from backend."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { kpis, recommendations, telemetry } = dashboardQuery.data;
|
||||
const topScore = deferredRankings[0]?.score ?? 0;
|
||||
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<div className="radial-glow" />
|
||||
<header className="dashboard-header">
|
||||
<div>
|
||||
<p className="eyebrow">Business Intelligence Command Center</p>
|
||||
<h1>Warehouse Forecasting and Ranking Dashboard</h1>
|
||||
<p className="subtitle">
|
||||
Data sources: <strong>WorldWideImporters</strong> +{" "}
|
||||
<strong>AdventureWorks2022DWH</strong> (read-only) with
|
||||
OpenTelemetry traces from browser to SQL.
|
||||
</p>
|
||||
<p className="trace-id">
|
||||
Last backend trace:{" "}
|
||||
<code>{telemetry.backendTraceId ?? "missing-trace-id-header"}</code>
|
||||
</p>
|
||||
<div className="flex min-h-screen">
|
||||
{/* Sidebar */}
|
||||
<nav className="
|
||||
w-[220px] max-[980px]:w-[180px]
|
||||
shrink-0
|
||||
bg-[rgba(8,16,28,0.92)]
|
||||
border-r border-[rgba(186,212,255,0.22)]
|
||||
flex flex-col
|
||||
py-5 px-3
|
||||
sticky top-0 h-screen overflow-y-auto
|
||||
max-sm:w-full max-sm:h-auto max-sm:static
|
||||
max-sm:flex-row max-sm:flex-wrap max-sm:gap-2 max-sm:p-3
|
||||
">
|
||||
<div className="flex items-center gap-2 font-bold text-[0.95rem] tracking-tight mb-6 px-[0.4rem] max-sm:mb-0">
|
||||
<span className="text-[#57d4ff] text-[0.7rem]">●</span>
|
||||
<span>OTel BI Platform</span>
|
||||
</div>
|
||||
<div className="auth-actions">
|
||||
<p className="subtitle">
|
||||
User: <strong>{auth.subject ?? "unknown"}</strong>
|
||||
</p>
|
||||
<div className="header-actions">
|
||||
<button
|
||||
className="refresh-button"
|
||||
onClick={refreshData}
|
||||
type="button"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
{auth.enabled ? (
|
||||
<button
|
||||
className="logout-button"
|
||||
onClick={() => void auth.logout()}
|
||||
type="button"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<div className="mb-5">
|
||||
<div className="text-[0.65rem] uppercase tracking-[0.12em] text-[rgba(233,244,255,0.7)] px-[0.4rem] mb-1">
|
||||
AdventureWorks DW
|
||||
</div>
|
||||
<NavItem to="/aw/sales" label="Sales & Forecast" />
|
||||
<NavItem to="/aw/reps" label="Rep Scores" />
|
||||
<NavItem to="/aw/products" label="Product Demand" />
|
||||
<NavItem to="/aw/anomalies" label="Anomaly Detection" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="kpi-grid">
|
||||
<article className="kpi-card">
|
||||
<p>Total Revenue</p>
|
||||
<h2>{money.format(kpis.total_revenue)}</h2>
|
||||
</article>
|
||||
<article className="kpi-card">
|
||||
<p>Gross Margin</p>
|
||||
<h2>{kpis.gross_margin_pct.toFixed(2)}%</h2>
|
||||
</article>
|
||||
<article className="kpi-card">
|
||||
<p>Avg Order Value</p>
|
||||
<h2>{money.format(kpis.avg_order_value)}</h2>
|
||||
</article>
|
||||
<article className="kpi-card">
|
||||
<p>Total Quantity</p>
|
||||
<h2>
|
||||
{kpis.total_quantity.toLocaleString("en-US", {
|
||||
maximumFractionDigits: 0,
|
||||
})}
|
||||
</h2>
|
||||
</article>
|
||||
</section>
|
||||
<div className="mb-5">
|
||||
<div className="text-[0.65rem] uppercase tracking-[0.12em] text-[rgba(233,244,255,0.7)] px-[0.4rem] mb-1">
|
||||
WideWorldImporters DW
|
||||
</div>
|
||||
<NavItem to="/wwi/stock" label="Stock & Reorder" />
|
||||
<NavItem to="/wwi/suppliers" label="Supplier Scores" />
|
||||
<NavItem to="/wwi/whatif" label="What-if Scenarios" />
|
||||
<NavItem to="/wwi/events" label="Business Events" />
|
||||
</div>
|
||||
|
||||
<section className="panel-grid">
|
||||
<article className="panel wide">
|
||||
<div className="panel-title-row">
|
||||
<h3>Revenue Trend + Forecast</h3>
|
||||
<span>{trendData.length} points</span>
|
||||
<div className="mb-5">
|
||||
<div className="text-[0.65rem] uppercase tracking-[0.12em] text-[rgba(233,244,255,0.7)] px-[0.4rem] mb-1">
|
||||
Platform
|
||||
</div>
|
||||
<div className="chart-wrap">
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<LineChart data={trendData}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="4 4"
|
||||
stroke="rgba(255,255,255,0.08)"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={formatCompactDate}
|
||||
stroke="rgba(255,255,255,0.65)"
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(value) => money.format(value)}
|
||||
stroke="rgba(255,255,255,0.65)"
|
||||
/>
|
||||
<Tooltip
|
||||
labelFormatter={(label) =>
|
||||
new Date(label).toLocaleDateString("en-US")
|
||||
}
|
||||
formatter={formatTooltipMoney}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="upper"
|
||||
stroke="none"
|
||||
fill="rgba(90, 201, 255, 0.1)"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="lower"
|
||||
stroke="none"
|
||||
fill="rgba(15, 20, 31, 0.9)"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="actual"
|
||||
stroke="#f9de70"
|
||||
strokeWidth={2.5}
|
||||
dot={false}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="forecast"
|
||||
stroke="#57d4ff"
|
||||
strokeWidth={2.5}
|
||||
strokeDasharray="8 5"
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</article>
|
||||
<NavItem to="/ops/jobs" label="Operations" />
|
||||
<NavItem to="/ops/audit" label="Audit Log" />
|
||||
<NavItem to="/ops/exports" label="Export History" />
|
||||
</div>
|
||||
|
||||
<article className="panel">
|
||||
<div className="panel-title-row">
|
||||
<h3>Top Product Score</h3>
|
||||
<span>Weighted ranking index</span>
|
||||
</div>
|
||||
<div className="score-wrap">
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<AreaChart
|
||||
data={[
|
||||
{ label: "baseline", value: 0 },
|
||||
{ label: "current", value: topScore },
|
||||
]}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="rgba(255,255,255,0.08)"
|
||||
/>
|
||||
<XAxis dataKey="label" stroke="rgba(255,255,255,0.65)" />
|
||||
<YAxis stroke="rgba(255,255,255,0.65)" />
|
||||
<Tooltip formatter={formatTooltipNumber} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="#8ef2c7"
|
||||
fill="rgba(142, 242, 199, 0.28)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
<p className="score-caption">
|
||||
Current leader score <strong>{topScore.toFixed(2)}</strong> / 100
|
||||
<div className="mt-auto pt-4 border-t border-[rgba(186,212,255,0.22)] max-sm:mt-0 max-sm:pt-0 max-sm:border-t-0">
|
||||
{auth.subject && (
|
||||
<p className="text-[0.78rem] text-[rgba(233,244,255,0.7)] m-0 mb-2 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{auth.subject}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
{auth.enabled && (
|
||||
<button className="btn-ghost" onClick={() => void auth.logout()} type="button">
|
||||
Sign Out
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<article className="panel wide">
|
||||
<div className="panel-title-row">
|
||||
<h3>Product Rankings</h3>
|
||||
<span>Top {deferredRankings.length}</span>
|
||||
</div>
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Rank</th>
|
||||
<th>Product</th>
|
||||
<th>Category</th>
|
||||
<th>Revenue</th>
|
||||
<th>Margin</th>
|
||||
<th>Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{deferredRankings.map((item) => (
|
||||
<tr key={`${item.rank}-${item.product_id}`}>
|
||||
<td>{item.rank}</td>
|
||||
<td>{item.product_name}</td>
|
||||
<td>{item.category}</td>
|
||||
<td>{money.format(item.revenue)}</td>
|
||||
<td>{item.margin_pct.toFixed(2)}%</td>
|
||||
<td>{item.score.toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
<Routes>
|
||||
<Route index element={<Navigate to="/aw/sales" replace />} />
|
||||
|
||||
<article className="panel">
|
||||
<div className="panel-title-row">
|
||||
<h3>Recommendations</h3>
|
||||
<span>Action queue</span>
|
||||
</div>
|
||||
<ul className="recommendations-list">
|
||||
{recommendations.map((item, index) => (
|
||||
<li key={`${item.title}-${index}`}>
|
||||
<span className={`priority ${item.priority}`}>
|
||||
{item.priority}
|
||||
</span>
|
||||
<h4>{item.title}</h4>
|
||||
<p>{item.summary}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
<Route path="/aw/sales" element={<SalesDashboard />} />
|
||||
<Route path="/aw/reps" element={<RepScores />} />
|
||||
<Route path="/aw/products" element={<ProductDemand />} />
|
||||
<Route path="/aw/anomalies" element={<AnomalyDetection />} />
|
||||
|
||||
<Route path="/wwi/stock" element={<StockDashboard />} />
|
||||
<Route path="/wwi/suppliers" element={<SupplierScores />} />
|
||||
<Route path="/wwi/whatif" element={<WhatIf />} />
|
||||
<Route path="/wwi/events" element={<BusinessEvents />} />
|
||||
|
||||
<Route path="/ops/jobs" element={<OperationsPage />} />
|
||||
<Route path="/ops/audit" element={<AuditPage />} />
|
||||
<Route path="/ops/exports" element={<ExportsPage />} />
|
||||
|
||||
<Route path="*" element={<Navigate to="/aw/sales" replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user