@cosmicdrift/kumiko-renderer-web 0.130.2 → 0.132.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 +86 -0
- package/src/__tests__/projection-list-actions.test.tsx +89 -0
- package/src/__tests__/toast.test.tsx +9 -9
- package/src/app/create-app.tsx +18 -14
- package/src/app/dashboard-body.tsx +146 -0
- package/src/index.ts +28 -0
- package/src/primitives/index.tsx +48 -3
- package/src/primitives/toast.tsx +14 -9
- package/src/styles.css +14 -0
- package/src/widgets/__tests__/query-table.test.tsx +118 -0
- package/src/widgets/__tests__/widgets.test.tsx +229 -0
- package/src/widgets/charts.tsx +228 -0
- package/src/widgets/collapsible-section.tsx +42 -0
- package/src/widgets/detail-list.tsx +25 -0
- package/src/widgets/index.ts +21 -0
- package/src/widgets/mode-switch.tsx +40 -0
- package/src/widgets/progress-bar.tsx +27 -0
- package/src/widgets/query-table.tsx +103 -0
- package/src/widgets/section-card.tsx +30 -0
- package/src/widgets/stat.tsx +181 -0
- package/src/widgets/states.tsx +89 -0
- package/src/widgets/status-badge.tsx +51 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
|
|
4
|
+
/** Segmented-Control für sich ausschließende Modi — die prominente
|
|
5
|
+
* Alternative zum vergrabenen <select>. */
|
|
6
|
+
export function ModeSwitch<T extends string>({
|
|
7
|
+
value,
|
|
8
|
+
options,
|
|
9
|
+
onChange,
|
|
10
|
+
testId,
|
|
11
|
+
}: {
|
|
12
|
+
readonly value: T;
|
|
13
|
+
readonly options: readonly { readonly value: T; readonly label: string }[];
|
|
14
|
+
readonly onChange: (value: T) => void;
|
|
15
|
+
readonly testId?: string;
|
|
16
|
+
}): ReactNode {
|
|
17
|
+
return (
|
|
18
|
+
<div data-testid={testId} className="flex flex-wrap gap-1">
|
|
19
|
+
{options.map((o) => {
|
|
20
|
+
const active = o.value === value;
|
|
21
|
+
return (
|
|
22
|
+
<button
|
|
23
|
+
key={o.value}
|
|
24
|
+
type="button"
|
|
25
|
+
aria-pressed={active}
|
|
26
|
+
onClick={() => onChange(o.value)}
|
|
27
|
+
className={cn(
|
|
28
|
+
"rounded-md border px-3 py-1.5 text-sm transition-colors",
|
|
29
|
+
active
|
|
30
|
+
? "border-transparent bg-secondary font-semibold text-secondary-foreground"
|
|
31
|
+
: "text-muted-foreground hover:bg-muted",
|
|
32
|
+
)}
|
|
33
|
+
>
|
|
34
|
+
{o.label}
|
|
35
|
+
</button>
|
|
36
|
+
);
|
|
37
|
+
})}
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
|
|
4
|
+
/** Fortschrittsbalken, `value` 0..1 (wird geclampt). */
|
|
5
|
+
export function ProgressBar({
|
|
6
|
+
value,
|
|
7
|
+
className,
|
|
8
|
+
testId,
|
|
9
|
+
}: {
|
|
10
|
+
readonly value: number;
|
|
11
|
+
readonly className?: string;
|
|
12
|
+
readonly testId?: string;
|
|
13
|
+
}): ReactNode {
|
|
14
|
+
const pct = Math.max(0, Math.min(1, value));
|
|
15
|
+
return (
|
|
16
|
+
<div
|
|
17
|
+
data-testid={testId}
|
|
18
|
+
role="progressbar"
|
|
19
|
+
aria-valuenow={Math.round(pct * 100)}
|
|
20
|
+
aria-valuemin={0}
|
|
21
|
+
aria-valuemax={100}
|
|
22
|
+
className={cn("h-2 w-full overflow-hidden rounded-full bg-muted", className)}
|
|
23
|
+
>
|
|
24
|
+
<div className="h-full rounded-full bg-primary" style={{ width: `${pct * 100}%` }} />
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { ListRowViewModel } from "@cosmicdrift/kumiko-headless";
|
|
2
|
+
import {
|
|
3
|
+
type DataTableProps,
|
|
4
|
+
usePrimitives,
|
|
5
|
+
useQuery,
|
|
6
|
+
useTranslation,
|
|
7
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
8
|
+
import type { ReactNode } from "react";
|
|
9
|
+
import { EmptyState, ErrorState, LoadingState } from "./states";
|
|
10
|
+
|
|
11
|
+
// Query-backed Tabelle für Custom-Screens: ein Widget statt der
|
|
12
|
+
// handgebauten useState+fetch+<table>-Trios. Deklarativ: Query-Type +
|
|
13
|
+
// Spalten rein, Loading/Error/Empty/Render übernimmt das Widget über
|
|
14
|
+
// das DataTable-Primitive (gleiche Optik wie entityList-Screens).
|
|
15
|
+
//
|
|
16
|
+
// ponytail: Zellen rendern über die DataTable-Typ-Formatierung (text/
|
|
17
|
+
// number/money/…), kein render-Prop pro Spalte — für Custom-Zellen die
|
|
18
|
+
// entityList-columnRenderers nutzen oder das Widget erweitern, wenn eine
|
|
19
|
+
// Migration es wirklich braucht.
|
|
20
|
+
|
|
21
|
+
export type QueryTableColumn = {
|
|
22
|
+
readonly field: string;
|
|
23
|
+
/** Translated Header-Label (i18n-Key auflösen liegt beim Caller). */
|
|
24
|
+
readonly label: string;
|
|
25
|
+
/** Field-Type für die Zell-Formatierung. Default "text". */
|
|
26
|
+
readonly type?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type QueryTableProps<TData = unknown> = {
|
|
30
|
+
/** Dispatcher-Query-Type (`<feature>:query:<entity>:<verb>`). */
|
|
31
|
+
readonly query: string;
|
|
32
|
+
readonly payload?: unknown;
|
|
33
|
+
/** SSE-Invalidierung — refetcht bei Entity-Events (useQuery live-mode). */
|
|
34
|
+
readonly live?: boolean;
|
|
35
|
+
readonly columns: readonly QueryTableColumn[];
|
|
36
|
+
/** Rows aus dem Query-Result ziehen. Default: Result selbst als Array. */
|
|
37
|
+
readonly rows?: (data: TData) => readonly Readonly<Record<string, unknown>>[];
|
|
38
|
+
/** Row-Id für Keys/Row-Click. Default: `row.id`, sonst der Index. */
|
|
39
|
+
readonly rowId?: (row: Readonly<Record<string, unknown>>, index: number) => string;
|
|
40
|
+
readonly onRowClick?: DataTableProps["onRowClick"];
|
|
41
|
+
readonly rowActions?: DataTableProps["rowActions"];
|
|
42
|
+
/** Empty-Inhalt — Default ist der Standard-Empty-State der Listen. */
|
|
43
|
+
readonly emptyState?: ReactNode;
|
|
44
|
+
readonly toolbarTitle?: ReactNode;
|
|
45
|
+
readonly toolbarEnd?: ReactNode;
|
|
46
|
+
readonly testId?: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function defaultRows(data: unknown): readonly Readonly<Record<string, unknown>>[] {
|
|
50
|
+
return Array.isArray(data) ? (data as readonly Readonly<Record<string, unknown>>[]) : [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function QueryTable<TData = unknown>({
|
|
54
|
+
query,
|
|
55
|
+
payload,
|
|
56
|
+
live = false,
|
|
57
|
+
columns,
|
|
58
|
+
rows,
|
|
59
|
+
rowId,
|
|
60
|
+
onRowClick,
|
|
61
|
+
rowActions,
|
|
62
|
+
emptyState,
|
|
63
|
+
toolbarTitle,
|
|
64
|
+
toolbarEnd,
|
|
65
|
+
testId,
|
|
66
|
+
}: QueryTableProps<TData>): ReactNode {
|
|
67
|
+
const { DataTable } = usePrimitives();
|
|
68
|
+
const t = useTranslation();
|
|
69
|
+
const { data, error, loading, refetch } = useQuery<TData>(query, payload ?? {}, { live });
|
|
70
|
+
|
|
71
|
+
if (loading && data === null) return <LoadingState rows={4} testId={testId} />;
|
|
72
|
+
if (error !== null)
|
|
73
|
+
return <ErrorState error={error} onRetry={() => void refetch()} testId={testId} />;
|
|
74
|
+
|
|
75
|
+
const rawRows = rows !== undefined ? rows(data as TData) : defaultRows(data);
|
|
76
|
+
const vmRows: readonly ListRowViewModel[] = rawRows.map((row, index) => ({
|
|
77
|
+
id:
|
|
78
|
+
rowId !== undefined
|
|
79
|
+
? rowId(row, index)
|
|
80
|
+
: typeof row["id"] === "string"
|
|
81
|
+
? row["id"]
|
|
82
|
+
: String(index),
|
|
83
|
+
values: row,
|
|
84
|
+
}));
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<DataTable
|
|
88
|
+
columns={columns.map((c) => ({
|
|
89
|
+
field: c.field,
|
|
90
|
+
label: c.label,
|
|
91
|
+
type: c.type ?? "text",
|
|
92
|
+
sortable: false,
|
|
93
|
+
}))}
|
|
94
|
+
rows={vmRows}
|
|
95
|
+
onRowClick={onRowClick}
|
|
96
|
+
rowActions={rowActions}
|
|
97
|
+
emptyState={emptyState ?? <EmptyState title={t("kumiko.list.no-entries")} />}
|
|
98
|
+
toolbarTitle={toolbarTitle}
|
|
99
|
+
toolbarEnd={toolbarEnd}
|
|
100
|
+
testId={testId}
|
|
101
|
+
/>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { usePrimitives } from "@cosmicdrift/kumiko-renderer";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
/** Sektion mit Titel + optionalem Action-Slot (Range-Umschalter, Filter) —
|
|
5
|
+
* ersetzt die wiederholten `<section className={CARD}>…<h2>`-Blöcke in
|
|
6
|
+
* Custom-Screens. Dünner Wrapper über das Card-Primitive. */
|
|
7
|
+
export function SectionCard({
|
|
8
|
+
title,
|
|
9
|
+
subtitle,
|
|
10
|
+
action,
|
|
11
|
+
footer,
|
|
12
|
+
children,
|
|
13
|
+
testId,
|
|
14
|
+
}: {
|
|
15
|
+
readonly title: string;
|
|
16
|
+
readonly subtitle?: string;
|
|
17
|
+
readonly action?: ReactNode;
|
|
18
|
+
readonly footer?: ReactNode;
|
|
19
|
+
readonly children: ReactNode;
|
|
20
|
+
readonly testId?: string;
|
|
21
|
+
}): ReactNode {
|
|
22
|
+
const { Card } = usePrimitives();
|
|
23
|
+
return (
|
|
24
|
+
<Card slots={{ title, subtitle, headerActions: action, footer }} testId={testId}>
|
|
25
|
+
{/* h-full füllt den Card-Body (grow) → ein Kind mit mt-auto kann sich unten
|
|
26
|
+
ankern, damit Card-Reihen über mehrere SectionCards fluchten. */}
|
|
27
|
+
<div className="flex h-full flex-col gap-4">{children}</div>
|
|
28
|
+
</Card>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { usePrimitives } from "@cosmicdrift/kumiko-renderer";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
|
|
5
|
+
// KPI-Kacheln. `tone` steuert Value-/Chip-Farbe semantisch (default |
|
|
6
|
+
// positive | warn), `accentColor` färbt NUR den Icon-Chip mit einer
|
|
7
|
+
// App-semantischen Farbe (z.B. Finanz-Rollen) — Value/Delta/Sparkline
|
|
8
|
+
// bleiben an `tone`.
|
|
9
|
+
|
|
10
|
+
export type StatTone = "default" | "positive" | "warn";
|
|
11
|
+
|
|
12
|
+
const TONE_CHIP: Record<StatTone, string> = {
|
|
13
|
+
default: "bg-muted text-foreground",
|
|
14
|
+
positive: "bg-primary/10 text-primary",
|
|
15
|
+
warn: "bg-destructive/10 text-destructive",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const TONE_VALUE: Record<StatTone, string> = {
|
|
19
|
+
default: "text-foreground",
|
|
20
|
+
positive: "text-primary",
|
|
21
|
+
warn: "text-destructive",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type StatDelta = {
|
|
25
|
+
readonly value: string;
|
|
26
|
+
readonly direction: "up" | "down";
|
|
27
|
+
readonly tone?: StatTone;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Inline-SVG-Sparkline (kein Chart-Dep für eine 28px-Kurve): Linie +
|
|
31
|
+
// schwacher Flächen-Verlauf, Farbe via currentColor (Caller setzt text-tone).
|
|
32
|
+
export function Sparkline({
|
|
33
|
+
points,
|
|
34
|
+
className,
|
|
35
|
+
}: {
|
|
36
|
+
readonly points: readonly number[];
|
|
37
|
+
readonly className?: string;
|
|
38
|
+
}): ReactNode {
|
|
39
|
+
if (points.length < 2) return null;
|
|
40
|
+
const w = 100;
|
|
41
|
+
const h = 28;
|
|
42
|
+
const min = Math.min(...points);
|
|
43
|
+
const max = Math.max(...points);
|
|
44
|
+
const span = max - min || 1;
|
|
45
|
+
const line = points
|
|
46
|
+
.map((p, i) => {
|
|
47
|
+
const x = (i / (points.length - 1)) * w;
|
|
48
|
+
const y = h - ((p - min) / span) * h;
|
|
49
|
+
return `${i === 0 ? "M" : "L"}${x.toFixed(1)},${y.toFixed(1)}`;
|
|
50
|
+
})
|
|
51
|
+
.join(" ");
|
|
52
|
+
return (
|
|
53
|
+
<svg
|
|
54
|
+
viewBox={`0 0 ${w} ${h}`}
|
|
55
|
+
preserveAspectRatio="none"
|
|
56
|
+
className={className ?? "h-7 w-full"}
|
|
57
|
+
aria-hidden="true"
|
|
58
|
+
>
|
|
59
|
+
<path d={`${line} L${w},${h} L0,${h} Z`} fill="currentColor" fillOpacity={0.12} />
|
|
60
|
+
<path
|
|
61
|
+
d={line}
|
|
62
|
+
fill="none"
|
|
63
|
+
stroke="currentColor"
|
|
64
|
+
strokeWidth={1.5}
|
|
65
|
+
strokeLinecap="round"
|
|
66
|
+
strokeLinejoin="round"
|
|
67
|
+
vectorEffect="non-scaling-stroke"
|
|
68
|
+
/>
|
|
69
|
+
</svg>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Icon-tragende Kennzahl-Kachel mit optionalem Delta-Chip, Trend-Zeile
|
|
74
|
+
* und Sparkline. `icon` ist ein fertiger Knoten (App liefert ihr SVG). */
|
|
75
|
+
export function StatCard({
|
|
76
|
+
icon,
|
|
77
|
+
label,
|
|
78
|
+
value,
|
|
79
|
+
sub,
|
|
80
|
+
tone = "default",
|
|
81
|
+
accentColor,
|
|
82
|
+
delta,
|
|
83
|
+
trend,
|
|
84
|
+
spark,
|
|
85
|
+
testId,
|
|
86
|
+
}: {
|
|
87
|
+
readonly icon?: ReactNode;
|
|
88
|
+
readonly label: string;
|
|
89
|
+
readonly value: string;
|
|
90
|
+
readonly sub?: string;
|
|
91
|
+
readonly tone?: StatTone;
|
|
92
|
+
readonly accentColor?: string;
|
|
93
|
+
readonly delta?: StatDelta;
|
|
94
|
+
readonly trend?: string;
|
|
95
|
+
readonly spark?: readonly number[];
|
|
96
|
+
readonly testId?: string;
|
|
97
|
+
}): ReactNode {
|
|
98
|
+
const { Card } = usePrimitives();
|
|
99
|
+
return (
|
|
100
|
+
<Card options={{ padded: false }} className="p-4" testId={testId}>
|
|
101
|
+
<div className="flex items-center justify-between gap-2">
|
|
102
|
+
<div className="flex items-center gap-2 text-muted-foreground">
|
|
103
|
+
{icon !== undefined && (
|
|
104
|
+
<span
|
|
105
|
+
className={cn(
|
|
106
|
+
"flex size-7 items-center justify-center rounded-lg",
|
|
107
|
+
accentColor === undefined && TONE_CHIP[tone],
|
|
108
|
+
)}
|
|
109
|
+
style={
|
|
110
|
+
accentColor === undefined
|
|
111
|
+
? undefined
|
|
112
|
+
: {
|
|
113
|
+
color: accentColor,
|
|
114
|
+
backgroundColor: `color-mix(in srgb, ${accentColor} 12%, transparent)`,
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
>
|
|
118
|
+
{icon}
|
|
119
|
+
</span>
|
|
120
|
+
)}
|
|
121
|
+
<span className="text-xs font-medium">{label}</span>
|
|
122
|
+
</div>
|
|
123
|
+
{delta !== undefined && (
|
|
124
|
+
<span
|
|
125
|
+
className={cn(
|
|
126
|
+
"inline-flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums",
|
|
127
|
+
TONE_CHIP[delta.tone ?? tone],
|
|
128
|
+
)}
|
|
129
|
+
>
|
|
130
|
+
{delta.direction === "up" ? "↑" : "↓"}
|
|
131
|
+
{delta.value}
|
|
132
|
+
</span>
|
|
133
|
+
)}
|
|
134
|
+
</div>
|
|
135
|
+
<div className="mt-2 text-xl font-semibold tabular-nums text-foreground">{value}</div>
|
|
136
|
+
{sub !== undefined && <div className="mt-0.5 text-xs text-muted-foreground">{sub}</div>}
|
|
137
|
+
{trend !== undefined && (
|
|
138
|
+
<div className="mt-0.5 text-xs font-medium text-foreground/80">{trend}</div>
|
|
139
|
+
)}
|
|
140
|
+
{spark !== undefined && (
|
|
141
|
+
<Sparkline points={spark} className={cn("mt-2 h-7 w-full", TONE_VALUE[tone])} />
|
|
142
|
+
)}
|
|
143
|
+
</Card>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Kompakte Kennzahl-Kachel (ohne Icon) — für dichte KPI-Raster.
|
|
148
|
+
* StatCard ist die icon-tragende Variante. */
|
|
149
|
+
export function MiniStat({
|
|
150
|
+
label,
|
|
151
|
+
value,
|
|
152
|
+
tone = "default",
|
|
153
|
+
emphasize = false,
|
|
154
|
+
testId,
|
|
155
|
+
}: {
|
|
156
|
+
readonly label: string;
|
|
157
|
+
readonly value: string;
|
|
158
|
+
readonly tone?: StatTone;
|
|
159
|
+
readonly emphasize?: boolean;
|
|
160
|
+
readonly testId?: string;
|
|
161
|
+
}): ReactNode {
|
|
162
|
+
const { Card } = usePrimitives();
|
|
163
|
+
return (
|
|
164
|
+
<Card
|
|
165
|
+
options={{ padded: false }}
|
|
166
|
+
className={cn("p-3", emphasize && "ring-1 ring-primary/30")}
|
|
167
|
+
testId={testId}
|
|
168
|
+
>
|
|
169
|
+
<div className="text-xs text-muted-foreground">{label}</div>
|
|
170
|
+
<div
|
|
171
|
+
className={cn(
|
|
172
|
+
"mt-0.5 font-semibold tabular-nums",
|
|
173
|
+
TONE_VALUE[tone],
|
|
174
|
+
emphasize ? "text-lg" : "text-sm",
|
|
175
|
+
)}
|
|
176
|
+
>
|
|
177
|
+
{value}
|
|
178
|
+
</div>
|
|
179
|
+
</Card>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { DispatcherError } from "@cosmicdrift/kumiko-headless";
|
|
2
|
+
import { dispatcherErrorText, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { cn } from "../lib/cn";
|
|
5
|
+
import { Skeleton } from "../ui/skeleton";
|
|
6
|
+
|
|
7
|
+
/** Leerer-Zustand mit optionalem Icon + CTA — die dashed-Box-Optik der
|
|
8
|
+
* entityList-Empty-States, als Standalone-Widget für Custom-Screens. */
|
|
9
|
+
export function EmptyState({
|
|
10
|
+
icon,
|
|
11
|
+
title,
|
|
12
|
+
description,
|
|
13
|
+
action,
|
|
14
|
+
testId,
|
|
15
|
+
}: {
|
|
16
|
+
readonly icon?: ReactNode;
|
|
17
|
+
readonly title: string;
|
|
18
|
+
readonly description?: string;
|
|
19
|
+
readonly action?: ReactNode;
|
|
20
|
+
readonly testId?: string;
|
|
21
|
+
}): ReactNode {
|
|
22
|
+
return (
|
|
23
|
+
<div
|
|
24
|
+
data-testid={testId}
|
|
25
|
+
className="flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-6 py-10 text-center"
|
|
26
|
+
>
|
|
27
|
+
{icon !== undefined && <div className="text-muted-foreground">{icon}</div>}
|
|
28
|
+
<div className="text-sm font-medium">{title}</div>
|
|
29
|
+
{description !== undefined && (
|
|
30
|
+
<div className="text-sm text-muted-foreground">{description}</div>
|
|
31
|
+
)}
|
|
32
|
+
{action !== undefined && <div className="mt-2">{action}</div>}
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Lade-Zustand: Skeleton-Zeilen in Card-Optik. `rows` steuert die Höhe. */
|
|
38
|
+
export function LoadingState({
|
|
39
|
+
rows = 3,
|
|
40
|
+
className,
|
|
41
|
+
testId,
|
|
42
|
+
}: {
|
|
43
|
+
readonly rows?: number;
|
|
44
|
+
readonly className?: string;
|
|
45
|
+
readonly testId?: string;
|
|
46
|
+
}): ReactNode {
|
|
47
|
+
const t = useTranslation();
|
|
48
|
+
return (
|
|
49
|
+
<output
|
|
50
|
+
data-testid={testId}
|
|
51
|
+
aria-label={t("kumiko.widget.loading")}
|
|
52
|
+
className={cn("flex w-full flex-col gap-2", className)}
|
|
53
|
+
>
|
|
54
|
+
{Array.from({ length: rows }, (_, i) => (
|
|
55
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: statische Skeleton-Liste, Index ist stabil
|
|
56
|
+
<Skeleton key={i} className="h-9 w-full" />
|
|
57
|
+
))}
|
|
58
|
+
</output>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Fehler-Zustand auf Basis des Banner-Primitives, mit Retry-Button. */
|
|
63
|
+
export function ErrorState({
|
|
64
|
+
error,
|
|
65
|
+
onRetry,
|
|
66
|
+
testId,
|
|
67
|
+
}: {
|
|
68
|
+
readonly error: DispatcherError;
|
|
69
|
+
readonly onRetry?: () => void;
|
|
70
|
+
readonly testId?: string;
|
|
71
|
+
}): ReactNode {
|
|
72
|
+
const { Banner, Button } = usePrimitives();
|
|
73
|
+
const t = useTranslation();
|
|
74
|
+
return (
|
|
75
|
+
<Banner
|
|
76
|
+
variant="error"
|
|
77
|
+
testId={testId}
|
|
78
|
+
actions={
|
|
79
|
+
onRetry !== undefined ? (
|
|
80
|
+
<Button variant="secondary" onClick={onRetry}>
|
|
81
|
+
{t("kumiko.actions.reload")}
|
|
82
|
+
</Button>
|
|
83
|
+
) : undefined
|
|
84
|
+
}
|
|
85
|
+
>
|
|
86
|
+
{t("kumiko.widget.error.title")} {dispatcherErrorText(error, t)}
|
|
87
|
+
</Banner>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
|
|
4
|
+
// Semantische Status-Tones — eine Farb-Familie für Component-Status,
|
|
5
|
+
// Incident-Status, Severity, Job-Zustände. Farben kommen aus den
|
|
6
|
+
// --color-status-* Theme-Tokens (styles.css), Apps überschreiben zentral.
|
|
7
|
+
export type StatusTone = "ok" | "warn" | "bad" | "critical" | "muted";
|
|
8
|
+
|
|
9
|
+
export const STATUS_TONE_TEXT: Record<StatusTone, string> = {
|
|
10
|
+
ok: "text-status-ok",
|
|
11
|
+
warn: "text-status-warn",
|
|
12
|
+
bad: "text-status-bad",
|
|
13
|
+
critical: "text-status-critical",
|
|
14
|
+
muted: "text-muted-foreground",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const TONE_PILL: Record<StatusTone, string> = {
|
|
18
|
+
ok: "bg-status-ok/10 text-status-ok",
|
|
19
|
+
warn: "bg-status-warn/10 text-status-warn",
|
|
20
|
+
bad: "bg-status-bad/10 text-status-bad",
|
|
21
|
+
critical: "bg-status-critical/15 text-status-critical",
|
|
22
|
+
muted: "bg-muted text-muted-foreground",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Pill-Badge für Status-Werte. Caller mappt Domain-Werte → Tone
|
|
26
|
+
* (z.B. operational→ok, investigating→warn) und liefert das
|
|
27
|
+
* translated Label als children. */
|
|
28
|
+
export function StatusBadge({
|
|
29
|
+
tone,
|
|
30
|
+
children,
|
|
31
|
+
className,
|
|
32
|
+
testId,
|
|
33
|
+
}: {
|
|
34
|
+
readonly tone: StatusTone;
|
|
35
|
+
readonly children: ReactNode;
|
|
36
|
+
readonly className?: string;
|
|
37
|
+
readonly testId?: string;
|
|
38
|
+
}): ReactNode {
|
|
39
|
+
return (
|
|
40
|
+
<span
|
|
41
|
+
data-testid={testId}
|
|
42
|
+
className={cn(
|
|
43
|
+
"inline-block rounded-xl px-2.5 py-1 text-xs font-semibold",
|
|
44
|
+
TONE_PILL[tone],
|
|
45
|
+
className,
|
|
46
|
+
)}
|
|
47
|
+
>
|
|
48
|
+
{children}
|
|
49
|
+
</span>
|
|
50
|
+
);
|
|
51
|
+
}
|