From 03b9196875c73f8c29adfb27ab13e9f472d45228 Mon Sep 17 00:00:00 2001 From: akim136 <27785257+akim136@users.noreply.github.com> Date: Wed, 10 Jun 2026 07:20:53 -0400 Subject: [PATCH 1/3] feat(web): reports/dashboards composer UI (composer Part B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A /dashboards surface to compose a page of widgets from any table. - Routes: /d (list + create), /d/[dashboardId] (render). Sidebar "Dashboards" nav. - DashboardRenderer (add/edit/remove/reorder widgets → PATCH config + refresh); DashboardWidget (KPI number · line · bar · small table); WidgetBuilder modal (type → table → measure/metric → group-by → date bucket → title); BarMini (recharts), reuses MetricChart. Per-widget data computed server-side (computeWidgets) so the API secret stays off the client; client mutates via BFF. - A demo "Sales pipeline" dashboard seeded over the CRM (KPIs + by-stage bar + amount-by-month line + deals table). - Review hardening (no high/med findings): aggregate filter now bounded (shared assertFilterBounds), widget filter field refs validated. 36 tests pass; apps/web builds; typecheck clean. Stacked on #2. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/app/d/[dashboardId]/page.tsx | 21 ++++ apps/web/app/d/page.tsx | 38 ++++++ apps/web/components/BarMini.tsx | 16 +++ apps/web/components/DashboardRenderer.tsx | 83 +++++++++++++ apps/web/components/DashboardWidget.tsx | 83 +++++++++++++ apps/web/components/NewDashboardButton.tsx | 27 +++++ apps/web/components/Sidebar.tsx | 22 ++++ apps/web/components/WidgetBuilder.tsx | 134 +++++++++++++++++++++ apps/web/lib/client.ts | 12 +- apps/web/lib/dashboard.test.ts | 1 + apps/web/lib/server/gridApi.ts | 44 ++++++- apps/web/lib/types.ts | 35 ++++++ packages/api/src/dashboards.ts | 1 + packages/api/src/index.ts | 30 +++-- scripts/seed.mjs | 14 +++ 15 files changed, 547 insertions(+), 14 deletions(-) create mode 100644 apps/web/app/d/[dashboardId]/page.tsx create mode 100644 apps/web/app/d/page.tsx create mode 100644 apps/web/components/BarMini.tsx create mode 100644 apps/web/components/DashboardRenderer.tsx create mode 100644 apps/web/components/DashboardWidget.tsx create mode 100644 apps/web/components/NewDashboardButton.tsx create mode 100644 apps/web/components/WidgetBuilder.tsx diff --git a/apps/web/app/d/[dashboardId]/page.tsx b/apps/web/app/d/[dashboardId]/page.tsx new file mode 100644 index 0000000..ca1a466 --- /dev/null +++ b/apps/web/app/d/[dashboardId]/page.tsx @@ -0,0 +1,21 @@ +import { notFound } from "next/navigation"; +import { DashboardRenderer } from "@/components/DashboardRenderer"; +import { computeWidgets, getMeta } from "@/lib/server/gridApi"; + +export const dynamic = "force-dynamic"; + +export default async function DashboardPage({ params }: { params: Promise<{ dashboardId: string }> }) { + const { dashboardId } = await params; + const meta = await getMeta(); + const dashboard = (meta.dashboards ?? []).find((d) => d.dashboardId === dashboardId); + if (!dashboard) notFound(); + + // Every widget's data is computed server-side (the API secret stays on the server). + const data = await computeWidgets(dashboard.config.widgets ?? []); + + return ( +
+ +
+ ); +} diff --git a/apps/web/app/d/page.tsx b/apps/web/app/d/page.tsx new file mode 100644 index 0000000..f5ca8ff --- /dev/null +++ b/apps/web/app/d/page.tsx @@ -0,0 +1,38 @@ +import Link from "next/link"; +import { NewDashboardButton } from "@/components/NewDashboardButton"; +import { getMeta } from "@/lib/server/gridApi"; + +export const dynamic = "force-dynamic"; + +/** Dashboards index — the workspace's report pages. */ +export default async function DashboardsPage() { + const meta = await getMeta(); + const dashboards = (meta.dashboards ?? []).filter((d) => !d.isHidden); + + return ( +
+
+

Dashboards

+ +
+ + {dashboards.length === 0 ? ( +

No dashboards yet. Create one to compose KPIs, charts, and reports from any table.

+ ) : ( + + )} +
+ ); +} diff --git a/apps/web/components/BarMini.tsx b/apps/web/components/BarMini.tsx new file mode 100644 index 0000000..8e077db --- /dev/null +++ b/apps/web/components/BarMini.tsx @@ -0,0 +1,16 @@ +"use client"; +import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; + +/** A small bar chart for a dashboard widget. Loaded client-only (recharts measures the DOM). */ +export function BarMini({ data }: { data: Array<{ label: string; value: number }> }) { + return ( + + + + + + + + + ); +} diff --git a/apps/web/components/DashboardRenderer.tsx b/apps/web/components/DashboardRenderer.tsx new file mode 100644 index 0000000..883e34b --- /dev/null +++ b/apps/web/components/DashboardRenderer.tsx @@ -0,0 +1,83 @@ +"use client"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { updateDashboard } from "@/lib/client"; +import type { DashboardMeta, Meta, Widget } from "@/lib/types"; +import { DashboardWidget, type WidgetResult } from "./DashboardWidget"; +import { WidgetBuilder } from "./WidgetBuilder"; + +/** A dashboard: a grid of widgets you can add/edit/remove/reorder. Each edit + * PATCHes the dashboard config, then refreshes so the server recomputes widget data. */ +export function DashboardRenderer({ dashboard, meta, data }: { dashboard: DashboardMeta; meta: Meta; data: Record }) { + const router = useRouter(); + const [editing, setEditing] = useState(null); + const [busy, setBusy] = useState(false); + const widgets = dashboard.config.widgets ?? []; + + async function persist(next: Widget[]) { + setBusy(true); + try { + await updateDashboard(dashboard.dashboardId, { config: { widgets: next } }); + router.refresh(); + } catch (e) { + alert(`Save failed: ${e instanceof Error ? e.message : String(e)}`); + } finally { + setBusy(false); + } + } + + function onSave(w: Widget) { + const exists = widgets.some((x) => x.widgetId === w.widgetId); + setEditing(null); + void persist(exists ? widgets.map((x) => (x.widgetId === w.widgetId ? w : x)) : [...widgets, w]); + } + function remove(id: string) { + if (window.confirm("Remove this widget?")) void persist(widgets.filter((w) => w.widgetId !== id)); + } + function move(i: number, dir: -1 | 1) { + const j = i + dir; + if (j < 0 || j >= widgets.length) return; + const next = [...widgets]; + [next[i], next[j]] = [next[j]!, next[i]!]; + void persist(next); + } + + return ( +
+
+

{dashboard.name}

+
+ {busy ? Saving… : null} + +
+
+ + {widgets.length === 0 ? ( +
+ No widgets yet — add a KPI, chart, or report from any table. +
+ ) : ( +
+ {widgets.map((w, i) => ( +
+
+ {w.title || "(untitled)"} + + + + + + +
+ +
+ ))} +
+ )} + + {editing ? setEditing(null)} /> : null} +
+ ); +} diff --git a/apps/web/components/DashboardWidget.tsx b/apps/web/components/DashboardWidget.tsx new file mode 100644 index 0000000..ac604ee --- /dev/null +++ b/apps/web/components/DashboardWidget.tsx @@ -0,0 +1,83 @@ +"use client"; +import dynamic from "next/dynamic"; +import type { AggregateRow, Meta, RecordEnvelope, Widget } from "@/lib/types"; + +// recharts is client-only — load the chart pieces without SSR. +const MetricChart = dynamic(() => import("./MetricChart").then((m) => m.MetricChart), { ssr: false }); +const BarMini = dynamic(() => import("./BarMini").then((m) => m.BarMini), { ssr: false }); + +export interface WidgetResult { + rows?: AggregateRow[]; + records?: RecordEnvelope[]; + error?: string; +} + +function fmtNum(v: number): string { + if (!Number.isFinite(v)) return "—"; + if (Math.abs(v) >= 1000) return v.toLocaleString(); + return Number.isInteger(v) ? String(v) : v.toFixed(2); +} +function label(s: string | null): string { + if (!s) return "—"; + return s.length > 14 ? `${s.slice(0, 13)}…` : s; +} +function cellText(v: unknown): string { + if (v == null) return ""; + if (Array.isArray(v)) return v.map((x) => (x && typeof x === "object" && "name" in x ? String((x as { name: unknown }).name) : String(x))).join(", "); + if (typeof v === "object") return "name" in (v as object) ? String((v as { name: unknown }).name) : JSON.stringify(v); + return String(v); +} + +function Empty({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +/** Render one widget from its server-computed result. Read-only. */ +export function DashboardWidget({ widget, meta, result }: { widget: Widget; meta: Meta; result?: WidgetResult }) { + if (result?.error) return Couldn’t load: {result.error}; + + if (widget.type === "kpi") { + return
{fmtNum(result?.rows?.[0]?.value ?? 0)}
; + } + + if (widget.type === "line") { + const data = (result?.rows ?? []).map((r) => ({ date: label(r.groupValue), v: r.value })); + return data.length ?
: No data; + } + + if (widget.type === "bar") { + const data = (result?.rows ?? []).map((r) => ({ label: label(r.groupValue), value: r.value })); + return data.length ?
: No data; + } + + // table + const records = result?.records ?? []; + const fieldById = new Map(meta.fields.map((f) => [f.fieldId, f])); + const cols = + widget.fieldIds && widget.fieldIds.length + ? widget.fieldIds + : meta.fields.filter((f) => f.tableId === widget.tableId).slice(0, 4).map((f) => f.fieldId); + if (!records.length) return No rows; + return ( +
+ + + + {cols.map((c) => ( + + ))} + + + + {records.slice(0, 8).map((rec) => ( + + {cols.map((c) => ( + + ))} + + ))} + +
{fieldById.get(c)?.name ?? c}
{cellText(rec.fields[c])}
+
+ ); +} diff --git a/apps/web/components/NewDashboardButton.tsx b/apps/web/components/NewDashboardButton.tsx new file mode 100644 index 0000000..056e052 --- /dev/null +++ b/apps/web/components/NewDashboardButton.tsx @@ -0,0 +1,27 @@ +"use client"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { createDashboard } from "@/lib/client"; + +/** Prompt for a name, create a dashboard, navigate to it. */ +export function NewDashboardButton() { + const router = useRouter(); + const [busy, setBusy] = useState(false); + async function create() { + const name = window.prompt("Dashboard name?", "Untitled dashboard"); + if (!name) return; + setBusy(true); + try { + const d = await createDashboard({ name }); + router.push(`/d/${d.dashboardId}`); + } catch (e) { + alert(`Create failed: ${e instanceof Error ? e.message : String(e)}`); + setBusy(false); + } + } + return ( + + ); +} diff --git a/apps/web/components/Sidebar.tsx b/apps/web/components/Sidebar.tsx index 7269883..daff954 100644 --- a/apps/web/components/Sidebar.tsx +++ b/apps/web/components/Sidebar.tsx @@ -33,6 +33,17 @@ export function Sidebar({ tables }: { tables: SidebarTable[] }) { »