@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,154 @@
1
+ // Export dialog: lets the user download all records matching the current
2
+ // list filters/sorting as CSV or JSON. Rendered inside a generic <Dialog>
3
+ // via useDialogs().open() — the parent passes resourceId + visible
4
+ // properties + the active ListQuery.
5
+
6
+ import * as React from 'react'
7
+ import {
8
+ Button,
9
+ DialogDescription,
10
+ DialogFooter,
11
+ DialogHeader,
12
+ DialogTitle,
13
+ } from '@modern-admin/ui'
14
+ import { Download, FileJson, FileSpreadsheet, X } from 'lucide-react'
15
+ import { useAdminClient } from '../provider.js'
16
+ import { useI18n } from '../i18n.js'
17
+ import { useNotify } from '../notify.js'
18
+ import {
19
+ downloadText,
20
+ exportFilename,
21
+ fetchAllRecords,
22
+ recordsToCsv,
23
+ recordsToJson,
24
+ type ExportFormat,
25
+ } from '../export.js'
26
+ import type { ListQuery, PropertyJSON } from '../types.js'
27
+
28
+ export interface ExportDialogProps {
29
+ resourceId: string
30
+ resourceLabel: string
31
+ properties: PropertyJSON[]
32
+ query: ListQuery | undefined
33
+ onClose(): void
34
+ }
35
+
36
+ export function ExportDialog({
37
+ resourceId,
38
+ resourceLabel,
39
+ properties,
40
+ query,
41
+ onClose,
42
+ }: ExportDialogProps): React.ReactElement {
43
+ const client = useAdminClient()
44
+ const { t } = useI18n()
45
+ const notify = useNotify()
46
+ const [busy, setBusy] = React.useState<ExportFormat | null>(null)
47
+ const [progress, setProgress] = React.useState<{ loaded: number; total: number } | null>(null)
48
+ const abortRef = React.useRef<AbortController | null>(null)
49
+
50
+ React.useEffect(() => {
51
+ return () => abortRef.current?.abort()
52
+ }, [])
53
+
54
+ const run = async (format: ExportFormat): Promise<void> => {
55
+ if (busy) return
56
+ setBusy(format)
57
+ setProgress({ loaded: 0, total: 0 })
58
+ const ctrl = new AbortController()
59
+ abortRef.current = ctrl
60
+ try {
61
+ const records = await fetchAllRecords(client, resourceId, query, {
62
+ signal: ctrl.signal,
63
+ onProgress: (loaded, total) => setProgress({ loaded, total }),
64
+ })
65
+ const body =
66
+ format === 'csv'
67
+ ? recordsToCsv(records, { properties, query })
68
+ : recordsToJson(records, { properties, query })
69
+ const mime = format === 'csv' ? 'text/csv' : 'application/json'
70
+ downloadText(exportFilename(resourceId, format), mime, body)
71
+ notify.success({ key: 'export:exported', params: { count: records.length } })
72
+ onClose()
73
+ } catch (err) {
74
+ if ((err as { name?: string }).name === 'AbortError') return
75
+ notify.error(
76
+ { key: 'export:exportFailed' },
77
+ { description: err instanceof Error ? err.message : String(err) },
78
+ )
79
+ } finally {
80
+ setBusy(null)
81
+ setProgress(null)
82
+ abortRef.current = null
83
+ }
84
+ }
85
+
86
+ return (
87
+ <>
88
+ <DialogHeader>
89
+ <DialogTitle>{t('export:title')}</DialogTitle>
90
+ <DialogDescription>
91
+ {t('export:description', { resource: resourceLabel })}
92
+ </DialogDescription>
93
+ </DialogHeader>
94
+
95
+ <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
96
+ <Button
97
+ type="button"
98
+ variant="outline"
99
+ className="h-auto justify-start gap-3 py-3"
100
+ disabled={!!busy}
101
+ onClick={() => run('csv')}
102
+ >
103
+ <FileSpreadsheet className="size-5 shrink-0" />
104
+ <div className="min-w-0 flex flex-col items-start text-left overflow-hidden">
105
+ <span className="font-medium">CSV</span>
106
+ <span className="text-xs text-muted-foreground truncate w-full">
107
+ {t('export:csvHint')}
108
+ </span>
109
+ </div>
110
+ </Button>
111
+ <Button
112
+ type="button"
113
+ variant="outline"
114
+ className="h-auto justify-start gap-3 py-3"
115
+ disabled={!!busy}
116
+ onClick={() => run('json')}
117
+ >
118
+ <FileJson className="size-5 shrink-0" />
119
+ <div className="min-w-0 flex flex-col items-start text-left overflow-hidden">
120
+ <span className="font-medium">JSON</span>
121
+ <span className="text-xs text-muted-foreground truncate w-full">
122
+ {t('export:jsonHint')}
123
+ </span>
124
+ </div>
125
+ </Button>
126
+ </div>
127
+
128
+ {busy && (
129
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
130
+ <Download className="size-4 animate-pulse" />
131
+ <span>
132
+ {progress && progress.total > 0
133
+ ? t('export:progress', { loaded: progress.loaded, total: progress.total })
134
+ : t('export:downloading')}
135
+ </span>
136
+ </div>
137
+ )}
138
+
139
+ <DialogFooter>
140
+ <Button
141
+ type="button"
142
+ variant="ghost"
143
+ onClick={() => {
144
+ abortRef.current?.abort()
145
+ onClose()
146
+ }}
147
+ >
148
+ <X className="size-4" />
149
+ {busy ? t('common:cancel') : t('common:close')}
150
+ </Button>
151
+ </DialogFooter>
152
+ </>
153
+ )
154
+ }
@@ -0,0 +1,318 @@
1
+ import * as React from 'react'
2
+ import { Plus, Database, BarChart2, FolderPlus, Pencil, Trash2 } from 'lucide-react'
3
+ import {
4
+ Button,
5
+ Card,
6
+ CardContent,
7
+ CardHeader,
8
+ CardTitle,
9
+ Empty,
10
+ EmptyDescription,
11
+ EmptyHeader,
12
+ EmptyMedia,
13
+ EmptyTitle,
14
+ Tabs,
15
+ TabsList,
16
+ TabsTrigger,
17
+ } from '@modern-admin/ui'
18
+ import { useResources, useCurrentUser } from '../hooks.js'
19
+ import { useI18n } from '../i18n.js'
20
+ import { Link } from '../router.js'
21
+ import { useDashboardCharts, ServerDashboardStore } from '../use-dashboard-charts.js'
22
+ import { useAdminClient } from '../provider.js'
23
+ import { useDialogs } from '../dialogs.js'
24
+ import { ChartWidget } from '../components/chart-widget.js'
25
+ import { ChartBuilderDialog } from '../components/chart-builder-dialog.js'
26
+ import { GroupSettingsDialog } from '../components/group-settings-dialog.js'
27
+ import { MoveChartDialog } from '../components/move-chart-dialog.js'
28
+ import type { ChartDef, ChartGroup } from '@modern-admin/core'
29
+
30
+ export function HomePage(): React.ReactElement {
31
+ const { t } = useI18n()
32
+ const resources = useResources()
33
+ const { user } = useCurrentUser()
34
+ const adminClient = useAdminClient()
35
+ const dialogs = useDialogs()
36
+ // Use server-backed store so charts persist across devices/browsers.
37
+ // Falls back gracefully when configStore is not configured server-side.
38
+ const serverStore = React.useMemo(() => new ServerDashboardStore(adminClient), [adminClient])
39
+ const {
40
+ charts,
41
+ groups,
42
+ addChart,
43
+ updateChart,
44
+ removeChart,
45
+ addGroup,
46
+ updateGroup,
47
+ removeGroup,
48
+ } = useDashboardCharts({
49
+ userId: user?.id ?? null,
50
+ store: serverStore,
51
+ })
52
+
53
+ // Only show resources explicitly visible in navigation (same rule as sidebar).
54
+ const navResources = React.useMemo(
55
+ () => resources.filter((r) => r.navigation !== null),
56
+ [resources],
57
+ )
58
+
59
+ const [building, setBuilding] = React.useState(false)
60
+ const [editingId, setEditingId] = React.useState<string | null>(null)
61
+ // null = no dialog; 'new' = create; ChartGroup = edit-existing.
62
+ const [groupDialog, setGroupDialog] = React.useState<ChartGroup | 'new' | null>(null)
63
+ const [activeGroupId, setActiveGroupId] = React.useState<string | null>(null)
64
+ // Chart being moved — null when dialog is closed.
65
+ const [movingChart, setMovingChart] = React.useState<ChartDef | null>(null)
66
+
67
+ // Keep `activeGroupId` valid when groups change (group removed, list reloaded
68
+ // from store, etc.). When at least one group exists we always render charts
69
+ // bucketed by group, so we need a definite active id.
70
+ React.useEffect(() => {
71
+ if (groups.length === 0) {
72
+ setActiveGroupId(null)
73
+ return
74
+ }
75
+ if (!activeGroupId || !groups.some((g) => g.id === activeGroupId)) {
76
+ setActiveGroupId(groups[0]!.id)
77
+ }
78
+ }, [groups, activeGroupId])
79
+
80
+ const editingChart = editingId ? charts.find((c) => c.id === editingId) : undefined
81
+
82
+ // Build the chart bucket for the active group: charts whose `groupId`
83
+ // matches OR (fallback) ungrouped charts when the active group is the
84
+ // first one. Sorted by `order` then `createdAt`.
85
+ const firstGroupId = groups[0]?.id ?? null
86
+ const visibleCharts = React.useMemo(() => {
87
+ if (groups.length === 0) return [...charts].sort(byChartOrder)
88
+ if (!activeGroupId) return []
89
+ const filtered = charts.filter((c) => {
90
+ if (c.groupId) return c.groupId === activeGroupId
91
+ // Ungrouped charts default into the first group so they remain visible.
92
+ return activeGroupId === firstGroupId
93
+ })
94
+ return filtered.sort(byChartOrder)
95
+ }, [charts, groups.length, activeGroupId, firstGroupId])
96
+
97
+ const activeGroup = activeGroupId ? groups.find((g) => g.id === activeGroupId) ?? null : null
98
+
99
+ const handleDeleteChart = async (chart: ChartDef): Promise<void> => {
100
+ const ok = await dialogs.confirm({
101
+ title: t('chart:deleteChartConfirm'),
102
+ description: t('chart:deleteChartConfirmHint'),
103
+ confirmLabel: t('common:delete'),
104
+ destructive: true,
105
+ })
106
+ if (ok) removeChart(chart.id)
107
+ }
108
+
109
+ const handleDeleteGroup = async (group: ChartGroup): Promise<void> => {
110
+ const count = charts.filter((c) => {
111
+ if (c.groupId) return c.groupId === group.id
112
+ // Ungrouped charts also belong to the first group at display time.
113
+ return group.id === firstGroupId
114
+ }).length
115
+ const ok = await dialogs.confirm({
116
+ title: t('chart:deleteGroupConfirm'),
117
+ description: t('chart:deleteGroupConfirmHint').replace('{count}', String(count)),
118
+ confirmLabel: t('common:delete'),
119
+ destructive: true,
120
+ })
121
+ if (ok) removeGroup(group.id)
122
+ }
123
+
124
+ return (
125
+ <div className="space-y-3 sm:space-y-6">
126
+ {/* ── Dashboard charts ──────────────────────────────────────── */}
127
+ <Card>
128
+ <CardHeader className="flex flex-row items-center justify-between p-3 pb-2 space-y-0 gap-2 sm:p-6 sm:pb-2">
129
+ <CardTitle>{t('chart:dashboard')}</CardTitle>
130
+ <div className="flex items-center gap-2">
131
+ <Button
132
+ size="sm"
133
+ variant="outline"
134
+ onClick={() => setGroupDialog('new')}
135
+ >
136
+ <FolderPlus className="size-4" />
137
+ <span className="hidden sm:inline ml-1.5">{t('chart:addGroup')}</span>
138
+ </Button>
139
+ <Button size="sm" onClick={() => setBuilding(true)} disabled={navResources.length === 0}>
140
+ <Plus className="size-4" />
141
+ <span className="hidden sm:inline ml-1.5">{t('chart:addChart')}</span>
142
+ </Button>
143
+ </div>
144
+ </CardHeader>
145
+ <CardContent className="p-3 pt-0 sm:p-6 sm:pt-0">
146
+ {groups.length > 0 && activeGroupId && (
147
+ <Tabs
148
+ value={activeGroupId}
149
+ onValueChange={(v) => setActiveGroupId(v)}
150
+ className="mb-4"
151
+ >
152
+ <div className="flex items-end justify-between gap-2">
153
+ <TabsList className="flex-1">
154
+ {groups.map((g) => (
155
+ <TabsTrigger key={g.id} value={g.id}>
156
+ {g.name}
157
+ </TabsTrigger>
158
+ ))}
159
+ </TabsList>
160
+ {activeGroup && (
161
+ <div className="mb-1 flex shrink-0 items-center gap-1">
162
+ <Button
163
+ size="sm"
164
+ variant="ghost"
165
+ onClick={() => setGroupDialog(activeGroup)}
166
+ aria-label={t('chart:editGroup')}
167
+ >
168
+ <Pencil className="size-3.5" />
169
+ <span className="hidden sm:inline ml-1.5">{t('chart:editGroup')}</span>
170
+ </Button>
171
+ <Button
172
+ size="sm"
173
+ variant="ghost"
174
+ onClick={() => void handleDeleteGroup(activeGroup)}
175
+ aria-label={t('chart:deleteGroup')}
176
+ >
177
+ <Trash2 className="size-3.5" />
178
+ <span className="hidden sm:inline ml-1.5">{t('chart:deleteGroup')}</span>
179
+ </Button>
180
+ </div>
181
+ )}
182
+ </div>
183
+ </Tabs>
184
+ )}
185
+
186
+ {visibleCharts.length === 0 ? (
187
+ <Empty className="border-0 py-4">
188
+ <EmptyHeader>
189
+ <EmptyMedia>
190
+ <BarChart2 />
191
+ </EmptyMedia>
192
+ <EmptyTitle>{t('chart:noCharts')}</EmptyTitle>
193
+ <EmptyDescription>{t('chart:noChartsHint')}</EmptyDescription>
194
+ </EmptyHeader>
195
+ </Empty>
196
+ ) : (
197
+ <div className="grid gap-3 grid-cols-1 sm:gap-4 md:grid-cols-2">
198
+ {visibleCharts.map((c) => (
199
+ <div
200
+ key={c.id}
201
+ className={c.width === 'full' ? 'md:col-span-2' : undefined}
202
+ >
203
+ <ChartWidget
204
+ config={c}
205
+ onEdit={() => setEditingId(c.id)}
206
+ onDelete={() => void handleDeleteChart(c)}
207
+ onMove={() => setMovingChart(c)}
208
+ onUpdate={(input) => updateChart(c.id, input)}
209
+ />
210
+ </div>
211
+ ))}
212
+ </div>
213
+ )}
214
+ </CardContent>
215
+ </Card>
216
+
217
+ {/* ── Resources list ────────────────────────────────────────── */}
218
+ <Card>
219
+ <CardHeader>
220
+ <CardTitle>{t('common:resources')}</CardTitle>
221
+ </CardHeader>
222
+ <CardContent>
223
+ {navResources.length === 0 ? (
224
+ <Empty className="border-0">
225
+ <EmptyHeader>
226
+ <EmptyMedia>
227
+ <Database />
228
+ </EmptyMedia>
229
+ <EmptyTitle>{t('common:noRecords')}</EmptyTitle>
230
+ <EmptyDescription>
231
+ {t('common:noRecordsHint', { resource: 'resource' })}
232
+ </EmptyDescription>
233
+ </EmptyHeader>
234
+ </Empty>
235
+ ) : (
236
+ <ul className="grid gap-2 sm:grid-cols-2 md:grid-cols-3">
237
+ {navResources.map((r) => (
238
+ <li key={r.id}>
239
+ <Link
240
+ to={{ name: 'list', resourceId: r.id }}
241
+ className="block rounded-md border border-border bg-card p-4 hover:border-primary/40 hover:shadow-sm transition-shadow"
242
+ >
243
+ <div className="font-semibold">{r.name}</div>
244
+ {r.name !== r.id && (
245
+ <div className="text-xs text-muted-foreground">{r.id}</div>
246
+ )}
247
+ </Link>
248
+ </li>
249
+ ))}
250
+ </ul>
251
+ )}
252
+ </CardContent>
253
+ </Card>
254
+
255
+ {/* ── Chart builder dialog ──────────────────────────────────── */}
256
+ {building && (
257
+ <ChartBuilderDialog
258
+ onSave={(input) => {
259
+ // New charts inherit the active group so the user sees them in
260
+ // the tab they were on. The hook also auto-falls back to the
261
+ // first group if `groupId` is undefined.
262
+ addChart(activeGroupId ? { ...input, groupId: activeGroupId } : input)
263
+ setBuilding(false)
264
+ }}
265
+ onClose={() => setBuilding(false)}
266
+ />
267
+ )}
268
+ {editingChart && (
269
+ <ChartBuilderDialog
270
+ initial={editingChart}
271
+ onSave={(input) => { updateChart(editingChart.id, input); setEditingId(null) }}
272
+ onClose={() => setEditingId(null)}
273
+ />
274
+ )}
275
+
276
+ {/* ── Move chart dialog ─────────────────────────────────────── */}
277
+ {movingChart && (
278
+ <MoveChartDialog
279
+ groups={groups}
280
+ initialGroupId={movingChart.groupId}
281
+ initialOrder={movingChart.order}
282
+ onSave={({ groupId, order }) => {
283
+ updateChart(movingChart.id, { ...movingChart, groupId, order })
284
+ setMovingChart(null)
285
+ }}
286
+ onClose={() => setMovingChart(null)}
287
+ onCreateGroup={() => {
288
+ setMovingChart(null)
289
+ setGroupDialog('new')
290
+ }}
291
+ />
292
+ )}
293
+
294
+ {/* ── Group settings dialog ─────────────────────────────────── */}
295
+ {groupDialog && (
296
+ <GroupSettingsDialog
297
+ initial={groupDialog === 'new' ? undefined : groupDialog}
298
+ onSave={(input) => {
299
+ if (groupDialog === 'new') {
300
+ const newId = addGroup(input)
301
+ setActiveGroupId(newId)
302
+ } else {
303
+ updateGroup(groupDialog.id, input)
304
+ }
305
+ setGroupDialog(null)
306
+ }}
307
+ onClose={() => setGroupDialog(null)}
308
+ />
309
+ )}
310
+ </div>
311
+ )
312
+ }
313
+
314
+ /** Sort charts within a group: by `order` asc, ties broken by `createdAt`. */
315
+ function byChartOrder(a: ChartDef, b: ChartDef): number {
316
+ if (a.order !== b.order) return a.order - b.order
317
+ return a.createdAt.localeCompare(b.createdAt)
318
+ }