@modern-admin/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 (268) hide show
  1. package/dist/components/accordion.d.ts +7 -0
  2. package/dist/components/accordion.d.ts.map +1 -0
  3. package/dist/components/accordion.jsx +19 -0
  4. package/dist/components/accordion.jsx.map +1 -0
  5. package/dist/components/alert-dialog.d.ts +22 -0
  6. package/dist/components/alert-dialog.d.ts.map +1 -0
  7. package/dist/components/alert-dialog.jsx +27 -0
  8. package/dist/components/alert-dialog.jsx.map +1 -0
  9. package/dist/components/audit-timeline.d.ts +24 -0
  10. package/dist/components/audit-timeline.d.ts.map +1 -0
  11. package/dist/components/audit-timeline.jsx +60 -0
  12. package/dist/components/audit-timeline.jsx.map +1 -0
  13. package/dist/components/avatar.d.ts +6 -0
  14. package/dist/components/avatar.d.ts.map +1 -0
  15. package/dist/components/avatar.jsx +10 -0
  16. package/dist/components/avatar.jsx.map +1 -0
  17. package/dist/components/badge.d.ts +10 -0
  18. package/dist/components/badge.d.ts.map +1 -0
  19. package/dist/components/badge.jsx +19 -0
  20. package/dist/components/badge.jsx.map +1 -0
  21. package/dist/components/breadcrumb.d.ts +17 -0
  22. package/dist/components/breadcrumb.d.ts.map +1 -0
  23. package/dist/components/breadcrumb.jsx +27 -0
  24. package/dist/components/breadcrumb.jsx.map +1 -0
  25. package/dist/components/button.d.ts +12 -0
  26. package/dist/components/button.d.ts.map +1 -0
  27. package/dist/components/button.jsx +37 -0
  28. package/dist/components/button.jsx.map +1 -0
  29. package/dist/components/calendar.d.ts +9 -0
  30. package/dist/components/calendar.d.ts.map +1 -0
  31. package/dist/components/calendar.jsx +102 -0
  32. package/dist/components/calendar.jsx.map +1 -0
  33. package/dist/components/card.d.ts +8 -0
  34. package/dist/components/card.d.ts.map +1 -0
  35. package/dist/components/card.jsx +18 -0
  36. package/dist/components/card.jsx.map +1 -0
  37. package/dist/components/chart.d.ts +97 -0
  38. package/dist/components/chart.d.ts.map +1 -0
  39. package/dist/components/chart.jsx +233 -0
  40. package/dist/components/chart.jsx.map +1 -0
  41. package/dist/components/checkbox.d.ts +4 -0
  42. package/dist/components/checkbox.d.ts.map +1 -0
  43. package/dist/components/checkbox.jsx +11 -0
  44. package/dist/components/checkbox.jsx.map +1 -0
  45. package/dist/components/combobox.d.ts +46 -0
  46. package/dist/components/combobox.d.ts.map +1 -0
  47. package/dist/components/combobox.jsx +145 -0
  48. package/dist/components/combobox.jsx.map +1 -0
  49. package/dist/components/command.d.ts +80 -0
  50. package/dist/components/command.d.ts.map +1 -0
  51. package/dist/components/command.jsx +32 -0
  52. package/dist/components/command.jsx.map +1 -0
  53. package/dist/components/date-picker.d.ts +24 -0
  54. package/dist/components/date-picker.d.ts.map +1 -0
  55. package/dist/components/date-picker.jsx +149 -0
  56. package/dist/components/date-picker.jsx.map +1 -0
  57. package/dist/components/date-range-input.d.ts +22 -0
  58. package/dist/components/date-range-input.d.ts.map +1 -0
  59. package/dist/components/date-range-input.jsx +202 -0
  60. package/dist/components/date-range-input.jsx.map +1 -0
  61. package/dist/components/dialog.d.ts +19 -0
  62. package/dist/components/dialog.d.ts.map +1 -0
  63. package/dist/components/dialog.jsx +30 -0
  64. package/dist/components/dialog.jsx.map +1 -0
  65. package/dist/components/diff-view.d.ts +24 -0
  66. package/dist/components/diff-view.d.ts.map +1 -0
  67. package/dist/components/diff-view.jsx +69 -0
  68. package/dist/components/diff-view.jsx.map +1 -0
  69. package/dist/components/dropdown-menu.d.ts +27 -0
  70. package/dist/components/dropdown-menu.d.ts.map +1 -0
  71. package/dist/components/dropdown-menu.jsx +48 -0
  72. package/dist/components/dropdown-menu.jsx.map +1 -0
  73. package/dist/components/empty.d.ts +15 -0
  74. package/dist/components/empty.d.ts.map +1 -0
  75. package/dist/components/empty.jsx +27 -0
  76. package/dist/components/empty.jsx.map +1 -0
  77. package/dist/components/field.d.ts +23 -0
  78. package/dist/components/field.d.ts.map +1 -0
  79. package/dist/components/field.jsx +60 -0
  80. package/dist/components/field.jsx.map +1 -0
  81. package/dist/components/file-input.d.ts +50 -0
  82. package/dist/components/file-input.d.ts.map +1 -0
  83. package/dist/components/file-input.jsx +104 -0
  84. package/dist/components/file-input.jsx.map +1 -0
  85. package/dist/components/form.d.ts +20 -0
  86. package/dist/components/form.d.ts.map +1 -0
  87. package/dist/components/form.jsx +66 -0
  88. package/dist/components/form.jsx.map +1 -0
  89. package/dist/components/info-tooltip.d.ts +11 -0
  90. package/dist/components/info-tooltip.d.ts.map +1 -0
  91. package/dist/components/info-tooltip.jsx +17 -0
  92. package/dist/components/info-tooltip.jsx.map +1 -0
  93. package/dist/components/input.d.ts +13 -0
  94. package/dist/components/input.d.ts.map +1 -0
  95. package/dist/components/input.jsx +19 -0
  96. package/dist/components/input.jsx.map +1 -0
  97. package/dist/components/json-editor.d.ts +23 -0
  98. package/dist/components/json-editor.d.ts.map +1 -0
  99. package/dist/components/json-editor.jsx +143 -0
  100. package/dist/components/json-editor.jsx.map +1 -0
  101. package/dist/components/kbd.d.ts +15 -0
  102. package/dist/components/kbd.d.ts.map +1 -0
  103. package/dist/components/kbd.jsx +23 -0
  104. package/dist/components/kbd.jsx.map +1 -0
  105. package/dist/components/key-value-editor.d.ts +92 -0
  106. package/dist/components/key-value-editor.d.ts.map +1 -0
  107. package/dist/components/key-value-editor.jsx +187 -0
  108. package/dist/components/key-value-editor.jsx.map +1 -0
  109. package/dist/components/keyboard-shortcuts-help.d.ts +17 -0
  110. package/dist/components/keyboard-shortcuts-help.d.ts.map +1 -0
  111. package/dist/components/keyboard-shortcuts-help.jsx +97 -0
  112. package/dist/components/keyboard-shortcuts-help.jsx.map +1 -0
  113. package/dist/components/label.d.ts +5 -0
  114. package/dist/components/label.d.ts.map +1 -0
  115. package/dist/components/label.jsx +8 -0
  116. package/dist/components/label.jsx.map +1 -0
  117. package/dist/components/media-preview.d.ts +30 -0
  118. package/dist/components/media-preview.d.ts.map +1 -0
  119. package/dist/components/media-preview.jsx +189 -0
  120. package/dist/components/media-preview.jsx.map +1 -0
  121. package/dist/components/multi-file-input.d.ts +76 -0
  122. package/dist/components/multi-file-input.d.ts.map +1 -0
  123. package/dist/components/multi-file-input.jsx +131 -0
  124. package/dist/components/multi-file-input.jsx.map +1 -0
  125. package/dist/components/password-input.d.ts +10 -0
  126. package/dist/components/password-input.d.ts.map +1 -0
  127. package/dist/components/password-input.jsx +18 -0
  128. package/dist/components/password-input.jsx.map +1 -0
  129. package/dist/components/popover.d.ts +7 -0
  130. package/dist/components/popover.d.ts.map +1 -0
  131. package/dist/components/popover.jsx +11 -0
  132. package/dist/components/popover.jsx.map +1 -0
  133. package/dist/components/revision-timeline.d.ts +30 -0
  134. package/dist/components/revision-timeline.d.ts.map +1 -0
  135. package/dist/components/revision-timeline.jsx +42 -0
  136. package/dist/components/revision-timeline.jsx.map +1 -0
  137. package/dist/components/richtext-editor.d.ts +43 -0
  138. package/dist/components/richtext-editor.d.ts.map +1 -0
  139. package/dist/components/richtext-editor.jsx +319 -0
  140. package/dist/components/richtext-editor.jsx.map +1 -0
  141. package/dist/components/richtext-mode.d.ts +23 -0
  142. package/dist/components/richtext-mode.d.ts.map +1 -0
  143. package/dist/components/richtext-mode.js +36 -0
  144. package/dist/components/richtext-mode.js.map +1 -0
  145. package/dist/components/richtext-render.d.ts +8 -0
  146. package/dist/components/richtext-render.d.ts.map +1 -0
  147. package/dist/components/richtext-render.jsx +33 -0
  148. package/dist/components/richtext-render.jsx.map +1 -0
  149. package/dist/components/richtext-sync.d.ts +37 -0
  150. package/dist/components/richtext-sync.d.ts.map +1 -0
  151. package/dist/components/richtext-sync.js +46 -0
  152. package/dist/components/richtext-sync.js.map +1 -0
  153. package/dist/components/scroll-area.d.ts +5 -0
  154. package/dist/components/scroll-area.d.ts.map +1 -0
  155. package/dist/components/scroll-area.jsx +16 -0
  156. package/dist/components/scroll-area.jsx.map +1 -0
  157. package/dist/components/select.d.ts +36 -0
  158. package/dist/components/select.d.ts.map +1 -0
  159. package/dist/components/select.jsx +87 -0
  160. package/dist/components/select.jsx.map +1 -0
  161. package/dist/components/separator.d.ts +4 -0
  162. package/dist/components/separator.d.ts.map +1 -0
  163. package/dist/components/separator.jsx +6 -0
  164. package/dist/components/separator.jsx.map +1 -0
  165. package/dist/components/sheet.d.ts +29 -0
  166. package/dist/components/sheet.d.ts.map +1 -0
  167. package/dist/components/sheet.jsx +44 -0
  168. package/dist/components/sheet.jsx.map +1 -0
  169. package/dist/components/sidebar.d.ts +70 -0
  170. package/dist/components/sidebar.d.ts.map +1 -0
  171. package/dist/components/sidebar.jsx +245 -0
  172. package/dist/components/sidebar.jsx.map +1 -0
  173. package/dist/components/skeleton.d.ts +3 -0
  174. package/dist/components/skeleton.d.ts.map +1 -0
  175. package/dist/components/skeleton.jsx +6 -0
  176. package/dist/components/skeleton.jsx.map +1 -0
  177. package/dist/components/sonner.d.ts +6 -0
  178. package/dist/components/sonner.d.ts.map +1 -0
  179. package/dist/components/sonner.jsx +29 -0
  180. package/dist/components/sonner.jsx.map +1 -0
  181. package/dist/components/switch.d.ts +4 -0
  182. package/dist/components/switch.d.ts.map +1 -0
  183. package/dist/components/switch.jsx +8 -0
  184. package/dist/components/switch.jsx.map +1 -0
  185. package/dist/components/table.d.ts +10 -0
  186. package/dist/components/table.d.ts.map +1 -0
  187. package/dist/components/table.jsx +21 -0
  188. package/dist/components/table.jsx.map +1 -0
  189. package/dist/components/tabs.d.ts +7 -0
  190. package/dist/components/tabs.d.ts.map +1 -0
  191. package/dist/components/tabs.jsx +14 -0
  192. package/dist/components/tabs.jsx.map +1 -0
  193. package/dist/components/textarea.d.ts +4 -0
  194. package/dist/components/textarea.d.ts.map +1 -0
  195. package/dist/components/textarea.jsx +5 -0
  196. package/dist/components/textarea.jsx.map +1 -0
  197. package/dist/components/tooltip.d.ts +7 -0
  198. package/dist/components/tooltip.d.ts.map +1 -0
  199. package/dist/components/tooltip.jsx +11 -0
  200. package/dist/components/tooltip.jsx.map +1 -0
  201. package/dist/index.d.ts +52 -0
  202. package/dist/index.d.ts.map +1 -0
  203. package/dist/index.js +72 -0
  204. package/dist/index.js.map +1 -0
  205. package/dist/lib/theme.d.ts +11 -0
  206. package/dist/lib/theme.d.ts.map +1 -0
  207. package/dist/lib/theme.js +44 -0
  208. package/dist/lib/theme.js.map +1 -0
  209. package/dist/lib/utils.d.ts +3 -0
  210. package/dist/lib/utils.d.ts.map +1 -0
  211. package/dist/lib/utils.js +6 -0
  212. package/dist/lib/utils.js.map +1 -0
  213. package/dist/styles.css +242 -0
  214. package/package.json +85 -0
  215. package/src/components/accordion.tsx +48 -0
  216. package/src/components/alert-dialog.tsx +113 -0
  217. package/src/components/audit-timeline.tsx +102 -0
  218. package/src/components/avatar.tsx +42 -0
  219. package/src/components/badge.tsx +34 -0
  220. package/src/components/breadcrumb.tsx +99 -0
  221. package/src/components/button.tsx +58 -0
  222. package/src/components/calendar.tsx +176 -0
  223. package/src/components/card.tsx +60 -0
  224. package/src/components/chart.tsx +558 -0
  225. package/src/components/checkbox.tsx +23 -0
  226. package/src/components/combobox.tsx +264 -0
  227. package/src/components/command.tsx +120 -0
  228. package/src/components/date-picker.tsx +221 -0
  229. package/src/components/date-range-input.tsx +295 -0
  230. package/src/components/dialog.tsx +94 -0
  231. package/src/components/diff-view.tsx +182 -0
  232. package/src/components/dropdown-menu.tsx +165 -0
  233. package/src/components/empty.tsx +100 -0
  234. package/src/components/field.tsx +168 -0
  235. package/src/components/file-input.tsx +233 -0
  236. package/src/components/form.tsx +152 -0
  237. package/src/components/info-tooltip.tsx +40 -0
  238. package/src/components/input.tsx +55 -0
  239. package/src/components/json-editor.tsx +210 -0
  240. package/src/components/kbd.tsx +35 -0
  241. package/src/components/key-value-editor.tsx +423 -0
  242. package/src/components/keyboard-shortcuts-help.tsx +136 -0
  243. package/src/components/label.tsx +16 -0
  244. package/src/components/media-preview.tsx +278 -0
  245. package/src/components/multi-file-input.tsx +315 -0
  246. package/src/components/password-input.tsx +50 -0
  247. package/src/components/popover.tsx +26 -0
  248. package/src/components/revision-timeline.tsx +93 -0
  249. package/src/components/richtext-editor.tsx +624 -0
  250. package/src/components/richtext-mode.ts +39 -0
  251. package/src/components/richtext-render.tsx +51 -0
  252. package/src/components/richtext-sync.ts +57 -0
  253. package/src/components/scroll-area.tsx +41 -0
  254. package/src/components/select.tsx +200 -0
  255. package/src/components/separator.tsx +21 -0
  256. package/src/components/sheet.tsx +109 -0
  257. package/src/components/sidebar.tsx +660 -0
  258. package/src/components/skeleton.tsx +9 -0
  259. package/src/components/sonner.tsx +45 -0
  260. package/src/components/switch.tsx +24 -0
  261. package/src/components/table.tsx +93 -0
  262. package/src/components/tabs.tsx +57 -0
  263. package/src/components/textarea.tsx +18 -0
  264. package/src/components/tooltip.tsx +25 -0
  265. package/src/index.ts +342 -0
  266. package/src/lib/theme.ts +45 -0
  267. package/src/lib/utils.ts +6 -0
  268. package/src/styles.css +242 -0
@@ -0,0 +1,558 @@
1
+ import * as React from 'react'
2
+ import {
3
+ ResponsiveContainer,
4
+ LineChart,
5
+ AreaChart,
6
+ BarChart,
7
+ PieChart,
8
+ Line,
9
+ Area,
10
+ Bar,
11
+ Pie,
12
+ Cell,
13
+ XAxis,
14
+ YAxis,
15
+ CartesianGrid,
16
+ Tooltip,
17
+ Legend,
18
+ } from 'recharts'
19
+ import { cn } from '../lib/utils.js'
20
+
21
+ export type ChartType = 'line' | 'area' | 'bar' | 'pie'
22
+
23
+ export interface ChartDataPoint {
24
+ label: string
25
+ value: number
26
+ }
27
+
28
+ export interface ChartPanelLabels {
29
+ noData?: string
30
+ value?: string
31
+ }
32
+
33
+ export interface ChartPanelProps {
34
+ data: ChartDataPoint[]
35
+ type?: ChartType
36
+ color?: string
37
+ height?: number
38
+ labels?: ChartPanelLabels
39
+ className?: string
40
+ }
41
+
42
+ const PALETTE = [
43
+ 'hsl(220, 70%, 55%)',
44
+ 'hsl(160, 60%, 45%)',
45
+ 'hsl(30, 80%, 55%)',
46
+ 'hsl(280, 65%, 60%)',
47
+ 'hsl(0, 65%, 55%)',
48
+ ]
49
+
50
+ const TOOLTIP_STYLE: React.CSSProperties = {
51
+ background: 'var(--card, #fff)',
52
+ border: '1px solid var(--border, #e2e8f0)',
53
+ borderRadius: '6px',
54
+ fontSize: 12,
55
+ padding: '6px 10px',
56
+ }
57
+
58
+ const AXIS_STYLE = { fontSize: 11 }
59
+ const GRID_STROKE = 'hsl(215 16% 85%)'
60
+
61
+ export function ChartPanel({
62
+ data,
63
+ type = 'line',
64
+ color = PALETTE[0]!,
65
+ height = 260,
66
+ labels,
67
+ className,
68
+ }: ChartPanelProps): React.ReactElement {
69
+ if (data.length === 0) {
70
+ return (
71
+ <div
72
+ className={cn(
73
+ 'flex items-center justify-center text-sm text-muted-foreground',
74
+ className,
75
+ )}
76
+ style={{ height }}
77
+ >
78
+ {labels?.noData ?? 'No data'}
79
+ </div>
80
+ )
81
+ }
82
+
83
+ const vKey = labels?.value ?? 'value'
84
+ const mapped = data.map((d) => ({ label: d.label, [vKey]: d.value }))
85
+
86
+ if (type === 'pie') {
87
+ return (
88
+ <div
89
+ className={cn(
90
+ 'w-full [&_*:focus]:outline-none [&_*:focus-visible]:outline-none',
91
+ className,
92
+ )}
93
+ style={{ height }}
94
+ >
95
+ <ResponsiveContainer width="100%" height="100%">
96
+ <PieChart>
97
+ <Pie
98
+ data={mapped}
99
+ dataKey={vKey}
100
+ nameKey="label"
101
+ cx="50%"
102
+ cy="50%"
103
+ outerRadius="65%"
104
+ >
105
+ {mapped.map((_, i) => (
106
+ <Cell key={i} fill={PALETTE[i % PALETTE.length]!} />
107
+ ))}
108
+ </Pie>
109
+ <Tooltip
110
+ contentStyle={TOOLTIP_STYLE}
111
+ formatter={(value) => [value, vKey]}
112
+ />
113
+ </PieChart>
114
+ </ResponsiveContainer>
115
+ </div>
116
+ )
117
+ }
118
+
119
+ const ChartCmp =
120
+ type === 'area' ? AreaChart : type === 'bar' ? BarChart : LineChart
121
+
122
+ return (
123
+ <div
124
+ className={cn(
125
+ 'w-full [&_*:focus]:outline-none [&_*:focus-visible]:outline-none',
126
+ className,
127
+ )}
128
+ style={{ height }}
129
+ >
130
+ <ResponsiveContainer width="100%" height="100%">
131
+ <ChartCmp data={mapped} margin={{ top: 8, right: 8, left: -16, bottom: 0 }}>
132
+ <CartesianGrid strokeDasharray="3 3" stroke={GRID_STROKE} />
133
+ <XAxis
134
+ dataKey="label"
135
+ tick={AXIS_STYLE}
136
+ interval="preserveStartEnd"
137
+ tickLine={false}
138
+ axisLine={false}
139
+ />
140
+ <YAxis tick={AXIS_STYLE} tickLine={false} axisLine={false} />
141
+ <Tooltip contentStyle={TOOLTIP_STYLE} />
142
+ {type === 'area' ? (
143
+ <Area
144
+ type="monotone"
145
+ dataKey={vKey}
146
+ stroke={color}
147
+ fill={color}
148
+ fillOpacity={0.18}
149
+ strokeWidth={2}
150
+ dot={false}
151
+ activeDot={{ r: 4 }}
152
+ />
153
+ ) : type === 'bar' ? (
154
+ <Bar dataKey={vKey} fill={color} radius={[3, 3, 0, 0] as never} maxBarSize={48} />
155
+ ) : (
156
+ <Line
157
+ type="monotone"
158
+ dataKey={vKey}
159
+ stroke={color}
160
+ strokeWidth={2}
161
+ dot={false}
162
+ activeDot={{ r: 4 }}
163
+ />
164
+ )}
165
+ </ChartCmp>
166
+ </ResponsiveContainer>
167
+ </div>
168
+ )
169
+ }
170
+
171
+ // ─── KPI ────────────────────────────────────────────────────────────────
172
+
173
+ export interface KpiCardLabels {
174
+ noData?: string
175
+ /** Template with `{value}` and `{percent}` placeholders. */
176
+ deltaUp?: string
177
+ deltaDown?: string
178
+ deltaFlat?: string
179
+ previousPeriod?: string
180
+ }
181
+
182
+ export interface KpiCardProps {
183
+ /** Aggregated value over the current window. */
184
+ value: number | null | undefined
185
+ /** Aggregated value over the equal-length previous window. */
186
+ previousValue?: number | null
187
+ /** Custom number formatter — defaults to `Intl.NumberFormat()`. */
188
+ formatNumber?: (n: number) => string
189
+ labels?: KpiCardLabels
190
+ className?: string
191
+ }
192
+
193
+ const defaultFormat = (n: number): string => new Intl.NumberFormat().format(n)
194
+
195
+ const fillTemplate = (tpl: string, value: string, percent: string): string =>
196
+ tpl.replace('{value}', value).replace('{percent}', percent)
197
+
198
+ /**
199
+ * Big-number KPI card with optional period-over-period delta. Renders a
200
+ * single value when `previousValue` is null/undefined; otherwise shows the
201
+ * absolute and percentage difference and tints up/down/flat.
202
+ *
203
+ * i18n-unaware: pass localised templates via `labels`. Templates use
204
+ * `{value}` / `{percent}` placeholders which are substituted at render time.
205
+ */
206
+ export function KpiCard({
207
+ value,
208
+ previousValue,
209
+ formatNumber = defaultFormat,
210
+ labels,
211
+ className,
212
+ }: KpiCardProps): React.ReactElement {
213
+ if (value == null) {
214
+ return (
215
+ <div
216
+ className={cn(
217
+ 'flex h-32 items-center justify-center text-sm text-muted-foreground',
218
+ className,
219
+ )}
220
+ >
221
+ {labels?.noData ?? 'No data'}
222
+ </div>
223
+ )
224
+ }
225
+
226
+ const hasPrev = previousValue != null && Number.isFinite(previousValue)
227
+ const delta = hasPrev ? value - (previousValue as number) : 0
228
+ const percent = hasPrev && (previousValue as number) !== 0
229
+ ? Math.round((delta / (previousValue as number)) * 1000) / 10
230
+ : 0
231
+
232
+ const direction: 'up' | 'down' | 'flat' =
233
+ !hasPrev ? 'flat' : delta > 0 ? 'up' : delta < 0 ? 'down' : 'flat'
234
+
235
+ const tone =
236
+ direction === 'up'
237
+ ? 'text-emerald-600 dark:text-emerald-400'
238
+ : direction === 'down'
239
+ ? 'text-rose-600 dark:text-rose-400'
240
+ : 'text-muted-foreground'
241
+
242
+ const tpl =
243
+ direction === 'up'
244
+ ? labels?.deltaUp ?? '+{value} ({percent}%)'
245
+ : direction === 'down'
246
+ ? labels?.deltaDown ?? '−{value} ({percent}%)'
247
+ : labels?.deltaFlat ?? '{value} ({percent}%)'
248
+
249
+ return (
250
+ <div className={cn('flex h-32 flex-col justify-center px-1', className)}>
251
+ <div className="text-3xl font-semibold tabular-nums sm:text-4xl">
252
+ {formatNumber(value)}
253
+ </div>
254
+ {hasPrev && (
255
+ <div className={cn('mt-2 text-xs sm:text-sm', tone)}>
256
+ <span className="tabular-nums">
257
+ {fillTemplate(
258
+ tpl,
259
+ formatNumber(Math.abs(delta)),
260
+ String(Math.abs(percent)),
261
+ )}
262
+ </span>
263
+ {labels?.previousPeriod && (
264
+ <span className="ml-1 text-muted-foreground">
265
+ {labels.previousPeriod}
266
+ </span>
267
+ )}
268
+ </div>
269
+ )}
270
+ </div>
271
+ )
272
+ }
273
+
274
+ // ─── Time-series chart ───────────────────────────────────────────────────
275
+
276
+ export interface TimeSeriesChartSeries {
277
+ /** Stable key (used for React keys). */
278
+ key: string
279
+ /** Human-readable label shown in legend / tooltip. */
280
+ label: string
281
+ /** Aligned points — every series MUST share the same `date` axis (zero-filled upstream). */
282
+ points: ReadonlyArray<{ date: string; value: number }>
283
+ }
284
+
285
+ export interface TimeSeriesChartLabels {
286
+ noData?: string
287
+ showAll?: string
288
+ hideAll?: string
289
+ }
290
+
291
+ export type TimeSeriesChartVisualisation = 'area' | 'line' | 'bar'
292
+
293
+ export interface TimeSeriesChartProps {
294
+ series: ReadonlyArray<TimeSeriesChartSeries>
295
+ height?: number
296
+ /** ISO `YYYY-MM-DD` → short axis tick (e.g. `01.05`). */
297
+ tickFormatter?: (iso: string) => string
298
+ /** ISO `YYYY-MM-DD` → tooltip header (e.g. `01 May 2024`). */
299
+ labelFormatter?: (iso: string) => string
300
+ /** Tooltip / Y-axis number formatting. */
301
+ valueFormatter?: (value: number) => string
302
+ /**
303
+ * Forces a specific Recharts primitive. When omitted, falls back to an
304
+ * auto heuristic: `area` for ≤2 series (single-metric look), `line`
305
+ * for more (multi-series comparison).
306
+ */
307
+ visualisation?: TimeSeriesChartVisualisation
308
+ labels?: TimeSeriesChartLabels
309
+ className?: string
310
+ }
311
+
312
+ // Six-color HSL palette mirroring the legacy AdminJS dashboard. For >6
313
+ // series we generate evenly-spaced hues so each line stays distinguishable.
314
+ const TS_PALETTE = [
315
+ 'hsl(2, 50%, 50%)',
316
+ 'hsl(25, 56%, 50%)',
317
+ 'hsl(48, 65%, 49%)',
318
+ 'hsl(189, 50%, 50%)',
319
+ 'hsl(143, 60%, 55%)',
320
+ 'hsl(286, 70%, 55%)',
321
+ ] as const
322
+
323
+ function paletteFor(n: number): string[] {
324
+ if (n <= TS_PALETTE.length) return TS_PALETTE.slice(0, Math.max(n, 1)) as string[]
325
+ const stepDeg = Math.floor(360 / n)
326
+ return Array.from({ length: n }, (_, i) => `hsl(${stepDeg * (i + 1)}, 55%, 55%)`)
327
+ }
328
+
329
+ const TS_TOOLTIP_STYLE: React.CSSProperties = {
330
+ background: 'hsla(220, 10%, 12%, 0.92)',
331
+ border: '1px solid hsla(220, 10%, 30%, 0.5)',
332
+ borderRadius: 6,
333
+ color: '#fff',
334
+ fontSize: 12,
335
+ padding: '6px 10px',
336
+ }
337
+
338
+ const TS_TOOLTIP_LABEL_STYLE: React.CSSProperties = {
339
+ textAlign: 'center',
340
+ color: '#fff',
341
+ marginBottom: 2,
342
+ }
343
+
344
+ /**
345
+ * Date-axis chart used by dashboard widgets. The Recharts primitive is
346
+ * chosen by `visualisation` (`area` | `line` | `bar`). When the caller
347
+ * does not pass `visualisation` the component falls back to a heuristic:
348
+ * `area` for ≤2 series (single-metric look) and `line` for more
349
+ * (multi-series comparison).
350
+ *
351
+ * Legend is clickable: click a label to toggle that series' visibility;
352
+ * hover dims the rest. When >7 series a "show/hide all" button appears
353
+ * under the chart.
354
+ *
355
+ * Pure presentation — i18n-unaware. Caller supplies pre-aligned, zero-
356
+ * filled `series` plus locale-aware tick/label/value formatters.
357
+ */
358
+ export function TimeSeriesChart({
359
+ series,
360
+ height = 320,
361
+ tickFormatter,
362
+ labelFormatter,
363
+ valueFormatter,
364
+ visualisation,
365
+ labels,
366
+ className,
367
+ }: TimeSeriesChartProps): React.ReactElement {
368
+ const [hovered, setHovered] = React.useState<string | null>(null)
369
+ const [hidden, setHidden] = React.useState<ReadonlySet<string>>(() => new Set())
370
+
371
+ // Merge all series into row-oriented data keyed by date. Memoized so
372
+ // Recharts only sees a new `data` prop when series content actually changes.
373
+ // Uses per-series Maps for O(1) lookup instead of O(N) `.find()` per date.
374
+ const { rows, palette, tickInterval } = React.useMemo(() => {
375
+ const dates = Array.from(
376
+ new Set(series.flatMap((s) => s.points.map((p) => p.date))),
377
+ ).sort()
378
+ const maps = series.map((s) => new Map(s.points.map((p) => [p.date, p.value])))
379
+ const rows = dates.map((date) => {
380
+ const row: Record<string, string | number> = { date }
381
+ series.forEach((s, i) => {
382
+ row[s.key] = maps[i]!.get(date) ?? 0
383
+ })
384
+ return row
385
+ })
386
+ return {
387
+ rows,
388
+ palette: paletteFor(series.length),
389
+ tickInterval: Math.max(0, Math.floor(dates.length / 32)),
390
+ }
391
+ }, [series])
392
+
393
+ if (series.length === 0 || series.every((s) => s.points.length === 0)) {
394
+ return (
395
+ <div
396
+ className={cn(
397
+ 'flex items-center justify-center text-sm text-muted-foreground',
398
+ className,
399
+ )}
400
+ style={{ height }}
401
+ >
402
+ {labels?.noData ?? 'No data'}
403
+ </div>
404
+ )
405
+ }
406
+
407
+ // Explicit `visualisation` wins; otherwise auto: area for ≤2 series,
408
+ // line for more (preserves legacy behaviour for callers that don't
409
+ // pass `visualisation`).
410
+ const resolvedVis: TimeSeriesChartVisualisation =
411
+ visualisation ?? (series.length > 2 ? 'line' : 'area')
412
+ const ChartCmp =
413
+ resolvedVis === 'bar' ? BarChart
414
+ : resolvedVis === 'line' ? LineChart
415
+ : AreaChart
416
+ // Suppress animation for large datasets — animating thousands of path
417
+ // points is expensive and retains old state in Recharts' animation subsystem.
418
+ const animateChart = rows.length <= 200
419
+
420
+ const toggleHidden = (key: string): void => {
421
+ setHidden((prev) => {
422
+ const next = new Set(prev)
423
+ if (next.has(key)) next.delete(key)
424
+ else next.add(key)
425
+ return next
426
+ })
427
+ }
428
+
429
+ const allHidden = hidden.size === series.length
430
+ const handleToggleAll = (): void => {
431
+ setHidden(allHidden ? new Set() : new Set(series.map((s) => s.key)))
432
+ }
433
+
434
+ const formatValue = (v: number | string): string => {
435
+ const n = typeof v === 'number' ? v : Number(v)
436
+ if (!Number.isFinite(n)) return String(v)
437
+ return valueFormatter ? valueFormatter(n) : new Intl.NumberFormat().format(n)
438
+ }
439
+
440
+ return (
441
+ <div
442
+ className={cn(
443
+ 'w-full [&_*:focus]:outline-none [&_*:focus-visible]:outline-none',
444
+ className,
445
+ )}
446
+ >
447
+ <ResponsiveContainer width="100%" height={height}>
448
+ <ChartCmp data={rows} margin={{ top: 16, right: 12, left: -28, bottom: 8 }}>
449
+ <CartesianGrid strokeDasharray="6 6" stroke={GRID_STROKE} />
450
+ <XAxis
451
+ dataKey="date"
452
+ tick={AXIS_STYLE}
453
+ interval={tickInterval}
454
+ tickLine={false}
455
+ axisLine={false}
456
+ tickFormatter={tickFormatter}
457
+ />
458
+ <YAxis
459
+ tick={AXIS_STYLE}
460
+ tickLine={false}
461
+ axisLine={false}
462
+ tickFormatter={(v) => formatValue(v as number)}
463
+ allowDecimals={false}
464
+ />
465
+ <Tooltip
466
+ contentStyle={TS_TOOLTIP_STYLE}
467
+ labelStyle={TS_TOOLTIP_LABEL_STYLE}
468
+ itemStyle={{ color: '#fff' }}
469
+ cursor={{ stroke: 'hsla(0, 0%, 50%, 0.4)', strokeWidth: 1 }}
470
+ labelFormatter={(value) =>
471
+ labelFormatter ? labelFormatter(String(value)) : String(value)
472
+ }
473
+ formatter={(value, name) => [formatValue(value as number | string), name]}
474
+ />
475
+ <Legend
476
+ layout="horizontal"
477
+ align="center"
478
+ verticalAlign="bottom"
479
+ wrapperStyle={{ fontSize: 12 }}
480
+ onClick={(data) => {
481
+ const key = data.dataKey as string | undefined
482
+ if (key) toggleHidden(key)
483
+ }}
484
+ onMouseEnter={(data) => {
485
+ const key = data.dataKey as string | undefined
486
+ if (key) setHovered(key)
487
+ }}
488
+ onMouseLeave={() => setHovered(null)}
489
+ />
490
+ {series.map((s, i) => {
491
+ if (resolvedVis === 'line') {
492
+ return (
493
+ <Line
494
+ key={s.key}
495
+ type="monotone"
496
+ dataKey={s.key}
497
+ name={s.label}
498
+ stroke={palette[i]}
499
+ strokeWidth={2}
500
+ strokeOpacity={hovered === null || hovered === s.key ? 1 : 0.2}
501
+ dot={false}
502
+ activeDot={{ r: 4 }}
503
+ hide={hidden.has(s.key)}
504
+ isAnimationActive={animateChart}
505
+ animationDuration={300}
506
+ />
507
+ )
508
+ }
509
+ if (resolvedVis === 'bar') {
510
+ return (
511
+ <Bar
512
+ key={s.key}
513
+ dataKey={s.key}
514
+ name={s.label}
515
+ fill={palette[i]}
516
+ fillOpacity={hovered === null || hovered === s.key ? 1 : 0.25}
517
+ radius={[3, 3, 0, 0] as never}
518
+ maxBarSize={48}
519
+ hide={hidden.has(s.key)}
520
+ isAnimationActive={animateChart}
521
+ animationDuration={300}
522
+ />
523
+ )
524
+ }
525
+ return (
526
+ <Area
527
+ key={s.key}
528
+ type="monotone"
529
+ dataKey={s.key}
530
+ name={s.label}
531
+ stroke={palette[i]}
532
+ strokeWidth={2}
533
+ fill={palette[i]}
534
+ fillOpacity={0.25}
535
+ dot={false}
536
+ activeDot={{ r: 4 }}
537
+ hide={hidden.has(s.key)}
538
+ isAnimationActive={animateChart}
539
+ animationDuration={300}
540
+ />
541
+ )
542
+ })}
543
+ </ChartCmp>
544
+ </ResponsiveContainer>
545
+ {series.length > 7 && (
546
+ <div className="mt-2 flex justify-end">
547
+ <button
548
+ type="button"
549
+ onClick={handleToggleAll}
550
+ className="text-xs text-muted-foreground hover:text-foreground"
551
+ >
552
+ {allHidden ? labels?.showAll ?? 'Show all' : labels?.hideAll ?? 'Hide all'}
553
+ </button>
554
+ </div>
555
+ )}
556
+ </div>
557
+ )
558
+ }
@@ -0,0 +1,23 @@
1
+ import * as React from 'react'
2
+ import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
3
+ import { Check } from 'lucide-react'
4
+ import { cn } from '../lib/utils.js'
5
+
6
+ export const Checkbox = React.forwardRef<
7
+ React.ElementRef<typeof CheckboxPrimitive.Root>,
8
+ React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
9
+ >(({ className, ...props }, ref) => (
10
+ <CheckboxPrimitive.Root
11
+ ref={ref}
12
+ className={cn(
13
+ 'peer h-4 w-4 cursor-pointer shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
14
+ className,
15
+ )}
16
+ {...props}
17
+ >
18
+ <CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
19
+ <Check className="h-3.5 w-3.5" />
20
+ </CheckboxPrimitive.Indicator>
21
+ </CheckboxPrimitive.Root>
22
+ ))
23
+ Checkbox.displayName = CheckboxPrimitive.Root.displayName