@cosmicdrift/kumiko-renderer-web 0.133.0 → 0.134.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/package.json +4 -4
- package/src/__tests__/dashboard-screen.test.tsx +207 -1
- package/src/__tests__/workspace-shell.test.tsx +5 -37
- package/src/app/dashboard-body.tsx +273 -30
- package/src/index.ts +4 -0
- package/src/widgets/feed-list.tsx +35 -0
- package/src/widgets/index.ts +2 -0
- package/src/widgets/progress-list.tsx +38 -0
- package/src/ui/card.tsx +0 -93
- package/src/ui/collapsible.tsx +0 -34
- package/src/ui/dropdown-menu.tsx +0 -258
- package/src/ui/select.tsx +0 -191
|
@@ -1,40 +1,72 @@
|
|
|
1
1
|
// Web-Implementierung des dashboard-Screen-Typs: rendert die deklarierten
|
|
2
|
-
// Panels über das Widget-Kit (StatCard, TimeseriesChart, QueryTable
|
|
3
|
-
// einem responsiven Grid. Registriert via
|
|
4
|
-
// createKumikoApp — der KumikoScreen-Switch bleibt
|
|
2
|
+
// Panels über das Widget-Kit (StatCard, TimeseriesChart, QueryTable, FeedList,
|
|
3
|
+
// ProgressList) in einem responsiven Grid. Registriert via
|
|
4
|
+
// DashboardBodyProvider in createKumikoApp — der KumikoScreen-Switch bleibt
|
|
5
|
+
// plattform-agnostisch.
|
|
5
6
|
//
|
|
6
7
|
// Panel-Daten-Contracts (siehe DashboardPanelDefinition in kumiko-framework):
|
|
7
|
-
// stat
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
8
|
+
// stat → flaches Record, valueField/subField/toneField zeigen auf
|
|
9
|
+
// anzeigefertige Werte (der Query-Handler formatiert).
|
|
10
|
+
// stat-group → mehrere stat-Panels unter einem Sektions-Titel, jedes
|
|
11
|
+
// Kind bleibt eine eigenständige Query.
|
|
12
|
+
// chart → { points: { atMs, value | null }[], windowStartMs,
|
|
13
|
+
// windowEndMs }
|
|
14
|
+
// list → paged envelope { rows, nextCursor, total? } wie
|
|
15
|
+
// projectionList.
|
|
16
|
+
// feed → { rows: { id, primary, trailing? }[] }
|
|
17
|
+
// progress-list → { rows: { id, label, value, fraction }[] }
|
|
18
|
+
// custom → keine Query — eine über extensionSectionComponents
|
|
19
|
+
// registrierte App-Komponente holt sich ihre Daten selbst.
|
|
20
|
+
//
|
|
21
|
+
// Screen-Filter (DashboardFilterDefinition): der gewählte Wert wird unter
|
|
22
|
+
// `filter.id` in JEDE Panel-Query gemerged. useQuery refetcht automatisch
|
|
23
|
+
// über seinen bestehenden payloadKey-Mechanismus — kein Sonderfall nötig.
|
|
11
24
|
|
|
12
25
|
import type {
|
|
13
26
|
DashboardChartPanel,
|
|
27
|
+
DashboardCustomPanel,
|
|
28
|
+
DashboardFeedPanel,
|
|
14
29
|
DashboardListPanel,
|
|
30
|
+
DashboardPanelDefinition,
|
|
31
|
+
DashboardProgressListPanel,
|
|
32
|
+
DashboardScreenDefinition,
|
|
33
|
+
DashboardStatGroupPanel,
|
|
15
34
|
DashboardStatPanel,
|
|
16
35
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
17
36
|
import { normalizeListColumn } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
18
|
-
import {
|
|
19
|
-
|
|
37
|
+
import {
|
|
38
|
+
type DashboardBodyProps,
|
|
39
|
+
extensionSectionName,
|
|
40
|
+
useExtensionSectionComponent,
|
|
41
|
+
usePrimitives,
|
|
42
|
+
useQuery,
|
|
43
|
+
useTranslation,
|
|
44
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
45
|
+
import { type ReactNode, useEffect, useState } from "react";
|
|
20
46
|
import { TimeseriesChart, type TimeseriesPoint } from "../widgets/charts";
|
|
47
|
+
import { FeedList, type FeedRow } from "../widgets/feed-list";
|
|
48
|
+
import { ProgressList, type ProgressListRow } from "../widgets/progress-list";
|
|
21
49
|
import { QueryTable } from "../widgets/query-table";
|
|
22
50
|
import { SectionCard } from "../widgets/section-card";
|
|
23
51
|
import { StatCard, type StatTone } from "../widgets/stat";
|
|
24
52
|
import { ErrorState, LoadingState } from "../widgets/states";
|
|
25
53
|
|
|
26
54
|
const STAT_TONES: ReadonlySet<string> = new Set(["default", "positive", "warn"]);
|
|
55
|
+
const WIDE_PANEL = "sm:col-span-2 lg:col-span-4";
|
|
56
|
+
const HALF_PANEL = "sm:col-span-2 lg:col-span-2";
|
|
27
57
|
|
|
28
58
|
function StatPanelBody({
|
|
29
59
|
panel,
|
|
30
60
|
label,
|
|
61
|
+
filterParams,
|
|
31
62
|
}: {
|
|
32
63
|
readonly panel: DashboardStatPanel;
|
|
33
64
|
readonly label: string;
|
|
65
|
+
readonly filterParams: Readonly<Record<string, unknown>>;
|
|
34
66
|
}): ReactNode {
|
|
35
67
|
const { data, error, loading, refetch } = useQuery<Readonly<Record<string, unknown>>>(
|
|
36
68
|
panel.query,
|
|
37
|
-
|
|
69
|
+
filterParams,
|
|
38
70
|
{ live: true },
|
|
39
71
|
);
|
|
40
72
|
if (loading && data === null) return <LoadingState rows={2} />;
|
|
@@ -55,6 +87,32 @@ function StatPanelBody({
|
|
|
55
87
|
);
|
|
56
88
|
}
|
|
57
89
|
|
|
90
|
+
function StatGroupPanelBody({
|
|
91
|
+
panel,
|
|
92
|
+
label,
|
|
93
|
+
filterParams,
|
|
94
|
+
}: {
|
|
95
|
+
readonly panel: DashboardStatGroupPanel;
|
|
96
|
+
readonly label: string;
|
|
97
|
+
readonly filterParams: Readonly<Record<string, unknown>>;
|
|
98
|
+
}): ReactNode {
|
|
99
|
+
const t = useTranslation();
|
|
100
|
+
return (
|
|
101
|
+
<SectionCard title={label} testId={`dashboard-panel-${panel.id}`}>
|
|
102
|
+
<section className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
|
103
|
+
{panel.stats.map((stat) => (
|
|
104
|
+
<StatPanelBody
|
|
105
|
+
key={stat.id}
|
|
106
|
+
panel={stat}
|
|
107
|
+
label={t(stat.label)}
|
|
108
|
+
filterParams={filterParams}
|
|
109
|
+
/>
|
|
110
|
+
))}
|
|
111
|
+
</section>
|
|
112
|
+
</SectionCard>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
58
116
|
type TimeseriesEnvelope = {
|
|
59
117
|
readonly points: readonly TimeseriesPoint[];
|
|
60
118
|
readonly windowStartMs: number;
|
|
@@ -64,14 +122,16 @@ type TimeseriesEnvelope = {
|
|
|
64
122
|
function ChartPanelBody({
|
|
65
123
|
panel,
|
|
66
124
|
label,
|
|
125
|
+
filterParams,
|
|
67
126
|
}: {
|
|
68
127
|
readonly panel: DashboardChartPanel;
|
|
69
128
|
readonly label: string;
|
|
129
|
+
readonly filterParams: Readonly<Record<string, unknown>>;
|
|
70
130
|
}): ReactNode {
|
|
71
131
|
const t = useTranslation();
|
|
72
132
|
const { data, error, loading, refetch } = useQuery<TimeseriesEnvelope>(
|
|
73
133
|
panel.query,
|
|
74
|
-
|
|
134
|
+
filterParams,
|
|
75
135
|
{ live: true },
|
|
76
136
|
);
|
|
77
137
|
if (loading && data === null) return <LoadingState rows={3} />;
|
|
@@ -92,15 +152,18 @@ function ChartPanelBody({
|
|
|
92
152
|
function ListPanelBody({
|
|
93
153
|
panel,
|
|
94
154
|
label,
|
|
155
|
+
filterParams,
|
|
95
156
|
}: {
|
|
96
157
|
readonly panel: DashboardListPanel;
|
|
97
158
|
readonly label: string;
|
|
159
|
+
readonly filterParams: Readonly<Record<string, unknown>>;
|
|
98
160
|
}): ReactNode {
|
|
99
161
|
const t = useTranslation();
|
|
100
162
|
return (
|
|
101
163
|
<SectionCard title={label} testId={`dashboard-panel-${panel.id}`}>
|
|
102
164
|
<QueryTable<{ readonly rows: readonly Readonly<Record<string, unknown>>[] }>
|
|
103
165
|
query={panel.query}
|
|
166
|
+
payload={filterParams}
|
|
104
167
|
live
|
|
105
168
|
columns={panel.columns.map((c) => {
|
|
106
169
|
const normalized = normalizeListColumn(c);
|
|
@@ -115,32 +178,212 @@ function ListPanelBody({
|
|
|
115
178
|
);
|
|
116
179
|
}
|
|
117
180
|
|
|
181
|
+
type FeedEnvelope = {
|
|
182
|
+
readonly rows: readonly { readonly primary: string; readonly trailing?: string }[];
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
function FeedPanelBody({
|
|
186
|
+
panel,
|
|
187
|
+
label,
|
|
188
|
+
filterParams,
|
|
189
|
+
}: {
|
|
190
|
+
readonly panel: DashboardFeedPanel;
|
|
191
|
+
readonly label: string;
|
|
192
|
+
readonly filterParams: Readonly<Record<string, unknown>>;
|
|
193
|
+
}): ReactNode {
|
|
194
|
+
const t = useTranslation();
|
|
195
|
+
const { data, error, loading, refetch } = useQuery<FeedEnvelope>(panel.query, filterParams, {
|
|
196
|
+
live: true,
|
|
197
|
+
});
|
|
198
|
+
if (loading && data === null) return <LoadingState rows={3} />;
|
|
199
|
+
if (error !== null) return <ErrorState error={error} onRetry={() => void refetch()} />;
|
|
200
|
+
const rows: readonly FeedRow[] = (data?.rows ?? []).map((row, i) => ({ id: String(i), ...row }));
|
|
201
|
+
return (
|
|
202
|
+
<SectionCard title={label} testId={`dashboard-panel-${panel.id}`}>
|
|
203
|
+
<FeedList rows={rows} emptyContent={t(panel.emptyLabel ?? "kumiko.list.no-entries")} />
|
|
204
|
+
</SectionCard>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
type ProgressListEnvelope = {
|
|
209
|
+
readonly rows: readonly {
|
|
210
|
+
readonly label: string;
|
|
211
|
+
readonly value: string;
|
|
212
|
+
readonly fraction: number;
|
|
213
|
+
}[];
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
function ProgressListPanelBody({
|
|
217
|
+
panel,
|
|
218
|
+
label,
|
|
219
|
+
filterParams,
|
|
220
|
+
}: {
|
|
221
|
+
readonly panel: DashboardProgressListPanel;
|
|
222
|
+
readonly label: string;
|
|
223
|
+
readonly filterParams: Readonly<Record<string, unknown>>;
|
|
224
|
+
}): ReactNode {
|
|
225
|
+
const t = useTranslation();
|
|
226
|
+
const { data, error, loading, refetch } = useQuery<ProgressListEnvelope>(
|
|
227
|
+
panel.query,
|
|
228
|
+
filterParams,
|
|
229
|
+
{ live: true },
|
|
230
|
+
);
|
|
231
|
+
if (loading && data === null) return <LoadingState rows={3} />;
|
|
232
|
+
if (error !== null) return <ErrorState error={error} onRetry={() => void refetch()} />;
|
|
233
|
+
const rows: readonly ProgressListRow[] = (data?.rows ?? []).map((row, i) => ({
|
|
234
|
+
id: String(i),
|
|
235
|
+
...row,
|
|
236
|
+
}));
|
|
237
|
+
return (
|
|
238
|
+
<SectionCard title={label} testId={`dashboard-panel-${panel.id}`}>
|
|
239
|
+
<ProgressList rows={rows} emptyContent={t("kumiko.list.no-entries")} />
|
|
240
|
+
</SectionCard>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Löst panel.component über dieselbe extensionSectionComponents-Registry auf
|
|
245
|
+
// wie entityEdit-Extension-Sections und List-Header-Slots — bleibt an seiner
|
|
246
|
+
// Array-Position statt in einen separaten Slot zu wandern (siehe Datei-Kopf-
|
|
247
|
+
// Kommentar zur Registry). Rendert nichts + dev-Warnung bei unregistriertem
|
|
248
|
+
// Namen, analog zu ListHeaderSlotMount in render-list.tsx.
|
|
249
|
+
function CustomPanelBody({
|
|
250
|
+
panel,
|
|
251
|
+
screenId,
|
|
252
|
+
filterParams,
|
|
253
|
+
}: {
|
|
254
|
+
readonly panel: DashboardCustomPanel;
|
|
255
|
+
readonly screenId: string;
|
|
256
|
+
readonly filterParams: Readonly<Record<string, unknown>>;
|
|
257
|
+
}): ReactNode {
|
|
258
|
+
const name = extensionSectionName(panel.component);
|
|
259
|
+
const Component = useExtensionSectionComponent(name);
|
|
260
|
+
useEffect(() => {
|
|
261
|
+
if (name !== undefined && Component === undefined) {
|
|
262
|
+
// biome-ignore lint/suspicious/noConsole: dev-warning für Setup-Fehler
|
|
263
|
+
console.warn(
|
|
264
|
+
`[kumiko] Dashboard custom-panel "${panel.id}" on screen "${screenId}" references component ` +
|
|
265
|
+
`"${name}", which is not registered in clientFeatures.extensionSectionComponents — the panel renders nothing.`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
}, [name, Component, panel.id, screenId]);
|
|
269
|
+
if (Component === undefined) return null;
|
|
270
|
+
// Dashboard-Panels haben keine Entity — entityName trägt die screen.id,
|
|
271
|
+
// damit ExtensionSectionProps nicht extra für diesen einen Mount-Ort
|
|
272
|
+
// aufgeweicht werden muss (das bräche jede bestehende registrierte
|
|
273
|
+
// Section, die entityName als garantiert gesetzten string erwartet).
|
|
274
|
+
return (
|
|
275
|
+
<Component
|
|
276
|
+
entityName={screenId}
|
|
277
|
+
entityId={null}
|
|
278
|
+
screenId={screenId}
|
|
279
|
+
filterParams={filterParams}
|
|
280
|
+
/>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Screen-Filter: hält den gewählten Wert, rendert die Combobox, und liefert
|
|
285
|
+
// den Payload-Merge für jede Panel-Query. Statische `options` werden als
|
|
286
|
+
// i18n-Keys übersetzt; `optionsQuery`-Ergebnisse sind Server-Daten und werden
|
|
287
|
+
// unverändert übernommen.
|
|
288
|
+
function useFilterParams(screen: DashboardScreenDefinition): {
|
|
289
|
+
readonly params: Readonly<Record<string, unknown>>;
|
|
290
|
+
readonly picker: ReactNode;
|
|
291
|
+
} {
|
|
292
|
+
const { Field, Input } = usePrimitives();
|
|
293
|
+
const t = useTranslation();
|
|
294
|
+
const filter = screen.filter;
|
|
295
|
+
const [value, setValue] = useState("");
|
|
296
|
+
const optionsQueryResult = useQuery<{
|
|
297
|
+
readonly rows: readonly { readonly value: string; readonly label: string }[];
|
|
298
|
+
}>(filter?.optionsQuery ?? "", {}, { enabled: filter?.optionsQuery !== undefined });
|
|
299
|
+
|
|
300
|
+
if (filter === undefined) {
|
|
301
|
+
return { params: {}, picker: null };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const dynamicOptions =
|
|
305
|
+
filter.optionsQuery !== undefined
|
|
306
|
+
? (optionsQueryResult.data?.rows ?? [])
|
|
307
|
+
: (filter.options ?? []).map((o) => ({ value: o.value, label: t(o.label) }));
|
|
308
|
+
const allLabel = t(filter.allLabel ?? "kumiko.dashboard.filter.all");
|
|
309
|
+
const options = [{ value: "", label: allLabel }, ...dynamicOptions];
|
|
310
|
+
|
|
311
|
+
const picker = (
|
|
312
|
+
<div className="max-w-xs">
|
|
313
|
+
<Field id={`dashboard-filter-${filter.id}`} label={t(filter.label)}>
|
|
314
|
+
<Input
|
|
315
|
+
kind="combobox"
|
|
316
|
+
id={`dashboard-filter-${filter.id}`}
|
|
317
|
+
name={filter.id}
|
|
318
|
+
options={options}
|
|
319
|
+
value={value}
|
|
320
|
+
onChange={setValue}
|
|
321
|
+
placeholder={filter.placeholder !== undefined ? t(filter.placeholder) : allLabel}
|
|
322
|
+
/>
|
|
323
|
+
</Field>
|
|
324
|
+
</div>
|
|
325
|
+
);
|
|
326
|
+
const params = value === "" ? {} : { [filter.id]: value };
|
|
327
|
+
return { params, picker };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function panelSpanClassName(panel: DashboardPanelDefinition): string | undefined {
|
|
331
|
+
if (panel.kind === "stat") return undefined;
|
|
332
|
+
if (panel.kind === "feed" || panel.kind === "progress-list") return HALF_PANEL;
|
|
333
|
+
return WIDE_PANEL;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function PanelBody({
|
|
337
|
+
panel,
|
|
338
|
+
label,
|
|
339
|
+
screenId,
|
|
340
|
+
filterParams,
|
|
341
|
+
}: {
|
|
342
|
+
readonly panel: DashboardPanelDefinition;
|
|
343
|
+
readonly label: string;
|
|
344
|
+
readonly screenId: string;
|
|
345
|
+
readonly filterParams: Readonly<Record<string, unknown>>;
|
|
346
|
+
}): ReactNode {
|
|
347
|
+
if (panel.kind === "stat")
|
|
348
|
+
return <StatPanelBody panel={panel} label={label} filterParams={filterParams} />;
|
|
349
|
+
if (panel.kind === "stat-group") {
|
|
350
|
+
return <StatGroupPanelBody panel={panel} label={label} filterParams={filterParams} />;
|
|
351
|
+
}
|
|
352
|
+
if (panel.kind === "chart")
|
|
353
|
+
return <ChartPanelBody panel={panel} label={label} filterParams={filterParams} />;
|
|
354
|
+
if (panel.kind === "list")
|
|
355
|
+
return <ListPanelBody panel={panel} label={label} filterParams={filterParams} />;
|
|
356
|
+
if (panel.kind === "feed")
|
|
357
|
+
return <FeedPanelBody panel={panel} label={label} filterParams={filterParams} />;
|
|
358
|
+
if (panel.kind === "progress-list") {
|
|
359
|
+
return <ProgressListPanelBody panel={panel} label={label} filterParams={filterParams} />;
|
|
360
|
+
}
|
|
361
|
+
return <CustomPanelBody panel={panel} screenId={screenId} filterParams={filterParams} />;
|
|
362
|
+
}
|
|
363
|
+
|
|
118
364
|
export function WebDashboardBody({ screen, translate }: DashboardBodyProps): ReactNode {
|
|
119
365
|
const t = useTranslation();
|
|
120
366
|
const effectiveTranslate = translate ?? t;
|
|
367
|
+
const { params: filterParams, picker } = useFilterParams(screen);
|
|
121
368
|
return (
|
|
122
|
-
<div
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (panel.kind === "stat") {
|
|
129
|
-
return <StatPanelBody key={panel.id} panel={panel} label={label} />;
|
|
130
|
-
}
|
|
131
|
-
if (panel.kind === "chart") {
|
|
369
|
+
<div className="flex flex-col gap-4 p-6" data-testid={`dashboard-${screen.id}`}>
|
|
370
|
+
{picker}
|
|
371
|
+
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
372
|
+
{screen.panels.map((panel) => {
|
|
373
|
+
const label = panel.kind === "custom" ? "" : effectiveTranslate(panel.label);
|
|
374
|
+
const span = panelSpanClassName(panel);
|
|
132
375
|
return (
|
|
133
|
-
<div key={panel.id} className=
|
|
134
|
-
<
|
|
376
|
+
<div key={panel.id} className={span}>
|
|
377
|
+
<PanelBody
|
|
378
|
+
panel={panel}
|
|
379
|
+
label={label}
|
|
380
|
+
screenId={screen.id}
|
|
381
|
+
filterParams={filterParams}
|
|
382
|
+
/>
|
|
135
383
|
</div>
|
|
136
384
|
);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
<div key={panel.id} className="sm:col-span-2 lg:col-span-4">
|
|
140
|
-
<ListPanelBody panel={panel} label={label} />
|
|
141
|
-
</div>
|
|
142
|
-
);
|
|
143
|
-
})}
|
|
385
|
+
})}
|
|
386
|
+
</div>
|
|
144
387
|
</div>
|
|
145
388
|
);
|
|
146
389
|
}
|
package/src/index.ts
CHANGED
|
@@ -155,6 +155,8 @@ export {
|
|
|
155
155
|
} from "./tokens";
|
|
156
156
|
export { SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider } from "./ui/sidebar";
|
|
157
157
|
export type {
|
|
158
|
+
FeedRow,
|
|
159
|
+
ProgressListRow,
|
|
158
160
|
QueryTableColumn,
|
|
159
161
|
QueryTableProps,
|
|
160
162
|
StatDelta,
|
|
@@ -168,10 +170,12 @@ export {
|
|
|
168
170
|
DetailList,
|
|
169
171
|
EmptyState,
|
|
170
172
|
ErrorState,
|
|
173
|
+
FeedList,
|
|
171
174
|
LoadingState,
|
|
172
175
|
MiniStat,
|
|
173
176
|
ModeSwitch,
|
|
174
177
|
ProgressBar,
|
|
178
|
+
ProgressList,
|
|
175
179
|
QueryTable,
|
|
176
180
|
SectionCard,
|
|
177
181
|
Sparkline,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type FeedRow = {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly primary: string;
|
|
6
|
+
readonly trailing?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/** Nicht-tabellarische Kurzliste (z.B. "nächste Termine") — Primary-Text +
|
|
10
|
+
* optionaler rechtsbündiger Trailing-Wert pro Zeile. */
|
|
11
|
+
export function FeedList({
|
|
12
|
+
rows,
|
|
13
|
+
emptyContent,
|
|
14
|
+
testId,
|
|
15
|
+
}: {
|
|
16
|
+
readonly rows: readonly FeedRow[];
|
|
17
|
+
readonly emptyContent?: ReactNode;
|
|
18
|
+
readonly testId?: string;
|
|
19
|
+
}): ReactNode {
|
|
20
|
+
if (rows.length === 0) {
|
|
21
|
+
return <div data-testid={testId}>{emptyContent}</div>;
|
|
22
|
+
}
|
|
23
|
+
return (
|
|
24
|
+
<ul data-testid={testId} className="flex max-h-64 flex-col overflow-y-auto pr-1 text-sm">
|
|
25
|
+
{rows.map((row) => (
|
|
26
|
+
<li key={row.id} className="flex justify-between gap-4 border-b py-1.5 last:border-b-0">
|
|
27
|
+
<span>{row.primary}</span>
|
|
28
|
+
{row.trailing !== undefined && (
|
|
29
|
+
<span className="tabular-nums text-muted-foreground">{row.trailing}</span>
|
|
30
|
+
)}
|
|
31
|
+
</li>
|
|
32
|
+
))}
|
|
33
|
+
</ul>
|
|
34
|
+
);
|
|
35
|
+
}
|
package/src/widgets/index.ts
CHANGED
|
@@ -12,8 +12,10 @@ export {
|
|
|
12
12
|
} from "./charts";
|
|
13
13
|
export { CollapsibleSection } from "./collapsible-section";
|
|
14
14
|
export { DetailList } from "./detail-list";
|
|
15
|
+
export { FeedList, type FeedRow } from "./feed-list";
|
|
15
16
|
export { ModeSwitch } from "./mode-switch";
|
|
16
17
|
export { ProgressBar } from "./progress-bar";
|
|
18
|
+
export { ProgressList, type ProgressListRow } from "./progress-list";
|
|
17
19
|
export { QueryTable, type QueryTableColumn, type QueryTableProps } from "./query-table";
|
|
18
20
|
export { SectionCard } from "./section-card";
|
|
19
21
|
export { MiniStat, Sparkline, StatCard, type StatDelta, type StatTone } from "./stat";
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { ProgressBar } from "./progress-bar";
|
|
3
|
+
|
|
4
|
+
export type ProgressListRow = {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly label: string;
|
|
7
|
+
readonly value: string;
|
|
8
|
+
readonly fraction: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** Liste aus Label/Wert-Kopfzeile + Fortschrittsbalken pro Eintrag (z.B.
|
|
12
|
+
* Tilgungsfortschritt pro Kredit). */
|
|
13
|
+
export function ProgressList({
|
|
14
|
+
rows,
|
|
15
|
+
emptyContent,
|
|
16
|
+
testId,
|
|
17
|
+
}: {
|
|
18
|
+
readonly rows: readonly ProgressListRow[];
|
|
19
|
+
readonly emptyContent?: ReactNode;
|
|
20
|
+
readonly testId?: string;
|
|
21
|
+
}): ReactNode {
|
|
22
|
+
if (rows.length === 0) {
|
|
23
|
+
return <div data-testid={testId}>{emptyContent}</div>;
|
|
24
|
+
}
|
|
25
|
+
return (
|
|
26
|
+
<ul data-testid={testId} className="flex max-h-64 flex-col gap-3 overflow-y-auto pr-1">
|
|
27
|
+
{rows.map((row) => (
|
|
28
|
+
<li key={row.id} className="flex flex-col gap-1">
|
|
29
|
+
<div className="flex items-baseline justify-between gap-2 text-sm">
|
|
30
|
+
<span className="font-medium">{row.label}</span>
|
|
31
|
+
<span className="tabular-nums text-muted-foreground">{row.value}</span>
|
|
32
|
+
</div>
|
|
33
|
+
<ProgressBar value={row.fraction} />
|
|
34
|
+
</li>
|
|
35
|
+
))}
|
|
36
|
+
</ul>
|
|
37
|
+
);
|
|
38
|
+
}
|
package/src/ui/card.tsx
DELETED
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
// @ts-nocheck — vendored shadcn, regenerate via scripts/sync-shadcn.ts
|
|
2
|
-
import * as React from "react"
|
|
3
|
-
|
|
4
|
-
import { cn } from "../lib/cn"
|
|
5
|
-
|
|
6
|
-
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
|
7
|
-
return (
|
|
8
|
-
<div
|
|
9
|
-
data-slot="card"
|
|
10
|
-
className={cn(
|
|
11
|
-
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
|
|
12
|
-
className
|
|
13
|
-
)}
|
|
14
|
-
{...props}
|
|
15
|
-
/>
|
|
16
|
-
)
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|
20
|
-
return (
|
|
21
|
-
<div
|
|
22
|
-
data-slot="card-header"
|
|
23
|
-
className={cn(
|
|
24
|
-
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
|
25
|
-
className
|
|
26
|
-
)}
|
|
27
|
-
{...props}
|
|
28
|
-
/>
|
|
29
|
-
)
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|
33
|
-
return (
|
|
34
|
-
<div
|
|
35
|
-
data-slot="card-title"
|
|
36
|
-
className={cn("leading-none font-semibold", className)}
|
|
37
|
-
{...props}
|
|
38
|
-
/>
|
|
39
|
-
)
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
|
43
|
-
return (
|
|
44
|
-
<div
|
|
45
|
-
data-slot="card-description"
|
|
46
|
-
className={cn("text-sm text-muted-foreground", className)}
|
|
47
|
-
{...props}
|
|
48
|
-
/>
|
|
49
|
-
)
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
|
53
|
-
return (
|
|
54
|
-
<div
|
|
55
|
-
data-slot="card-action"
|
|
56
|
-
className={cn(
|
|
57
|
-
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
|
58
|
-
className
|
|
59
|
-
)}
|
|
60
|
-
{...props}
|
|
61
|
-
/>
|
|
62
|
-
)
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
|
66
|
-
return (
|
|
67
|
-
<div
|
|
68
|
-
data-slot="card-content"
|
|
69
|
-
className={cn("px-6", className)}
|
|
70
|
-
{...props}
|
|
71
|
-
/>
|
|
72
|
-
)
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|
76
|
-
return (
|
|
77
|
-
<div
|
|
78
|
-
data-slot="card-footer"
|
|
79
|
-
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
|
80
|
-
{...props}
|
|
81
|
-
/>
|
|
82
|
-
)
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export {
|
|
86
|
-
Card,
|
|
87
|
-
CardHeader,
|
|
88
|
-
CardFooter,
|
|
89
|
-
CardTitle,
|
|
90
|
-
CardAction,
|
|
91
|
-
CardDescription,
|
|
92
|
-
CardContent,
|
|
93
|
-
}
|
package/src/ui/collapsible.tsx
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
// @ts-nocheck — vendored shadcn, regenerate via scripts/sync-shadcn.ts
|
|
2
|
-
"use client"
|
|
3
|
-
|
|
4
|
-
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
|
|
5
|
-
|
|
6
|
-
function Collapsible({
|
|
7
|
-
...props
|
|
8
|
-
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
|
9
|
-
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
function CollapsibleTrigger({
|
|
13
|
-
...props
|
|
14
|
-
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
|
15
|
-
return (
|
|
16
|
-
<CollapsiblePrimitive.CollapsibleTrigger
|
|
17
|
-
data-slot="collapsible-trigger"
|
|
18
|
-
{...props}
|
|
19
|
-
/>
|
|
20
|
-
)
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function CollapsibleContent({
|
|
24
|
-
...props
|
|
25
|
-
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
|
26
|
-
return (
|
|
27
|
-
<CollapsiblePrimitive.CollapsibleContent
|
|
28
|
-
data-slot="collapsible-content"
|
|
29
|
-
{...props}
|
|
30
|
-
/>
|
|
31
|
-
)
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|