@modern-admin/react 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 (261) hide show
  1. package/dist/action-guard.d.ts +13 -0
  2. package/dist/action-guard.d.ts.map +1 -0
  3. package/dist/action-guard.js +15 -0
  4. package/dist/action-guard.js.map +1 -0
  5. package/dist/action-menu.d.ts +17 -0
  6. package/dist/action-menu.d.ts.map +1 -0
  7. package/dist/action-menu.jsx +80 -0
  8. package/dist/action-menu.jsx.map +1 -0
  9. package/dist/admin-app.d.ts +23 -0
  10. package/dist/admin-app.d.ts.map +1 -0
  11. package/dist/admin-app.jsx +407 -0
  12. package/dist/admin-app.jsx.map +1 -0
  13. package/dist/admin-router.d.ts +29 -0
  14. package/dist/admin-router.d.ts.map +1 -0
  15. package/dist/admin-router.jsx +215 -0
  16. package/dist/admin-router.jsx.map +1 -0
  17. package/dist/breadcrumbs.d.ts +17 -0
  18. package/dist/breadcrumbs.d.ts.map +1 -0
  19. package/dist/breadcrumbs.jsx +40 -0
  20. package/dist/breadcrumbs.jsx.map +1 -0
  21. package/dist/client.d.ts +526 -0
  22. package/dist/client.d.ts.map +1 -0
  23. package/dist/client.js +582 -0
  24. package/dist/client.js.map +1 -0
  25. package/dist/component-loader.d.ts +10 -0
  26. package/dist/component-loader.d.ts.map +1 -0
  27. package/dist/component-loader.js +23 -0
  28. package/dist/component-loader.js.map +1 -0
  29. package/dist/components/ai-assistant-widget.d.ts +3 -0
  30. package/dist/components/ai-assistant-widget.d.ts.map +1 -0
  31. package/dist/components/ai-assistant-widget.jsx +390 -0
  32. package/dist/components/ai-assistant-widget.jsx.map +1 -0
  33. package/dist/components/ai-fill-dialog.d.ts +9 -0
  34. package/dist/components/ai-fill-dialog.d.ts.map +1 -0
  35. package/dist/components/ai-fill-dialog.jsx +105 -0
  36. package/dist/components/ai-fill-dialog.jsx.map +1 -0
  37. package/dist/components/chart-builder-dialog.d.ts +10 -0
  38. package/dist/components/chart-builder-dialog.d.ts.map +1 -0
  39. package/dist/components/chart-builder-dialog.jsx +433 -0
  40. package/dist/components/chart-builder-dialog.jsx.map +1 -0
  41. package/dist/components/chart-widget.d.ts +12 -0
  42. package/dist/components/chart-widget.d.ts.map +1 -0
  43. package/dist/components/chart-widget.jsx +365 -0
  44. package/dist/components/chart-widget.jsx.map +1 -0
  45. package/dist/components/global-search-dialog.d.ts +7 -0
  46. package/dist/components/global-search-dialog.d.ts.map +1 -0
  47. package/dist/components/global-search-dialog.jsx +187 -0
  48. package/dist/components/global-search-dialog.jsx.map +1 -0
  49. package/dist/components/group-settings-dialog.d.ts +13 -0
  50. package/dist/components/group-settings-dialog.d.ts.map +1 -0
  51. package/dist/components/group-settings-dialog.jsx +53 -0
  52. package/dist/components/group-settings-dialog.jsx.map +1 -0
  53. package/dist/components/move-chart-dialog.d.ts +18 -0
  54. package/dist/components/move-chart-dialog.d.ts.map +1 -0
  55. package/dist/components/move-chart-dialog.jsx +68 -0
  56. package/dist/components/move-chart-dialog.jsx.map +1 -0
  57. package/dist/components/reference-multi-table-dialog.d.ts +12 -0
  58. package/dist/components/reference-multi-table-dialog.d.ts.map +1 -0
  59. package/dist/components/reference-multi-table-dialog.jsx +126 -0
  60. package/dist/components/reference-multi-table-dialog.jsx.map +1 -0
  61. package/dist/components/related-records-tabs.d.ts +8 -0
  62. package/dist/components/related-records-tabs.d.ts.map +1 -0
  63. package/dist/components/related-records-tabs.jsx +75 -0
  64. package/dist/components/related-records-tabs.jsx.map +1 -0
  65. package/dist/components/revisions-button.d.ts +7 -0
  66. package/dist/components/revisions-button.d.ts.map +1 -0
  67. package/dist/components/revisions-button.jsx +152 -0
  68. package/dist/components/revisions-button.jsx.map +1 -0
  69. package/dist/components/wizard-form.d.ts +43 -0
  70. package/dist/components/wizard-form.d.ts.map +1 -0
  71. package/dist/components/wizard-form.jsx +136 -0
  72. package/dist/components/wizard-form.jsx.map +1 -0
  73. package/dist/dashboard/time-series.d.ts +20 -0
  74. package/dist/dashboard/time-series.d.ts.map +1 -0
  75. package/dist/dashboard/time-series.js +108 -0
  76. package/dist/dashboard/time-series.js.map +1 -0
  77. package/dist/dialogs.d.ts +35 -0
  78. package/dist/dialogs.d.ts.map +1 -0
  79. package/dist/dialogs.jsx +152 -0
  80. package/dist/dialogs.jsx.map +1 -0
  81. package/dist/export.d.ts +39 -0
  82. package/dist/export.d.ts.map +1 -0
  83. package/dist/export.js +114 -0
  84. package/dist/export.js.map +1 -0
  85. package/dist/extension-registry.d.ts +122 -0
  86. package/dist/extension-registry.d.ts.map +1 -0
  87. package/dist/extension-registry.js +93 -0
  88. package/dist/extension-registry.js.map +1 -0
  89. package/dist/header-controls.d.ts +4 -0
  90. package/dist/header-controls.d.ts.map +1 -0
  91. package/dist/header-controls.jsx +70 -0
  92. package/dist/header-controls.jsx.map +1 -0
  93. package/dist/hooks.d.ts +104 -0
  94. package/dist/hooks.d.ts.map +1 -0
  95. package/dist/hooks.js +374 -0
  96. package/dist/hooks.js.map +1 -0
  97. package/dist/hotkey-help.d.ts +3 -0
  98. package/dist/hotkey-help.d.ts.map +1 -0
  99. package/dist/hotkey-help.jsx +32 -0
  100. package/dist/hotkey-help.jsx.map +1 -0
  101. package/dist/hotkey-registry.d.ts +18 -0
  102. package/dist/hotkey-registry.d.ts.map +1 -0
  103. package/dist/hotkey-registry.jsx +34 -0
  104. package/dist/hotkey-registry.jsx.map +1 -0
  105. package/dist/i18n.d.ts +74 -0
  106. package/dist/i18n.d.ts.map +1 -0
  107. package/dist/i18n.jsx +127 -0
  108. package/dist/i18n.jsx.map +1 -0
  109. package/dist/index.d.ts +35 -0
  110. package/dist/index.d.ts.map +1 -0
  111. package/dist/index.js +36 -0
  112. package/dist/index.js.map +1 -0
  113. package/dist/notify.d.ts +41 -0
  114. package/dist/notify.d.ts.map +1 -0
  115. package/dist/notify.jsx +58 -0
  116. package/dist/notify.jsx.map +1 -0
  117. package/dist/pages/ai-assistant-settings-section.d.ts +3 -0
  118. package/dist/pages/ai-assistant-settings-section.d.ts.map +1 -0
  119. package/dist/pages/ai-assistant-settings-section.jsx +126 -0
  120. package/dist/pages/ai-assistant-settings-section.jsx.map +1 -0
  121. package/dist/pages/audit-log-page.d.ts +3 -0
  122. package/dist/pages/audit-log-page.d.ts.map +1 -0
  123. package/dist/pages/audit-log-page.jsx +354 -0
  124. package/dist/pages/audit-log-page.jsx.map +1 -0
  125. package/dist/pages/edit-page.d.ts +7 -0
  126. package/dist/pages/edit-page.d.ts.map +1 -0
  127. package/dist/pages/edit-page.jsx +614 -0
  128. package/dist/pages/edit-page.jsx.map +1 -0
  129. package/dist/pages/export-dialog.d.ts +11 -0
  130. package/dist/pages/export-dialog.d.ts.map +1 -0
  131. package/dist/pages/export-dialog.jsx +102 -0
  132. package/dist/pages/export-dialog.jsx.map +1 -0
  133. package/dist/pages/home-page.d.ts +3 -0
  134. package/dist/pages/home-page.d.ts.map +1 -0
  135. package/dist/pages/home-page.jsx +211 -0
  136. package/dist/pages/home-page.jsx.map +1 -0
  137. package/dist/pages/list-page.d.ts +42 -0
  138. package/dist/pages/list-page.d.ts.map +1 -0
  139. package/dist/pages/list-page.jsx +1596 -0
  140. package/dist/pages/list-page.jsx.map +1 -0
  141. package/dist/pages/login-page.d.ts +11 -0
  142. package/dist/pages/login-page.d.ts.map +1 -0
  143. package/dist/pages/login-page.jsx +157 -0
  144. package/dist/pages/login-page.jsx.map +1 -0
  145. package/dist/pages/settings-page.d.ts +5 -0
  146. package/dist/pages/settings-page.d.ts.map +1 -0
  147. package/dist/pages/settings-page.jsx +787 -0
  148. package/dist/pages/settings-page.jsx.map +1 -0
  149. package/dist/pages/settings-shared.d.ts +51 -0
  150. package/dist/pages/settings-shared.d.ts.map +1 -0
  151. package/dist/pages/settings-shared.jsx +66 -0
  152. package/dist/pages/settings-shared.jsx.map +1 -0
  153. package/dist/pages/show-page.d.ts +7 -0
  154. package/dist/pages/show-page.d.ts.map +1 -0
  155. package/dist/pages/show-page.jsx +147 -0
  156. package/dist/pages/show-page.jsx.map +1 -0
  157. package/dist/pages/wizard-create-page.d.ts +14 -0
  158. package/dist/pages/wizard-create-page.d.ts.map +1 -0
  159. package/dist/pages/wizard-create-page.jsx +106 -0
  160. package/dist/pages/wizard-create-page.jsx.map +1 -0
  161. package/dist/property-renderer.d.ts +8 -0
  162. package/dist/property-renderer.d.ts.map +1 -0
  163. package/dist/property-renderer.jsx +690 -0
  164. package/dist/property-renderer.jsx.map +1 -0
  165. package/dist/provider.d.ts +20 -0
  166. package/dist/provider.d.ts.map +1 -0
  167. package/dist/provider.jsx +32 -0
  168. package/dist/provider.jsx.map +1 -0
  169. package/dist/realtime.d.ts +22 -0
  170. package/dist/realtime.d.ts.map +1 -0
  171. package/dist/realtime.js +38 -0
  172. package/dist/realtime.js.map +1 -0
  173. package/dist/reference.d.ts +52 -0
  174. package/dist/reference.d.ts.map +1 -0
  175. package/dist/reference.jsx +224 -0
  176. package/dist/reference.jsx.map +1 -0
  177. package/dist/relations.d.ts +11 -0
  178. package/dist/relations.d.ts.map +1 -0
  179. package/dist/relations.js +36 -0
  180. package/dist/relations.js.map +1 -0
  181. package/dist/router.d.ts +82 -0
  182. package/dist/router.d.ts.map +1 -0
  183. package/dist/router.jsx +187 -0
  184. package/dist/router.jsx.map +1 -0
  185. package/dist/show-when.d.ts +7 -0
  186. package/dist/show-when.d.ts.map +1 -0
  187. package/dist/show-when.js +77 -0
  188. package/dist/show-when.js.map +1 -0
  189. package/dist/types.d.ts +194 -0
  190. package/dist/types.d.ts.map +1 -0
  191. package/dist/types.js +18 -0
  192. package/dist/types.js.map +1 -0
  193. package/dist/use-dashboard-charts.d.ts +93 -0
  194. package/dist/use-dashboard-charts.d.ts.map +1 -0
  195. package/dist/use-dashboard-charts.js +263 -0
  196. package/dist/use-dashboard-charts.js.map +1 -0
  197. package/dist/use-hotkey.d.ts +17 -0
  198. package/dist/use-hotkey.d.ts.map +1 -0
  199. package/dist/use-hotkey.js +103 -0
  200. package/dist/use-hotkey.js.map +1 -0
  201. package/dist/user-directory.d.ts +18 -0
  202. package/dist/user-directory.d.ts.map +1 -0
  203. package/dist/user-directory.js +51 -0
  204. package/dist/user-directory.js.map +1 -0
  205. package/dist/validation.d.ts +22 -0
  206. package/dist/validation.d.ts.map +1 -0
  207. package/dist/validation.js +338 -0
  208. package/dist/validation.js.map +1 -0
  209. package/package.json +59 -0
  210. package/src/action-guard.ts +20 -0
  211. package/src/action-menu.tsx +161 -0
  212. package/src/admin-app.tsx +630 -0
  213. package/src/admin-router.tsx +273 -0
  214. package/src/breadcrumbs.tsx +75 -0
  215. package/src/client.ts +1093 -0
  216. package/src/component-loader.ts +33 -0
  217. package/src/components/ai-assistant-widget.tsx +565 -0
  218. package/src/components/ai-fill-dialog.tsx +143 -0
  219. package/src/components/chart-builder-dialog.tsx +618 -0
  220. package/src/components/chart-widget.tsx +654 -0
  221. package/src/components/global-search-dialog.tsx +272 -0
  222. package/src/components/group-settings-dialog.tsx +93 -0
  223. package/src/components/move-chart-dialog.tsx +130 -0
  224. package/src/components/reference-multi-table-dialog.tsx +196 -0
  225. package/src/components/related-records-tabs.tsx +130 -0
  226. package/src/components/revisions-button.tsx +237 -0
  227. package/src/components/wizard-form.tsx +302 -0
  228. package/src/dashboard/time-series.ts +125 -0
  229. package/src/dialogs.tsx +265 -0
  230. package/src/export.ts +140 -0
  231. package/src/extension-registry.ts +195 -0
  232. package/src/header-controls.tsx +125 -0
  233. package/src/hooks.ts +509 -0
  234. package/src/hotkey-help.tsx +56 -0
  235. package/src/hotkey-registry.tsx +60 -0
  236. package/src/i18n.tsx +267 -0
  237. package/src/index.ts +192 -0
  238. package/src/notify.tsx +94 -0
  239. package/src/pages/ai-assistant-settings-section.tsx +167 -0
  240. package/src/pages/audit-log-page.tsx +580 -0
  241. package/src/pages/edit-page.tsx +743 -0
  242. package/src/pages/export-dialog.tsx +154 -0
  243. package/src/pages/home-page.tsx +318 -0
  244. package/src/pages/list-page.tsx +2645 -0
  245. package/src/pages/login-page.tsx +242 -0
  246. package/src/pages/settings-page.tsx +1143 -0
  247. package/src/pages/settings-shared.tsx +134 -0
  248. package/src/pages/show-page.tsx +223 -0
  249. package/src/pages/wizard-create-page.tsx +164 -0
  250. package/src/property-renderer.tsx +1143 -0
  251. package/src/provider.tsx +70 -0
  252. package/src/realtime.ts +55 -0
  253. package/src/reference.tsx +386 -0
  254. package/src/relations.ts +55 -0
  255. package/src/router.tsx +211 -0
  256. package/src/show-when.ts +76 -0
  257. package/src/types.ts +198 -0
  258. package/src/use-dashboard-charts.ts +362 -0
  259. package/src/use-hotkey.ts +128 -0
  260. package/src/user-directory.ts +56 -0
  261. package/src/validation.ts +361 -0
@@ -0,0 +1,654 @@
1
+ // Dashboard tile rendering one ChartDef. Phase 10 brings:
2
+ // • Per-widget toolbar (step / range / width / SQL toggle) — changes persist
3
+ // via `onUpdate` so a tweak survives reload without re-opening the builder.
4
+ // • Time-series chart (date X-axis, value Y-axis) with multi-series via
5
+ // secondary `groupBy`. Zero-fill happens UI-side regardless of adapter.
6
+ // • KPI mode = `step: 'all'` — sums the single bucket and shows period-over-
7
+ // period delta.
8
+ // • Graceful degradation when the adapter cannot do time-series aggregation
9
+ // (e.g. non-relational DB) — shows a friendly message instead of erroring.
10
+
11
+ import * as React from 'react'
12
+ import {
13
+ MoreHorizontal,
14
+ Pencil,
15
+ Trash2,
16
+ RefreshCw,
17
+ Maximize2,
18
+ Minimize2,
19
+ Code,
20
+ Copy,
21
+ Check,
22
+ FolderSymlink,
23
+ } from 'lucide-react'
24
+ import {
25
+ Card,
26
+ CardContent,
27
+ CardHeader,
28
+ CardTitle,
29
+ TimeSeriesChart,
30
+ KpiCard,
31
+ DatePicker,
32
+ DropdownMenu,
33
+ DropdownMenuContent,
34
+ DropdownMenuItem,
35
+ DropdownMenuSeparator,
36
+ DropdownMenuTrigger,
37
+ Button,
38
+ Input,
39
+ Select,
40
+ SelectContent,
41
+ SelectItem,
42
+ SelectTrigger,
43
+ SelectValue,
44
+ Skeleton,
45
+ } from '@modern-admin/ui'
46
+ import { ReferenceCombobox } from '../reference.js'
47
+ import type { PropertyJSON } from '../types.js'
48
+ import type {
49
+ ChartDef,
50
+ ChartDefInput,
51
+ AggregationStep,
52
+ ChartWidth,
53
+ TimeRange,
54
+ TimeRangePreset,
55
+ } from '@modern-admin/core'
56
+ import { useTimeSeries, useResource } from '../hooks.js'
57
+ import { useI18n } from '../i18n.js'
58
+ import { resolveRange } from '../use-dashboard-charts.js'
59
+ import {
60
+ fillTimeSeries,
61
+ makeLabelFormatter,
62
+ makeTickFormatter,
63
+ } from '../dashboard/time-series.js'
64
+ import type { TimeSeriesQuery, TimeSeriesSeries } from '../client.js'
65
+
66
+ const PRESETS: TimeRangePreset[] = ['7d', '30d', '90d', '1y', 'all', 'custom']
67
+ const STEPS: Exclude<AggregationStep, 'all'>[] = ['day', 'week', 'month', 'year']
68
+
69
+ export interface ChartWidgetProps {
70
+ config: ChartDef
71
+ onEdit(): void
72
+ onDelete(): void
73
+ onMove?(): void
74
+ /** Called when the user tweaks step / range / width directly on the widget. */
75
+ onUpdate(input: ChartDefInput): void
76
+ }
77
+
78
+ export function ChartWidget({
79
+ config,
80
+ onEdit,
81
+ onDelete,
82
+ onMove,
83
+ onUpdate,
84
+ }: ChartWidgetProps): React.ReactElement {
85
+ const { t, locale } = useI18n()
86
+
87
+ const isKpi = config.visualisation === 'kpi'
88
+ // KPI charts force `step: 'all'`; the schema enforces this at save time.
89
+ // For very wide presets, automatically coarsen granularity so the point
90
+ // count stays manageable — 3650 daily buckets for 'all' would render an
91
+ // unreadable axis and cause significant memory pressure in Recharts.
92
+ const renderStep: AggregationStep =
93
+ isKpi ? 'all'
94
+ : config.timeRange.preset === 'all' && (config.step === 'day' || config.step === 'week') ? 'month'
95
+ : config.timeRange.preset === '1y' && config.step === 'day' ? 'week'
96
+ : config.step
97
+
98
+ // Resolve the time-range preset to concrete from/to per render so cards
99
+ // automatically reflect "now" as days roll over without re-saving.
100
+ const range = React.useMemo(() => resolveRange(config.timeRange), [config.timeRange])
101
+
102
+ // If the saved ChartDef pre-dates the groupByLabelResource feature (i.e.,
103
+ // groupBy is set but groupByLabelResource is not), derive it from the
104
+ // resource's property metadata so labels resolve without requiring a re-save.
105
+ const resourceConfig = useResource(config.resource)
106
+ const effectiveLabelResource = React.useMemo(() => {
107
+ if (config.groupByLabelResource) return config.groupByLabelResource
108
+ if (!config.groupBy || !resourceConfig) return undefined
109
+ const props = resourceConfig.properties
110
+ const prop = props.find((p) => p.path === config.groupBy)
111
+ if (!prop) return undefined
112
+ if (prop.reference) return prop.reference
113
+ // FK heuristic: strip Id / _id suffix and look for a sibling reference prop.
114
+ const base = prop.path.endsWith('_id')
115
+ ? prop.path.slice(0, -3)
116
+ : prop.path.endsWith('Id')
117
+ ? prop.path.slice(0, -2)
118
+ : ''
119
+ if (base) {
120
+ const sibling = props.find((p) => p.path === base && p.reference)
121
+ if (sibling?.reference) return sibling.reference
122
+ }
123
+ return undefined
124
+ }, [config.groupBy, config.groupByLabelResource, resourceConfig])
125
+
126
+ const query = React.useMemo<TimeSeriesQuery>(
127
+ () => ({
128
+ resource: config.resource,
129
+ dateField: config.dateField,
130
+ step: renderStep as TimeSeriesQuery['step'],
131
+ metric: config.metric,
132
+ from: range.from,
133
+ to: range.to,
134
+ ...(config.field ? { field: config.field } : {}),
135
+ ...(!isKpi && config.groupBy ? { groupBy: config.groupBy } : {}),
136
+ ...(!isKpi && config.groupBy ? { topN: config.topN } : {}),
137
+ ...(!isKpi && config.groupBy && effectiveLabelResource
138
+ ? { groupByLabelResource: effectiveLabelResource }
139
+ : {}),
140
+ ...(Object.keys(config.filters).length ? { filters: config.filters } : {}),
141
+ ...(isKpi ? { comparePrevious: true as const } : {}),
142
+ }),
143
+ [config, range, isKpi, renderStep, effectiveLabelResource],
144
+ )
145
+
146
+ const { data, isLoading, isError, refetch, isFetching } = useTimeSeries(query)
147
+
148
+ const [showSql, setShowSql] = React.useState(false)
149
+ const [sqlCopied, setSqlCopied] = React.useState(false)
150
+
151
+ // Draft from/to — only committed when the user clicks Apply.
152
+ // Seeded from `config.timeRange` when preset changes to 'custom'.
153
+ const [draftFrom, setDraftFrom] = React.useState(range.from)
154
+ const [draftTo, setDraftTo] = React.useState(range.to)
155
+
156
+ // Draft state for quick filters exposed above the chart. The user tweaks
157
+ // values inline and clicks Apply to refetch — mirroring the custom-range
158
+ // pattern so widgets don't refetch on every keystroke.
159
+ const quickFilterPaths = config.quickFilters ?? []
160
+ const [draftFilters, setDraftFilters] = React.useState<Record<string, string>>(
161
+ () => seedDraftFilters(quickFilterPaths, config.filters),
162
+ )
163
+ // Re-seed whenever the saved chart definition changes externally.
164
+ const quickFiltersKey = quickFilterPaths.join('|')
165
+ const savedFiltersKey = React.useMemo(
166
+ () =>
167
+ Object.entries(config.filters)
168
+ .map(([k, v]) => `${k}=${v}`)
169
+ .sort()
170
+ .join('|'),
171
+ [config.filters],
172
+ )
173
+ React.useEffect(() => {
174
+ setDraftFilters(seedDraftFilters(quickFilterPaths, config.filters))
175
+ // eslint-disable-next-line react-hooks/exhaustive-deps
176
+ }, [quickFiltersKey, savedFiltersKey])
177
+
178
+ const applyQuickFilters = (): void => {
179
+ const next: Record<string, string> = { ...config.filters }
180
+ for (const p of quickFilterPaths) {
181
+ const v = draftFilters[p] ?? ''
182
+ if (v === '') delete next[p]
183
+ else next[p] = v
184
+ }
185
+ update({ filters: next })
186
+ }
187
+
188
+ const draftDirty = quickFilterPaths.some(
189
+ (p) => (draftFilters[p] ?? '') !== (config.filters[p] ?? ''),
190
+ )
191
+
192
+ // Mutators — persisted by the parent via `onUpdate(updatedDef)`.
193
+ const update = (patch: Partial<ChartDefInput>): void => {
194
+ onUpdate({ ...config, ...patch, updatedAt: new Date().toISOString() })
195
+ }
196
+
197
+ const onPresetChange = (preset: TimeRangePreset): void => {
198
+ if (preset === 'custom') {
199
+ // Seed draft with the currently resolved window — user can then narrow
200
+ // it and click Apply without triggering an immediate refetch.
201
+ setDraftFrom(range.from)
202
+ setDraftTo(range.to)
203
+ update({
204
+ timeRange: { preset: 'custom', from: range.from, to: range.to } as TimeRange,
205
+ })
206
+ } else {
207
+ update({ timeRange: { preset } as TimeRange })
208
+ }
209
+ }
210
+
211
+ const applyCustomRange = (): void => {
212
+ if (draftFrom && draftTo) {
213
+ update({
214
+ timeRange: { preset: 'custom', from: draftFrom, to: draftTo } as TimeRange,
215
+ })
216
+ }
217
+ }
218
+
219
+ const onStepChange = (step: AggregationStep): void => {
220
+ if (step === 'all') return // KPI is selected via the builder, not the toolbar
221
+ update({ step })
222
+ }
223
+
224
+ const onWidthToggle = (): void => {
225
+ update({ width: (config.width === 'full' ? 'half' : 'full') as ChartWidth })
226
+ }
227
+
228
+ // ── Render ────────────────────────────────────────────────────────────
229
+
230
+ const heightClass = isKpi ? 'h-32' : 'h-[320px]'
231
+
232
+ const resolvedLabels = data?.resolvedLabels
233
+ const seriesLabel = React.useCallback(
234
+ (key: string): string => {
235
+ if (key === '__total__') return t('dashboard:seriesTotal')
236
+ if (key === '__other__') return t('dashboard:seriesOther')
237
+ if (key === '__null__') return t('dashboard:seriesNull')
238
+ return resolvedLabels?.[key] ?? key
239
+ },
240
+ [resolvedLabels, t],
241
+ )
242
+
243
+ const chartSeries = React.useMemo(
244
+ () => prepareSeries(data?.series ?? [], range, renderStep, seriesLabel),
245
+ // eslint-disable-next-line react-hooks/exhaustive-deps
246
+ [data?.series, range.from, range.to, renderStep, seriesLabel],
247
+ )
248
+
249
+ // Adapter cannot do time-series — friendly message, no toolbar churn.
250
+ const unsupported = data && data.supported === false
251
+
252
+ return (
253
+ <Card className="flex flex-col">
254
+ <CardHeader className="flex flex-row items-center justify-between gap-2 p-3 pb-2 space-y-0 sm:p-6 sm:pb-2">
255
+ <CardTitle className="text-sm font-medium truncate pr-2">
256
+ {config.title || t('chart:untitled')}
257
+ </CardTitle>
258
+ <div className="flex items-center gap-1 shrink-0">
259
+ <Button
260
+ variant="ghost"
261
+ size="icon"
262
+ className="size-7"
263
+ onClick={onWidthToggle}
264
+ aria-label={
265
+ config.width === 'full'
266
+ ? t('dashboard:widget.shrink')
267
+ : t('dashboard:widget.expand')
268
+ }
269
+ >
270
+ {config.width === 'full' ? (
271
+ <Minimize2 className="size-3.5" />
272
+ ) : (
273
+ <Maximize2 className="size-3.5" />
274
+ )}
275
+ </Button>
276
+ {data?.sql && (
277
+ <Button
278
+ variant={showSql ? 'secondary' : 'ghost'}
279
+ size="icon"
280
+ className="size-7"
281
+ onClick={() => setShowSql((v) => !v)}
282
+ aria-label={t('dashboard:widget.toggleSql')}
283
+ >
284
+ <Code className="size-3.5" />
285
+ </Button>
286
+ )}
287
+ <Button
288
+ variant="ghost"
289
+ size="icon"
290
+ className="size-7"
291
+ onClick={() => refetch()}
292
+ disabled={isFetching}
293
+ aria-label={t('common:refresh')}
294
+ >
295
+ <RefreshCw className={`size-3.5 ${isFetching ? 'animate-spin' : ''}`} />
296
+ </Button>
297
+ <DropdownMenu>
298
+ <DropdownMenuTrigger asChild>
299
+ <Button
300
+ variant="ghost"
301
+ size="icon"
302
+ className="size-7"
303
+ aria-label={t('common:openMenu')}
304
+ >
305
+ <MoreHorizontal className="size-3.5" />
306
+ </Button>
307
+ </DropdownMenuTrigger>
308
+ <DropdownMenuContent align="end">
309
+ <DropdownMenuItem onClick={onEdit}>
310
+ <Pencil className="size-4 mr-2" />
311
+ {t('chart:editChart')}
312
+ </DropdownMenuItem>
313
+ {onMove && (
314
+ <DropdownMenuItem onClick={onMove}>
315
+ <FolderSymlink className="size-4 mr-2" />
316
+ {t('chart:moveToGroup')}
317
+ </DropdownMenuItem>
318
+ )}
319
+ <DropdownMenuSeparator />
320
+ <DropdownMenuItem
321
+ onClick={onDelete}
322
+ className="text-destructive focus:text-destructive"
323
+ >
324
+ <Trash2 className="size-4 mr-2" />
325
+ {t('chart:deleteChart')}
326
+ </DropdownMenuItem>
327
+ </DropdownMenuContent>
328
+ </DropdownMenu>
329
+ </div>
330
+ </CardHeader>
331
+
332
+ <CardContent className="flex-1 p-3 pt-0 space-y-3 sm:p-6 sm:pt-0">
333
+ {/* Quick filters — compact inline row, label as placeholder, Apply on the right. */}
334
+ {!unsupported && quickFilterPaths.length > 0 && resourceConfig && (
335
+ <div className="flex flex-wrap items-center gap-2">
336
+ {quickFilterPaths.map((path) => {
337
+ const prop = resourceConfig.properties.find((p) => p.path === path)
338
+ if (!prop) return null
339
+ return (
340
+ <QuickFilterInput
341
+ key={path}
342
+ property={prop}
343
+ placeholder={prop.label}
344
+ value={draftFilters[path] ?? ''}
345
+ onChange={(v) => setDraftFilters((prev) => ({ ...prev, [path]: v }))}
346
+ />
347
+ )
348
+ })}
349
+ <Button
350
+ size="sm"
351
+ className="h-8 px-3 text-xs shrink-0"
352
+ onClick={applyQuickFilters}
353
+ disabled={!draftDirty}
354
+ >
355
+ <Check className="size-3.5 mr-1" />
356
+ {t('common:apply')}
357
+ </Button>
358
+ </div>
359
+ )}
360
+
361
+ {/* Toolbar — step + range + window display. Hidden for unsupported
362
+ adapters because changing knobs would have no effect. */}
363
+ {!unsupported && (
364
+ <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
365
+ {!isKpi && (
366
+ <Select
367
+ value={config.step === 'all' ? 'day' : config.step}
368
+ onValueChange={(v) => onStepChange(v as AggregationStep)}
369
+ >
370
+ <SelectTrigger
371
+ className="h-8 px-2 text-xs w-auto"
372
+ aria-label={t('chart:step')}
373
+ >
374
+ <SelectValue />
375
+ </SelectTrigger>
376
+ <SelectContent>
377
+ {STEPS.map((s) => (
378
+ <SelectItem key={s} value={s}>
379
+ {t(`chart:step${cap(s)}`)}
380
+ </SelectItem>
381
+ ))}
382
+ </SelectContent>
383
+ </Select>
384
+ )}
385
+ <Select
386
+ value={config.timeRange.preset}
387
+ onValueChange={(v) => onPresetChange(v as TimeRangePreset)}
388
+ >
389
+ <SelectTrigger
390
+ className="h-8 px-2 text-xs w-auto"
391
+ aria-label={t('dashboard:builder.range')}
392
+ >
393
+ <SelectValue />
394
+ </SelectTrigger>
395
+ <SelectContent>
396
+ {PRESETS.map((p) => (
397
+ <SelectItem key={p} value={p}>
398
+ {t(`dashboard:range.${p}`)}
399
+ </SelectItem>
400
+ ))}
401
+ </SelectContent>
402
+ </Select>
403
+ {config.timeRange.preset === 'custom' ? (
404
+ <>
405
+ <DatePicker
406
+ value={draftFrom}
407
+ onChange={setDraftFrom}
408
+ ariaLabel={t('common:from')}
409
+ openCalendarLabel={t('common:openCalendar')}
410
+ className="w-[130px]"
411
+ inputClassName="h-8 text-xs"
412
+ />
413
+ <span className="text-muted-foreground">—</span>
414
+ <DatePicker
415
+ value={draftTo}
416
+ onChange={setDraftTo}
417
+ ariaLabel={t('common:to')}
418
+ openCalendarLabel={t('common:openCalendar')}
419
+ className="w-[130px]"
420
+ inputClassName="h-8 text-xs"
421
+ />
422
+ <Button
423
+ size="sm"
424
+ className="h-8 px-3 shrink-0"
425
+ onClick={applyCustomRange}
426
+ disabled={!draftFrom || !draftTo}
427
+ aria-label={t('common:apply')}
428
+ >
429
+ <Check className="size-3.5 mr-1" />
430
+ {t('common:apply')}
431
+ </Button>
432
+ </>
433
+ ) : (
434
+ <span className="ml-auto tabular-nums">
435
+ {range.from} — {range.to}
436
+ </span>
437
+ )}
438
+ </div>
439
+ )}
440
+
441
+ {/* Body */}
442
+ {isLoading ? (
443
+ <Skeleton className={`${heightClass} w-full rounded-md`} />
444
+ ) : isError ? (
445
+ <div
446
+ className={`flex items-center justify-center text-sm text-muted-foreground ${heightClass}`}
447
+ >
448
+ {t('chart:loadError')}
449
+ </div>
450
+ ) : unsupported ? (
451
+ <div
452
+ className={`flex items-center justify-center text-center text-sm text-muted-foreground px-4 ${heightClass}`}
453
+ >
454
+ {t('dashboard:widget.unsupported')}
455
+ </div>
456
+ ) : isKpi ? (
457
+ <KpiBody data={data} labels={kpiLabels(t)} />
458
+ ) : (
459
+ <TimeSeriesChart
460
+ series={chartSeries}
461
+ height={320}
462
+ visualisation={config.visualisation === 'kpi' ? undefined : config.visualisation}
463
+ tickFormatter={makeTickFormatter(renderStep, locale)}
464
+ labelFormatter={makeLabelFormatter(renderStep, locale)}
465
+ labels={{
466
+ noData: t('chart:noData'),
467
+ showAll: t('dashboard:widget.showAll'),
468
+ hideAll: t('dashboard:widget.hideAll'),
469
+ }}
470
+ />
471
+ )}
472
+
473
+ {/* Captured SQL — only when the user toggled and server returned it. */}
474
+ {data?.sql && showSql && (
475
+ <div className="relative">
476
+ <Button
477
+ variant="ghost"
478
+ size="icon"
479
+ className="absolute top-1 right-1 size-6"
480
+ onClick={() => {
481
+ void navigator.clipboard.writeText(data.sql ?? '')
482
+ setSqlCopied(true)
483
+ setTimeout(() => setSqlCopied(false), 2000)
484
+ }}
485
+ aria-label={sqlCopied ? t('common:copied') : t('common:copy')}
486
+ >
487
+ {sqlCopied
488
+ ? <Check className="size-3 text-green-500" />
489
+ : <Copy className="size-3" />}
490
+ </Button>
491
+ <pre className="text-[11px] leading-snug bg-muted/50 border border-border rounded-md p-2 pr-8 overflow-x-auto whitespace-pre">
492
+ {data.sql}
493
+ </pre>
494
+ </div>
495
+ )}
496
+ </CardContent>
497
+ </Card>
498
+ )
499
+ }
500
+
501
+ // ── Helpers ──────────────────────────────────────────────────────────────
502
+
503
+ const cap = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1)
504
+
505
+ const QF_NONE = '__none__'
506
+
507
+ function seedDraftFilters(
508
+ paths: ReadonlyArray<string>,
509
+ filters: Record<string, string>,
510
+ ): Record<string, string> {
511
+ const out: Record<string, string> = {}
512
+ for (const p of paths) out[p] = filters[p] ?? ''
513
+ return out
514
+ }
515
+
516
+ /** Compact inline filter input — label is passed as placeholder so no
517
+ * extra row is needed. Matches the `h-7 text-xs` sizing of toolbar controls. */
518
+ function QuickFilterInput({
519
+ property,
520
+ placeholder,
521
+ value,
522
+ onChange,
523
+ }: {
524
+ property: PropertyJSON
525
+ placeholder?: string
526
+ value: string
527
+ onChange(next: string): void
528
+ }): React.ReactElement {
529
+ const ph = placeholder ?? property.label
530
+ if (property.reference) {
531
+ return (
532
+ <div className="w-36">
533
+ <ReferenceCombobox
534
+ referenceResourceId={property.reference}
535
+ value={value || null}
536
+ onChange={(next) => onChange(next == null ? '' : String(next))}
537
+ placeholder={ph}
538
+ className="h-8 text-xs"
539
+ />
540
+ </div>
541
+ )
542
+ }
543
+ if (property.availableValues && property.availableValues.length > 0) {
544
+ return (
545
+ <Select
546
+ value={value || QF_NONE}
547
+ onValueChange={(v) => onChange(v === QF_NONE ? '' : v)}
548
+ >
549
+ <SelectTrigger className="h-8 px-2 text-xs w-36">
550
+ <SelectValue placeholder={ph} />
551
+ </SelectTrigger>
552
+ <SelectContent>
553
+ <SelectItem value={QF_NONE}>{ph}</SelectItem>
554
+ {property.availableValues.map((opt) => (
555
+ <SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
556
+ ))}
557
+ </SelectContent>
558
+ </Select>
559
+ )
560
+ }
561
+ if (property.type === 'boolean') {
562
+ return (
563
+ <Select
564
+ value={value || QF_NONE}
565
+ onValueChange={(v) => onChange(v === QF_NONE ? '' : v)}
566
+ >
567
+ <SelectTrigger className="h-8 px-2 text-xs w-36">
568
+ <SelectValue placeholder={ph} />
569
+ </SelectTrigger>
570
+ <SelectContent>
571
+ <SelectItem value={QF_NONE}>{ph}</SelectItem>
572
+ <SelectItem value="true">true</SelectItem>
573
+ <SelectItem value="false">false</SelectItem>
574
+ </SelectContent>
575
+ </Select>
576
+ )
577
+ }
578
+ const isNumeric =
579
+ property.type === 'number' || property.type === 'float' || property.type === 'currency'
580
+ return (
581
+ <Input
582
+ type={isNumeric ? 'number' : 'text'}
583
+ className="h-8 px-2 text-xs w-36"
584
+ value={value}
585
+ placeholder={ph}
586
+ onChange={(e) => onChange(e.target.value)}
587
+ />
588
+ )
589
+ }
590
+
591
+ /**
592
+ * Zero-fill every series across the resolved date range and re-tag each
593
+ * with its display label (so legend shows "Total" / "Other" / actual
594
+ * groupBy values rather than the wire-format internal keys).
595
+ */
596
+ function prepareSeries(
597
+ series: ReadonlyArray<TimeSeriesSeries>,
598
+ range: { from: string; to: string },
599
+ step: AggregationStep,
600
+ labelFor: (key: string) => string,
601
+ ): { key: string; label: string; points: { date: string; value: number }[] }[] {
602
+ const filled = fillTimeSeries(
603
+ series.map((s) => ({ key: s.key, points: s.points })),
604
+ range.from,
605
+ range.to,
606
+ step as Exclude<AggregationStep, 'all'> | 'all',
607
+ )
608
+ return filled.map((s) => ({
609
+ key: s.key,
610
+ label: labelFor(s.key),
611
+ points: [...s.points],
612
+ }))
613
+ }
614
+
615
+ interface KpiBodyProps {
616
+ data: { series: ReadonlyArray<TimeSeriesSeries>; previous?: ReadonlyArray<TimeSeriesSeries> } | undefined
617
+ labels: {
618
+ noData: string
619
+ deltaUp: string
620
+ deltaDown: string
621
+ deltaFlat: string
622
+ previousPeriod: string
623
+ }
624
+ }
625
+
626
+ /** KPI mode summarises the single-bucket response to one scalar. */
627
+ function KpiBody({ data, labels }: KpiBodyProps): React.ReactElement {
628
+ const value = sumAll(data?.series)
629
+ const prev = sumAll(data?.previous)
630
+ return <KpiCard value={value} previousValue={prev} labels={labels} />
631
+ }
632
+
633
+ function sumAll(series: ReadonlyArray<TimeSeriesSeries> | undefined): number | null {
634
+ if (!series || series.length === 0) return null
635
+ let total = 0
636
+ for (const s of series) for (const p of s.points) total += p.value
637
+ return total
638
+ }
639
+
640
+ function kpiLabels(t: (key: string) => string): {
641
+ noData: string
642
+ deltaUp: string
643
+ deltaDown: string
644
+ deltaFlat: string
645
+ previousPeriod: string
646
+ } {
647
+ return {
648
+ noData: t('chart:noData'),
649
+ deltaUp: t('dashboard:widget.deltaUp'),
650
+ deltaDown: t('dashboard:widget.deltaDown'),
651
+ deltaFlat: t('dashboard:widget.deltaFlat'),
652
+ previousPeriod: t('dashboard:widget.previousPeriod'),
653
+ }
654
+ }