@cosmicdrift/kumiko-renderer-web 0.136.1 → 0.138.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.136.1",
3
+ "version": "0.138.0",
4
4
  "description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -16,9 +16,9 @@
16
16
  "./styles.css": "./src/styles.css"
17
17
  },
18
18
  "dependencies": {
19
- "@cosmicdrift/kumiko-dispatcher-live": "0.136.1",
20
- "@cosmicdrift/kumiko-headless": "0.136.1",
21
- "@cosmicdrift/kumiko-renderer": "0.136.1",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.138.0",
20
+ "@cosmicdrift/kumiko-headless": "0.138.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.138.0",
22
22
  "@radix-ui/react-dialog": "^1.1.15",
23
23
  "@radix-ui/react-dropdown-menu": "^2.1.16",
24
24
  "@radix-ui/react-label": "^2.1.8",
@@ -110,6 +110,8 @@ const richScreen: DashboardScreenDefinition = {
110
110
  deltaField: "delta",
111
111
  deltaDirectionField: "deltaDirection",
112
112
  deltaToneField: "deltaTone",
113
+ icon: { react: { __component: "rich-dashboard-kpi-icon" } },
114
+ accentColor: "#123456",
113
115
  },
114
116
  {
115
117
  kind: "stat-group",
@@ -282,6 +284,50 @@ describe("KumikoScreen dashboard — neue Panel-Kinds", () => {
282
284
  expect(screen.getByText("↓23 %")).toBeTruthy();
283
285
  });
284
286
 
287
+ test("stat-Panel: registriertes Icon rendert, unregistriertes rendert nichts (kein Crash)", async () => {
288
+ function KpiIcon(): ReactNode {
289
+ return <svg data-testid="kpi-icon" />;
290
+ }
291
+ const dispatcher = createMockDispatcher({
292
+ query: (async (type: string) => {
293
+ if (type === "widgets:query:metrics:kpi") {
294
+ return { isSuccess: true, data: { value: "92.753 €" } };
295
+ }
296
+ return { isSuccess: true, data: {} };
297
+ }) as unknown as Dispatcher["query"],
298
+ });
299
+ render(
300
+ <DispatcherProvider dispatcher={dispatcher}>
301
+ <ExtensionSectionsProvider value={{ "rich-dashboard-kpi-icon": KpiIcon }}>
302
+ <DashboardBodyProvider value={WebDashboardBody}>
303
+ <KumikoScreen schema={richSchema} qn="widgets:screen:rich" />
304
+ </DashboardBodyProvider>
305
+ </ExtensionSectionsProvider>
306
+ </DispatcherProvider>,
307
+ );
308
+ await waitFor(() => expect(screen.getByTestId("kpi-icon")).toBeTruthy());
309
+ });
310
+
311
+ test("stat-Panel: unregistriertes Icon rendert nichts, wirft nicht", async () => {
312
+ const dispatcher = createMockDispatcher({
313
+ query: (async (type: string) => {
314
+ if (type === "widgets:query:metrics:kpi") {
315
+ return { isSuccess: true, data: { value: "92.753 €" } };
316
+ }
317
+ return { isSuccess: true, data: {} };
318
+ }) as unknown as Dispatcher["query"],
319
+ });
320
+ render(
321
+ <DispatcherProvider dispatcher={dispatcher}>
322
+ <DashboardBodyProvider value={WebDashboardBody}>
323
+ <KumikoScreen schema={richSchema} qn="widgets:screen:rich" />
324
+ </DashboardBodyProvider>
325
+ </DispatcherProvider>,
326
+ );
327
+ await waitFor(() => expect(screen.getByText("92.753 €")).toBeTruthy());
328
+ expect(screen.queryByTestId("kpi-icon")).toBeNull();
329
+ });
330
+
285
331
  test("stat-Panel: fehlende deltaDirection unterdrückt den Chip (kein Crash)", async () => {
286
332
  const dispatcher = createMockDispatcher({
287
333
  query: (async (type: string) => {
@@ -10,6 +10,9 @@
10
10
  // deltaField/deltaDirectionField(+deltaToneField) sind
11
11
  // optional — nur wenn BEIDE Felder gesetzt sind UND geliefert
12
12
  // werden, zeigt die Kachel einen Delta-Chip ("↓ 23 %").
13
+ // icon/accentColor sind statisch am Panel (keine Query-
14
+ // Felder) — icon über extensionSectionComponents wie bei
15
+ // custom-Panels, accentColor ein roher CSS-Farbwert.
13
16
  // stat-group → mehrere stat-Panels unter einem Sektions-Titel, jedes
14
17
  // Kind bleibt eine eigenständige Query.
15
18
  // chart → { points: { atMs, value | null }[], windowStartMs,
@@ -58,13 +61,41 @@ const STAT_TONES: ReadonlySet<string> = new Set(["default", "positive", "warn"])
58
61
  const WIDE_PANEL = "sm:col-span-2 lg:col-span-4";
59
62
  const HALF_PANEL = "sm:col-span-2 lg:col-span-2";
60
63
 
64
+ function StatPanelIcon({
65
+ panel,
66
+ screenId,
67
+ filterParams,
68
+ }: {
69
+ readonly panel: DashboardStatPanel;
70
+ readonly screenId: string;
71
+ readonly filterParams: Readonly<Record<string, unknown>>;
72
+ }): ReactNode {
73
+ const name = panel.icon !== undefined ? extensionSectionName(panel.icon) : undefined;
74
+ const Icon = useExtensionSectionComponent(name);
75
+ useEffect(() => {
76
+ if (panel.icon !== undefined && name !== undefined && Icon === undefined) {
77
+ // biome-ignore lint/suspicious/noConsole: dev-warning für Setup-Fehler
78
+ console.warn(
79
+ `[kumiko] Dashboard stat-panel "${panel.id}" on screen "${screenId}" references icon ` +
80
+ `"${name}", which is not registered in clientFeatures.extensionSectionComponents.`,
81
+ );
82
+ }
83
+ }, [panel.icon, panel.id, name, Icon, screenId]);
84
+ if (Icon === undefined) return null;
85
+ return (
86
+ <Icon entityName={screenId} entityId={null} screenId={screenId} filterParams={filterParams} />
87
+ );
88
+ }
89
+
61
90
  function StatPanelBody({
62
91
  panel,
63
92
  label,
93
+ screenId,
64
94
  filterParams,
65
95
  }: {
66
96
  readonly panel: DashboardStatPanel;
67
97
  readonly label: string;
98
+ readonly screenId: string;
68
99
  readonly filterParams: Readonly<Record<string, unknown>>;
69
100
  }): ReactNode {
70
101
  const { data, error, loading, refetch } = useQuery<Readonly<Record<string, unknown>>>(
@@ -82,9 +113,15 @@ function StatPanelBody({
82
113
  const delta = readDelta(panel, record);
83
114
  return (
84
115
  <StatCard
116
+ icon={
117
+ panel.icon !== undefined ? (
118
+ <StatPanelIcon panel={panel} screenId={screenId} filterParams={filterParams} />
119
+ ) : undefined
120
+ }
85
121
  label={label}
86
122
  value={String(record[panel.valueField] ?? "—")}
87
123
  tone={tone}
124
+ accentColor={panel.accentColor}
88
125
  {...(sub !== undefined && sub !== null && { sub: String(sub) })}
89
126
  {...(delta !== undefined && { delta })}
90
127
  testId={`dashboard-panel-${panel.id}`}
@@ -110,10 +147,12 @@ function readDelta(
110
147
  function StatGroupPanelBody({
111
148
  panel,
112
149
  label,
150
+ screenId,
113
151
  filterParams,
114
152
  }: {
115
153
  readonly panel: DashboardStatGroupPanel;
116
154
  readonly label: string;
155
+ readonly screenId: string;
117
156
  readonly filterParams: Readonly<Record<string, unknown>>;
118
157
  }): ReactNode {
119
158
  const t = useTranslation();
@@ -125,6 +164,7 @@ function StatGroupPanelBody({
125
164
  key={stat.id}
126
165
  panel={stat}
127
166
  label={t(stat.label)}
167
+ screenId={screenId}
128
168
  filterParams={filterParams}
129
169
  />
130
170
  ))}
@@ -365,9 +405,18 @@ function PanelBody({
365
405
  readonly filterParams: Readonly<Record<string, unknown>>;
366
406
  }): ReactNode {
367
407
  if (panel.kind === "stat")
368
- return <StatPanelBody panel={panel} label={label} filterParams={filterParams} />;
408
+ return (
409
+ <StatPanelBody panel={panel} label={label} screenId={screenId} filterParams={filterParams} />
410
+ );
369
411
  if (panel.kind === "stat-group") {
370
- return <StatGroupPanelBody panel={panel} label={label} filterParams={filterParams} />;
412
+ return (
413
+ <StatGroupPanelBody
414
+ panel={panel}
415
+ label={label}
416
+ screenId={screenId}
417
+ filterParams={filterParams}
418
+ />
419
+ );
371
420
  }
372
421
  if (panel.kind === "chart")
373
422
  return <ChartPanelBody panel={panel} label={label} filterParams={filterParams} />;
package/src/index.ts CHANGED
@@ -155,20 +155,29 @@ export {
155
155
  } from "./tokens";
156
156
  export { SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider } from "./ui/sidebar";
157
157
  export type {
158
+ BooleanFieldProps,
159
+ ComparisonMetric,
160
+ DateFieldProps,
158
161
  FeedRow,
159
162
  NumberFieldProps,
160
163
  ProgressListRow,
161
164
  QueryTableColumn,
162
165
  QueryTableProps,
163
166
  ResultColumn,
167
+ SelectFieldProps,
164
168
  StatDelta,
165
169
  StatTone,
166
170
  StatusBarEntry,
167
171
  StatusTone,
172
+ TextareaFieldProps,
173
+ TextFieldProps,
168
174
  TimeseriesPoint,
169
175
  } from "./widgets";
170
176
  export {
177
+ BooleanField,
171
178
  CollapsibleSection,
179
+ ComparisonTable,
180
+ DateField,
172
181
  DetailList,
173
182
  EmptyState,
174
183
  ErrorState,
@@ -185,12 +194,15 @@ export {
185
194
  ResultPanel,
186
195
  ResultTable,
187
196
  SectionCard,
197
+ SelectField,
188
198
  Sparkline,
189
199
  STATUS_TONE_TEXT,
190
200
  StatCard,
191
201
  StatusBadge,
192
202
  StatusBarChart,
193
203
  smoothPath,
204
+ TextareaField,
205
+ TextField,
194
206
  TimeseriesChart,
195
207
  useDraft,
196
208
  } from "./widgets";
@@ -1,8 +1,17 @@
1
1
  import { describe, expect, mock, test } from "bun:test";
2
2
  import { act, fireEvent, render, renderHook, screen } from "../../__tests__/test-utils";
3
3
  import { DetailList } from "../detail-list";
4
- import { MoneyField, NumberField, PercentField } from "../form-fields";
5
- import { ResultPanel, ResultTable } from "../result-panel";
4
+ import {
5
+ BooleanField,
6
+ DateField,
7
+ MoneyField,
8
+ NumberField,
9
+ PercentField,
10
+ SelectField,
11
+ TextareaField,
12
+ TextField,
13
+ } from "../form-fields";
14
+ import { ComparisonTable, ResultPanel, ResultTable } from "../result-panel";
6
15
  import { useDraft } from "../use-draft";
7
16
 
8
17
  describe("useDraft", () => {
@@ -103,3 +112,105 @@ describe("ResultTable", () => {
103
112
  expect(screen.getByText("300.000 €").className).toContain("tabular-nums");
104
113
  });
105
114
  });
115
+
116
+ describe("Feld-Widgets (Select/Date/Text/Boolean/Textarea)", () => {
117
+ const noopStr = (): void => {};
118
+
119
+ test("SelectField rendert Label und den aktuell gewählten Wert", () => {
120
+ render(
121
+ <SelectField
122
+ label="Land"
123
+ id="l"
124
+ name="l"
125
+ value="NW"
126
+ onChange={noopStr}
127
+ options={[
128
+ { value: "NW", label: "Nordrhein-Westfalen" },
129
+ { value: "BY", label: "Bayern" },
130
+ ]}
131
+ />,
132
+ );
133
+ expect(screen.getByText("Land")).toBeTruthy();
134
+ expect(screen.getByText("Nordrhein-Westfalen")).toBeTruthy();
135
+ });
136
+
137
+ test("TextField meldet Eingabe", () => {
138
+ const onChange = mock();
139
+ render(<TextField label="Name" id="n" name="n" value="A" onChange={onChange} />);
140
+ fireEvent.change(screen.getByRole("textbox"), { target: { value: "Neu" } });
141
+ expect(onChange).toHaveBeenCalledWith("Neu");
142
+ });
143
+
144
+ test("DateField rendert mit Wert", () => {
145
+ render(
146
+ <DateField
147
+ label="Datum"
148
+ id="d"
149
+ name="d"
150
+ value="2026-07-10"
151
+ onChange={noopStr}
152
+ max="2030-01-01"
153
+ />,
154
+ );
155
+ expect(screen.getByText("Datum")).toBeTruthy();
156
+ });
157
+
158
+ test("BooleanField meldet Umschalten", () => {
159
+ const onChange = mock();
160
+ render(<BooleanField label="Aktiv" id="b" name="b" value={false} onChange={onChange} />);
161
+ fireEvent.click(screen.getByRole("checkbox"));
162
+ expect(onChange).toHaveBeenCalledWith(true);
163
+ });
164
+
165
+ test("TextareaField meldet Eingabe", () => {
166
+ const onChange = mock();
167
+ render(<TextareaField label="Notiz" id="t" name="t" value="" onChange={onChange} rows={3} />);
168
+ fireEvent.change(screen.getByRole("textbox"), { target: { value: "Text" } });
169
+ expect(onChange).toHaveBeenCalledWith("Text");
170
+ });
171
+ });
172
+
173
+ describe("ResultPanel footer", () => {
174
+ test("rendert den footer-Slot", () => {
175
+ render(
176
+ <ResultPanel
177
+ title="R"
178
+ rows={[{ label: "X", value: "1" }]}
179
+ footer={<button type="button">Los</button>}
180
+ >
181
+ <span>body</span>
182
+ </ResultPanel>,
183
+ );
184
+ expect(screen.getByRole("button", { name: "Los" })).toBeTruthy();
185
+ });
186
+ });
187
+
188
+ describe("ComparisonTable", () => {
189
+ test("transponierte Matrix mit Best-Highlight je Kennzahl", () => {
190
+ const cols = [
191
+ { name: "A", rate: 900 },
192
+ { name: "B", rate: 850 },
193
+ ];
194
+ render(
195
+ <ComparisonTable
196
+ testId="cmp"
197
+ columns={cols}
198
+ columnHeader={(c) => c.name}
199
+ columnKey={(c) => c.name}
200
+ metricLabel="Kennzahl"
201
+ metrics={[
202
+ {
203
+ label: "Rate",
204
+ value: (c: { name: string; rate: number }) => `${c.rate} €`,
205
+ bestIndex: (cs) => ((cs[0]?.rate ?? 0) <= (cs[1]?.rate ?? 0) ? 0 : 1),
206
+ },
207
+ ]}
208
+ />,
209
+ );
210
+ expect(screen.getByText("Kennzahl")).toBeTruthy();
211
+ expect(screen.getByText("A")).toBeTruthy();
212
+ // B (850) ist günstiger → hervorgehoben (font-semibold text-primary span)
213
+ expect(screen.getByText("850 €").className).toContain("text-primary");
214
+ expect(screen.getByText("900 €").className).not.toContain("text-primary");
215
+ });
216
+ });
@@ -52,3 +52,192 @@ export function MoneyField(props: NumberFieldProps): ReactNode {
52
52
  export function PercentField(props: NumberFieldProps): ReactNode {
53
53
  return <NumberField {...props} />;
54
54
  }
55
+
56
+ interface FieldBase {
57
+ readonly label: string;
58
+ readonly id: string;
59
+ readonly name: string;
60
+ readonly required?: boolean;
61
+ readonly disabled?: boolean;
62
+ readonly testId?: string;
63
+ }
64
+
65
+ export interface TextFieldProps extends FieldBase {
66
+ readonly value: string;
67
+ readonly onChange: (v: string) => void;
68
+ readonly placeholder?: string;
69
+ readonly autoComplete?: string;
70
+ }
71
+
72
+ /** Textfeld = Field + Input(kind:"text"). */
73
+ export function TextField({
74
+ label,
75
+ id,
76
+ name,
77
+ value,
78
+ onChange,
79
+ required,
80
+ disabled,
81
+ placeholder,
82
+ autoComplete,
83
+ testId,
84
+ }: TextFieldProps): ReactNode {
85
+ const { Field, Input } = usePrimitives();
86
+ return (
87
+ <Field id={id} label={label} required={required} testId={testId}>
88
+ <Input
89
+ kind="text"
90
+ id={id}
91
+ name={name}
92
+ value={value}
93
+ onChange={onChange}
94
+ required={required}
95
+ disabled={disabled}
96
+ {...(placeholder !== undefined && { placeholder })}
97
+ {...(autoComplete !== undefined && { autoComplete })}
98
+ />
99
+ </Field>
100
+ );
101
+ }
102
+
103
+ export interface SelectFieldProps extends FieldBase {
104
+ readonly value: string;
105
+ readonly onChange: (v: string) => void;
106
+ readonly options:
107
+ | readonly string[]
108
+ | readonly { readonly value: string; readonly label: string }[];
109
+ }
110
+
111
+ /** Auswahlfeld = Field + Input(kind:"select"). */
112
+ export function SelectField({
113
+ label,
114
+ id,
115
+ name,
116
+ value,
117
+ onChange,
118
+ options,
119
+ required,
120
+ disabled,
121
+ testId,
122
+ }: SelectFieldProps): ReactNode {
123
+ const { Field, Input } = usePrimitives();
124
+ return (
125
+ <Field id={id} label={label} required={required} testId={testId}>
126
+ <Input
127
+ kind="select"
128
+ id={id}
129
+ name={name}
130
+ value={value}
131
+ onChange={onChange}
132
+ options={options}
133
+ required={required}
134
+ disabled={disabled}
135
+ />
136
+ </Field>
137
+ );
138
+ }
139
+
140
+ export interface DateFieldProps extends FieldBase {
141
+ readonly value: string;
142
+ readonly onChange: (v: string | undefined) => void;
143
+ readonly min?: string;
144
+ readonly max?: string;
145
+ }
146
+
147
+ /** Datumsfeld = Field + Input(kind:"date"), ISO yyyy-mm-dd. */
148
+ export function DateField({
149
+ label,
150
+ id,
151
+ name,
152
+ value,
153
+ onChange,
154
+ min,
155
+ max,
156
+ required,
157
+ disabled,
158
+ testId,
159
+ }: DateFieldProps): ReactNode {
160
+ const { Field, Input } = usePrimitives();
161
+ return (
162
+ <Field id={id} label={label} required={required} testId={testId}>
163
+ <Input
164
+ kind="date"
165
+ id={id}
166
+ name={name}
167
+ value={value}
168
+ onChange={onChange}
169
+ required={required}
170
+ disabled={disabled}
171
+ {...(min !== undefined && { min })}
172
+ {...(max !== undefined && { max })}
173
+ />
174
+ </Field>
175
+ );
176
+ }
177
+
178
+ export interface BooleanFieldProps extends FieldBase {
179
+ readonly value: boolean;
180
+ readonly onChange: (v: boolean) => void;
181
+ }
182
+
183
+ /** Checkbox/Switch = Field(layout:"inline") + Input(kind:"boolean"). */
184
+ export function BooleanField({
185
+ label,
186
+ id,
187
+ name,
188
+ value,
189
+ onChange,
190
+ required,
191
+ disabled,
192
+ testId,
193
+ }: BooleanFieldProps): ReactNode {
194
+ const { Field, Input } = usePrimitives();
195
+ return (
196
+ <Field id={id} label={label} required={required} layout="inline" testId={testId}>
197
+ <Input
198
+ kind="boolean"
199
+ id={id}
200
+ name={name}
201
+ value={value}
202
+ onChange={onChange}
203
+ required={required}
204
+ disabled={disabled}
205
+ />
206
+ </Field>
207
+ );
208
+ }
209
+
210
+ export interface TextareaFieldProps extends FieldBase {
211
+ readonly value: string;
212
+ readonly onChange: (v: string) => void;
213
+ readonly rows?: number;
214
+ }
215
+
216
+ /** Mehrzeiliges Textfeld = Field + Input(kind:"textarea"). */
217
+ export function TextareaField({
218
+ label,
219
+ id,
220
+ name,
221
+ value,
222
+ onChange,
223
+ rows,
224
+ required,
225
+ disabled,
226
+ testId,
227
+ }: TextareaFieldProps): ReactNode {
228
+ const { Field, Input } = usePrimitives();
229
+ return (
230
+ <Field id={id} label={label} required={required} testId={testId}>
231
+ <Input
232
+ kind="textarea"
233
+ id={id}
234
+ name={name}
235
+ value={value}
236
+ onChange={onChange}
237
+ required={required}
238
+ disabled={disabled}
239
+ {...(rows !== undefined && { rows })}
240
+ />
241
+ </Field>
242
+ );
243
+ }
@@ -13,12 +13,33 @@ export {
13
13
  export { CollapsibleSection } from "./collapsible-section";
14
14
  export { DetailList } from "./detail-list";
15
15
  export { FeedList, type FeedRow } from "./feed-list";
16
- export { MoneyField, NumberField, type NumberFieldProps, PercentField } from "./form-fields";
16
+ export {
17
+ BooleanField,
18
+ type BooleanFieldProps,
19
+ DateField,
20
+ type DateFieldProps,
21
+ MoneyField,
22
+ NumberField,
23
+ type NumberFieldProps,
24
+ PercentField,
25
+ SelectField,
26
+ type SelectFieldProps,
27
+ TextareaField,
28
+ type TextareaFieldProps,
29
+ TextField,
30
+ type TextFieldProps,
31
+ } from "./form-fields";
17
32
  export { ModeSwitch } from "./mode-switch";
18
33
  export { ProgressBar } from "./progress-bar";
19
34
  export { ProgressList, type ProgressListRow } from "./progress-list";
20
35
  export { QueryTable, type QueryTableColumn, type QueryTableProps } from "./query-table";
21
- export { type ResultColumn, ResultPanel, ResultTable } from "./result-panel";
36
+ export {
37
+ type ComparisonMetric,
38
+ ComparisonTable,
39
+ type ResultColumn,
40
+ ResultPanel,
41
+ ResultTable,
42
+ } from "./result-panel";
22
43
  export { SectionCard } from "./section-card";
23
44
  export { MiniStat, Sparkline, StatCard, type StatDelta, type StatTone } from "./stat";
24
45
  export { EmptyState, ErrorState, LoadingState } from "./states";
@@ -13,6 +13,7 @@ export function ResultPanel({
13
13
  empty,
14
14
  emptyText,
15
15
  rows,
16
+ footer,
16
17
  children,
17
18
  testId,
18
19
  }: {
@@ -25,12 +26,14 @@ export function ResultPanel({
25
26
  readonly value: ReactNode;
26
27
  readonly emphasize?: boolean;
27
28
  }[];
29
+ /** Action-Slot am Karten-Fuß (z.B. „In Finanzierung übernehmen"). */
30
+ readonly footer?: ReactNode;
28
31
  readonly children?: ReactNode;
29
32
  readonly testId?: string;
30
33
  }): ReactNode {
31
34
  const { Banner } = usePrimitives();
32
35
  return (
33
- <SectionCard title={title} subtitle={subtitle} testId={testId}>
36
+ <SectionCard title={title} subtitle={subtitle} footer={footer} testId={testId}>
34
37
  {empty === true ? (
35
38
  <Banner variant="info" padded>
36
39
  {emptyText}
@@ -99,3 +102,67 @@ export function ResultTable<Row>({
99
102
  </div>
100
103
  );
101
104
  }
105
+
106
+ export interface ComparisonMetric<Col> {
107
+ readonly label: string;
108
+ readonly value: (col: Col, index: number) => ReactNode;
109
+ /** Index der besten Spalte für diese Zeile (hervorgehoben); -1 = keine. */
110
+ readonly bestIndex?: (cols: readonly Col[]) => number;
111
+ }
112
+
113
+ /** Transponierte Vergleichstabelle: Zeile = Kennzahl, Spalte = Variante, je
114
+ * Kennzahl optional die beste Spalte hervorgehoben. Für Szenario-/Angebots-
115
+ * Vergleiche, wo ResultTable (Zeile=Datensatz) nicht passt. */
116
+ export function ComparisonTable<Col>({
117
+ columns,
118
+ columnHeader,
119
+ columnKey,
120
+ metrics,
121
+ metricLabel,
122
+ testId,
123
+ }: {
124
+ readonly columns: readonly Col[];
125
+ readonly columnHeader: (col: Col, index: number) => string;
126
+ readonly columnKey: (col: Col, index: number) => string;
127
+ readonly metrics: readonly ComparisonMetric<Col>[];
128
+ readonly metricLabel: string;
129
+ readonly testId?: string;
130
+ }): ReactNode {
131
+ return (
132
+ <div className="overflow-x-auto">
133
+ <table data-testid={testId} className="w-full min-w-[24rem] text-sm">
134
+ <thead>
135
+ <tr className="border-b text-left text-muted-foreground">
136
+ <th className="py-1.5 font-medium">{metricLabel}</th>
137
+ {columns.map((col, i) => (
138
+ <th key={columnKey(col, i)} className="py-1.5 text-right font-medium">
139
+ {columnHeader(col, i)}
140
+ </th>
141
+ ))}
142
+ </tr>
143
+ </thead>
144
+ <tbody>
145
+ {metrics.map((metric) => {
146
+ const best = metric.bestIndex !== undefined ? metric.bestIndex(columns) : -1;
147
+ return (
148
+ <tr key={metric.label} className="border-b last:border-0">
149
+ <td className="py-1.5 text-muted-foreground">{metric.label}</td>
150
+ {columns.map((col, i) => (
151
+ <td key={columnKey(col, i)} className="py-1.5 text-right tabular-nums">
152
+ {i === best ? (
153
+ <span className="inline-block rounded bg-primary/10 px-2 py-0.5 font-semibold text-primary">
154
+ {metric.value(col, i)}
155
+ </span>
156
+ ) : (
157
+ metric.value(col, i)
158
+ )}
159
+ </td>
160
+ ))}
161
+ </tr>
162
+ );
163
+ })}
164
+ </tbody>
165
+ </table>
166
+ </div>
167
+ );
168
+ }