@nubitio/dashboard 0.5.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,527 @@
1
+ import { AppToolbar, Badge, EmptyState, StatCard } from "@nubitio/ui";
2
+ import { useCallback, useEffect, useState } from "react";
3
+ import { getCoreApiBaseUrl, getCoreCurrency, getCoreLocale } from "@nubitio/core";
4
+ import { jsx, jsxs } from "react/jsx-runtime";
5
+ import { Link } from "react-router-dom";
6
+ //#region packages/dashboard/dashboard/defineDashboard.ts
7
+ const dashboardCache = /* @__PURE__ */ new Map();
8
+ function cacheKey(config) {
9
+ try {
10
+ return JSON.stringify({
11
+ id: config.id,
12
+ title: config.title,
13
+ dataUrl: config.dataUrl,
14
+ sections: config.sections
15
+ });
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+ /**
21
+ * Declarative dashboard definition — mirrors `defineResource` ergonomics.
22
+ *
23
+ * ```ts
24
+ * const overview = defineDashboard({
25
+ * title: 'Dashboard',
26
+ * dataUrl: '/api/dashboard/overview',
27
+ * sections: [
28
+ * { layout: 'stats', widgets: [statWidget({ id: 'revenue', title: 'Revenue', valuePath: 'stats.revenue', format: 'currency' })] },
29
+ * ],
30
+ * });
31
+ *
32
+ * export const DashboardRoute = () => <DashboardPage config={overview} />;
33
+ * ```
34
+ */
35
+ function defineDashboard(config) {
36
+ const key = cacheKey(config);
37
+ if (key !== null && dashboardCache.has(key)) return dashboardCache.get(key);
38
+ const dashboard = {
39
+ id: "dashboard",
40
+ ...config
41
+ };
42
+ if (key !== null) dashboardCache.set(key, dashboard);
43
+ return dashboard;
44
+ }
45
+ //#endregion
46
+ //#region packages/dashboard/dashboard/useDashboardData.ts
47
+ function resolveDataUrl(dataUrl) {
48
+ if (/^https?:\/\//i.test(dataUrl)) return dataUrl;
49
+ return `${getCoreApiBaseUrl().replace(/\/+$/, "")}${dataUrl.startsWith("/") ? dataUrl : `/${dataUrl}`}`;
50
+ }
51
+ const EMPTY = {
52
+ data: {},
53
+ loading: false,
54
+ error: null
55
+ };
56
+ function useDashboardData(dataUrl, refreshInterval) {
57
+ const [state, setState] = useState({
58
+ ...EMPTY,
59
+ loading: !!dataUrl
60
+ });
61
+ const refetch = useCallback(async () => {
62
+ if (!dataUrl) {
63
+ setState(EMPTY);
64
+ return;
65
+ }
66
+ setState((prev) => ({
67
+ ...prev,
68
+ loading: true,
69
+ error: null
70
+ }));
71
+ try {
72
+ const response = await fetch(resolveDataUrl(dataUrl), { credentials: "include" });
73
+ if (!response.ok) throw new Error(`Dashboard request failed (${response.status})`);
74
+ setState({
75
+ data: await response.json(),
76
+ loading: false,
77
+ error: null
78
+ });
79
+ } catch (error) {
80
+ setState({
81
+ data: {},
82
+ loading: false,
83
+ error: error instanceof Error ? error.message : "Failed to load dashboard"
84
+ });
85
+ }
86
+ }, [dataUrl]);
87
+ useEffect(() => {
88
+ refetch();
89
+ }, [refetch]);
90
+ useEffect(() => {
91
+ if (!refreshInterval || !dataUrl) return void 0;
92
+ const timer = window.setInterval(() => {
93
+ refetch();
94
+ }, refreshInterval);
95
+ return () => window.clearInterval(timer);
96
+ }, [
97
+ dataUrl,
98
+ refreshInterval,
99
+ refetch
100
+ ]);
101
+ return {
102
+ ...state,
103
+ refetch
104
+ };
105
+ }
106
+ //#endregion
107
+ //#region packages/dashboard/dashboard/formatValue.ts
108
+ function formatDashboardValue(value, format = "text") {
109
+ if (value === null || value === void 0) return "—";
110
+ if (format === "text") return String(value);
111
+ if (format === "date" || format === "datetime") {
112
+ const date = value instanceof Date ? value : new Date(String(value));
113
+ if (Number.isNaN(date.getTime())) return String(value);
114
+ return new Intl.DateTimeFormat(getCoreLocale(), {
115
+ dateStyle: format === "date" ? "medium" : void 0,
116
+ timeStyle: format === "datetime" ? "short" : void 0
117
+ }).format(date);
118
+ }
119
+ const numeric = typeof value === "number" ? value : Number(value);
120
+ if (!Number.isFinite(numeric)) return String(value);
121
+ const baseOptions = {
122
+ minimumFractionDigits: format === "currency" ? 2 : void 0,
123
+ maximumFractionDigits: format === "currency" ? 2 : void 0
124
+ };
125
+ if (format === "currency") {
126
+ const currency = getCoreCurrency();
127
+ if (!currency) return new Intl.NumberFormat(getCoreLocale(), baseOptions).format(numeric);
128
+ return new Intl.NumberFormat(getCoreLocale(), {
129
+ ...baseOptions,
130
+ style: "currency",
131
+ currency,
132
+ currencyDisplay: "narrowSymbol"
133
+ }).format(numeric);
134
+ }
135
+ if (format === "percent") {
136
+ const normalized = Math.abs(numeric) <= 1 ? numeric : numeric / 100;
137
+ return new Intl.NumberFormat(getCoreLocale(), {
138
+ style: "percent",
139
+ minimumFractionDigits: 1,
140
+ maximumFractionDigits: 1
141
+ }).format(normalized);
142
+ }
143
+ return new Intl.NumberFormat(getCoreLocale()).format(numeric);
144
+ }
145
+ //#endregion
146
+ //#region packages/dashboard/dashboard/resolvePath.ts
147
+ function resolvePath(source, path) {
148
+ if (path === void 0 || path === "") return source;
149
+ if (source === null || source === void 0) return void 0;
150
+ return path.split(".").reduce((current, segment) => {
151
+ if (current === null || current === void 0) return void 0;
152
+ if (typeof current !== "object") return void 0;
153
+ return current[segment];
154
+ }, source);
155
+ }
156
+ function resolveArray(source, path) {
157
+ const value = resolvePath(source, path);
158
+ if (!Array.isArray(value)) return [];
159
+ return value.filter((item) => item !== null && typeof item === "object");
160
+ }
161
+ //#endregion
162
+ //#region packages/dashboard/dashboard/widgets/BarChartWidgetView.tsx
163
+ function toNumber$1(value) {
164
+ const numeric = typeof value === "number" ? value : Number(value);
165
+ return Number.isFinite(numeric) ? numeric : 0;
166
+ }
167
+ function BarChartWidgetView({ widget, data, loading }) {
168
+ const rows = resolveArray(data, widget.dataPath);
169
+ const max = rows.reduce((peak, row) => Math.max(peak, toNumber$1(row[widget.yKey])), 0) || 1;
170
+ const height = widget.height ?? 200;
171
+ return /* @__PURE__ */ jsx(StatCard, {
172
+ title: widget.title,
173
+ headerExtra: widget.subtitle ? /* @__PURE__ */ jsx("span", {
174
+ className: "nb-dashboard-widget__subtitle",
175
+ children: widget.subtitle
176
+ }) : void 0,
177
+ menuVisible: widget.menuVisible,
178
+ isLoading: loading,
179
+ className: "nb-dashboard-chart-card",
180
+ children: rows.length === 0 ? /* @__PURE__ */ jsx("div", {
181
+ className: "nb-dashboard-chart-empty",
182
+ children: "No data"
183
+ }) : /* @__PURE__ */ jsx("div", {
184
+ className: "nb-dashboard-bar-chart",
185
+ style: { height },
186
+ role: "img",
187
+ "aria-label": widget.title,
188
+ children: rows.map((row, index) => {
189
+ const label = String(row[widget.xKey] ?? "");
190
+ const value = toNumber$1(row[widget.yKey]);
191
+ return /* @__PURE__ */ jsxs("div", {
192
+ className: "nb-dashboard-bar-chart__item",
193
+ children: [/* @__PURE__ */ jsx("div", {
194
+ className: "nb-dashboard-bar-chart__bar-wrap",
195
+ children: /* @__PURE__ */ jsx("div", {
196
+ className: "nb-dashboard-bar-chart__bar",
197
+ style: { height: `${Math.max(4, value / max * 100)}%` },
198
+ title: `${label}: ${formatDashboardValue(value, widget.valueFormat)}`
199
+ })
200
+ }), /* @__PURE__ */ jsx("span", {
201
+ className: "nb-dashboard-bar-chart__label",
202
+ children: label
203
+ })]
204
+ }, `${label}-${index}`);
205
+ })
206
+ })
207
+ });
208
+ }
209
+ //#endregion
210
+ //#region packages/dashboard/dashboard/widgets/DonutChartWidgetView.tsx
211
+ const DEFAULT_COLORS = [
212
+ "var(--accent-color)",
213
+ "var(--success-color)",
214
+ "var(--warning-color)",
215
+ "var(--info-color)",
216
+ "#6b4fc8",
217
+ "#c2410c"
218
+ ];
219
+ function toNumber(value) {
220
+ const numeric = typeof value === "number" ? value : Number(value);
221
+ return Number.isFinite(numeric) ? numeric : 0;
222
+ }
223
+ function DonutChartWidgetView({ widget, data, loading }) {
224
+ const rows = resolveArray(data, widget.dataPath);
225
+ const colors = widget.colors ?? DEFAULT_COLORS;
226
+ const total = rows.reduce((sum, row) => sum + toNumber(row[widget.valueKey]), 0);
227
+ const centerValue = widget.centerValuePath !== void 0 ? resolvePath(data, widget.centerValuePath) : total;
228
+ const radius = 38;
229
+ const stroke = 14;
230
+ const circumference = 2 * Math.PI * radius;
231
+ const arcs = rows.reduce((segments, row, index) => {
232
+ const pct = toNumber(row[widget.valueKey]) / total;
233
+ const offset = segments.reduce((sum, segment) => sum + segment.dash, 0);
234
+ segments.push({
235
+ key: `${String(row[widget.labelKey])}-${index}`,
236
+ dash: pct * circumference,
237
+ offset,
238
+ color: colors[index % colors.length]
239
+ });
240
+ return segments;
241
+ }, []);
242
+ return /* @__PURE__ */ jsx(StatCard, {
243
+ title: widget.title,
244
+ headerExtra: widget.subtitle ? /* @__PURE__ */ jsx("span", {
245
+ className: "nb-dashboard-widget__subtitle",
246
+ children: widget.subtitle
247
+ }) : void 0,
248
+ menuVisible: widget.menuVisible,
249
+ isLoading: loading,
250
+ className: "nb-dashboard-chart-card",
251
+ children: rows.length === 0 || total <= 0 ? /* @__PURE__ */ jsx("div", {
252
+ className: "nb-dashboard-chart-empty",
253
+ children: "No data"
254
+ }) : /* @__PURE__ */ jsxs("div", {
255
+ className: "nb-dashboard-donut",
256
+ children: [/* @__PURE__ */ jsxs("div", {
257
+ className: "nb-dashboard-donut__chart",
258
+ role: "img",
259
+ "aria-label": widget.title,
260
+ children: [/* @__PURE__ */ jsxs("svg", {
261
+ viewBox: "0 0 100 100",
262
+ className: "nb-dashboard-donut__svg",
263
+ children: [/* @__PURE__ */ jsx("circle", {
264
+ cx: "50",
265
+ cy: "50",
266
+ r: radius,
267
+ fill: "none",
268
+ stroke: "var(--surface-3)",
269
+ strokeWidth: stroke
270
+ }), arcs.map((arc) => /* @__PURE__ */ jsx("circle", {
271
+ cx: "50",
272
+ cy: "50",
273
+ r: radius,
274
+ fill: "none",
275
+ stroke: arc.color,
276
+ strokeWidth: stroke,
277
+ strokeDasharray: `${arc.dash} ${circumference - arc.dash}`,
278
+ strokeDashoffset: -arc.offset,
279
+ transform: "rotate(-90 50 50)",
280
+ strokeLinecap: "butt"
281
+ }, arc.key))]
282
+ }), /* @__PURE__ */ jsxs("div", {
283
+ className: "nb-dashboard-donut__center",
284
+ children: [widget.centerLabel && /* @__PURE__ */ jsx("span", {
285
+ className: "nb-dashboard-donut__center-label",
286
+ children: widget.centerLabel
287
+ }), /* @__PURE__ */ jsx("span", {
288
+ className: "nb-dashboard-donut__center-value",
289
+ children: formatDashboardValue(centerValue, widget.valueFormat)
290
+ })]
291
+ })]
292
+ }), widget.showLegend !== false && /* @__PURE__ */ jsx("ul", {
293
+ className: "nb-dashboard-donut__legend",
294
+ children: rows.map((row, index) => {
295
+ const label = String(row[widget.labelKey] ?? "");
296
+ const value = toNumber(row[widget.valueKey]);
297
+ const pct = total > 0 ? value / total * 100 : 0;
298
+ return /* @__PURE__ */ jsxs("li", {
299
+ className: "nb-dashboard-donut__legend-item",
300
+ children: [
301
+ /* @__PURE__ */ jsx("span", {
302
+ className: "nb-dashboard-donut__legend-swatch",
303
+ style: { background: colors[index % colors.length] }
304
+ }),
305
+ /* @__PURE__ */ jsx("span", {
306
+ className: "nb-dashboard-donut__legend-label",
307
+ children: label
308
+ }),
309
+ /* @__PURE__ */ jsxs("span", {
310
+ className: "nb-dashboard-donut__legend-value",
311
+ children: [pct.toFixed(0), "%"]
312
+ })
313
+ ]
314
+ }, `${label}-${index}`);
315
+ })
316
+ })]
317
+ })
318
+ });
319
+ }
320
+ //#endregion
321
+ //#region packages/dashboard/dashboard/widgets/StatWidgetView.tsx
322
+ function resolveTrend(widget, data) {
323
+ if (!widget.trend) return { value: null };
324
+ const raw = widget.trend.valuePath !== void 0 ? resolvePath(data, widget.trend.valuePath) : widget.trend.value;
325
+ const numeric = typeof raw === "number" ? raw : Number(raw);
326
+ return {
327
+ value: Number.isFinite(numeric) ? numeric : null,
328
+ label: widget.trend.label,
329
+ invert: widget.trend.invertColors
330
+ };
331
+ }
332
+ function StatWidgetView({ widget, data, loading }) {
333
+ const valueText = formatDashboardValue(widget.valuePath !== void 0 ? resolvePath(data, widget.valuePath) : widget.value, widget.format ?? "text");
334
+ const trend = resolveTrend(widget, data);
335
+ const trendDirection = trend.value === null || trend.value === 0 ? "flat" : trend.value > 0 ? "up" : "down";
336
+ const trendClass = trendDirection === "flat" ? "nb-stat-card__delta--flat" : trendDirection === "up" ? trend.invert ? "nb-stat-card__delta--down" : "nb-stat-card__delta--up" : trend.invert ? "nb-stat-card__delta--up" : "nb-stat-card__delta--down";
337
+ const trendArrow = trendDirection === "up" ? "▲" : trendDirection === "down" ? "▼" : "—";
338
+ const trendText = trend.value === null ? null : `${trendArrow} ${formatDashboardValue(Math.abs(trend.value), "percent")}${trend.label ? ` ${trend.label}` : ""}`;
339
+ return /* @__PURE__ */ jsx(StatCard, {
340
+ title: widget.title,
341
+ menuVisible: widget.menuVisible,
342
+ isLoading: loading,
343
+ className: `nb-dashboard-stat nb-dashboard-stat--${widget.iconTone ?? "accent"}`,
344
+ children: /* @__PURE__ */ jsxs("div", {
345
+ className: "nb-dashboard-stat__content",
346
+ children: [widget.icon && /* @__PURE__ */ jsx("span", {
347
+ className: `nb-dashboard-stat__icon nb-dashboard-stat__icon--${widget.iconTone ?? "accent"}`,
348
+ children: /* @__PURE__ */ jsx("i", {
349
+ className: `ph ${widget.icon}`,
350
+ "aria-hidden": "true"
351
+ })
352
+ }), /* @__PURE__ */ jsxs("div", {
353
+ className: "nb-dashboard-stat__metrics",
354
+ children: [/* @__PURE__ */ jsx("span", {
355
+ className: "nb-stat-card__value nb-stat-card__value--2xl",
356
+ children: valueText
357
+ }), trendText && /* @__PURE__ */ jsx("span", {
358
+ className: `nb-stat-card__delta ${trendClass}`,
359
+ children: trendText
360
+ })]
361
+ })]
362
+ })
363
+ });
364
+ }
365
+ //#endregion
366
+ //#region packages/dashboard/dashboard/widgets/TableWidgetView.tsx
367
+ function renderCell(row, column) {
368
+ const raw = row[column.key];
369
+ if (column.badge) {
370
+ const key = raw == null ? "" : String(raw);
371
+ return /* @__PURE__ */ jsx(Badge, {
372
+ variant: column.badge[key] ?? "secondary",
373
+ size: "sm",
374
+ children: column.badgeLabels?.[key] ?? key.replace(/_/g, " ")
375
+ });
376
+ }
377
+ return formatDashboardValue(raw, column.format ?? "text");
378
+ }
379
+ function TableWidgetView({ widget, data, loading }) {
380
+ const rows = resolveArray(data, widget.dataPath);
381
+ return /* @__PURE__ */ jsx(StatCard, {
382
+ title: widget.title,
383
+ headerExtra: /* @__PURE__ */ jsxs("div", {
384
+ className: "nb-dashboard-table__header-extra",
385
+ children: [widget.subtitle && /* @__PURE__ */ jsx("span", {
386
+ className: "nb-dashboard-widget__subtitle",
387
+ children: widget.subtitle
388
+ }), widget.viewAll && /* @__PURE__ */ jsx(Link, {
389
+ to: widget.viewAll.to,
390
+ className: "nb-dashboard-table__view-all",
391
+ children: widget.viewAll.label ?? "View all"
392
+ })]
393
+ }),
394
+ menuVisible: widget.menuVisible,
395
+ isLoading: loading,
396
+ className: "nb-dashboard-table-card",
397
+ children: rows.length === 0 ? /* @__PURE__ */ jsx(EmptyState, {
398
+ size: "sm",
399
+ icon: "ph ph-table",
400
+ title: widget.emptyTitle ?? "No records yet",
401
+ description: widget.emptyDescription
402
+ }) : /* @__PURE__ */ jsx("div", {
403
+ className: "nb-dashboard-table-wrap",
404
+ children: /* @__PURE__ */ jsxs("table", {
405
+ className: "nb-dashboard-table",
406
+ children: [/* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsx("tr", { children: widget.columns.map((column) => /* @__PURE__ */ jsx("th", {
407
+ className: column.align ? `nb-dashboard-table__cell--${column.align}` : void 0,
408
+ children: column.label
409
+ }, column.key)) }) }), /* @__PURE__ */ jsx("tbody", { children: rows.map((row, rowIndex) => /* @__PURE__ */ jsx("tr", { children: widget.columns.map((column) => /* @__PURE__ */ jsx("td", {
410
+ className: column.align ? `nb-dashboard-table__cell--${column.align}` : void 0,
411
+ children: renderCell(row, column)
412
+ }, column.key)) }, rowIndex)) })]
413
+ })
414
+ })
415
+ });
416
+ }
417
+ //#endregion
418
+ //#region packages/dashboard/dashboard/widgets/WidgetRenderer.tsx
419
+ function WidgetRenderer({ widget, data, loading }) {
420
+ switch (widget.type) {
421
+ case "stat": return /* @__PURE__ */ jsx(StatWidgetView, {
422
+ widget,
423
+ data,
424
+ loading
425
+ });
426
+ case "bar-chart": return /* @__PURE__ */ jsx(BarChartWidgetView, {
427
+ widget,
428
+ data,
429
+ loading
430
+ });
431
+ case "donut-chart": return /* @__PURE__ */ jsx(DonutChartWidgetView, {
432
+ widget,
433
+ data,
434
+ loading
435
+ });
436
+ case "table": return /* @__PURE__ */ jsx(TableWidgetView, {
437
+ widget,
438
+ data,
439
+ loading
440
+ });
441
+ default: return null;
442
+ }
443
+ }
444
+ //#endregion
445
+ //#region packages/dashboard/dashboard/DashboardPage.tsx
446
+ function sectionClassName(section) {
447
+ const layout = section.layout ?? "grid";
448
+ if (layout === "grid") return (typeof section.columns === "number" ? `repeat(${section.columns}, minmax(0, 1fr))` : section.columns) ? "nb-dashboard-section nb-dashboard-section--grid" : "nb-dashboard-section nb-dashboard-section--grid";
449
+ return `nb-dashboard-section nb-dashboard-section--${layout}`;
450
+ }
451
+ function sectionStyle(section) {
452
+ if (section.layout !== "grid" && section.layout !== void 0) return void 0;
453
+ const columns = typeof section.columns === "number" ? `repeat(${section.columns}, minmax(0, 1fr))` : section.columns;
454
+ if (!columns) return void 0;
455
+ return { gridTemplateColumns: String(columns) };
456
+ }
457
+ function DashboardSectionView({ section, data, loading }) {
458
+ return /* @__PURE__ */ jsx("section", {
459
+ className: sectionClassName(section),
460
+ style: sectionStyle(section),
461
+ "data-section": section.id,
462
+ children: section.widgets.map((widget) => /* @__PURE__ */ jsx(WidgetRenderer, {
463
+ widget,
464
+ data,
465
+ loading
466
+ }, widget.id))
467
+ });
468
+ }
469
+ function DashboardPage({ config }) {
470
+ const fetched = useDashboardData(config.dataUrl, config.refreshInterval);
471
+ const { data, loading, error, refetch } = config.useData?.() ?? fetched;
472
+ return /* @__PURE__ */ jsxs("div", {
473
+ className: "view-wrapper-scroll nb-dashboard-page",
474
+ children: [/* @__PURE__ */ jsxs(AppToolbar, {
475
+ title: config.title,
476
+ onRefresh: refetch,
477
+ children: [error && /* @__PURE__ */ jsx("div", {
478
+ className: "nb-dashboard-page__error",
479
+ children: error
480
+ }), config.sections.map((section, index) => /* @__PURE__ */ jsx(DashboardSectionView, {
481
+ section,
482
+ data,
483
+ loading
484
+ }, section.id ?? `section-${index}`))]
485
+ }), loading && /* @__PURE__ */ jsx("div", {
486
+ className: "nb-dashboard-page__loading",
487
+ "aria-hidden": "true",
488
+ children: /* @__PURE__ */ jsx("span", { className: "nb-dashboard-page__spinner" })
489
+ })]
490
+ });
491
+ }
492
+ //#endregion
493
+ //#region packages/dashboard/dashboard/widgetBuilders.ts
494
+ function statWidget(config) {
495
+ return {
496
+ type: "stat",
497
+ menuVisible: false,
498
+ ...config
499
+ };
500
+ }
501
+ function barChartWidget(config) {
502
+ return {
503
+ type: "bar-chart",
504
+ menuVisible: false,
505
+ valueFormat: "currency",
506
+ height: 200,
507
+ ...config
508
+ };
509
+ }
510
+ function donutChartWidget(config) {
511
+ return {
512
+ type: "donut-chart",
513
+ menuVisible: false,
514
+ showLegend: true,
515
+ valueFormat: "currency",
516
+ ...config
517
+ };
518
+ }
519
+ function tableWidget(config) {
520
+ return {
521
+ type: "table",
522
+ menuVisible: false,
523
+ ...config
524
+ };
525
+ }
526
+ //#endregion
527
+ export { DashboardPage, barChartWidget, defineDashboard, donutChartWidget, formatDashboardValue, resolveArray, resolvePath, statWidget, tableWidget, useDashboardData };