@llodev/bi-dashboard 0.0.0-stage → 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1 -0
- package/LICENSE +21 -0
- package/README.md +60 -2
- package/SKILL.md +201 -0
- package/anti-patterns/charts.md +14 -0
- package/docs/i18n/README.es-ES.md +61 -0
- package/docs/i18n/README.pt-BR.md +61 -0
- package/package.json +32 -4
- package/references/bi-tools.md +34 -0
- package/references/capability-map.md +17 -0
- package/references/echarts.md +124 -0
- package/references/explore.md +71 -0
- package/references/form.md +32 -0
- package/references/ggplot2.md +37 -0
- package/references/layout.md +44 -0
- package/references/python.md +103 -0
- package/references/react-recharts.md +512 -0
- package/references/story.md +211 -0
- package/references/vega-lite.md +85 -0
- package/references/verify.md +29 -0
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
### 9.1 React + shadcn/ui charts (Recharts) — TypeScript
|
|
2
|
+
|
|
3
|
+
Assumes the shadcn/ui `Card` and `Chart` components are installed (`components/ui/card.tsx`, `components/ui/chart.tsx`). In Tailwind v4 the tokens are used as `var(--chart-1)`; in v3, as `hsl(var(--chart-1))`. `ChartContainer` exposes each `config` key as `--color-<key>`.
|
|
4
|
+
|
|
5
|
+
```tsx
|
|
6
|
+
"use client";
|
|
7
|
+
|
|
8
|
+
import { Bar, BarChart, Cell, ReferenceDot, ReferenceLine, XAxis, YAxis } from "recharts";
|
|
9
|
+
import {
|
|
10
|
+
Card,
|
|
11
|
+
CardContent,
|
|
12
|
+
CardDescription,
|
|
13
|
+
CardFooter,
|
|
14
|
+
CardHeader,
|
|
15
|
+
CardTitle,
|
|
16
|
+
} from "@/components/ui/card";
|
|
17
|
+
import {
|
|
18
|
+
ChartContainer,
|
|
19
|
+
ChartTooltip,
|
|
20
|
+
ChartTooltipContent,
|
|
21
|
+
type ChartConfig,
|
|
22
|
+
} from "@/components/ui/chart";
|
|
23
|
+
import { makeFormat, peakTitle, peakVsBaseline, times } from "@/lib/chart-story";
|
|
24
|
+
|
|
25
|
+
export type DailyPoint = { date: string; value: number }; // date as ISO "2025-11-28"
|
|
26
|
+
|
|
27
|
+
const chartConfig = {
|
|
28
|
+
value: { label: "Sales", color: "var(--chart-1)" },
|
|
29
|
+
context: { label: "Context", color: "var(--muted-foreground)" },
|
|
30
|
+
highlight: { label: "Highlight", color: "var(--chart-1)" },
|
|
31
|
+
} satisfies ChartConfig;
|
|
32
|
+
|
|
33
|
+
type PeakLabelProps = { viewBox?: { x?: number; y?: number }; value: string; note: string };
|
|
34
|
+
|
|
35
|
+
function PeakLabel({ viewBox, value, note }: PeakLabelProps) {
|
|
36
|
+
const x = (viewBox?.x ?? 0) - 8;
|
|
37
|
+
const y = viewBox?.y ?? 0;
|
|
38
|
+
return (
|
|
39
|
+
<g>
|
|
40
|
+
<text
|
|
41
|
+
x={x}
|
|
42
|
+
y={y + 18}
|
|
43
|
+
textAnchor="end"
|
|
44
|
+
style={{ fill: "var(--color-highlight)", fontSize: 22, fontWeight: 800 }}
|
|
45
|
+
>
|
|
46
|
+
{value}
|
|
47
|
+
</text>
|
|
48
|
+
<text
|
|
49
|
+
x={x}
|
|
50
|
+
y={y + 36}
|
|
51
|
+
textAnchor="end"
|
|
52
|
+
style={{ fill: "var(--color-highlight)", fontSize: 12 }}
|
|
53
|
+
>
|
|
54
|
+
{note}
|
|
55
|
+
</text>
|
|
56
|
+
</g>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function PeakBarsCard({
|
|
61
|
+
data,
|
|
62
|
+
eventName,
|
|
63
|
+
subtitle,
|
|
64
|
+
source,
|
|
65
|
+
locale = "pt-BR",
|
|
66
|
+
currency = "BRL",
|
|
67
|
+
}: {
|
|
68
|
+
data: DailyPoint[];
|
|
69
|
+
eventName?: string;
|
|
70
|
+
subtitle: string;
|
|
71
|
+
source?: string;
|
|
72
|
+
locale?: string;
|
|
73
|
+
currency?: string;
|
|
74
|
+
}) {
|
|
75
|
+
const f = makeFormat(locale, currency);
|
|
76
|
+
const { index, row, peak, baseline, factor } = peakVsBaseline(data, (d) => d.value);
|
|
77
|
+
const monthTicks = data.filter((d) => d.date.endsWith("-01")).map((d) => d.date);
|
|
78
|
+
const toDate = (iso: string) => new Date(`${iso}T00:00:00Z`);
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<Card>
|
|
82
|
+
<CardHeader>
|
|
83
|
+
<CardTitle className="text-xl font-extrabold leading-tight">
|
|
84
|
+
{peakTitle(factor, eventName, f)}
|
|
85
|
+
</CardTitle>
|
|
86
|
+
<CardDescription>{subtitle}</CardDescription>
|
|
87
|
+
</CardHeader>
|
|
88
|
+
<CardContent>
|
|
89
|
+
<ChartContainer config={chartConfig} className="aspect-auto h-[320px] w-full">
|
|
90
|
+
<BarChart
|
|
91
|
+
accessibilityLayer
|
|
92
|
+
data={data}
|
|
93
|
+
margin={{ top: 16, right: 8, left: 8, bottom: 0 }}
|
|
94
|
+
barCategoryGap="30%"
|
|
95
|
+
>
|
|
96
|
+
<XAxis
|
|
97
|
+
dataKey="date"
|
|
98
|
+
ticks={monthTicks}
|
|
99
|
+
interval={0}
|
|
100
|
+
tickLine={false}
|
|
101
|
+
axisLine={false}
|
|
102
|
+
tickMargin={8}
|
|
103
|
+
tickFormatter={(iso: string) => f.month(toDate(iso))}
|
|
104
|
+
/>
|
|
105
|
+
<YAxis hide domain={[0, "dataMax"]} />
|
|
106
|
+
<ChartTooltip
|
|
107
|
+
cursor={false}
|
|
108
|
+
content={
|
|
109
|
+
<ChartTooltipContent
|
|
110
|
+
labelFormatter={(iso) => f.day(toDate(String(iso)))}
|
|
111
|
+
formatter={(v) => f.moneyFull.format(Number(v))}
|
|
112
|
+
hideIndicator
|
|
113
|
+
/>
|
|
114
|
+
}
|
|
115
|
+
/>
|
|
116
|
+
<ReferenceLine
|
|
117
|
+
y={baseline}
|
|
118
|
+
stroke="var(--color-context)"
|
|
119
|
+
strokeDasharray="3 3"
|
|
120
|
+
label={{
|
|
121
|
+
value: `normal day (median) · ${f.money.format(baseline)}`,
|
|
122
|
+
position: "insideBottomLeft",
|
|
123
|
+
fill: "var(--color-context)",
|
|
124
|
+
fontSize: 11,
|
|
125
|
+
stroke: "var(--card)",
|
|
126
|
+
strokeWidth: 4,
|
|
127
|
+
paintOrder: "stroke",
|
|
128
|
+
}}
|
|
129
|
+
/>
|
|
130
|
+
<Bar dataKey="value" isAnimationActive={false}>
|
|
131
|
+
{data.map((d, i) => (
|
|
132
|
+
<Cell
|
|
133
|
+
key={d.date}
|
|
134
|
+
fill={i === index ? "var(--color-highlight)" : "var(--color-context)"}
|
|
135
|
+
fillOpacity={i === index ? 1 : 0.35}
|
|
136
|
+
/>
|
|
137
|
+
))}
|
|
138
|
+
</Bar>
|
|
139
|
+
<ReferenceDot
|
|
140
|
+
x={row.date}
|
|
141
|
+
y={peak}
|
|
142
|
+
r={0}
|
|
143
|
+
label={
|
|
144
|
+
<PeakLabel
|
|
145
|
+
value={f.money.format(peak).toUpperCase()}
|
|
146
|
+
note={`${f.day(toDate(row.date))} · ${times(factor, f)} a normal day`}
|
|
147
|
+
/>
|
|
148
|
+
}
|
|
149
|
+
/>
|
|
150
|
+
</BarChart>
|
|
151
|
+
</ChartContainer>
|
|
152
|
+
</CardContent>
|
|
153
|
+
{source && (
|
|
154
|
+
<CardFooter className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
|
155
|
+
{source}
|
|
156
|
+
</CardFooter>
|
|
157
|
+
)}
|
|
158
|
+
</Card>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Line with a protagonist, a direct label at the end, and a mark at the crossing:
|
|
164
|
+
|
|
165
|
+
```tsx
|
|
166
|
+
"use client";
|
|
167
|
+
|
|
168
|
+
import { CartesianGrid, LabelList, Line, LineChart, ReferenceDot, XAxis, YAxis } from "recharts";
|
|
169
|
+
import {
|
|
170
|
+
Card,
|
|
171
|
+
CardContent,
|
|
172
|
+
CardDescription,
|
|
173
|
+
CardFooter,
|
|
174
|
+
CardHeader,
|
|
175
|
+
CardTitle,
|
|
176
|
+
} from "@/components/ui/card";
|
|
177
|
+
import {
|
|
178
|
+
ChartContainer,
|
|
179
|
+
ChartTooltip,
|
|
180
|
+
ChartTooltipContent,
|
|
181
|
+
type ChartConfig,
|
|
182
|
+
} from "@/components/ui/chart";
|
|
183
|
+
import { firstCrossing, makeFormat, overtakeTitle } from "@/lib/chart-story";
|
|
184
|
+
|
|
185
|
+
type Row = { week: number } & Record<string, number>;
|
|
186
|
+
|
|
187
|
+
export function HighlightLineCard({
|
|
188
|
+
data,
|
|
189
|
+
series,
|
|
190
|
+
highlight,
|
|
191
|
+
rival,
|
|
192
|
+
subtitle,
|
|
193
|
+
source,
|
|
194
|
+
locale = "pt-BR",
|
|
195
|
+
}: {
|
|
196
|
+
data: Row[];
|
|
197
|
+
series: string[];
|
|
198
|
+
highlight: string;
|
|
199
|
+
rival: string;
|
|
200
|
+
subtitle: string;
|
|
201
|
+
source?: string;
|
|
202
|
+
locale?: string;
|
|
203
|
+
}) {
|
|
204
|
+
const f = makeFormat(locale);
|
|
205
|
+
const h = data.map((r) => r[highlight]);
|
|
206
|
+
const crossing = firstCrossing(
|
|
207
|
+
h,
|
|
208
|
+
data.map((r) => r[rival]),
|
|
209
|
+
);
|
|
210
|
+
const factor = h[h.length - 1] / h[0];
|
|
211
|
+
const title = overtakeTitle(
|
|
212
|
+
highlight,
|
|
213
|
+
rival,
|
|
214
|
+
factor,
|
|
215
|
+
crossing >= 0 ? `week ${data[crossing].week}` : null,
|
|
216
|
+
f,
|
|
217
|
+
);
|
|
218
|
+
const last = data.length - 1;
|
|
219
|
+
const config = Object.fromEntries(
|
|
220
|
+
series.map((k) => [
|
|
221
|
+
k,
|
|
222
|
+
{ label: k, color: k === highlight ? "var(--chart-1)" : "var(--muted-foreground)" },
|
|
223
|
+
]),
|
|
224
|
+
) satisfies ChartConfig;
|
|
225
|
+
|
|
226
|
+
return (
|
|
227
|
+
<Card>
|
|
228
|
+
<CardHeader>
|
|
229
|
+
<CardTitle className="text-xl font-extrabold leading-tight">{title}</CardTitle>
|
|
230
|
+
<CardDescription>{subtitle}</CardDescription>
|
|
231
|
+
</CardHeader>
|
|
232
|
+
<CardContent>
|
|
233
|
+
<ChartContainer config={config} className="aspect-auto h-[300px] w-full">
|
|
234
|
+
<LineChart
|
|
235
|
+
accessibilityLayer
|
|
236
|
+
data={data}
|
|
237
|
+
margin={{ top: 24, right: 96, left: 8, bottom: 0 }}
|
|
238
|
+
>
|
|
239
|
+
<CartesianGrid vertical={false} horizontal={false} />
|
|
240
|
+
<XAxis
|
|
241
|
+
dataKey="week"
|
|
242
|
+
type="number"
|
|
243
|
+
domain={["dataMin", "dataMax"]}
|
|
244
|
+
ticks={[1, 13, 26, 39, 52]}
|
|
245
|
+
tickFormatter={(w: number) => `wk ${w}`}
|
|
246
|
+
tickLine={false}
|
|
247
|
+
axisLine={false}
|
|
248
|
+
/>
|
|
249
|
+
<YAxis hide />
|
|
250
|
+
<ChartTooltip
|
|
251
|
+
cursor={false}
|
|
252
|
+
content={
|
|
253
|
+
<ChartTooltipContent labelFormatter={(_, p) => `week ${p?.[0]?.payload?.week}`} />
|
|
254
|
+
}
|
|
255
|
+
/>
|
|
256
|
+
{series.map((k) => (
|
|
257
|
+
<Line
|
|
258
|
+
key={k}
|
|
259
|
+
dataKey={k}
|
|
260
|
+
type="monotone"
|
|
261
|
+
dot={false}
|
|
262
|
+
isAnimationActive={false}
|
|
263
|
+
stroke={`var(--color-${k})`}
|
|
264
|
+
strokeWidth={k === highlight ? 3 : 1.5}
|
|
265
|
+
strokeOpacity={k === highlight ? 1 : 0.5}
|
|
266
|
+
>
|
|
267
|
+
<LabelList
|
|
268
|
+
dataKey={k}
|
|
269
|
+
content={(p) =>
|
|
270
|
+
p.index === last ? (
|
|
271
|
+
<text
|
|
272
|
+
x={Number(p.x) + 8}
|
|
273
|
+
y={Number(p.y)}
|
|
274
|
+
dominantBaseline="middle"
|
|
275
|
+
style={{
|
|
276
|
+
fill:
|
|
277
|
+
k === highlight ? "var(--color-" + k + ")" : "var(--muted-foreground)",
|
|
278
|
+
fontSize: 12,
|
|
279
|
+
fontWeight: 700,
|
|
280
|
+
}}
|
|
281
|
+
>
|
|
282
|
+
{`${k} ${f.num.format(Number(p.value))}`}
|
|
283
|
+
</text>
|
|
284
|
+
) : null
|
|
285
|
+
}
|
|
286
|
+
/>
|
|
287
|
+
</Line>
|
|
288
|
+
))}
|
|
289
|
+
{crossing >= 0 && (
|
|
290
|
+
<ReferenceDot
|
|
291
|
+
x={data[crossing].week}
|
|
292
|
+
y={h[crossing]}
|
|
293
|
+
r={4}
|
|
294
|
+
fill={`var(--color-${highlight})`}
|
|
295
|
+
stroke="none"
|
|
296
|
+
label={{
|
|
297
|
+
value: `week ${data[crossing].week} · ${highlight} passes ${rival}`,
|
|
298
|
+
position: "top",
|
|
299
|
+
offset: 12,
|
|
300
|
+
fill: `var(--color-${highlight})`,
|
|
301
|
+
fontSize: 12,
|
|
302
|
+
fontWeight: 700,
|
|
303
|
+
}}
|
|
304
|
+
/>
|
|
305
|
+
)}
|
|
306
|
+
</LineChart>
|
|
307
|
+
</ChartContainer>
|
|
308
|
+
</CardContent>
|
|
309
|
+
{source && (
|
|
310
|
+
<CardFooter className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
|
311
|
+
{source}
|
|
312
|
+
</CardFooter>
|
|
313
|
+
)}
|
|
314
|
+
</Card>
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
Ranked horizontal bars with a marker for the previous period and the change annotated:
|
|
320
|
+
|
|
321
|
+
```tsx
|
|
322
|
+
"use client";
|
|
323
|
+
|
|
324
|
+
import { Bar, Cell, ComposedChart, LabelList, Scatter, XAxis, YAxis } from "recharts";
|
|
325
|
+
import {
|
|
326
|
+
Card,
|
|
327
|
+
CardContent,
|
|
328
|
+
CardDescription,
|
|
329
|
+
CardFooter,
|
|
330
|
+
CardHeader,
|
|
331
|
+
CardTitle,
|
|
332
|
+
} from "@/components/ui/card";
|
|
333
|
+
import { ChartContainer, type ChartConfig } from "@/components/ui/chart";
|
|
334
|
+
import { growth, makeFormat } from "@/lib/chart-story";
|
|
335
|
+
|
|
336
|
+
export type RankedItem = { name: string; value: number; previous?: number };
|
|
337
|
+
|
|
338
|
+
const rankedConfig = {
|
|
339
|
+
value: { label: "Current", color: "var(--muted-foreground)" },
|
|
340
|
+
previous: { label: "Previous", color: "var(--foreground)" },
|
|
341
|
+
highlight: { label: "Highlight", color: "var(--chart-1)" },
|
|
342
|
+
} satisfies ChartConfig;
|
|
343
|
+
|
|
344
|
+
export function RankedBarsCard({
|
|
345
|
+
items,
|
|
346
|
+
highlight,
|
|
347
|
+
title,
|
|
348
|
+
subtitle,
|
|
349
|
+
source,
|
|
350
|
+
locale = "pt-BR",
|
|
351
|
+
currency = "BRL",
|
|
352
|
+
}: {
|
|
353
|
+
items: RankedItem[];
|
|
354
|
+
highlight: string;
|
|
355
|
+
title: string;
|
|
356
|
+
subtitle: string;
|
|
357
|
+
source?: string;
|
|
358
|
+
locale?: string;
|
|
359
|
+
currency?: string;
|
|
360
|
+
}) {
|
|
361
|
+
const f = makeFormat(locale, currency);
|
|
362
|
+
const data = [...items]
|
|
363
|
+
.sort((a, b) => b.value - a.value)
|
|
364
|
+
.map((d) => {
|
|
365
|
+
const g = d.previous === undefined ? null : growth(d.value, d.previous);
|
|
366
|
+
return {
|
|
367
|
+
...d,
|
|
368
|
+
label:
|
|
369
|
+
g === null
|
|
370
|
+
? f.money.format(d.value)
|
|
371
|
+
: `${f.money.format(d.value)} · ${f.delta.format(g)}`,
|
|
372
|
+
};
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
return (
|
|
376
|
+
<Card>
|
|
377
|
+
<CardHeader>
|
|
378
|
+
<CardTitle className="text-xl font-extrabold leading-tight">{title}</CardTitle>
|
|
379
|
+
<CardDescription>{subtitle}</CardDescription>
|
|
380
|
+
</CardHeader>
|
|
381
|
+
<CardContent>
|
|
382
|
+
<ChartContainer
|
|
383
|
+
config={rankedConfig}
|
|
384
|
+
className="aspect-auto w-full"
|
|
385
|
+
style={{ height: data.length * 44 + 16 }}
|
|
386
|
+
>
|
|
387
|
+
<ComposedChart
|
|
388
|
+
layout="vertical"
|
|
389
|
+
data={data}
|
|
390
|
+
margin={{ top: 0, right: 140, left: 0, bottom: 0 }}
|
|
391
|
+
>
|
|
392
|
+
<XAxis type="number" hide domain={[0, "dataMax"]} />
|
|
393
|
+
<YAxis
|
|
394
|
+
type="category"
|
|
395
|
+
dataKey="name"
|
|
396
|
+
width={96}
|
|
397
|
+
tickLine={false}
|
|
398
|
+
axisLine={false}
|
|
399
|
+
tick={({ x, y, payload }) => (
|
|
400
|
+
<text
|
|
401
|
+
x={Number(x) - 8}
|
|
402
|
+
y={Number(y)}
|
|
403
|
+
textAnchor="end"
|
|
404
|
+
dominantBaseline="middle"
|
|
405
|
+
style={{
|
|
406
|
+
fontSize: 12,
|
|
407
|
+
fontWeight: payload.value === highlight ? 700 : 400,
|
|
408
|
+
fill:
|
|
409
|
+
payload.value === highlight
|
|
410
|
+
? "var(--color-highlight)"
|
|
411
|
+
: "var(--muted-foreground)",
|
|
412
|
+
}}
|
|
413
|
+
>
|
|
414
|
+
{payload.value}
|
|
415
|
+
</text>
|
|
416
|
+
)}
|
|
417
|
+
/>
|
|
418
|
+
<Bar dataKey="value" barSize={14} radius={3} isAnimationActive={false}>
|
|
419
|
+
{data.map((d) => (
|
|
420
|
+
<Cell
|
|
421
|
+
key={d.name}
|
|
422
|
+
fill={d.name === highlight ? "var(--color-highlight)" : "var(--color-value)"}
|
|
423
|
+
fillOpacity={d.name === highlight ? 1 : 0.35}
|
|
424
|
+
/>
|
|
425
|
+
))}
|
|
426
|
+
<LabelList
|
|
427
|
+
dataKey="label"
|
|
428
|
+
content={(p) => {
|
|
429
|
+
const d = data[Number(p.index)];
|
|
430
|
+
// the label starts after whichever is farther right: the bar or the previous-period marker
|
|
431
|
+
const reach = d && d.value > 0 ? Math.max(1, (d.previous ?? 0) / d.value) : 1;
|
|
432
|
+
return (
|
|
433
|
+
<text
|
|
434
|
+
x={Number(p.x) + Number(p.width) * reach + 8}
|
|
435
|
+
y={Number(p.y) + Number(p.height) / 2}
|
|
436
|
+
dominantBaseline="middle"
|
|
437
|
+
style={{
|
|
438
|
+
fontSize: 12,
|
|
439
|
+
fontWeight: data[Number(p.index)]?.name === highlight ? 700 : 400,
|
|
440
|
+
fill:
|
|
441
|
+
data[Number(p.index)]?.name === highlight
|
|
442
|
+
? "var(--color-highlight)"
|
|
443
|
+
: "var(--muted-foreground)",
|
|
444
|
+
}}
|
|
445
|
+
>
|
|
446
|
+
{p.value}
|
|
447
|
+
</text>
|
|
448
|
+
);
|
|
449
|
+
}}
|
|
450
|
+
/>
|
|
451
|
+
</Bar>
|
|
452
|
+
<Scatter
|
|
453
|
+
dataKey="previous"
|
|
454
|
+
isAnimationActive={false}
|
|
455
|
+
shape={(p: { cx?: number; cy?: number }) => (
|
|
456
|
+
<rect
|
|
457
|
+
x={(p.cx ?? 0) - 1}
|
|
458
|
+
y={(p.cy ?? 0) - 10}
|
|
459
|
+
width={2}
|
|
460
|
+
height={20}
|
|
461
|
+
fill="var(--color-previous)"
|
|
462
|
+
/>
|
|
463
|
+
)}
|
|
464
|
+
/>
|
|
465
|
+
</ComposedChart>
|
|
466
|
+
</ChartContainer>
|
|
467
|
+
</CardContent>
|
|
468
|
+
{source && (
|
|
469
|
+
<CardFooter className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
|
470
|
+
{source}
|
|
471
|
+
</CardFooter>
|
|
472
|
+
)}
|
|
473
|
+
</Card>
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
The subtitle explains the marker: "Revenue by channel in 2025 · stroke = 2024". A shaded band on any cartesian chart: `<ReferenceArea x1={start} x2={end} fill="var(--color-highlight)" fillOpacity={0.12} />`.
|
|
479
|
+
|
|
480
|
+
KPI card with shadcn:
|
|
481
|
+
|
|
482
|
+
```tsx
|
|
483
|
+
import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
|
484
|
+
|
|
485
|
+
export function KpiCard({
|
|
486
|
+
label,
|
|
487
|
+
value,
|
|
488
|
+
delta,
|
|
489
|
+
base,
|
|
490
|
+
}: {
|
|
491
|
+
label: string;
|
|
492
|
+
value: string;
|
|
493
|
+
delta?: string;
|
|
494
|
+
base?: string;
|
|
495
|
+
}) {
|
|
496
|
+
return (
|
|
497
|
+
<Card className="gap-1 py-4 shadow-none">
|
|
498
|
+
<CardHeader className="px-5">
|
|
499
|
+
<CardDescription className="text-xs uppercase tracking-wide">{label}</CardDescription>
|
|
500
|
+
<CardTitle className="text-3xl font-bold tabular-nums">{value}</CardTitle>
|
|
501
|
+
</CardHeader>
|
|
502
|
+
{delta && (
|
|
503
|
+
<CardFooter className="px-5 text-sm text-muted-foreground">
|
|
504
|
+
<span className="font-medium text-foreground tabular-nums">{delta}</span> {base}
|
|
505
|
+
</CardFooter>
|
|
506
|
+
)}
|
|
507
|
+
</Card>
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
To group related KPIs on a single background (common region), put them inside **one** `Card` with an internal grid, instead of one `Card` per number.
|