@payglocal_ui/flux-ui 0.1.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.
Files changed (72) hide show
  1. package/package.json +72 -0
  2. package/src/accordion.tsx +68 -0
  3. package/src/alert.tsx +107 -0
  4. package/src/avatar-group.tsx +96 -0
  5. package/src/avatar-tag.tsx +136 -0
  6. package/src/avatar.tsx +39 -0
  7. package/src/badge.tsx +98 -0
  8. package/src/blanket.tsx +61 -0
  9. package/src/breadcrumb.tsx +119 -0
  10. package/src/button-group.tsx +218 -0
  11. package/src/button.tsx +83 -0
  12. package/src/calendar.tsx +227 -0
  13. package/src/callout.tsx +68 -0
  14. package/src/card.tsx +103 -0
  15. package/src/chart-templates.tsx +587 -0
  16. package/src/chart.tsx +379 -0
  17. package/src/checkbox-select.tsx +239 -0
  18. package/src/checkbox.tsx +54 -0
  19. package/src/code.tsx +154 -0
  20. package/src/command.tsx +77 -0
  21. package/src/country-select.tsx +242 -0
  22. package/src/currency-amount-input.tsx +72 -0
  23. package/src/data-table.tsx +378 -0
  24. package/src/date-picker.tsx +317 -0
  25. package/src/dialog.tsx +81 -0
  26. package/src/drawer.tsx +91 -0
  27. package/src/dropdown-menu.tsx +174 -0
  28. package/src/empty-state.tsx +32 -0
  29. package/src/field.tsx +243 -0
  30. package/src/flag.tsx +265 -0
  31. package/src/form.tsx +168 -0
  32. package/src/grid-flex.tsx +241 -0
  33. package/src/heading.tsx +202 -0
  34. package/src/icon-button.tsx +93 -0
  35. package/src/index.ts +332 -0
  36. package/src/inline-dialog.tsx +153 -0
  37. package/src/inline-edit.tsx +212 -0
  38. package/src/input-group.tsx +151 -0
  39. package/src/input.tsx +28 -0
  40. package/src/label.tsx +21 -0
  41. package/src/layout.tsx +119 -0
  42. package/src/link.tsx +80 -0
  43. package/src/lozenge.tsx +61 -0
  44. package/src/menu.tsx +146 -0
  45. package/src/otp-input.tsx +117 -0
  46. package/src/page-header.tsx +28 -0
  47. package/src/pagination.tsx +185 -0
  48. package/src/password-input.tsx +34 -0
  49. package/src/popover.tsx +31 -0
  50. package/src/progress-indicator.tsx +94 -0
  51. package/src/progress.tsx +95 -0
  52. package/src/radio-group.tsx +46 -0
  53. package/src/responsive.tsx +276 -0
  54. package/src/scroll-area.tsx +39 -0
  55. package/src/section-message.tsx +119 -0
  56. package/src/select.tsx +144 -0
  57. package/src/separator.tsx +26 -0
  58. package/src/side-nav.tsx +264 -0
  59. package/src/skeleton.tsx +74 -0
  60. package/src/slider.tsx +25 -0
  61. package/src/sonner.tsx +32 -0
  62. package/src/spinner.tsx +54 -0
  63. package/src/spotlight.tsx +141 -0
  64. package/src/status-badge.tsx +86 -0
  65. package/src/switch.tsx +70 -0
  66. package/src/tabs.tsx +57 -0
  67. package/src/tag.tsx +52 -0
  68. package/src/textarea.tsx +25 -0
  69. package/src/time-picker.tsx +443 -0
  70. package/src/tooltip.tsx +29 -0
  71. package/src/utils.ts +6 -0
  72. package/src/visually-hidden.tsx +25 -0
@@ -0,0 +1,587 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import {
5
+ Area,
6
+ AreaChart,
7
+ Bar,
8
+ BarChart,
9
+ CartesianGrid,
10
+ Line,
11
+ ResponsiveContainer,
12
+ Tooltip,
13
+ XAxis,
14
+ YAxis,
15
+ } from "recharts";
16
+ import {
17
+ ArrowDownRight,
18
+ ArrowUpRight,
19
+ ExternalLink,
20
+ Info,
21
+ Minus,
22
+ } from "lucide-react";
23
+ import { cn } from "./utils";
24
+ import { Button } from "./button";
25
+ import { Separator } from "./separator";
26
+
27
+ const gridStroke = "color-mix(in srgb, var(--border) 65%, transparent)";
28
+ const tickFill = "var(--muted-foreground)";
29
+
30
+ /** ─── KPI + sparkline (dashboard stat tiles) ─────────────────────────── */
31
+
32
+ export type MetricSparklinePoint = { x: string | number; y: number };
33
+
34
+ export type MetricSparklineCardProps = {
35
+ title: string;
36
+ icon?: React.ReactNode;
37
+ value: React.ReactNode;
38
+ /** e.g. "+8.4% vs last month" */
39
+ trend?: { direction: "up" | "down" | "flat"; label: string };
40
+ data: MetricSparklinePoint[];
41
+ /** Stroke / gradient accent (CSS color) */
42
+ accentColor?: string;
43
+ className?: string;
44
+ onInfoClick?: () => void;
45
+ };
46
+
47
+ export function MetricSparklineCard({
48
+ title,
49
+ icon,
50
+ value,
51
+ trend,
52
+ data,
53
+ accentColor = "var(--chart-1)",
54
+ className,
55
+ onInfoClick,
56
+ }: MetricSparklineCardProps) {
57
+ const gid = React.useId().replace(/:/g, "");
58
+ const chartData = data.map((d) => ({ ...d, y: d.y }));
59
+
60
+ const trendCls =
61
+ trend?.direction === "up"
62
+ ? "text-emerald-600 dark:text-emerald-400"
63
+ : trend?.direction === "down"
64
+ ? "text-red-600 dark:text-red-400"
65
+ : "text-muted-foreground";
66
+
67
+ const TrendIcon =
68
+ trend?.direction === "up" ? ArrowUpRight : trend?.direction === "down" ? ArrowDownRight : Minus;
69
+
70
+ return (
71
+ <div
72
+ className={cn(
73
+ "relative overflow-hidden rounded-xl border border-border bg-card p-5 text-card-foreground shadow-sm",
74
+ className
75
+ )}
76
+ >
77
+ <div className="flex items-start justify-between gap-2">
78
+ <div className="flex min-w-0 items-center gap-2">
79
+ {icon ? <span className="flex shrink-0 text-muted-foreground [&_svg]:size-4">{icon}</span> : null}
80
+ <span className="truncate text-sm font-semibold text-foreground">{title}</span>
81
+ </div>
82
+ {onInfoClick ? (
83
+ <button
84
+ type="button"
85
+ onClick={onInfoClick}
86
+ className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
87
+ aria-label="More info"
88
+ >
89
+ <Info className="size-3.5" />
90
+ </button>
91
+ ) : null}
92
+ </div>
93
+
94
+ <div className="mt-3 text-2xl font-semibold tabular-nums tracking-tight text-foreground">{value}</div>
95
+
96
+ <div className="pointer-events-none absolute bottom-3 right-3 h-14 w-[46%] max-w-[9rem] opacity-95">
97
+ <ResponsiveContainer width="100%" height="100%">
98
+ <AreaChart data={chartData} margin={{ top: 4, right: 0, left: 0, bottom: 0 }}>
99
+ <defs>
100
+ <linearGradient id={gid} x1="0" y1="0" x2="0" y2="1">
101
+ <stop offset="0%" stopColor={accentColor} stopOpacity={0.35} />
102
+ <stop offset="100%" stopColor={accentColor} stopOpacity={0} />
103
+ </linearGradient>
104
+ </defs>
105
+ <Area
106
+ type="monotone"
107
+ dataKey="y"
108
+ stroke={accentColor}
109
+ strokeWidth={2}
110
+ fill={`url(#${gid})`}
111
+ isAnimationActive={false}
112
+ />
113
+ </AreaChart>
114
+ </ResponsiveContainer>
115
+ </div>
116
+
117
+ {trend ? (
118
+ <div className={cn("mt-10 flex items-center gap-1 text-xs font-medium", trendCls)}>
119
+ <TrendIcon className="size-3.5 shrink-0" aria-hidden />
120
+ <span>{trend.label}</span>
121
+ </div>
122
+ ) : (
123
+ <div className="mt-10" />
124
+ )}
125
+ </div>
126
+ );
127
+ }
128
+
129
+ /** ─── Hero area + optional compare line + tabs + footer ──────────────── */
130
+
131
+ export type DashboardAreaChartPoint = Record<string, string | number>;
132
+
133
+ export type DashboardAreaChartTemplateProps = {
134
+ title: string;
135
+ tabs: { id: string; label: string }[];
136
+ activeTabId: string;
137
+ onTabChange: (id: string) => void;
138
+ headline: React.ReactNode;
139
+ delta?: React.ReactNode;
140
+ data: DashboardAreaChartPoint[];
141
+ xKey: string;
142
+ areaKey: string;
143
+ compareLineKey?: string;
144
+ height?: number;
145
+ formatYAxis?: (v: number) => string;
146
+ footer?: React.ReactNode;
147
+ className?: string;
148
+ };
149
+
150
+ export function DashboardAreaChartTemplate({
151
+ title,
152
+ tabs,
153
+ activeTabId,
154
+ onTabChange,
155
+ headline,
156
+ delta,
157
+ data,
158
+ xKey,
159
+ areaKey,
160
+ compareLineKey,
161
+ height = 220,
162
+ formatYAxis = (v) =>
163
+ v >= 1_000_000 ? `${(v / 1_000_000).toFixed(1)}L` : v >= 1_000 ? `${(v / 1_000).toFixed(0)}K` : `${v}`,
164
+ footer,
165
+ className,
166
+ }: DashboardAreaChartTemplateProps) {
167
+ const areaGid = React.useId().replace(/:/g, "");
168
+
169
+ return (
170
+ <div className={cn("rounded-xl border border-border bg-card text-card-foreground shadow-sm", className)}>
171
+ <div className="flex flex-col gap-4 border-b border-border px-5 pt-4 pb-3 sm:flex-row sm:items-start sm:justify-between">
172
+ <h3 className="text-sm font-semibold text-foreground">{title}</h3>
173
+ <div className="flex flex-wrap gap-1 rounded-lg border border-border bg-muted/30 p-0.5">
174
+ {tabs.map((t) => (
175
+ <button
176
+ key={t.id}
177
+ type="button"
178
+ onClick={() => onTabChange(t.id)}
179
+ className={cn(
180
+ "rounded-md px-2.5 py-1 text-[11px] font-medium transition-colors",
181
+ activeTabId === t.id
182
+ ? "bg-card text-foreground shadow-sm"
183
+ : "text-muted-foreground hover:text-foreground"
184
+ )}
185
+ >
186
+ {t.label}
187
+ </button>
188
+ ))}
189
+ </div>
190
+ </div>
191
+
192
+ <div className="px-5 pt-4">
193
+ <div className="text-2xl font-semibold tabular-nums tracking-tight text-foreground">{headline}</div>
194
+ {delta ? <div className="mt-1 text-sm">{delta}</div> : null}
195
+ </div>
196
+
197
+ <div className="px-3 pb-2 pt-2" style={{ height }}>
198
+ <ResponsiveContainer width="100%" height="100%">
199
+ <AreaChart data={data} margin={{ top: 8, right: 12, left: 0, bottom: 0 }}>
200
+ <defs>
201
+ <linearGradient id={areaGid} x1="0" y1="0" x2="0" y2="1">
202
+ <stop offset="0%" stopColor="var(--chart-1)" stopOpacity={0.35} />
203
+ <stop offset="100%" stopColor="var(--chart-1)" stopOpacity={0.02} />
204
+ </linearGradient>
205
+ </defs>
206
+ <CartesianGrid strokeDasharray="3 3" stroke={gridStroke} vertical={false} />
207
+ <XAxis
208
+ dataKey={xKey}
209
+ axisLine={false}
210
+ tickLine={false}
211
+ tick={{ fontSize: 10, fill: tickFill }}
212
+ interval="preserveStartEnd"
213
+ />
214
+ <YAxis
215
+ axisLine={false}
216
+ tickLine={false}
217
+ tick={{ fontSize: 10, fill: tickFill }}
218
+ tickFormatter={formatYAxis}
219
+ width={44}
220
+ />
221
+ <Tooltip
222
+ contentStyle={{
223
+ borderRadius: 10,
224
+ border: "1px solid var(--border)",
225
+ fontSize: 12,
226
+ background: "var(--popover)",
227
+ color: "var(--popover-foreground)",
228
+ }}
229
+ />
230
+ <Area
231
+ type="monotone"
232
+ dataKey={areaKey}
233
+ stroke="var(--chart-1)"
234
+ strokeWidth={2}
235
+ fill={`url(#${areaGid})`}
236
+ />
237
+ {compareLineKey ? (
238
+ <Line
239
+ type="monotone"
240
+ dataKey={compareLineKey}
241
+ stroke="var(--muted-foreground)"
242
+ strokeWidth={1.5}
243
+ strokeDasharray="4 4"
244
+ dot={false}
245
+ />
246
+ ) : null}
247
+ </AreaChart>
248
+ </ResponsiveContainer>
249
+ </div>
250
+
251
+ {footer ? (
252
+ <>
253
+ <Separator />
254
+ <div className="px-5 py-3">{footer}</div>
255
+ </>
256
+ ) : null}
257
+ </div>
258
+ );
259
+ }
260
+
261
+ /** ─── Grouped vertical bars + legend (e.g. volume vs settled) ───────── */
262
+
263
+ export type GroupedBarSeries = { key: string; label: string; color: string };
264
+
265
+ export type GroupedBarChartTemplateProps = {
266
+ title: string;
267
+ subtitle?: string;
268
+ data: DashboardAreaChartPoint[];
269
+ xKey: string;
270
+ series: GroupedBarSeries[];
271
+ height?: number;
272
+ formatYAxis?: (v: number) => string;
273
+ className?: string;
274
+ };
275
+
276
+ export function GroupedBarChartTemplate({
277
+ title,
278
+ subtitle,
279
+ data,
280
+ xKey,
281
+ series,
282
+ height = 200,
283
+ formatYAxis = (v) =>
284
+ v >= 1_000_000 ? `${(v / 1_000_000).toFixed(1)}M` : v >= 1_000 ? `${(v / 1_000).toFixed(0)}K` : `${v}`,
285
+ className,
286
+ }: GroupedBarChartTemplateProps) {
287
+ return (
288
+ <div className={cn("rounded-xl border border-border bg-card px-5 pt-4 pb-3 text-card-foreground shadow-sm", className)}>
289
+ <div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
290
+ <div>
291
+ <h3 className="text-sm font-semibold text-foreground">{title}</h3>
292
+ {subtitle ? <p className="mt-0.5 text-xs text-muted-foreground">{subtitle}</p> : null}
293
+ </div>
294
+ <div className="flex flex-wrap items-center gap-4">
295
+ {series.map((s) => (
296
+ <div key={s.key} className="flex items-center gap-1.5">
297
+ <div className="size-2.5 rounded-sm" style={{ background: s.color }} />
298
+ <span className="text-[11px] font-medium text-muted-foreground">{s.label}</span>
299
+ </div>
300
+ ))}
301
+ </div>
302
+ </div>
303
+ <div style={{ height }}>
304
+ <ResponsiveContainer width="100%" height="100%">
305
+ <BarChart data={data} barCategoryGap="22%" barGap={4}>
306
+ <CartesianGrid strokeDasharray="3 3" stroke={gridStroke} vertical={false} />
307
+ <XAxis dataKey={xKey} axisLine={false} tickLine={false} tick={{ fontSize: 11, fill: tickFill }} />
308
+ <YAxis
309
+ axisLine={false}
310
+ tickLine={false}
311
+ tick={{ fontSize: 11, fill: tickFill }}
312
+ tickFormatter={formatYAxis}
313
+ width={40}
314
+ />
315
+ <Tooltip
316
+ contentStyle={{
317
+ borderRadius: 10,
318
+ border: "1px solid var(--border)",
319
+ fontSize: 12,
320
+ background: "var(--popover)",
321
+ }}
322
+ />
323
+ {series.map((s) => (
324
+ <Bar key={s.key} dataKey={s.key} name={s.label} fill={s.color} radius={[5, 5, 0, 0]} maxBarSize={36} />
325
+ ))}
326
+ </BarChart>
327
+ </ResponsiveContainer>
328
+ </div>
329
+ </div>
330
+ );
331
+ }
332
+
333
+ /** ─── Ranked rows with horizontal bar (country / state insights) ────── */
334
+
335
+ export type RankedBarItem = {
336
+ id: string;
337
+ leading?: React.ReactNode;
338
+ label: string;
339
+ value: string;
340
+ /** 0–100 width of the filled bar */
341
+ percent: number;
342
+ };
343
+
344
+ export type RankedBarListTemplateProps = {
345
+ title: string;
346
+ subtitle?: string;
347
+ headerRight?: React.ReactNode;
348
+ items: RankedBarItem[];
349
+ /** CSS colors for bar gradient */
350
+ barFrom?: string;
351
+ barTo?: string;
352
+ className?: string;
353
+ };
354
+
355
+ export function RankedBarListTemplate({
356
+ title,
357
+ subtitle,
358
+ headerRight,
359
+ items,
360
+ barFrom = "var(--chart-1)",
361
+ barTo = "var(--chart-3)",
362
+ className,
363
+ }: RankedBarListTemplateProps) {
364
+ return (
365
+ <div className={cn("rounded-xl border border-border bg-card p-5 text-card-foreground shadow-sm", className)}>
366
+ <div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
367
+ <div>
368
+ <h3 className="text-sm font-semibold text-foreground">{title}</h3>
369
+ {subtitle ? <p className="mt-0.5 text-xs text-muted-foreground">{subtitle}</p> : null}
370
+ </div>
371
+ {headerRight ? <div className="shrink-0">{headerRight}</div> : null}
372
+ </div>
373
+ <ul className="space-y-3">
374
+ {items.map((row) => (
375
+ <li key={row.id} className="flex items-center gap-3 text-sm">
376
+ <div className="flex min-w-0 flex-1 items-center gap-2">
377
+ {row.leading ? <span className="shrink-0 text-muted-foreground">{row.leading}</span> : null}
378
+ <span className="truncate font-medium text-foreground">{row.label}</span>
379
+ </div>
380
+ <div className="relative hidden h-2 w-[min(40%,9rem)] overflow-hidden rounded-full bg-muted sm:block">
381
+ <div
382
+ className="absolute inset-y-0 left-0 rounded-full"
383
+ style={{
384
+ width: `${Math.min(100, Math.max(0, row.percent))}%`,
385
+ background: `linear-gradient(90deg, ${barFrom}, ${barTo})`,
386
+ }}
387
+ />
388
+ </div>
389
+ <span className="w-14 shrink-0 text-right text-xs font-semibold tabular-nums text-foreground">
390
+ {row.value}
391
+ </span>
392
+ </li>
393
+ ))}
394
+ </ul>
395
+ </div>
396
+ );
397
+ }
398
+
399
+ /** ─── Vertical category bars (e.g. T+N settlement mix) ──────────────── */
400
+
401
+ export type CategoryBarPoint = { category: string; value: number };
402
+
403
+ export type CategoryBarChartTemplateProps = {
404
+ title: string;
405
+ subtitle?: string;
406
+ data: CategoryBarPoint[];
407
+ valueLabel?: string;
408
+ barColor?: string;
409
+ height?: number;
410
+ className?: string;
411
+ };
412
+
413
+ export function CategoryBarChartTemplate({
414
+ title,
415
+ subtitle,
416
+ data,
417
+ valueLabel = "Share",
418
+ barColor = "var(--chart-1)",
419
+ height = 200,
420
+ className,
421
+ }: CategoryBarChartTemplateProps) {
422
+ const chartData = data.map((d) => ({ name: d.category, v: d.value }));
423
+
424
+ return (
425
+ <div className={cn("rounded-xl border border-border bg-card px-5 pt-4 pb-3 text-card-foreground shadow-sm", className)}>
426
+ <div className="mb-3">
427
+ <h3 className="text-sm font-semibold text-foreground">{title}</h3>
428
+ {subtitle ? <p className="mt-0.5 text-xs text-muted-foreground">{subtitle}</p> : null}
429
+ </div>
430
+ <div style={{ height }}>
431
+ <ResponsiveContainer width="100%" height="100%">
432
+ <BarChart data={chartData} barCategoryGap="28%">
433
+ <CartesianGrid strokeDasharray="3 3" stroke={gridStroke} vertical={false} />
434
+ <XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fontSize: 11, fill: tickFill }} />
435
+ <YAxis
436
+ axisLine={false}
437
+ tickLine={false}
438
+ tick={{ fontSize: 11, fill: tickFill }}
439
+ tickFormatter={(v) => `${v}%`}
440
+ domain={[0, "dataMax + 5"]}
441
+ width={36}
442
+ />
443
+ <Tooltip
444
+ formatter={(value) => [`${value ?? 0}%`, valueLabel]}
445
+ contentStyle={{
446
+ borderRadius: 10,
447
+ border: "1px solid var(--border)",
448
+ fontSize: 12,
449
+ background: "var(--popover)",
450
+ }}
451
+ />
452
+ <Bar dataKey="v" fill={barColor} radius={[6, 6, 0, 0]} maxBarSize={48} />
453
+ </BarChart>
454
+ </ResponsiveContainer>
455
+ </div>
456
+ </div>
457
+ );
458
+ }
459
+
460
+ /** ─── Mini sparkline + stat row (success / failed / avg) ────────────── */
461
+
462
+ export type MiniSparklinePoint = { x: string | number; y: number; compare?: number };
463
+
464
+ export type MiniSparklineStat = { label: string; value: string; dotClassName?: string };
465
+
466
+ export type MiniSparklineChartCardProps = {
467
+ title: string;
468
+ value: React.ReactNode;
469
+ data: MiniSparklinePoint[];
470
+ accentColor?: string;
471
+ stats: MiniSparklineStat[];
472
+ height?: number;
473
+ className?: string;
474
+ };
475
+
476
+ export function MiniSparklineChartCard({
477
+ title,
478
+ value,
479
+ data,
480
+ accentColor = "var(--chart-4)",
481
+ height = 100,
482
+ stats,
483
+ className,
484
+ }: MiniSparklineChartCardProps) {
485
+ const gid = React.useId().replace(/:/g, "");
486
+ const hasCompare = data.some((d) => d.compare != null);
487
+
488
+ return (
489
+ <div className={cn("rounded-xl border border-border bg-card p-5 text-card-foreground shadow-sm", className)}>
490
+ <h3 className="text-sm font-semibold text-foreground">{title}</h3>
491
+ <div className="mt-2 text-2xl font-semibold tabular-nums">{value}</div>
492
+ <div className="mt-2" style={{ height }}>
493
+ <ResponsiveContainer width="100%" height="100%">
494
+ <AreaChart data={data} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
495
+ <defs>
496
+ <linearGradient id={gid} x1="0" y1="0" x2="0" y2="1">
497
+ <stop offset="0%" stopColor={accentColor} stopOpacity={0.3} />
498
+ <stop offset="100%" stopColor={accentColor} stopOpacity={0} />
499
+ </linearGradient>
500
+ </defs>
501
+ <XAxis dataKey="x" hide />
502
+ <YAxis hide domain={["dataMin - 1", "dataMax + 1"]} />
503
+ <Area type="monotone" dataKey="y" stroke={accentColor} strokeWidth={2} fill={`url(#${gid})`} />
504
+ {hasCompare ? (
505
+ <Line
506
+ type="monotone"
507
+ dataKey="compare"
508
+ stroke="var(--muted-foreground)"
509
+ strokeWidth={1.5}
510
+ strokeDasharray="4 4"
511
+ dot={false}
512
+ connectNulls
513
+ />
514
+ ) : null}
515
+ </AreaChart>
516
+ </ResponsiveContainer>
517
+ </div>
518
+ <div className="mt-3 flex flex-wrap gap-x-4 gap-y-2 border-t border-border pt-3 text-[11px]">
519
+ {stats.map((s) => (
520
+ <div key={s.label} className="flex items-center gap-1.5">
521
+ {s.dotClassName ? <span className={cn("size-1.5 rounded-full", s.dotClassName)} /> : null}
522
+ <span className="text-muted-foreground">{s.label}</span>
523
+ <span className="font-semibold tabular-nums text-foreground">{s.value}</span>
524
+ </div>
525
+ ))}
526
+ </div>
527
+ </div>
528
+ );
529
+ }
530
+
531
+ /** ─── “Needs attention” list with actions ───────────────────────────── */
532
+
533
+ export type AttentionListItem = {
534
+ id: string;
535
+ title: string;
536
+ value: string;
537
+ valueTone?: "default" | "warning" | "danger";
538
+ meta?: string;
539
+ actionLabel: string;
540
+ onAction?: () => void;
541
+ };
542
+
543
+ export type AttentionListTemplateProps = {
544
+ title: string;
545
+ items: AttentionListItem[];
546
+ className?: string;
547
+ };
548
+
549
+ const toneCls = {
550
+ default: "text-foreground",
551
+ warning: "text-amber-600 dark:text-amber-400",
552
+ danger: "text-red-600 dark:text-red-400",
553
+ } as const;
554
+
555
+ export function AttentionListTemplate({ title, items, className }: AttentionListTemplateProps) {
556
+ return (
557
+ <div className={cn("rounded-xl border border-border bg-card p-5 text-card-foreground shadow-sm", className)}>
558
+ <h3 className="text-sm font-semibold text-foreground">{title}</h3>
559
+ <ul className="mt-4 space-y-4">
560
+ {items.map((item) => (
561
+ <li
562
+ key={item.id}
563
+ className="flex flex-col gap-3 border-b border-border/60 pb-4 last:border-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between"
564
+ >
565
+ <div className="min-w-0 space-y-0.5">
566
+ <p className="text-sm font-semibold text-foreground">{item.title}</p>
567
+ <p className={cn("text-lg font-semibold tabular-nums", toneCls[item.valueTone ?? "default"])}>
568
+ {item.value}
569
+ </p>
570
+ {item.meta ? <p className="text-xs text-muted-foreground">{item.meta}</p> : null}
571
+ </div>
572
+ <Button
573
+ type="button"
574
+ variant="outline"
575
+ size="md"
576
+ className="shrink-0 gap-1.5"
577
+ onClick={item.onAction}
578
+ >
579
+ {item.actionLabel}
580
+ <ExternalLink className="size-3.5 opacity-70" aria-hidden />
581
+ </Button>
582
+ </li>
583
+ ))}
584
+ </ul>
585
+ </div>
586
+ );
587
+ }