@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.
- package/dist/components/accordion.d.ts +7 -0
- package/dist/components/accordion.d.ts.map +1 -0
- package/dist/components/accordion.jsx +19 -0
- package/dist/components/accordion.jsx.map +1 -0
- package/dist/components/alert-dialog.d.ts +22 -0
- package/dist/components/alert-dialog.d.ts.map +1 -0
- package/dist/components/alert-dialog.jsx +27 -0
- package/dist/components/alert-dialog.jsx.map +1 -0
- package/dist/components/audit-timeline.d.ts +24 -0
- package/dist/components/audit-timeline.d.ts.map +1 -0
- package/dist/components/audit-timeline.jsx +60 -0
- package/dist/components/audit-timeline.jsx.map +1 -0
- package/dist/components/avatar.d.ts +6 -0
- package/dist/components/avatar.d.ts.map +1 -0
- package/dist/components/avatar.jsx +10 -0
- package/dist/components/avatar.jsx.map +1 -0
- package/dist/components/badge.d.ts +10 -0
- package/dist/components/badge.d.ts.map +1 -0
- package/dist/components/badge.jsx +19 -0
- package/dist/components/badge.jsx.map +1 -0
- package/dist/components/breadcrumb.d.ts +17 -0
- package/dist/components/breadcrumb.d.ts.map +1 -0
- package/dist/components/breadcrumb.jsx +27 -0
- package/dist/components/breadcrumb.jsx.map +1 -0
- package/dist/components/button.d.ts +12 -0
- package/dist/components/button.d.ts.map +1 -0
- package/dist/components/button.jsx +37 -0
- package/dist/components/button.jsx.map +1 -0
- package/dist/components/calendar.d.ts +9 -0
- package/dist/components/calendar.d.ts.map +1 -0
- package/dist/components/calendar.jsx +102 -0
- package/dist/components/calendar.jsx.map +1 -0
- package/dist/components/card.d.ts +8 -0
- package/dist/components/card.d.ts.map +1 -0
- package/dist/components/card.jsx +18 -0
- package/dist/components/card.jsx.map +1 -0
- package/dist/components/chart.d.ts +97 -0
- package/dist/components/chart.d.ts.map +1 -0
- package/dist/components/chart.jsx +233 -0
- package/dist/components/chart.jsx.map +1 -0
- package/dist/components/checkbox.d.ts +4 -0
- package/dist/components/checkbox.d.ts.map +1 -0
- package/dist/components/checkbox.jsx +11 -0
- package/dist/components/checkbox.jsx.map +1 -0
- package/dist/components/combobox.d.ts +46 -0
- package/dist/components/combobox.d.ts.map +1 -0
- package/dist/components/combobox.jsx +145 -0
- package/dist/components/combobox.jsx.map +1 -0
- package/dist/components/command.d.ts +80 -0
- package/dist/components/command.d.ts.map +1 -0
- package/dist/components/command.jsx +32 -0
- package/dist/components/command.jsx.map +1 -0
- package/dist/components/date-picker.d.ts +24 -0
- package/dist/components/date-picker.d.ts.map +1 -0
- package/dist/components/date-picker.jsx +149 -0
- package/dist/components/date-picker.jsx.map +1 -0
- package/dist/components/date-range-input.d.ts +22 -0
- package/dist/components/date-range-input.d.ts.map +1 -0
- package/dist/components/date-range-input.jsx +202 -0
- package/dist/components/date-range-input.jsx.map +1 -0
- package/dist/components/dialog.d.ts +19 -0
- package/dist/components/dialog.d.ts.map +1 -0
- package/dist/components/dialog.jsx +30 -0
- package/dist/components/dialog.jsx.map +1 -0
- package/dist/components/diff-view.d.ts +24 -0
- package/dist/components/diff-view.d.ts.map +1 -0
- package/dist/components/diff-view.jsx +69 -0
- package/dist/components/diff-view.jsx.map +1 -0
- package/dist/components/dropdown-menu.d.ts +27 -0
- package/dist/components/dropdown-menu.d.ts.map +1 -0
- package/dist/components/dropdown-menu.jsx +48 -0
- package/dist/components/dropdown-menu.jsx.map +1 -0
- package/dist/components/empty.d.ts +15 -0
- package/dist/components/empty.d.ts.map +1 -0
- package/dist/components/empty.jsx +27 -0
- package/dist/components/empty.jsx.map +1 -0
- package/dist/components/field.d.ts +23 -0
- package/dist/components/field.d.ts.map +1 -0
- package/dist/components/field.jsx +60 -0
- package/dist/components/field.jsx.map +1 -0
- package/dist/components/file-input.d.ts +50 -0
- package/dist/components/file-input.d.ts.map +1 -0
- package/dist/components/file-input.jsx +104 -0
- package/dist/components/file-input.jsx.map +1 -0
- package/dist/components/form.d.ts +20 -0
- package/dist/components/form.d.ts.map +1 -0
- package/dist/components/form.jsx +66 -0
- package/dist/components/form.jsx.map +1 -0
- package/dist/components/info-tooltip.d.ts +11 -0
- package/dist/components/info-tooltip.d.ts.map +1 -0
- package/dist/components/info-tooltip.jsx +17 -0
- package/dist/components/info-tooltip.jsx.map +1 -0
- package/dist/components/input.d.ts +13 -0
- package/dist/components/input.d.ts.map +1 -0
- package/dist/components/input.jsx +19 -0
- package/dist/components/input.jsx.map +1 -0
- package/dist/components/json-editor.d.ts +23 -0
- package/dist/components/json-editor.d.ts.map +1 -0
- package/dist/components/json-editor.jsx +143 -0
- package/dist/components/json-editor.jsx.map +1 -0
- package/dist/components/kbd.d.ts +15 -0
- package/dist/components/kbd.d.ts.map +1 -0
- package/dist/components/kbd.jsx +23 -0
- package/dist/components/kbd.jsx.map +1 -0
- package/dist/components/key-value-editor.d.ts +92 -0
- package/dist/components/key-value-editor.d.ts.map +1 -0
- package/dist/components/key-value-editor.jsx +187 -0
- package/dist/components/key-value-editor.jsx.map +1 -0
- package/dist/components/keyboard-shortcuts-help.d.ts +17 -0
- package/dist/components/keyboard-shortcuts-help.d.ts.map +1 -0
- package/dist/components/keyboard-shortcuts-help.jsx +97 -0
- package/dist/components/keyboard-shortcuts-help.jsx.map +1 -0
- package/dist/components/label.d.ts +5 -0
- package/dist/components/label.d.ts.map +1 -0
- package/dist/components/label.jsx +8 -0
- package/dist/components/label.jsx.map +1 -0
- package/dist/components/media-preview.d.ts +30 -0
- package/dist/components/media-preview.d.ts.map +1 -0
- package/dist/components/media-preview.jsx +189 -0
- package/dist/components/media-preview.jsx.map +1 -0
- package/dist/components/multi-file-input.d.ts +76 -0
- package/dist/components/multi-file-input.d.ts.map +1 -0
- package/dist/components/multi-file-input.jsx +131 -0
- package/dist/components/multi-file-input.jsx.map +1 -0
- package/dist/components/password-input.d.ts +10 -0
- package/dist/components/password-input.d.ts.map +1 -0
- package/dist/components/password-input.jsx +18 -0
- package/dist/components/password-input.jsx.map +1 -0
- package/dist/components/popover.d.ts +7 -0
- package/dist/components/popover.d.ts.map +1 -0
- package/dist/components/popover.jsx +11 -0
- package/dist/components/popover.jsx.map +1 -0
- package/dist/components/revision-timeline.d.ts +30 -0
- package/dist/components/revision-timeline.d.ts.map +1 -0
- package/dist/components/revision-timeline.jsx +42 -0
- package/dist/components/revision-timeline.jsx.map +1 -0
- package/dist/components/richtext-editor.d.ts +43 -0
- package/dist/components/richtext-editor.d.ts.map +1 -0
- package/dist/components/richtext-editor.jsx +319 -0
- package/dist/components/richtext-editor.jsx.map +1 -0
- package/dist/components/richtext-mode.d.ts +23 -0
- package/dist/components/richtext-mode.d.ts.map +1 -0
- package/dist/components/richtext-mode.js +36 -0
- package/dist/components/richtext-mode.js.map +1 -0
- package/dist/components/richtext-render.d.ts +8 -0
- package/dist/components/richtext-render.d.ts.map +1 -0
- package/dist/components/richtext-render.jsx +33 -0
- package/dist/components/richtext-render.jsx.map +1 -0
- package/dist/components/richtext-sync.d.ts +37 -0
- package/dist/components/richtext-sync.d.ts.map +1 -0
- package/dist/components/richtext-sync.js +46 -0
- package/dist/components/richtext-sync.js.map +1 -0
- package/dist/components/scroll-area.d.ts +5 -0
- package/dist/components/scroll-area.d.ts.map +1 -0
- package/dist/components/scroll-area.jsx +16 -0
- package/dist/components/scroll-area.jsx.map +1 -0
- package/dist/components/select.d.ts +36 -0
- package/dist/components/select.d.ts.map +1 -0
- package/dist/components/select.jsx +87 -0
- package/dist/components/select.jsx.map +1 -0
- package/dist/components/separator.d.ts +4 -0
- package/dist/components/separator.d.ts.map +1 -0
- package/dist/components/separator.jsx +6 -0
- package/dist/components/separator.jsx.map +1 -0
- package/dist/components/sheet.d.ts +29 -0
- package/dist/components/sheet.d.ts.map +1 -0
- package/dist/components/sheet.jsx +44 -0
- package/dist/components/sheet.jsx.map +1 -0
- package/dist/components/sidebar.d.ts +70 -0
- package/dist/components/sidebar.d.ts.map +1 -0
- package/dist/components/sidebar.jsx +245 -0
- package/dist/components/sidebar.jsx.map +1 -0
- package/dist/components/skeleton.d.ts +3 -0
- package/dist/components/skeleton.d.ts.map +1 -0
- package/dist/components/skeleton.jsx +6 -0
- package/dist/components/skeleton.jsx.map +1 -0
- package/dist/components/sonner.d.ts +6 -0
- package/dist/components/sonner.d.ts.map +1 -0
- package/dist/components/sonner.jsx +29 -0
- package/dist/components/sonner.jsx.map +1 -0
- package/dist/components/switch.d.ts +4 -0
- package/dist/components/switch.d.ts.map +1 -0
- package/dist/components/switch.jsx +8 -0
- package/dist/components/switch.jsx.map +1 -0
- package/dist/components/table.d.ts +10 -0
- package/dist/components/table.d.ts.map +1 -0
- package/dist/components/table.jsx +21 -0
- package/dist/components/table.jsx.map +1 -0
- package/dist/components/tabs.d.ts +7 -0
- package/dist/components/tabs.d.ts.map +1 -0
- package/dist/components/tabs.jsx +14 -0
- package/dist/components/tabs.jsx.map +1 -0
- package/dist/components/textarea.d.ts +4 -0
- package/dist/components/textarea.d.ts.map +1 -0
- package/dist/components/textarea.jsx +5 -0
- package/dist/components/textarea.jsx.map +1 -0
- package/dist/components/tooltip.d.ts +7 -0
- package/dist/components/tooltip.d.ts.map +1 -0
- package/dist/components/tooltip.jsx +11 -0
- package/dist/components/tooltip.jsx.map +1 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +72 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/theme.d.ts +11 -0
- package/dist/lib/theme.d.ts.map +1 -0
- package/dist/lib/theme.js +44 -0
- package/dist/lib/theme.js.map +1 -0
- package/dist/lib/utils.d.ts +3 -0
- package/dist/lib/utils.d.ts.map +1 -0
- package/dist/lib/utils.js +6 -0
- package/dist/lib/utils.js.map +1 -0
- package/dist/styles.css +242 -0
- package/package.json +85 -0
- package/src/components/accordion.tsx +48 -0
- package/src/components/alert-dialog.tsx +113 -0
- package/src/components/audit-timeline.tsx +102 -0
- package/src/components/avatar.tsx +42 -0
- package/src/components/badge.tsx +34 -0
- package/src/components/breadcrumb.tsx +99 -0
- package/src/components/button.tsx +58 -0
- package/src/components/calendar.tsx +176 -0
- package/src/components/card.tsx +60 -0
- package/src/components/chart.tsx +558 -0
- package/src/components/checkbox.tsx +23 -0
- package/src/components/combobox.tsx +264 -0
- package/src/components/command.tsx +120 -0
- package/src/components/date-picker.tsx +221 -0
- package/src/components/date-range-input.tsx +295 -0
- package/src/components/dialog.tsx +94 -0
- package/src/components/diff-view.tsx +182 -0
- package/src/components/dropdown-menu.tsx +165 -0
- package/src/components/empty.tsx +100 -0
- package/src/components/field.tsx +168 -0
- package/src/components/file-input.tsx +233 -0
- package/src/components/form.tsx +152 -0
- package/src/components/info-tooltip.tsx +40 -0
- package/src/components/input.tsx +55 -0
- package/src/components/json-editor.tsx +210 -0
- package/src/components/kbd.tsx +35 -0
- package/src/components/key-value-editor.tsx +423 -0
- package/src/components/keyboard-shortcuts-help.tsx +136 -0
- package/src/components/label.tsx +16 -0
- package/src/components/media-preview.tsx +278 -0
- package/src/components/multi-file-input.tsx +315 -0
- package/src/components/password-input.tsx +50 -0
- package/src/components/popover.tsx +26 -0
- package/src/components/revision-timeline.tsx +93 -0
- package/src/components/richtext-editor.tsx +624 -0
- package/src/components/richtext-mode.ts +39 -0
- package/src/components/richtext-render.tsx +51 -0
- package/src/components/richtext-sync.ts +57 -0
- package/src/components/scroll-area.tsx +41 -0
- package/src/components/select.tsx +200 -0
- package/src/components/separator.tsx +21 -0
- package/src/components/sheet.tsx +109 -0
- package/src/components/sidebar.tsx +660 -0
- package/src/components/skeleton.tsx +9 -0
- package/src/components/sonner.tsx +45 -0
- package/src/components/switch.tsx +24 -0
- package/src/components/table.tsx +93 -0
- package/src/components/tabs.tsx +57 -0
- package/src/components/textarea.tsx +18 -0
- package/src/components/tooltip.tsx +25 -0
- package/src/index.ts +342 -0
- package/src/lib/theme.ts +45 -0
- package/src/lib/utils.ts +6 -0
- 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
|