@cosmicdrift/kumiko-renderer-web 0.133.0 → 0.135.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.
@@ -1,40 +1,72 @@
1
1
  // Web-Implementierung des dashboard-Screen-Typs: rendert die deklarierten
2
- // Panels über das Widget-Kit (StatCard, TimeseriesChart, QueryTable) in
3
- // einem responsiven Grid. Registriert via DashboardBodyProvider in
4
- // createKumikoApp — der KumikoScreen-Switch bleibt plattform-agnostisch.
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 → flaches Record, valueField/subField/toneField zeigen auf
8
- // anzeigefertige Werte (der Query-Handler formatiert).
9
- // chart { points: { atMs, value | null }[], windowStartMs, windowEndMs }
10
- // list → paged envelope { rows, nextCursor, total? } wie projectionList.
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 { type DashboardBodyProps, useQuery, useTranslation } from "@cosmicdrift/kumiko-renderer";
19
- import type { ReactNode } from "react";
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
- className="grid gap-4 p-6 sm:grid-cols-2 lg:grid-cols-4"
124
- data-testid={`dashboard-${screen.id}`}
125
- >
126
- {screen.panels.map((panel) => {
127
- const label = effectiveTranslate(panel.label);
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="sm:col-span-2 lg:col-span-4">
134
- <ChartPanelBody panel={panel} label={label} />
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
- return (
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,8 +155,12 @@ export {
155
155
  } from "./tokens";
156
156
  export { SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider } from "./ui/sidebar";
157
157
  export type {
158
+ FeedRow,
159
+ NumberFieldProps,
160
+ ProgressListRow,
158
161
  QueryTableColumn,
159
162
  QueryTableProps,
163
+ ResultColumn,
160
164
  StatDelta,
161
165
  StatTone,
162
166
  StatusBarEntry,
@@ -168,11 +172,18 @@ export {
168
172
  DetailList,
169
173
  EmptyState,
170
174
  ErrorState,
175
+ FeedList,
171
176
  LoadingState,
172
177
  MiniStat,
173
178
  ModeSwitch,
179
+ MoneyField,
180
+ NumberField,
181
+ PercentField,
174
182
  ProgressBar,
183
+ ProgressList,
175
184
  QueryTable,
185
+ ResultPanel,
186
+ ResultTable,
176
187
  SectionCard,
177
188
  Sparkline,
178
189
  STATUS_TONE_TEXT,
@@ -181,4 +192,5 @@ export {
181
192
  StatusBarChart,
182
193
  smoothPath,
183
194
  TimeseriesChart,
195
+ useDraft,
184
196
  } from "./widgets";
@@ -0,0 +1,103 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import { act, fireEvent, render, renderHook, screen } from "../../__tests__/test-utils";
3
+ import { DetailList } from "../detail-list";
4
+ import { MoneyField, NumberField, PercentField } from "../form-fields";
5
+ import { ResultPanel, ResultTable } from "../result-panel";
6
+ import { useDraft } from "../use-draft";
7
+
8
+ describe("useDraft", () => {
9
+ test("field() liefert verdrahtete Props, onChange patcht den Draft", () => {
10
+ const { result } = renderHook(() => useDraft<{ sum: number | undefined }>({ sum: 100 }));
11
+ expect(result.current.field("sum")).toMatchObject({ id: "sum", name: "sum", value: 100 });
12
+ act(() => result.current.field("sum").onChange(250));
13
+ expect(result.current.draft.sum).toBe(250);
14
+ });
15
+
16
+ test("reset stellt die Defaults wieder her", () => {
17
+ const { result } = renderHook(() => useDraft<{ a: number | undefined }>({ a: 1 }));
18
+ act(() => result.current.patch({ a: 9 }));
19
+ expect(result.current.draft.a).toBe(9);
20
+ act(() => result.current.reset());
21
+ expect(result.current.draft.a).toBe(1);
22
+ });
23
+ });
24
+
25
+ describe("NumberField", () => {
26
+ test("rendert Label und meldet Zahl bei Eingabe", () => {
27
+ const onChange = mock();
28
+ render(<NumberField label="Summe" id="sum" name="sum" value={300} onChange={onChange} />);
29
+ expect(screen.getByText("Summe")).toBeTruthy();
30
+ fireEvent.change(screen.getByRole("spinbutton"), { target: { value: "42" } });
31
+ expect(onChange).toHaveBeenCalledWith(42);
32
+ });
33
+
34
+ test("leeres Feld meldet undefined", () => {
35
+ const onChange = mock();
36
+ render(<NumberField label="X" id="x" name="x" value={5} onChange={onChange} />);
37
+ fireEvent.change(screen.getByRole("spinbutton"), { target: { value: "" } });
38
+ expect(onChange).toHaveBeenCalledWith(undefined);
39
+ });
40
+
41
+ test("MoneyField/PercentField zeigen ihre Einheit", () => {
42
+ const noop = (): void => {};
43
+ const { rerender } = render(
44
+ <MoneyField label="Betrag" id="b" name="b" value={1} onChange={noop} />,
45
+ );
46
+ expect(screen.getByText("€")).toBeTruthy();
47
+ rerender(<PercentField label="Zins" id="z" name="z" value={1} onChange={noop} />);
48
+ expect(screen.getByText("%")).toBeTruthy();
49
+ });
50
+ });
51
+
52
+ describe("DetailList emphasize", () => {
53
+ test("emphasize hebt Label und Wert hervor", () => {
54
+ render(
55
+ <DetailList
56
+ testId="dl"
57
+ rows={[
58
+ { label: "Summe", value: "100" },
59
+ { label: "Effektiv", value: "3,1 %", emphasize: true },
60
+ ]}
61
+ />,
62
+ );
63
+ expect(screen.getByText("Effektiv").className).toContain("font-semibold");
64
+ expect(screen.getByText("Summe").className).not.toContain("font-semibold");
65
+ });
66
+ });
67
+
68
+ describe("ResultPanel", () => {
69
+ test("empty zeigt den Platzhalter, keine Liste", () => {
70
+ render(<ResultPanel title="Ergebnis" empty emptyText="Werte eingeben" />);
71
+ expect(screen.getByText("Werte eingeben")).toBeTruthy();
72
+ });
73
+
74
+ test("gefüllt zeigt Kennzahlen und children", () => {
75
+ render(
76
+ <ResultPanel title="Ergebnis" rows={[{ label: "Rate", value: "890 €" }]}>
77
+ <span>extra</span>
78
+ </ResultPanel>,
79
+ );
80
+ expect(screen.getByText("Rate")).toBeTruthy();
81
+ expect(screen.getByText("extra")).toBeTruthy();
82
+ });
83
+ });
84
+
85
+ describe("ResultTable", () => {
86
+ test("rendert Header, Zeilen und rechtsbündige Zahlenspalte", () => {
87
+ render(
88
+ <ResultTable
89
+ testId="rt"
90
+ columns={[
91
+ { header: "Tranche", cell: (r: { name: string; sum: string }) => r.name },
92
+ { header: "Summe", align: "right", cell: (r) => r.sum },
93
+ ]}
94
+ rows={[{ name: "Bank", sum: "300.000 €" }]}
95
+ rowKey={(r) => r.name}
96
+ />,
97
+ );
98
+ expect(screen.getByText("Tranche")).toBeTruthy();
99
+ expect(screen.getByText("Bank")).toBeTruthy();
100
+ expect(screen.getByText("Summe").className).toContain("text-right");
101
+ expect(screen.getByText("300.000 €").className).toContain("tabular-nums");
102
+ });
103
+ });
@@ -1,12 +1,18 @@
1
1
  import type { ReactNode } from "react";
2
+ import { cn } from "../lib/cn";
2
3
 
3
4
  /** Read-only Schlüssel-Wert-Liste für Detail-Masken (Label links gedimmt,
4
- * Wert rechts). Wert ist ReactNode → Badges/Chips möglich. */
5
+ * Wert rechts). Wert ist ReactNode → Badges/Chips möglich. `emphasize` hebt
6
+ * eine Zeile hervor (z.B. das Endergebnis in einem Rechner). */
5
7
  export function DetailList({
6
8
  rows,
7
9
  testId,
8
10
  }: {
9
- readonly rows: readonly { readonly label: string; readonly value: ReactNode }[];
11
+ readonly rows: readonly {
12
+ readonly label: string;
13
+ readonly value: ReactNode;
14
+ readonly emphasize?: boolean;
15
+ }[];
10
16
  readonly testId?: string;
11
17
  }): ReactNode {
12
18
  return (
@@ -16,8 +22,17 @@ export function DetailList({
16
22
  key={row.label}
17
23
  className="grid grid-cols-1 gap-0.5 py-2.5 sm:grid-cols-[200px_1fr] sm:gap-4"
18
24
  >
19
- <dt className="text-sm text-muted-foreground">{row.label}</dt>
20
- <dd className="text-sm font-medium">{row.value}</dd>
25
+ <dt
26
+ className={cn(
27
+ "text-sm text-muted-foreground",
28
+ row.emphasize === true && "font-semibold text-foreground",
29
+ )}
30
+ >
31
+ {row.label}
32
+ </dt>
33
+ <dd className={cn("text-sm font-medium", row.emphasize === true && "font-semibold")}>
34
+ {row.value}
35
+ </dd>
21
36
  </div>
22
37
  ))}
23
38
  </dl>
@@ -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
+ }
@@ -0,0 +1,66 @@
1
+ import { usePrimitives } from "@cosmicdrift/kumiko-renderer";
2
+ import type { ReactNode } from "react";
3
+
4
+ export interface NumberFieldProps {
5
+ readonly label: string;
6
+ readonly id: string;
7
+ readonly name: string;
8
+ readonly value: number | undefined;
9
+ readonly onChange: (v: number | undefined) => void;
10
+ readonly required?: boolean;
11
+ readonly disabled?: boolean;
12
+ /** Einheit rechts neben dem Label (z.B. "€", "%"). */
13
+ readonly unit?: string;
14
+ readonly testId?: string;
15
+ }
16
+
17
+ /** Zahlenfeld = Field + Input(kind:"number") in einem — nimmt der Screen die
18
+ * wiederholte id/name/value/onChange-Verdrahtung ab. `value` darf `undefined`
19
+ * sein (leeres Feld), intern auf den `""`-Empty-State des Inputs gemappt. */
20
+ export function NumberField({
21
+ label,
22
+ id,
23
+ name,
24
+ value,
25
+ onChange,
26
+ required,
27
+ disabled,
28
+ unit,
29
+ testId,
30
+ }: NumberFieldProps): ReactNode {
31
+ const { Field, Input } = usePrimitives();
32
+ return (
33
+ <Field
34
+ id={id}
35
+ label={label}
36
+ required={required}
37
+ labelAppendix={
38
+ unit !== undefined ? (
39
+ <span className="text-xs text-muted-foreground">{unit}</span>
40
+ ) : undefined
41
+ }
42
+ testId={testId}
43
+ >
44
+ <Input
45
+ kind="number"
46
+ id={id}
47
+ name={name}
48
+ value={value ?? ""}
49
+ onChange={onChange}
50
+ required={required}
51
+ disabled={disabled}
52
+ />
53
+ </Field>
54
+ );
55
+ }
56
+
57
+ /** Euro-Betrag in ganzen Einheiten (kein Cent-Integer wie Input kind:"money").
58
+ * Preset über NumberField mit "€"-Einheit. */
59
+ export function MoneyField(props: Omit<NumberFieldProps, "unit">): ReactNode {
60
+ return <NumberField {...props} unit="€" />;
61
+ }
62
+
63
+ /** Prozentwert (Zins, Tilgung, Rendite). Preset über NumberField mit "%". */
64
+ export function PercentField(props: Omit<NumberFieldProps, "unit">): ReactNode {
65
+ return <NumberField {...props} unit="%" />;
66
+ }
@@ -12,10 +12,15 @@ 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";
16
+ export { MoneyField, NumberField, type NumberFieldProps, PercentField } from "./form-fields";
15
17
  export { ModeSwitch } from "./mode-switch";
16
18
  export { ProgressBar } from "./progress-bar";
19
+ export { ProgressList, type ProgressListRow } from "./progress-list";
17
20
  export { QueryTable, type QueryTableColumn, type QueryTableProps } from "./query-table";
21
+ export { type ResultColumn, ResultPanel, ResultTable } from "./result-panel";
18
22
  export { SectionCard } from "./section-card";
19
23
  export { MiniStat, Sparkline, StatCard, type StatDelta, type StatTone } from "./stat";
20
24
  export { EmptyState, ErrorState, LoadingState } from "./states";
21
25
  export { STATUS_TONE_TEXT, StatusBadge, type StatusTone } from "./status-badge";
26
+ export { useDraft } from "./use-draft";