@zerotal/admin 1.0.0
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/CHANGELOG.md +69 -0
- package/LICENSE +21 -0
- package/README.md +344 -0
- package/package.json +78 -0
- package/src/Cluster.ts +50 -0
- package/src/Panel.ts +288 -0
- package/src/PanelInstance.ts +644 -0
- package/src/Resource.ts +918 -0
- package/src/actions/Action.ts +607 -0
- package/src/actions/ImportRecordsJob.ts +108 -0
- package/src/actions/csv.ts +123 -0
- package/src/actions/index.ts +39 -0
- package/src/actions/render.tsx +181 -0
- package/src/actions/transfer.ts +307 -0
- package/src/actions/xlsx.ts +304 -0
- package/src/auth/AuthLayout.tsx +34 -0
- package/src/auth/index.ts +13 -0
- package/src/auth/pages/ForgotPasswordPage.tsx +87 -0
- package/src/auth/pages/LoginPage.tsx +121 -0
- package/src/auth/pages/ProfilePage.tsx +216 -0
- package/src/auth/pages/ResetPasswordPage.tsx +103 -0
- package/src/auth/pages/VerifyEmailPage.tsx +68 -0
- package/src/auth/register.ts +44 -0
- package/src/authRoles.ts +141 -0
- package/src/commands/MakeAdminResourceCommand.ts +181 -0
- package/src/config.ts +128 -0
- package/src/dashboardLayout.ts +101 -0
- package/src/databaseMedia.ts +148 -0
- package/src/databaseNotifications.ts +169 -0
- package/src/form/Field.ts +928 -0
- package/src/form/ResourceForm.ts +48 -0
- package/src/form/Section.ts +364 -0
- package/src/form/editors.ts +43 -0
- package/src/form/index.ts +59 -0
- package/src/history.ts +151 -0
- package/src/impersonation.ts +126 -0
- package/src/index.ts +380 -0
- package/src/infolist/Entry.ts +537 -0
- package/src/infolist/Section.ts +99 -0
- package/src/infolist/index.ts +38 -0
- package/src/media.ts +297 -0
- package/src/notifications.ts +65 -0
- package/src/pages/AdminPage.ts +100 -0
- package/src/pages/ConsolePage.tsx +324 -0
- package/src/pages/DashboardPage.tsx +264 -0
- package/src/pages/MediaPage.tsx +346 -0
- package/src/pages/NotificationsPage.tsx +155 -0
- package/src/pages/RecordViewPage.tsx +951 -0
- package/src/pages/ResourceFormPage.tsx +1856 -0
- package/src/pages/ResourceListPage.tsx +2552 -0
- package/src/pages/RolesPage.tsx +325 -0
- package/src/pages/SearchPage.tsx +169 -0
- package/src/plugin.ts +283 -0
- package/src/provider/AdminAbilityMiddleware.ts +25 -0
- package/src/provider/AdminGuardMiddleware.ts +29 -0
- package/src/provider/AdminProvider.ts +334 -0
- package/src/relations/RelationManager.ts +114 -0
- package/src/renderHooks.ts +86 -0
- package/src/roles.ts +175 -0
- package/src/savedViews.ts +79 -0
- package/src/support/ability.ts +73 -0
- package/src/support/authorize.ts +105 -0
- package/src/support/countCache.ts +37 -0
- package/src/support/hostPage.ts +30 -0
- package/src/table/Column.ts +353 -0
- package/src/table/Constraint.ts +238 -0
- package/src/table/Filter.ts +275 -0
- package/src/table/Group.ts +73 -0
- package/src/table/Tab.ts +77 -0
- package/src/testing.ts +121 -0
- package/src/theme.ts +70 -0
- package/src/ui/AdminLayout.tsx +355 -0
- package/src/ui/Breadcrumbs.tsx +84 -0
- package/src/ui/environmentIndicator.tsx +63 -0
- package/src/ui/icons.tsx +124 -0
- package/src/widgets/Widget.ts +251 -0
- package/src/widgets/render.tsx +154 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard widgets — stat rows, charts and small tables whose values are
|
|
3
|
+
* computed on the server each time the dashboard renders. A widget is a *data
|
|
4
|
+
* provider*: the dashboard page owns the markup, so widgets stay
|
|
5
|
+
* framework-light and easy to test.
|
|
6
|
+
*
|
|
7
|
+
* Panel.widgets(
|
|
8
|
+
* statsWidget(async () => [
|
|
9
|
+
* stat("Users", await User.count()).icon("users").tone("primary"),
|
|
10
|
+
* stat("Posts", await Post.count()).description("Published").icon("document"),
|
|
11
|
+
* ]).poll("30s"),
|
|
12
|
+
* );
|
|
13
|
+
*
|
|
14
|
+
* A widget that polls re-renders itself on an interval, so an ops dashboard left
|
|
15
|
+
* open on a wall display stays current without anyone reloading it.
|
|
16
|
+
*/
|
|
17
|
+
import type { BadgeTone } from "../table/Column.ts";
|
|
18
|
+
|
|
19
|
+
export type WidgetTone = BadgeTone;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Re-render interval shared by every widget kind.
|
|
23
|
+
*
|
|
24
|
+
* Kept as the caller's own string ("30s", "5s") because that is what the poll
|
|
25
|
+
* directive takes; an unset interval means the widget renders once per page load.
|
|
26
|
+
*/
|
|
27
|
+
abstract class PollableWidget {
|
|
28
|
+
/** @internal */ _poll?: string;
|
|
29
|
+
/** @internal Stable identity, for a persisted dashboard layout. */ _key?: string;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Re-render this widget every `interval` — `"10s"`, `"1m"`. Costs a query per
|
|
33
|
+
* tick per viewer, so reach for it on dashboards that are watched, not on
|
|
34
|
+
* every widget by habit.
|
|
35
|
+
*/
|
|
36
|
+
poll(interval: string): this {
|
|
37
|
+
this._poll = interval;
|
|
38
|
+
return this;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Name this widget, so a saved dashboard layout can refer to it.
|
|
43
|
+
*
|
|
44
|
+
* Worth setting on any widget whose title might change: a layout keyed by
|
|
45
|
+
* title silently resets the moment somebody rewords a heading, whereas an
|
|
46
|
+
* explicit key survives it.
|
|
47
|
+
*/
|
|
48
|
+
key(key: string): this {
|
|
49
|
+
this._key = key;
|
|
50
|
+
return this;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The identity a layout stores. Falls back to the title, then the position. */
|
|
54
|
+
widgetKey(index: number): string {
|
|
55
|
+
if (this._key) return this._key;
|
|
56
|
+
const title = (this as { _title?: string })._title;
|
|
57
|
+
return title
|
|
58
|
+
? title
|
|
59
|
+
.toLowerCase()
|
|
60
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
61
|
+
.replace(/^-|-$/g, "")
|
|
62
|
+
: `widget-${index}`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class Stat {
|
|
67
|
+
/** @internal */ _label: string;
|
|
68
|
+
/** @internal */ _value: string | number;
|
|
69
|
+
/** @internal */ _description?: string;
|
|
70
|
+
/** @internal */ _icon?: string;
|
|
71
|
+
/** @internal */ _tone: WidgetTone = "default";
|
|
72
|
+
|
|
73
|
+
constructor(label: string, value: string | number) {
|
|
74
|
+
this._label = label;
|
|
75
|
+
this._value = value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
description(text: string): this {
|
|
79
|
+
this._description = text;
|
|
80
|
+
return this;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
icon(name: string): this {
|
|
84
|
+
this._icon = name;
|
|
85
|
+
return this;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
tone(tone: WidgetTone): this {
|
|
89
|
+
this._tone = tone;
|
|
90
|
+
return this;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Build a single stat card. */
|
|
95
|
+
export function stat(label: string, value: string | number): Stat {
|
|
96
|
+
return new Stat(label, value);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export type StatsResolver = () => Promise<Stat[]> | Stat[];
|
|
100
|
+
|
|
101
|
+
export class StatsWidget extends PollableWidget {
|
|
102
|
+
/** @internal */ _resolver: StatsResolver;
|
|
103
|
+
/** @internal */ _columns = 4;
|
|
104
|
+
|
|
105
|
+
constructor(resolver: StatsResolver) {
|
|
106
|
+
super();
|
|
107
|
+
this._resolver = resolver;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Number of cards per row (responsive; 1–4). */
|
|
111
|
+
columns(n: number): this {
|
|
112
|
+
this._columns = Math.min(4, Math.max(1, n));
|
|
113
|
+
return this;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Resolve the stats for this render. */
|
|
117
|
+
async stats(): Promise<Stat[]> {
|
|
118
|
+
return this._resolver();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A row of stat cards. */
|
|
123
|
+
export function statsWidget(resolver: StatsResolver): StatsWidget {
|
|
124
|
+
return new StatsWidget(resolver);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── Chart widget ─────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
export type ChartType = "line" | "bar" | "doughnut" | "pie";
|
|
130
|
+
|
|
131
|
+
export interface ChartDataset {
|
|
132
|
+
label?: string;
|
|
133
|
+
data: number[];
|
|
134
|
+
/** CSS color; defaults to the panel's primary token. */
|
|
135
|
+
color?: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface ChartData {
|
|
139
|
+
type: ChartType;
|
|
140
|
+
labels: string[];
|
|
141
|
+
datasets: ChartDataset[];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export type ChartResolver = () => Promise<ChartData> | ChartData;
|
|
145
|
+
|
|
146
|
+
export class ChartWidget extends PollableWidget {
|
|
147
|
+
/** @internal */ _title: string;
|
|
148
|
+
/** @internal */ _resolver: ChartResolver;
|
|
149
|
+
/** @internal */ _columns = 2;
|
|
150
|
+
/** @internal */ _height = 220;
|
|
151
|
+
|
|
152
|
+
constructor(title: string, resolver: ChartResolver) {
|
|
153
|
+
super();
|
|
154
|
+
this._title = title;
|
|
155
|
+
this._resolver = resolver;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Grid span on the dashboard (1–4). */
|
|
159
|
+
columns(n: number): this {
|
|
160
|
+
this._columns = Math.min(4, Math.max(1, n));
|
|
161
|
+
return this;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Canvas height in pixels. */
|
|
165
|
+
height(px: number): this {
|
|
166
|
+
this._height = px;
|
|
167
|
+
return this;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async data(): Promise<ChartData> {
|
|
171
|
+
return this._resolver();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** A chart, drawn with Chart.js on the dashboard. */
|
|
176
|
+
export function chartWidget(title: string, resolver: ChartResolver): ChartWidget {
|
|
177
|
+
return new ChartWidget(title, resolver);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── Table widget ─────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
export interface TableWidgetColumn {
|
|
183
|
+
key: string;
|
|
184
|
+
label: string;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export type TableRowsResolver = () =>
|
|
188
|
+
Promise<Record<string, unknown>[]> | Record<string, unknown>[];
|
|
189
|
+
|
|
190
|
+
export class TableWidget extends PollableWidget {
|
|
191
|
+
/** @internal */ _title: string;
|
|
192
|
+
/** @internal */ _columns: TableWidgetColumn[];
|
|
193
|
+
/** @internal */ _resolver: TableRowsResolver;
|
|
194
|
+
/** @internal */ _wide = true;
|
|
195
|
+
|
|
196
|
+
constructor(title: string, columns: TableWidgetColumn[], resolver: TableRowsResolver) {
|
|
197
|
+
super();
|
|
198
|
+
this._title = title;
|
|
199
|
+
this._columns = columns;
|
|
200
|
+
this._resolver = resolver;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Span the full dashboard width (default) or a single column. */
|
|
204
|
+
wide(value = true): this {
|
|
205
|
+
this._wide = value;
|
|
206
|
+
return this;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async rows(): Promise<Record<string, unknown>[]> {
|
|
210
|
+
return this._resolver();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** A small data table on the dashboard. */
|
|
215
|
+
export function tableWidget(
|
|
216
|
+
title: string,
|
|
217
|
+
columns: TableWidgetColumn[],
|
|
218
|
+
resolver: TableRowsResolver,
|
|
219
|
+
): TableWidget {
|
|
220
|
+
return new TableWidget(title, columns, resolver);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** A dashboard widget — stats overview, chart, or table. */
|
|
224
|
+
export type DashboardWidget = StatsWidget | ChartWidget | TableWidget;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The shortest poll interval among these widgets, or `undefined` when none
|
|
228
|
+
* polls. The dashboard polls as one unit, so the keenest widget sets the pace.
|
|
229
|
+
*/
|
|
230
|
+
export function widgetPollInterval(widgets: DashboardWidget[]): string | undefined {
|
|
231
|
+
const intervals = widgets.map((w) => w._poll).filter((i): i is string => Boolean(i));
|
|
232
|
+
if (intervals.length === 0) return undefined;
|
|
233
|
+
return intervals.reduce((a, b) => (pollMs(b) < pollMs(a) ? b : a));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Parse a poll interval to milliseconds; an unparseable one sorts last. */
|
|
237
|
+
function pollMs(interval: string): number {
|
|
238
|
+
const match = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(interval.trim());
|
|
239
|
+
if (!match) return Number.MAX_SAFE_INTEGER;
|
|
240
|
+
const n = Number(match[1]);
|
|
241
|
+
switch (match[2]) {
|
|
242
|
+
case "ms":
|
|
243
|
+
return n;
|
|
244
|
+
case "m":
|
|
245
|
+
return n * 60_000;
|
|
246
|
+
case "h":
|
|
247
|
+
return n * 3_600_000;
|
|
248
|
+
default:
|
|
249
|
+
return n * 1000;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/** @jsxImportSource @zerotal/flow */
|
|
2
|
+
// Widget rendering, shared by the dashboard and by resource pages.
|
|
3
|
+
//
|
|
4
|
+
// Widgets are data providers — they resolve values and leave the markup to the
|
|
5
|
+
// panel. Keeping that markup in one place is what lets the same widget appear on
|
|
6
|
+
// the dashboard and above a resource's table without being written twice.
|
|
7
|
+
|
|
8
|
+
import type { HtmlNode } from "@zerotal/flow";
|
|
9
|
+
import { Table } from "@zerotal/flow-ui";
|
|
10
|
+
import { Icon } from "../ui/icons.tsx";
|
|
11
|
+
import { StatsWidget, ChartWidget, TableWidget } from "./Widget.ts";
|
|
12
|
+
import type { DashboardWidget, WidgetTone } from "./Widget.ts";
|
|
13
|
+
|
|
14
|
+
const STAT_TONE: Record<WidgetTone, string> = {
|
|
15
|
+
default: "bg-secondary text-secondary-foreground",
|
|
16
|
+
primary: "bg-primary/10 text-primary",
|
|
17
|
+
success: "bg-success/10 text-success",
|
|
18
|
+
muted: "bg-muted text-muted-foreground",
|
|
19
|
+
destructive: "bg-destructive/10 text-destructive",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const STAT_COLS: Record<number, string> = {
|
|
23
|
+
1: "sm:grid-cols-1",
|
|
24
|
+
2: "sm:grid-cols-2",
|
|
25
|
+
3: "sm:grid-cols-3",
|
|
26
|
+
4: "sm:grid-cols-2 lg:grid-cols-4",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Build the inline Chart.js init script (injected raw via dangerouslySetInnerHTML
|
|
31
|
+
* so its `<`/`>` operators aren't HTML-escaped). Loads Chart.js from the CDN on
|
|
32
|
+
* demand, then draws each canvas once.
|
|
33
|
+
*
|
|
34
|
+
* `idPrefix` keeps canvases unique when two widget sets share a page — a
|
|
35
|
+
* resource's own widgets above its table, say, alongside a contributed one.
|
|
36
|
+
*/
|
|
37
|
+
export function chartInitScript(configs: unknown[]): string {
|
|
38
|
+
if (!Array.isArray(configs) || configs.length === 0) return "";
|
|
39
|
+
const data = JSON.stringify(configs);
|
|
40
|
+
return `(function(){var C=${data};function pal(n){var o=[];for(var i=0;i<n;i++){o.push('hsl('+((i*53)%360)+' 70% 55%)');}return o;}function draw(){var r=getComputedStyle(document.documentElement);var p='hsl('+r.getPropertyValue('--primary').trim()+')';C.forEach(function(c){var el=document.getElementById(c.id);if(!el||el.__k){return;}el.__k=1;var pie=(c.type==='doughnut'||c.type==='pie');var line=(c.type==='line');new Chart(el,{type:c.type,data:{labels:c.labels,datasets:c.datasets.map(function(d){var col=d.color||p;return{label:d.label,data:d.data,backgroundColor:pie?pal(c.labels.length):(line?col+'33':col),borderColor:pie?'transparent':col,borderWidth:2,tension:0.35,fill:line};})},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:(c.datasets.length>1)||pie}},scales:pie?{}:{y:{beginAtZero:true}}}});});}if(window.Chart){draw();}else{var s=document.createElement('script');s.src='https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js';s.onload=draw;document.head.appendChild(s);}})();`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve and render a set of widgets.
|
|
45
|
+
*
|
|
46
|
+
* Returns `null` when there is nothing to draw, so a caller can drop the whole
|
|
47
|
+
* block rather than leaving an empty gap above its table.
|
|
48
|
+
*/
|
|
49
|
+
export async function renderWidgets(
|
|
50
|
+
widgets: DashboardWidget[],
|
|
51
|
+
idPrefix = "kchart",
|
|
52
|
+
): Promise<HtmlNode | null> {
|
|
53
|
+
if (widgets.length === 0) return null;
|
|
54
|
+
|
|
55
|
+
const statGroups = await Promise.all(
|
|
56
|
+
widgets
|
|
57
|
+
.filter((w): w is StatsWidget => w instanceof StatsWidget)
|
|
58
|
+
.map(async (w) => ({ columns: w._columns, stats: await w.stats() })),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const charts = await Promise.all(
|
|
62
|
+
widgets
|
|
63
|
+
.filter((w): w is ChartWidget => w instanceof ChartWidget)
|
|
64
|
+
.map(async (w, i) => ({
|
|
65
|
+
id: `${idPrefix}-${i}`,
|
|
66
|
+
title: w._title,
|
|
67
|
+
wide: w._columns >= 2,
|
|
68
|
+
height: w._height,
|
|
69
|
+
data: await w.data(),
|
|
70
|
+
})),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const tables = await Promise.all(
|
|
74
|
+
widgets
|
|
75
|
+
.filter((w): w is TableWidget => w instanceof TableWidget)
|
|
76
|
+
.map(async (w) => ({
|
|
77
|
+
title: w._title,
|
|
78
|
+
wide: w._wide,
|
|
79
|
+
columns: w._columns.map((c) => ({ key: c.key, label: c.label })),
|
|
80
|
+
rows: await w.rows(),
|
|
81
|
+
})),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const chartScript = chartInitScript(
|
|
85
|
+
charts.map((c) => ({
|
|
86
|
+
id: c.id,
|
|
87
|
+
type: c.data.type,
|
|
88
|
+
labels: c.data.labels,
|
|
89
|
+
datasets: c.data.datasets,
|
|
90
|
+
})),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div class="space-y-4">
|
|
95
|
+
{statGroups.map((group) => (
|
|
96
|
+
<div
|
|
97
|
+
class={`grid grid-cols-1 gap-4 ${STAT_COLS[group.columns] ?? "sm:grid-cols-2 lg:grid-cols-4"}`}
|
|
98
|
+
>
|
|
99
|
+
{group.stats.map((s) => (
|
|
100
|
+
<div class="rounded-xl border border-border bg-card p-5 text-card-foreground shadow-sm">
|
|
101
|
+
<div class="flex items-center justify-between">
|
|
102
|
+
<p class="text-sm font-medium text-muted-foreground">{s._label}</p>
|
|
103
|
+
{s._icon ? (
|
|
104
|
+
<span
|
|
105
|
+
class={`flex h-8 w-8 items-center justify-center rounded-lg ${STAT_TONE[s._tone]}`}
|
|
106
|
+
>
|
|
107
|
+
<Icon name={s._icon} class="h-4 w-4" />
|
|
108
|
+
</span>
|
|
109
|
+
) : null}
|
|
110
|
+
</div>
|
|
111
|
+
<p class="mt-2 text-3xl font-semibold tracking-tight">{String(s._value)}</p>
|
|
112
|
+
{s._description ? (
|
|
113
|
+
<p class="mt-1 text-xs text-muted-foreground">{s._description}</p>
|
|
114
|
+
) : null}
|
|
115
|
+
</div>
|
|
116
|
+
))}
|
|
117
|
+
</div>
|
|
118
|
+
))}
|
|
119
|
+
|
|
120
|
+
{charts.length > 0 ? (
|
|
121
|
+
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
|
122
|
+
{charts.map((c) => (
|
|
123
|
+
<div
|
|
124
|
+
class={`rounded-xl border border-border bg-card p-5 text-card-foreground shadow-sm ${c.wide ? "lg:col-span-2" : ""}`}
|
|
125
|
+
>
|
|
126
|
+
<h3 class="text-sm font-semibold">{c.title}</h3>
|
|
127
|
+
<div class="mt-3" style={`position:relative;height:${c.height}px`}>
|
|
128
|
+
<canvas id={c.id} />
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
))}
|
|
132
|
+
</div>
|
|
133
|
+
) : null}
|
|
134
|
+
{chartScript ? <script dangerouslySetInnerHTML={{ __html: chartScript }} /> : null}
|
|
135
|
+
|
|
136
|
+
{tables.map((t) => (
|
|
137
|
+
<div
|
|
138
|
+
class={`overflow-hidden rounded-xl border border-border bg-card text-card-foreground shadow-sm ${t.wide ? "" : "lg:max-w-2xl"}`}
|
|
139
|
+
>
|
|
140
|
+
<div class="border-b border-border px-5 py-3">
|
|
141
|
+
<h3 class="text-sm font-semibold">{t.title}</h3>
|
|
142
|
+
</div>
|
|
143
|
+
<div class="overflow-x-auto p-1.5">
|
|
144
|
+
{t.rows.length > 0 ? (
|
|
145
|
+
<Table columns={t.columns} rows={t.rows} hover />
|
|
146
|
+
) : (
|
|
147
|
+
<p class="px-4 py-8 text-center text-sm text-muted-foreground">No data.</p>
|
|
148
|
+
)}
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
))}
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
}
|