@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,618 @@
1
+ // Phase 10: time-series-first chart builder.
2
+ //
3
+ // X-axis is ALWAYS a date — the user picks `dateField` (required). `groupBy`
4
+ // becomes an optional *secondary* breakdown that produces multiple series
5
+ // (Metabase-style: pick "status" → one line per status value). Pie removed.
6
+ // `width` lets the chart take half or full row on the dashboard grid.
7
+
8
+ import * as React from 'react'
9
+ import {
10
+ BarChart2,
11
+ LineChart,
12
+ AreaChart,
13
+ Activity,
14
+ } from 'lucide-react'
15
+ import {
16
+ Button,
17
+ Dialog,
18
+ DialogContent,
19
+ DialogFooter,
20
+ DialogHeader,
21
+ DialogTitle,
22
+ InfoTooltip,
23
+ Input,
24
+ Label,
25
+ Select,
26
+ SelectContent,
27
+ SelectItem,
28
+ SelectTrigger,
29
+ SelectValue,
30
+ Switch,
31
+ } from '@modern-admin/ui'
32
+ import {
33
+ chartDefZ,
34
+ uuidv7,
35
+ type AggregationOpName,
36
+ type ChartDef,
37
+ type ChartDefInput,
38
+ type ChartVisualisation,
39
+ type TimeRange,
40
+ type TimeRangePreset,
41
+ } from '@modern-admin/core'
42
+ import { useI18n } from '../i18n.js'
43
+ import { useResources } from '../hooks.js'
44
+ import { ReferenceCombobox } from '../reference.js'
45
+ import type { PropertyJSON } from '../types.js'
46
+
47
+ export interface ChartBuilderDialogProps {
48
+ /** Pre-populate for editing an existing chart. */
49
+ initial?: ChartDef
50
+ onSave(input: ChartDefInput): void
51
+ onClose(): void
52
+ }
53
+
54
+ const VIS_OPTIONS: { value: ChartVisualisation; icon: React.ReactElement; labelKey: string }[] = [
55
+ { value: 'kpi', icon: <Activity className="size-4" />, labelKey: 'dashboard:visKpi' },
56
+ { value: 'line', icon: <LineChart className="size-4" />, labelKey: 'dashboard:visLine' },
57
+ { value: 'area', icon: <AreaChart className="size-4" />, labelKey: 'dashboard:visArea' },
58
+ { value: 'bar', icon: <BarChart2 className="size-4" />, labelKey: 'dashboard:visBar' },
59
+ ]
60
+
61
+ const NONE = '__none__'
62
+ const METRICS: AggregationOpName[] = ['count', 'sum', 'avg', 'min', 'max']
63
+ // `custom` is intentionally excluded from the builder — the builder sets a
64
+ // default interval; ad-hoc custom ranges are picked on the widget toolbar.
65
+ const PRESETS: Exclude<TimeRangePreset, 'custom'>[] = ['7d', '30d', '90d', '1y', 'all']
66
+
67
+ /** Date-ish heuristic for the dateField selector when adapter metadata is sparse. */
68
+ function isDateProperty(p: { type?: string; path: string }): boolean {
69
+ if (p.type === 'date' || p.type === 'datetime') return true
70
+ const lower = p.path.toLowerCase()
71
+ return lower.endsWith('at') || lower.endsWith('_at') || lower.endsWith('date')
72
+ }
73
+
74
+ /**
75
+ * Properties that make sense as a `groupBy` axis: anything that resolves to
76
+ * a discrete column value the adapter can group on. Virtual relation columns
77
+ * (`type: 'reference'` with `isArray: false` and no FK suffix) carry full
78
+ * record objects in their values and would serialise to `[object Object]` —
79
+ * exclude them so the user picks the underlying FK column instead.
80
+ */
81
+ function isGroupable(p: { path: string; type?: string; isArray?: boolean }): boolean {
82
+ if (isDateProperty(p)) return false
83
+ if (p.type === 'reference' && !p.isArray) {
84
+ // Allow scalar FK columns (named `*Id`/`*_id` with type 'reference' set
85
+ // via adapter override) — they group cleanly to id strings. Reject virtual
86
+ // relation fields whose values are objects.
87
+ const path = p.path
88
+ return path.endsWith('Id') || path.endsWith('_id')
89
+ }
90
+ if (p.type === 'json' || p.type === 'mixed') return false
91
+ if (p.type === 'richtext' || p.type === 'markdown' || p.type === 'textarea') return false
92
+ if (p.type === 'previewMedia' || p.type === 'file') return false
93
+ return true
94
+ }
95
+
96
+ export function ChartBuilderDialog({
97
+ initial,
98
+ onSave,
99
+ onClose,
100
+ }: ChartBuilderDialogProps): React.ReactElement {
101
+ const { t } = useI18n()
102
+ const resources = useResources()
103
+
104
+ const [title, setTitle] = React.useState(initial?.title ?? '')
105
+ const [visualisation, setVisualisation] = React.useState<ChartVisualisation>(
106
+ initial?.visualisation ?? 'area',
107
+ )
108
+ const [resourceId, setResourceId] = React.useState(
109
+ initial?.resource ?? resources[0]?.id ?? '',
110
+ )
111
+ const [dateField, setDateField] = React.useState(initial?.dateField ?? '')
112
+ const [metric, setMetric] = React.useState<AggregationOpName>(initial?.metric ?? 'count')
113
+ const [field, setField] = React.useState(initial?.field ?? '')
114
+ const [groupBy, setGroupBy] = React.useState(initial?.groupBy ?? '')
115
+ const [groupByLabelResource, setGroupByLabelResource] = React.useState(
116
+ initial?.groupByLabelResource ?? '',
117
+ )
118
+ const [topN, setTopN] = React.useState(initial?.topN ?? 10)
119
+ // The builder only sets a default time-range preset; custom ranges are
120
+ // picked on the widget toolbar. If the saved chart is on 'custom', fall
121
+ // back to the most useful preset for the builder UI.
122
+ const [preset, setPreset] = React.useState<Exclude<TimeRangePreset, 'custom'>>(
123
+ initial && initial.timeRange.preset !== 'custom' ? initial.timeRange.preset : '30d',
124
+ )
125
+ const [filters, setFilters] = React.useState<Record<string, string>>(
126
+ initial?.filters ?? {},
127
+ )
128
+ const [quickFilters, setQuickFilters] = React.useState<string[]>(
129
+ initial?.quickFilters ?? [],
130
+ )
131
+ const [order, setOrder] = React.useState<number>(initial?.order ?? 0)
132
+ const [errors, setErrors] = React.useState<Record<string, string>>({})
133
+
134
+ const resource = resources.find((r) => r.id === resourceId)
135
+ const properties = resource?.properties ?? []
136
+ const dateProps = properties.filter(isDateProperty)
137
+ const numericProps = properties.filter(
138
+ (p) => p.type === 'number' || p.type === 'float' || p.type === 'currency',
139
+ )
140
+
141
+ // Auto-select dateField when resource changes (skip the very first
142
+ // render of edit-mode so we keep the saved value).
143
+ React.useEffect(() => {
144
+ if (initial?.resource === resourceId) return
145
+ setDateField(dateProps[0]?.path ?? '')
146
+ setField('')
147
+ setGroupBy('')
148
+ setGroupByLabelResource('')
149
+ setFilters({})
150
+ setQuickFilters([])
151
+ // eslint-disable-next-line react-hooks/exhaustive-deps
152
+ }, [resourceId])
153
+
154
+ // When opening an existing chart that predates the groupByLabelResource
155
+ // feature (groupByLabelResource is empty but groupBy is set): auto-derive
156
+ // the label resource so the user gets resolved labels without re-saving.
157
+ React.useEffect(() => {
158
+ if (groupByLabelResource || !groupBy || properties.length === 0) return
159
+ setGroupByLabelResource(resolveGroupByLabelResource(groupBy, properties))
160
+ // eslint-disable-next-line react-hooks/exhaustive-deps
161
+ }, [groupBy, properties])
162
+
163
+ const isKpi = visualisation === 'kpi'
164
+
165
+ const buildTimeRange = (): TimeRange => ({ preset })
166
+
167
+ const handleFilterChange = (path: string, value: string): void => {
168
+ setFilters((prev) => {
169
+ const next = { ...prev }
170
+ if (value === '') delete next[path]
171
+ else next[path] = value
172
+ return next
173
+ })
174
+ }
175
+
176
+ const toggleQuickFilter = (path: string): void => {
177
+ setQuickFilters((prev) =>
178
+ prev.includes(path) ? prev.filter((p) => p !== path) : [...prev, path],
179
+ )
180
+ }
181
+
182
+ const handleSave = (): void => {
183
+ const now = new Date().toISOString()
184
+ // step is not user-editable in the builder; keep the previous value (or
185
+ // pick a sane default) so chartDefZ stays valid. The widget toolbar
186
+ // remains the place to tweak step on the fly.
187
+ const savedStep = isKpi
188
+ ? 'all'
189
+ : initial && initial.step !== 'all'
190
+ ? initial.step
191
+ : 'day'
192
+ const candidate: ChartDefInput = {
193
+ id: initial?.id ?? uuidv7(),
194
+ title: title.trim() || resource?.name || resourceId,
195
+ resource: resourceId,
196
+ visualisation,
197
+ dateField,
198
+ step: savedStep,
199
+ metric,
200
+ width: initial?.width ?? 'half',
201
+ topN,
202
+ filters,
203
+ quickFilters: quickFilters.filter((p) => properties.some((q) => q.path === p)),
204
+ timeRange: buildTimeRange(),
205
+ order,
206
+ // Preserve groupId on edit — the builder does not (yet) change group
207
+ // membership directly; groups are managed from the dashboard tab strip.
208
+ ...(initial?.groupId ? { groupId: initial.groupId } : {}),
209
+ createdAt: initial?.createdAt ?? now,
210
+ updatedAt: now,
211
+ ...(metric !== 'count' && field ? { field } : {}),
212
+ ...(!isKpi && groupBy ? { groupBy } : {}),
213
+ ...(!isKpi && groupBy && groupByLabelResource ? { groupByLabelResource } : {}),
214
+ }
215
+ const result = chartDefZ.safeParse(candidate)
216
+ if (!result.success) {
217
+ const fieldErrs: Record<string, string> = {}
218
+ for (const issue of result.error.issues) {
219
+ const key = issue.path[0]
220
+ if (typeof key === 'string' && !fieldErrs[key]) fieldErrs[key] = issue.message
221
+ }
222
+ setErrors(fieldErrs)
223
+ return
224
+ }
225
+ setErrors({})
226
+ onSave(candidate)
227
+ }
228
+
229
+ return (
230
+ <Dialog open onOpenChange={(open) => { if (!open) onClose() }}>
231
+ <DialogContent className="w-full max-w-2xl max-h-[90vh] overflow-y-auto">
232
+ <DialogHeader>
233
+ <DialogTitle>
234
+ {initial ? t('chart:editChart') : t('chart:newChart')}
235
+ </DialogTitle>
236
+ </DialogHeader>
237
+
238
+ <div className="space-y-4 py-1">
239
+ {/* Title + order */}
240
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_8rem]">
241
+ <div className="space-y-1.5">
242
+ <Label htmlFor="chart-title">{t('chart:title')}</Label>
243
+ <Input
244
+ id="chart-title"
245
+ placeholder={t('chart:titlePlaceholder')}
246
+ value={title}
247
+ onChange={(e) => setTitle(e.target.value)}
248
+ />
249
+ </div>
250
+ <div className="space-y-1.5">
251
+ <div className="flex items-center gap-1.5">
252
+ <Label htmlFor="chart-order">{t('chart:order')}</Label>
253
+ <InfoTooltip content={t('chart:orderHint')} />
254
+ </div>
255
+ <Input
256
+ id="chart-order"
257
+ type="number"
258
+ step={1}
259
+ value={order}
260
+ onChange={(e) => setOrder(Number.isFinite(Number(e.target.value)) ? Math.trunc(Number(e.target.value)) : 0)}
261
+ />
262
+ </div>
263
+ </div>
264
+
265
+ {/* Visualisation (incl. KPI) */}
266
+ <div className="space-y-1.5">
267
+ <Label>{t('dashboard:vis')}</Label>
268
+ <div className="flex flex-wrap gap-2">
269
+ {VIS_OPTIONS.map(({ value, icon, labelKey }) => (
270
+ <Button
271
+ key={value}
272
+ type="button"
273
+ variant={visualisation === value ? 'default' : 'outline'}
274
+ size="sm"
275
+ className="flex-1 min-w-[5rem] gap-1.5 capitalize"
276
+ onClick={() => setVisualisation(value)}
277
+ >
278
+ {icon}
279
+ <span className="hidden sm:inline">{t(labelKey)}</span>
280
+ </Button>
281
+ ))}
282
+ </div>
283
+ </div>
284
+
285
+ {/* Resource */}
286
+ <div className="space-y-1.5">
287
+ <Label htmlFor="chart-resource">{t('chart:resource')}</Label>
288
+ <Select value={resourceId} onValueChange={(v) => setResourceId(v)}>
289
+ <SelectTrigger id="chart-resource">
290
+ <SelectValue />
291
+ </SelectTrigger>
292
+ <SelectContent>
293
+ {resources.map((r) => (
294
+ <SelectItem key={r.id} value={r.id}>
295
+ {r.name}
296
+ {r.name !== r.id && (
297
+ <span className="ml-1.5 text-xs text-muted-foreground">({r.id})</span>
298
+ )}
299
+ </SelectItem>
300
+ ))}
301
+ </SelectContent>
302
+ </Select>
303
+ {errors.resource && (
304
+ <p className="text-xs text-destructive">{errors.resource}</p>
305
+ )}
306
+ </div>
307
+
308
+ {/* Date field — required, drives the X-axis bucketing */}
309
+ <div className="space-y-1.5">
310
+ <Label htmlFor="chart-datefield">{t('dashboard:builder.dateField')}</Label>
311
+ <Select
312
+ value={dateField || NONE}
313
+ onValueChange={(v) => setDateField(v === NONE ? '' : v)}
314
+ >
315
+ <SelectTrigger id="chart-datefield">
316
+ <SelectValue placeholder={t('chart:selectField')} />
317
+ </SelectTrigger>
318
+ <SelectContent>
319
+ <SelectItem value={NONE}>{t('chart:selectField')}</SelectItem>
320
+ {(dateProps.length > 0 ? dateProps : properties).map((p) => (
321
+ <SelectItem key={p.path} value={p.path}>{p.label}</SelectItem>
322
+ ))}
323
+ </SelectContent>
324
+ </Select>
325
+ <p className="text-xs text-muted-foreground">
326
+ {t('dashboard:builder.dateFieldHint')}
327
+ </p>
328
+ {errors.dateField && (
329
+ <p className="text-xs text-destructive">{errors.dateField}</p>
330
+ )}
331
+ </div>
332
+
333
+ {/* Metric + field */}
334
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
335
+ <div className="space-y-1.5">
336
+ <Label htmlFor="chart-metric">{t('chart:metric')}</Label>
337
+ <Select value={metric} onValueChange={(v) => setMetric(v as AggregationOpName)}>
338
+ <SelectTrigger id="chart-metric">
339
+ <SelectValue />
340
+ </SelectTrigger>
341
+ <SelectContent>
342
+ {METRICS.map((m) => (
343
+ <SelectItem key={m} value={m}>{t(`dashboard:metric${cap(m)}`)}</SelectItem>
344
+ ))}
345
+ </SelectContent>
346
+ </Select>
347
+ </div>
348
+
349
+ {metric !== 'count' && (
350
+ <div className="space-y-1.5">
351
+ <Label htmlFor="chart-field">{t('chart:aggregateField')}</Label>
352
+ <Select
353
+ value={field || NONE}
354
+ onValueChange={(v) => setField(v === NONE ? '' : v)}
355
+ >
356
+ <SelectTrigger id="chart-field">
357
+ <SelectValue placeholder={t('chart:selectField')} />
358
+ </SelectTrigger>
359
+ <SelectContent>
360
+ <SelectItem value={NONE}>{t('chart:selectField')}</SelectItem>
361
+ {numericProps.map((p) => (
362
+ <SelectItem key={p.path} value={p.path}>{p.label}</SelectItem>
363
+ ))}
364
+ </SelectContent>
365
+ </Select>
366
+ {errors.field && (
367
+ <p className="text-xs text-destructive">{errors.field}</p>
368
+ )}
369
+ </div>
370
+ )}
371
+ </div>
372
+
373
+ {/* Secondary groupBy + topN — non-KPI only */}
374
+ {!isKpi && (
375
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
376
+ <div className="space-y-1.5">
377
+ <Label htmlFor="chart-groupby">
378
+ {t('dashboard:builder.secondaryGroupBy')}
379
+ </Label>
380
+ <Select
381
+ value={groupBy || NONE}
382
+ onValueChange={(v) => {
383
+ const path = v === NONE ? '' : v
384
+ setGroupBy(path)
385
+ setGroupByLabelResource(resolveGroupByLabelResource(path, properties))
386
+ }}
387
+ >
388
+ <SelectTrigger id="chart-groupby">
389
+ <SelectValue />
390
+ </SelectTrigger>
391
+ <SelectContent>
392
+ <SelectItem value={NONE}>{t('dashboard:builder.noBreakdown')}</SelectItem>
393
+ {properties
394
+ .filter((p) => isGroupable(p) && p.path !== dateField)
395
+ .map((p) => (
396
+ <SelectItem key={p.path} value={p.path}>{p.label}</SelectItem>
397
+ ))}
398
+ </SelectContent>
399
+ </Select>
400
+ </div>
401
+
402
+ {groupBy && (
403
+ <div className="space-y-1.5">
404
+ <Label htmlFor="chart-topn">{t('dashboard:builder.topN')}</Label>
405
+ <Input
406
+ id="chart-topn"
407
+ type="number"
408
+ min={1}
409
+ max={50}
410
+ value={topN}
411
+ onChange={(e) =>
412
+ setTopN(Math.max(1, Math.min(50, Number(e.target.value) || 10)))
413
+ }
414
+ />
415
+ </div>
416
+ )}
417
+ </div>
418
+ )}
419
+
420
+ {/* Default time-range preset. Custom ranges are picked on the
421
+ widget toolbar — not in the builder. */}
422
+ <div className="space-y-1.5">
423
+ <Label>{t('dashboard:builder.range')}</Label>
424
+ <div className="flex flex-wrap gap-2">
425
+ {PRESETS.map((p) => (
426
+ <Button
427
+ key={p}
428
+ type="button"
429
+ variant={preset === p ? 'default' : 'outline'}
430
+ size="sm"
431
+ className="flex-1 min-w-[4rem]"
432
+ onClick={() => setPreset(p)}
433
+ >
434
+ {t(`dashboard:range.${p}`)}
435
+ </Button>
436
+ ))}
437
+ </div>
438
+ </div>
439
+
440
+ {/* Filters — one row per property, with a checkbox to mark the
441
+ filter as a "quick filter" (exposed above the chart for inline
442
+ tweaking on the dashboard). Reference fields use a combobox. */}
443
+ {properties.length > 0 && (
444
+ <div className="space-y-1.5">
445
+ <Label>{t('dashboard:builder.filters')}</Label>
446
+ <p className="text-xs text-muted-foreground">
447
+ {t('dashboard:builder.filtersHint')}
448
+ </p>
449
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-3">
450
+ {properties.filter(isFilterable).map((p) => {
451
+ const exposed = quickFilters.includes(p.path)
452
+ return (
453
+ <div key={p.path} className="space-y-1">
454
+ <div className="flex items-center justify-between gap-2">
455
+ <Label
456
+ htmlFor={`flt-${p.path}`}
457
+ className="text-xs text-muted-foreground"
458
+ >
459
+ {p.label}
460
+ </Label>
461
+ <Switch
462
+ checked={exposed}
463
+ onCheckedChange={() => toggleQuickFilter(p.path)}
464
+ aria-label={t('dashboard:builder.quickFilterToggle').replace(
465
+ '{field}',
466
+ p.label,
467
+ )}
468
+ title={t('dashboard:builder.quickFilterHint')}
469
+ />
470
+ </div>
471
+ <FilterInput
472
+ property={p}
473
+ value={filters[p.path] ?? ''}
474
+ onChange={(v) => handleFilterChange(p.path, v)}
475
+ />
476
+ </div>
477
+ )
478
+ })}
479
+ </div>
480
+ </div>
481
+ )}
482
+ </div>
483
+
484
+ <DialogFooter>
485
+ <Button variant="outline" onClick={onClose}>{t('common:cancel')}</Button>
486
+ <Button onClick={handleSave} disabled={!resourceId || !dateField}>
487
+ {t('chart:saveChart')}
488
+ </Button>
489
+ </DialogFooter>
490
+ </DialogContent>
491
+ </Dialog>
492
+ )
493
+ }
494
+
495
+ const cap = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1)
496
+
497
+ /**
498
+ * Property is exposable as a filter. Excludes ids, array fields, free-form
499
+ * blobs (json/mixed/richtext), media fields, and virtual relation columns
500
+ * (we keep the underlying FK column so we don't list `authorId` and `author`
501
+ * twice).
502
+ */
503
+ function isFilterable(p: PropertyJSON): boolean {
504
+ if (p.isId) return false
505
+ if (p.isArray) return false
506
+ if (p.type === 'json' || p.type === 'mixed') return false
507
+ if (p.type === 'richtext' || p.type === 'markdown' || p.type === 'textarea') return false
508
+ if (p.type === 'previewMedia' || p.type === 'file') return false
509
+ // Drop virtual relation columns (full objects) — keep FK siblings instead.
510
+ if (p.type === 'reference' && !p.isArray) {
511
+ const path = p.path
512
+ if (!(path.endsWith('Id') || path.endsWith('_id'))) return false
513
+ }
514
+ return true
515
+ }
516
+
517
+ /**
518
+ * Renders an inline input for one filter row in the builder. Reference
519
+ * properties get the same combobox the resource forms use; enums/booleans
520
+ * get a Select; numerics get a number input; everything else falls back to
521
+ * a plain text input.
522
+ */
523
+ function FilterInput({
524
+ property,
525
+ value,
526
+ onChange,
527
+ }: {
528
+ property: PropertyJSON
529
+ value: string
530
+ onChange(next: string): void
531
+ }): React.ReactElement {
532
+ if (property.reference) {
533
+ return (
534
+ <ReferenceCombobox
535
+ referenceResourceId={property.reference}
536
+ value={value || null}
537
+ onChange={(next) => onChange(next == null ? '' : String(next))}
538
+ />
539
+ )
540
+ }
541
+ if (property.availableValues && property.availableValues.length > 0) {
542
+ return (
543
+ <Select
544
+ value={value || NONE}
545
+ onValueChange={(v) => onChange(v === NONE ? '' : v)}
546
+ >
547
+ <SelectTrigger id={`flt-${property.path}`}>
548
+ <SelectValue />
549
+ </SelectTrigger>
550
+ <SelectContent>
551
+ <SelectItem value={NONE}>—</SelectItem>
552
+ {property.availableValues.map((opt) => (
553
+ <SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
554
+ ))}
555
+ </SelectContent>
556
+ </Select>
557
+ )
558
+ }
559
+ if (property.type === 'boolean') {
560
+ return (
561
+ <Select
562
+ value={value || NONE}
563
+ onValueChange={(v) => onChange(v === NONE ? '' : v)}
564
+ >
565
+ <SelectTrigger id={`flt-${property.path}`}>
566
+ <SelectValue />
567
+ </SelectTrigger>
568
+ <SelectContent>
569
+ <SelectItem value={NONE}>—</SelectItem>
570
+ <SelectItem value="true">true</SelectItem>
571
+ <SelectItem value="false">false</SelectItem>
572
+ </SelectContent>
573
+ </Select>
574
+ )
575
+ }
576
+ const isNumeric =
577
+ property.type === 'number' || property.type === 'float' || property.type === 'currency'
578
+ return (
579
+ <Input
580
+ id={`flt-${property.path}`}
581
+ type={isNumeric ? 'number' : 'text'}
582
+ value={value}
583
+ onChange={(e) => onChange(e.target.value)}
584
+ />
585
+ )
586
+ }
587
+
588
+ /**
589
+ * Resolve which resource to use for groupBy label resolution.
590
+ *
591
+ * 1. If the property itself is `type: 'reference'` (virtual relation field),
592
+ * use its `reference` resource id directly — same mechanism as the record
593
+ * title display in resource forms.
594
+ * 2. If it is a raw FK column (e.g. `authorId`), look for a sibling property
595
+ * whose path is the FK name without the trailing `Id`/`_id` suffix AND
596
+ * has `reference` set. Covers the Prisma/Drizzle naming convention.
597
+ */
598
+ function resolveGroupByLabelResource(
599
+ path: string,
600
+ properties: ReadonlyArray<{ path: string; type: string; reference: string | null }>,
601
+ ): string {
602
+ if (!path) return ''
603
+ const prop = properties.find((p) => p.path === path)
604
+ if (!prop) return ''
605
+ // Direct reference property (virtual relation).
606
+ if (prop.reference) return prop.reference
607
+ // FK column heuristic: strip Id / _id suffix and look for sibling.
608
+ const base = prop.path.endsWith('_id')
609
+ ? prop.path.slice(0, -3)
610
+ : prop.path.endsWith('Id')
611
+ ? prop.path.slice(0, -2)
612
+ : ''
613
+ if (base) {
614
+ const sibling = properties.find((p) => p.path === base && p.reference)
615
+ if (sibling?.reference) return sibling.reference
616
+ }
617
+ return ''
618
+ }