@open-mercato/ui 0.4.2-canary-c02407ff85

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 (319) hide show
  1. package/build.mjs +62 -0
  2. package/dist/backend/AppShell.js +902 -0
  3. package/dist/backend/AppShell.js.map +7 -0
  4. package/dist/backend/ConfirmDialog.js +17 -0
  5. package/dist/backend/ConfirmDialog.js.map +7 -0
  6. package/dist/backend/ContextHelp.js +31 -0
  7. package/dist/backend/ContextHelp.js.map +7 -0
  8. package/dist/backend/CrudForm.js +2028 -0
  9. package/dist/backend/CrudForm.js.map +7 -0
  10. package/dist/backend/DataTable.js +1363 -0
  11. package/dist/backend/DataTable.js.map +7 -0
  12. package/dist/backend/EmptyState.js +52 -0
  13. package/dist/backend/EmptyState.js.map +7 -0
  14. package/dist/backend/FilterBar.js +140 -0
  15. package/dist/backend/FilterBar.js.map +7 -0
  16. package/dist/backend/FilterOverlay.js +279 -0
  17. package/dist/backend/FilterOverlay.js.map +7 -0
  18. package/dist/backend/FlashMessages.js +66 -0
  19. package/dist/backend/FlashMessages.js.map +7 -0
  20. package/dist/backend/JsonBuilder.js +322 -0
  21. package/dist/backend/JsonBuilder.js.map +7 -0
  22. package/dist/backend/JsonDisplay.js +203 -0
  23. package/dist/backend/JsonDisplay.js.map +7 -0
  24. package/dist/backend/Page.js +27 -0
  25. package/dist/backend/Page.js.map +7 -0
  26. package/dist/backend/PerspectiveSidebar.js +282 -0
  27. package/dist/backend/PerspectiveSidebar.js.map +7 -0
  28. package/dist/backend/RowActions.js +148 -0
  29. package/dist/backend/RowActions.js.map +7 -0
  30. package/dist/backend/TruncatedCell.js +92 -0
  31. package/dist/backend/TruncatedCell.js.map +7 -0
  32. package/dist/backend/UserMenu.js +107 -0
  33. package/dist/backend/UserMenu.js.map +7 -0
  34. package/dist/backend/ValueIcons.js +34 -0
  35. package/dist/backend/ValueIcons.js.map +7 -0
  36. package/dist/backend/custom-fields/FieldDefinitionsEditor.js +1264 -0
  37. package/dist/backend/custom-fields/FieldDefinitionsEditor.js.map +7 -0
  38. package/dist/backend/custom-fields/FieldDefinitionsManager.js +332 -0
  39. package/dist/backend/custom-fields/FieldDefinitionsManager.js.map +7 -0
  40. package/dist/backend/dashboard/DashboardScreen.js +578 -0
  41. package/dist/backend/dashboard/DashboardScreen.js.map +7 -0
  42. package/dist/backend/dashboard/index.js +5 -0
  43. package/dist/backend/dashboard/index.js.map +7 -0
  44. package/dist/backend/dashboard/widgetRegistry.js +55 -0
  45. package/dist/backend/dashboard/widgetRegistry.js.map +7 -0
  46. package/dist/backend/detail/ActivitiesSection.js +962 -0
  47. package/dist/backend/detail/ActivitiesSection.js.map +7 -0
  48. package/dist/backend/detail/AddressEditor.js +413 -0
  49. package/dist/backend/detail/AddressEditor.js.map +7 -0
  50. package/dist/backend/detail/AddressTiles.js +437 -0
  51. package/dist/backend/detail/AddressTiles.js.map +7 -0
  52. package/dist/backend/detail/AddressesSection.js +264 -0
  53. package/dist/backend/detail/AddressesSection.js.map +7 -0
  54. package/dist/backend/detail/AttachmentDeleteDialog.js +41 -0
  55. package/dist/backend/detail/AttachmentDeleteDialog.js.map +7 -0
  56. package/dist/backend/detail/AttachmentMetadataDialog.js +517 -0
  57. package/dist/backend/detail/AttachmentMetadataDialog.js.map +7 -0
  58. package/dist/backend/detail/AttachmentsSection.js +367 -0
  59. package/dist/backend/detail/AttachmentsSection.js.map +7 -0
  60. package/dist/backend/detail/CustomDataSection.js +433 -0
  61. package/dist/backend/detail/CustomDataSection.js.map +7 -0
  62. package/dist/backend/detail/DetailFieldsSection.js +75 -0
  63. package/dist/backend/detail/DetailFieldsSection.js.map +7 -0
  64. package/dist/backend/detail/ErrorMessage.js +28 -0
  65. package/dist/backend/detail/ErrorMessage.js.map +7 -0
  66. package/dist/backend/detail/InlineEditors.js +681 -0
  67. package/dist/backend/detail/InlineEditors.js.map +7 -0
  68. package/dist/backend/detail/LoadingMessage.js +14 -0
  69. package/dist/backend/detail/LoadingMessage.js.map +7 -0
  70. package/dist/backend/detail/NotesSection.js +1032 -0
  71. package/dist/backend/detail/NotesSection.js.map +7 -0
  72. package/dist/backend/detail/TabEmptyState.js +25 -0
  73. package/dist/backend/detail/TabEmptyState.js.map +7 -0
  74. package/dist/backend/detail/TagsSection.js +254 -0
  75. package/dist/backend/detail/TagsSection.js.map +7 -0
  76. package/dist/backend/detail/addressFormat.js +77 -0
  77. package/dist/backend/detail/addressFormat.js.map +7 -0
  78. package/dist/backend/detail/index.js +34 -0
  79. package/dist/backend/detail/index.js.map +7 -0
  80. package/dist/backend/fields/registry.generated.js +8 -0
  81. package/dist/backend/fields/registry.generated.js.map +7 -0
  82. package/dist/backend/fields/registry.js +29 -0
  83. package/dist/backend/fields/registry.js.map +7 -0
  84. package/dist/backend/indexes/PartialIndexBanner.js +58 -0
  85. package/dist/backend/indexes/PartialIndexBanner.js.map +7 -0
  86. package/dist/backend/indexes/store.js +62 -0
  87. package/dist/backend/indexes/store.js.map +7 -0
  88. package/dist/backend/injection/InjectionSpot.js +179 -0
  89. package/dist/backend/injection/InjectionSpot.js.map +7 -0
  90. package/dist/backend/injection/PageInjectionBoundary.js +26 -0
  91. package/dist/backend/injection/PageInjectionBoundary.js.map +7 -0
  92. package/dist/backend/injection/helpers.js +26 -0
  93. package/dist/backend/injection/helpers.js.map +7 -0
  94. package/dist/backend/injection/widgetRegistry.js +55 -0
  95. package/dist/backend/injection/widgetRegistry.js.map +7 -0
  96. package/dist/backend/inputs/ComboboxInput.js +225 -0
  97. package/dist/backend/inputs/ComboboxInput.js.map +7 -0
  98. package/dist/backend/inputs/LookupSelect.js +191 -0
  99. package/dist/backend/inputs/LookupSelect.js.map +7 -0
  100. package/dist/backend/inputs/PhoneNumberField.js +100 -0
  101. package/dist/backend/inputs/PhoneNumberField.js.map +7 -0
  102. package/dist/backend/inputs/SwitchableMarkdownInput.js +92 -0
  103. package/dist/backend/inputs/SwitchableMarkdownInput.js.map +7 -0
  104. package/dist/backend/inputs/TagsInput.js +222 -0
  105. package/dist/backend/inputs/TagsInput.js.map +7 -0
  106. package/dist/backend/inputs/index.js +6 -0
  107. package/dist/backend/inputs/index.js.map +7 -0
  108. package/dist/backend/operations/LastOperationBanner.js +80 -0
  109. package/dist/backend/operations/LastOperationBanner.js.map +7 -0
  110. package/dist/backend/operations/store.js +183 -0
  111. package/dist/backend/operations/store.js.map +7 -0
  112. package/dist/backend/schedule/ScheduleAgenda.js +107 -0
  113. package/dist/backend/schedule/ScheduleAgenda.js.map +7 -0
  114. package/dist/backend/schedule/ScheduleGrid.js +107 -0
  115. package/dist/backend/schedule/ScheduleGrid.js.map +7 -0
  116. package/dist/backend/schedule/ScheduleToolbar.js +166 -0
  117. package/dist/backend/schedule/ScheduleToolbar.js.map +7 -0
  118. package/dist/backend/schedule/ScheduleView.js +165 -0
  119. package/dist/backend/schedule/ScheduleView.js.map +7 -0
  120. package/dist/backend/schedule/index.js +6 -0
  121. package/dist/backend/schedule/index.js.map +7 -0
  122. package/dist/backend/schedule/recurrence.js +83 -0
  123. package/dist/backend/schedule/recurrence.js.map +7 -0
  124. package/dist/backend/schedule/types.js +1 -0
  125. package/dist/backend/schedule/types.js.map +7 -0
  126. package/dist/backend/upgrades/UpgradeActionBanner.js +91 -0
  127. package/dist/backend/upgrades/UpgradeActionBanner.js.map +7 -0
  128. package/dist/backend/utils/api.js +127 -0
  129. package/dist/backend/utils/api.js.map +7 -0
  130. package/dist/backend/utils/apiCall.js +48 -0
  131. package/dist/backend/utils/apiCall.js.map +7 -0
  132. package/dist/backend/utils/crud.js +126 -0
  133. package/dist/backend/utils/crud.js.map +7 -0
  134. package/dist/backend/utils/customFieldColumns.js +56 -0
  135. package/dist/backend/utils/customFieldColumns.js.map +7 -0
  136. package/dist/backend/utils/customFieldDefs.js +143 -0
  137. package/dist/backend/utils/customFieldDefs.js.map +7 -0
  138. package/dist/backend/utils/customFieldFilters.js +126 -0
  139. package/dist/backend/utils/customFieldFilters.js.map +7 -0
  140. package/dist/backend/utils/customFieldForms.js +162 -0
  141. package/dist/backend/utils/customFieldForms.js.map +7 -0
  142. package/dist/backend/utils/customFieldValues.js +26 -0
  143. package/dist/backend/utils/customFieldValues.js.map +7 -0
  144. package/dist/backend/utils/flash.js +16 -0
  145. package/dist/backend/utils/flash.js.map +7 -0
  146. package/dist/backend/utils/nav.js +185 -0
  147. package/dist/backend/utils/nav.js.map +7 -0
  148. package/dist/backend/utils/serverErrors.js +230 -0
  149. package/dist/backend/utils/serverErrors.js.map +7 -0
  150. package/dist/frontend/AuthFooter.js +23 -0
  151. package/dist/frontend/AuthFooter.js.map +7 -0
  152. package/dist/frontend/LanguageSwitcher.js +57 -0
  153. package/dist/frontend/LanguageSwitcher.js.map +7 -0
  154. package/dist/frontend/Layout.js +14 -0
  155. package/dist/frontend/Layout.js.map +7 -0
  156. package/dist/index.js +32 -0
  157. package/dist/index.js.map +7 -0
  158. package/dist/primitives/DataLoader.js +67 -0
  159. package/dist/primitives/DataLoader.js.map +7 -0
  160. package/dist/primitives/ErrorNotice.js +20 -0
  161. package/dist/primitives/ErrorNotice.js.map +7 -0
  162. package/dist/primitives/alert.js +38 -0
  163. package/dist/primitives/alert.js.map +7 -0
  164. package/dist/primitives/badge.js +28 -0
  165. package/dist/primitives/badge.js.map +7 -0
  166. package/dist/primitives/button.js +44 -0
  167. package/dist/primitives/button.js.map +7 -0
  168. package/dist/primitives/card.js +91 -0
  169. package/dist/primitives/card.js.map +7 -0
  170. package/dist/primitives/checkbox.js +28 -0
  171. package/dist/primitives/checkbox.js.map +7 -0
  172. package/dist/primitives/dialog.js +90 -0
  173. package/dist/primitives/dialog.js.map +7 -0
  174. package/dist/primitives/input.js +22 -0
  175. package/dist/primitives/input.js.map +7 -0
  176. package/dist/primitives/label.js +21 -0
  177. package/dist/primitives/label.js.map +7 -0
  178. package/dist/primitives/separator.js +9 -0
  179. package/dist/primitives/separator.js.map +7 -0
  180. package/dist/primitives/spinner.js +24 -0
  181. package/dist/primitives/spinner.js.map +7 -0
  182. package/dist/primitives/switch.js +80 -0
  183. package/dist/primitives/switch.js.map +7 -0
  184. package/dist/primitives/table.js +29 -0
  185. package/dist/primitives/table.js.map +7 -0
  186. package/dist/primitives/tabs.js +87 -0
  187. package/dist/primitives/tabs.js.map +7 -0
  188. package/dist/primitives/textarea.js +21 -0
  189. package/dist/primitives/textarea.js.map +7 -0
  190. package/dist/primitives/tooltip.js +60 -0
  191. package/dist/primitives/tooltip.js.map +7 -0
  192. package/dist/theme/QueryProvider.js +44 -0
  193. package/dist/theme/QueryProvider.js.map +7 -0
  194. package/dist/theme/ThemeProvider.js +95 -0
  195. package/dist/theme/ThemeProvider.js.map +7 -0
  196. package/dist/theme/ThemeToggle.js +88 -0
  197. package/dist/theme/ThemeToggle.js.map +7 -0
  198. package/dist/theme/index.js +10 -0
  199. package/dist/theme/index.js.map +7 -0
  200. package/dist/types/react-big-calendar.d.js +1 -0
  201. package/dist/types/react-big-calendar.d.js.map +7 -0
  202. package/jest.config.cjs +23 -0
  203. package/jest.setup.ts +55 -0
  204. package/package.json +105 -0
  205. package/src/backend/AppShell.tsx +1096 -0
  206. package/src/backend/ConfirmDialog.tsx +19 -0
  207. package/src/backend/ContextHelp.tsx +38 -0
  208. package/src/backend/CrudForm.tsx +2503 -0
  209. package/src/backend/DataTable.tsx +1730 -0
  210. package/src/backend/EmptyState.tsx +65 -0
  211. package/src/backend/FilterBar.tsx +161 -0
  212. package/src/backend/FilterOverlay.tsx +328 -0
  213. package/src/backend/FlashMessages.tsx +82 -0
  214. package/src/backend/JsonBuilder.tsx +362 -0
  215. package/src/backend/JsonDisplay.tsx +254 -0
  216. package/src/backend/Page.tsx +30 -0
  217. package/src/backend/PerspectiveSidebar.tsx +337 -0
  218. package/src/backend/RowActions.tsx +151 -0
  219. package/src/backend/TruncatedCell.tsx +133 -0
  220. package/src/backend/UserMenu.tsx +118 -0
  221. package/src/backend/ValueIcons.tsx +48 -0
  222. package/src/backend/__tests__/AppShell.test.tsx +115 -0
  223. package/src/backend/__tests__/CrudForm.render.test.tsx +30 -0
  224. package/src/backend/__tests__/DataTable.render.test.tsx +48 -0
  225. package/src/backend/__tests__/custom-field-filters.test.ts +72 -0
  226. package/src/backend/__tests__/custom-field-forms.test.ts +54 -0
  227. package/src/backend/__tests__/serverErrors.test.ts +83 -0
  228. package/src/backend/custom-fields/FieldDefinitionsEditor.tsx +1292 -0
  229. package/src/backend/custom-fields/FieldDefinitionsManager.tsx +381 -0
  230. package/src/backend/dashboard/DashboardScreen.tsx +684 -0
  231. package/src/backend/dashboard/__tests__/DashboardScreen.test.tsx +112 -0
  232. package/src/backend/dashboard/index.ts +1 -0
  233. package/src/backend/dashboard/widgetRegistry.ts +68 -0
  234. package/src/backend/detail/ActivitiesSection.tsx +1284 -0
  235. package/src/backend/detail/AddressEditor.tsx +472 -0
  236. package/src/backend/detail/AddressTiles.tsx +587 -0
  237. package/src/backend/detail/AddressesSection.tsx +346 -0
  238. package/src/backend/detail/AttachmentDeleteDialog.tsx +56 -0
  239. package/src/backend/detail/AttachmentMetadataDialog.tsx +672 -0
  240. package/src/backend/detail/AttachmentsSection.tsx +414 -0
  241. package/src/backend/detail/CustomDataSection.tsx +530 -0
  242. package/src/backend/detail/DetailFieldsSection.tsx +147 -0
  243. package/src/backend/detail/ErrorMessage.tsx +32 -0
  244. package/src/backend/detail/InlineEditors.tsx +877 -0
  245. package/src/backend/detail/LoadingMessage.tsx +14 -0
  246. package/src/backend/detail/NotesSection.tsx +1275 -0
  247. package/src/backend/detail/TabEmptyState.tsx +48 -0
  248. package/src/backend/detail/TagsSection.tsx +314 -0
  249. package/src/backend/detail/addressFormat.tsx +121 -0
  250. package/src/backend/detail/index.ts +44 -0
  251. package/src/backend/fields/registry.generated.ts +8 -0
  252. package/src/backend/fields/registry.ts +38 -0
  253. package/src/backend/indexes/PartialIndexBanner.tsx +68 -0
  254. package/src/backend/indexes/store.ts +88 -0
  255. package/src/backend/injection/InjectionSpot.tsx +236 -0
  256. package/src/backend/injection/PageInjectionBoundary.tsx +31 -0
  257. package/src/backend/injection/helpers.ts +35 -0
  258. package/src/backend/injection/widgetRegistry.ts +68 -0
  259. package/src/backend/inputs/ComboboxInput.tsx +269 -0
  260. package/src/backend/inputs/LookupSelect.tsx +247 -0
  261. package/src/backend/inputs/PhoneNumberField.tsx +129 -0
  262. package/src/backend/inputs/SwitchableMarkdownInput.tsx +128 -0
  263. package/src/backend/inputs/TagsInput.tsx +259 -0
  264. package/src/backend/inputs/index.ts +5 -0
  265. package/src/backend/operations/LastOperationBanner.tsx +85 -0
  266. package/src/backend/operations/__tests__/LastOperationBanner.test.tsx +99 -0
  267. package/src/backend/operations/store.ts +230 -0
  268. package/src/backend/schedule/ScheduleAgenda.tsx +136 -0
  269. package/src/backend/schedule/ScheduleGrid.tsx +136 -0
  270. package/src/backend/schedule/ScheduleToolbar.tsx +178 -0
  271. package/src/backend/schedule/ScheduleView.tsx +198 -0
  272. package/src/backend/schedule/index.ts +5 -0
  273. package/src/backend/schedule/recurrence.ts +99 -0
  274. package/src/backend/schedule/types.ts +26 -0
  275. package/src/backend/upgrades/UpgradeActionBanner.tsx +128 -0
  276. package/src/backend/utils/__tests__/apiCall.test.ts +109 -0
  277. package/src/backend/utils/__tests__/crud.test.ts +87 -0
  278. package/src/backend/utils/__tests__/customFieldDefs.test.ts +25 -0
  279. package/src/backend/utils/__tests__/customFieldValues.test.ts +35 -0
  280. package/src/backend/utils/api.ts +149 -0
  281. package/src/backend/utils/apiCall.ts +96 -0
  282. package/src/backend/utils/crud.ts +174 -0
  283. package/src/backend/utils/customFieldColumns.ts +71 -0
  284. package/src/backend/utils/customFieldDefs.ts +245 -0
  285. package/src/backend/utils/customFieldFilters.ts +145 -0
  286. package/src/backend/utils/customFieldForms.ts +196 -0
  287. package/src/backend/utils/customFieldValues.ts +41 -0
  288. package/src/backend/utils/flash.ts +17 -0
  289. package/src/backend/utils/nav.ts +238 -0
  290. package/src/backend/utils/serverErrors.ts +302 -0
  291. package/src/frontend/AuthFooter.tsx +29 -0
  292. package/src/frontend/LanguageSwitcher.tsx +66 -0
  293. package/src/frontend/Layout.tsx +13 -0
  294. package/src/index.ts +32 -0
  295. package/src/primitives/DataLoader.tsx +92 -0
  296. package/src/primitives/ErrorNotice.tsx +26 -0
  297. package/src/primitives/alert.tsx +52 -0
  298. package/src/primitives/badge.tsx +31 -0
  299. package/src/primitives/button.tsx +47 -0
  300. package/src/primitives/card.tsx +92 -0
  301. package/src/primitives/checkbox.tsx +28 -0
  302. package/src/primitives/dialog.tsx +110 -0
  303. package/src/primitives/input.tsx +20 -0
  304. package/src/primitives/label.tsx +18 -0
  305. package/src/primitives/separator.tsx +7 -0
  306. package/src/primitives/spinner.tsx +27 -0
  307. package/src/primitives/switch.tsx +86 -0
  308. package/src/primitives/table.tsx +27 -0
  309. package/src/primitives/tabs.tsx +128 -0
  310. package/src/primitives/textarea.tsx +20 -0
  311. package/src/primitives/tooltip.tsx +85 -0
  312. package/src/theme/QueryProvider.tsx +46 -0
  313. package/src/theme/ThemeProvider.tsx +120 -0
  314. package/src/theme/ThemeToggle.tsx +88 -0
  315. package/src/theme/index.ts +3 -0
  316. package/src/types/react-big-calendar.d.ts +16 -0
  317. package/tsconfig.build.json +11 -0
  318. package/tsconfig.json +9 -0
  319. package/watch.mjs +6 -0
@@ -0,0 +1,684 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { Button } from '@open-mercato/ui/primitives/button'
5
+ import { Spinner } from '@open-mercato/ui/primitives/spinner'
6
+ import { ErrorNotice } from '@open-mercato/ui/primitives/ErrorNotice'
7
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
8
+ import { loadDashboardWidgetModule } from './widgetRegistry'
9
+ import type { DashboardWidgetModule } from '@open-mercato/shared/modules/dashboard/widgets'
10
+ import { cn } from '@open-mercato/shared/lib/utils'
11
+ import { GripVertical, Plus, RefreshCw, Settings2, Trash2, X, Loader2 } from 'lucide-react'
12
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
13
+ import { InjectionSpot } from '../injection/InjectionSpot'
14
+
15
+ type DashboardWidgetSize = 'sm' | 'md' | 'lg'
16
+
17
+ type LayoutItem = {
18
+ id: string
19
+ widgetId: string
20
+ order: number
21
+ priority?: number
22
+ size?: DashboardWidgetSize
23
+ settings?: unknown
24
+ }
25
+
26
+ type WidgetMeta = {
27
+ id: string
28
+ title: string
29
+ description: string | null
30
+ defaultSize: DashboardWidgetSize
31
+ defaultEnabled: boolean
32
+ defaultSettings: unknown
33
+ features: string[]
34
+ moduleId: string
35
+ icon: string | null
36
+ loaderKey: string
37
+ supportsRefresh: boolean
38
+ }
39
+
40
+ type LayoutContext = {
41
+ userId: string
42
+ tenantId: string | null
43
+ organizationId: string | null
44
+ userName: string | null
45
+ userEmail: string | null
46
+ userLabel: string | null
47
+ }
48
+
49
+ type LayoutResponse = {
50
+ layout: { items: LayoutItem[] }
51
+ widgets: WidgetMeta[]
52
+ allowedWidgetIds: string[]
53
+ canConfigure: boolean
54
+ context: LayoutContext
55
+ }
56
+
57
+ type WidgetModule = DashboardWidgetModule<any>
58
+
59
+ function sizeClass(size: DashboardWidgetSize | undefined) {
60
+ switch (size) {
61
+ case 'lg':
62
+ return 'md:col-span-2'
63
+ case 'md':
64
+ return 'md:col-span-1'
65
+ case 'sm':
66
+ default:
67
+ return 'md:col-span-1'
68
+ }
69
+ }
70
+
71
+ function sortLayout(items: LayoutItem[]): LayoutItem[] {
72
+ return [...items]
73
+ .sort((a, b) => {
74
+ const aOrder = a.order ?? a.priority ?? 0
75
+ const bOrder = b.order ?? b.priority ?? 0
76
+ return aOrder - bOrder
77
+ })
78
+ .map((item, index) => ({ ...item, order: index, priority: index }))
79
+ }
80
+
81
+ const DEFAULT_SIZE: DashboardWidgetSize = 'md'
82
+
83
+ function generateId(): string {
84
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
85
+ return crypto.randomUUID()
86
+ }
87
+ // Fallback: timestamp + random for better uniqueness
88
+ return Date.now().toString(36) + Math.random().toString(36).slice(2)
89
+ }
90
+
91
+ export function DashboardScreen() {
92
+ const t = useT()
93
+ const [loading, setLoading] = React.useState(true)
94
+ const [error, setError] = React.useState<string | null>(null)
95
+ const [saving, setSaving] = React.useState(false)
96
+ const [layout, setLayout] = React.useState<LayoutItem[]>([])
97
+ const [widgetCatalog, setWidgetCatalog] = React.useState<WidgetMeta[]>([])
98
+ const [allowedWidgetIds, setAllowedWidgetIds] = React.useState<string[]>([])
99
+ const [canConfigure, setCanConfigure] = React.useState(false)
100
+ const [context, setContext] = React.useState<LayoutContext | null>(null)
101
+ const [editing, setEditing] = React.useState(false)
102
+ const [settingsId, setSettingsId] = React.useState<string | null>(null)
103
+ const pendingOpsRef = React.useRef(0)
104
+ const saveQueueRef = React.useRef(Promise.resolve())
105
+ const draggingIdRef = React.useRef<string | null>(null)
106
+
107
+ const adjustSaving = React.useCallback((delta: number) => {
108
+ pendingOpsRef.current = Math.max(0, pendingOpsRef.current + delta)
109
+ setSaving(pendingOpsRef.current > 0)
110
+ }, [])
111
+
112
+ const load = React.useCallback(async () => {
113
+ setLoading(true)
114
+ setError(null)
115
+ try {
116
+ const call = await apiCall<LayoutResponse>('/api/dashboards/layout')
117
+ if (!call.ok || !call.result) {
118
+ throw new Error(`Failed with status ${call.status}`)
119
+ }
120
+ const data = call.result
121
+ const normalizedLayout = sortLayout(data.layout?.items ?? [])
122
+ setLayout(normalizedLayout)
123
+ setWidgetCatalog(data.widgets ?? [])
124
+ setAllowedWidgetIds(data.allowedWidgetIds ?? [])
125
+ setCanConfigure(!!data.canConfigure)
126
+ if (data.context) {
127
+ setContext({
128
+ userId: data.context.userId,
129
+ tenantId: data.context.tenantId ?? null,
130
+ organizationId: data.context.organizationId ?? null,
131
+ userName: data.context.userName ?? null,
132
+ userEmail: data.context.userEmail ?? null,
133
+ userLabel: data.context.userLabel ?? null,
134
+ })
135
+ } else {
136
+ setContext(null)
137
+ }
138
+ if (!data.canConfigure) {
139
+ setEditing(false)
140
+ setSettingsId(null)
141
+ }
142
+ } catch (err) {
143
+ console.error('Failed to load dashboard layout', err)
144
+ setError(t('dashboard.loadError'))
145
+ } finally {
146
+ setLoading(false)
147
+ }
148
+ }, [t])
149
+
150
+ React.useEffect(() => {
151
+ load()
152
+ }, [load])
153
+
154
+ const metaById = React.useMemo(() => {
155
+ const map = new Map<string, WidgetMeta>()
156
+ for (const meta of widgetCatalog) map.set(meta.id, meta)
157
+ return map
158
+ }, [widgetCatalog])
159
+
160
+ const availableWidgets = React.useMemo(() => {
161
+ const currentIds = new Set(layout.map((item) => item.widgetId))
162
+ return widgetCatalog.filter((meta) => !currentIds.has(meta.id))
163
+ }, [layout, widgetCatalog])
164
+
165
+ const resolveWidgetTitle = React.useCallback((meta: WidgetMeta): string => {
166
+ const key = `dashboard.widgets.${meta.id}.title`
167
+ const translated = t(key)
168
+ return translated === key ? meta.title : translated
169
+ }, [t])
170
+
171
+ const resolveWidgetDescription = React.useCallback((meta: WidgetMeta): string | null => {
172
+ if (!meta.description) return null
173
+ const key = `dashboard.widgets.${meta.id}.description`
174
+ const translated = t(key)
175
+ return translated === key ? meta.description : translated
176
+ }, [t])
177
+
178
+ const queueLayoutSave = React.useCallback((items: LayoutItem[]) => {
179
+ saveQueueRef.current = saveQueueRef.current.then(async () => {
180
+ adjustSaving(1)
181
+ try {
182
+ const payload = {
183
+ items: items.map((item, index) => ({
184
+ id: item.id,
185
+ widgetId: item.widgetId,
186
+ order: index,
187
+ priority: index,
188
+ size: item.size ?? DEFAULT_SIZE,
189
+ settings: item.settings ?? null,
190
+ })),
191
+ }
192
+ const call = await apiCall('/api/dashboards/layout', {
193
+ method: 'PUT',
194
+ headers: { 'content-type': 'application/json' },
195
+ body: JSON.stringify(payload),
196
+ })
197
+ if (!call.ok) throw new Error(`Failed with status ${call.status}`)
198
+ setError(null)
199
+ } catch (err) {
200
+ console.error('Failed to save layout', err)
201
+ setError(t('dashboard.saveError'))
202
+ } finally {
203
+ adjustSaving(-1)
204
+ }
205
+ })
206
+ }, [adjustSaving, t])
207
+
208
+ const patchWidgetSettings = React.useCallback(async (itemId: string, nextSettings: unknown) => {
209
+ adjustSaving(1)
210
+ try {
211
+ const call = await apiCall(`/api/dashboards/layout/${encodeURIComponent(itemId)}`, {
212
+ method: 'PATCH',
213
+ headers: { 'content-type': 'application/json' },
214
+ body: JSON.stringify({ settings: nextSettings }),
215
+ })
216
+ if (!call.ok) throw new Error(`Failed with status ${call.status}`)
217
+ setError(null)
218
+ } catch (err) {
219
+ console.error('Failed to update widget settings', err)
220
+ setError(t('dashboard.saveError'))
221
+ } finally {
222
+ adjustSaving(-1)
223
+ }
224
+ }, [adjustSaving, t])
225
+
226
+ const handleAddWidget = React.useCallback((widgetId: string) => {
227
+ const meta = metaById.get(widgetId)
228
+ if (!meta) return
229
+ setLayout((prev) => {
230
+ const next: LayoutItem[] = sortLayout([
231
+ ...prev,
232
+ {
233
+ id: generateId(),
234
+ widgetId: meta.id,
235
+ order: prev.length,
236
+ priority: prev.length,
237
+ size: meta.defaultSize ?? DEFAULT_SIZE,
238
+ settings: meta.defaultSettings ?? null,
239
+ },
240
+ ])
241
+ queueLayoutSave(next)
242
+ return next
243
+ })
244
+ setSettingsId(null)
245
+ }, [metaById, queueLayoutSave])
246
+
247
+ const handleRemoveWidget = React.useCallback((itemId: string) => {
248
+ setLayout((prev) => {
249
+ const next = sortLayout(prev.filter((item) => item.id !== itemId))
250
+ queueLayoutSave(next)
251
+ return next
252
+ })
253
+ if (settingsId === itemId) setSettingsId(null)
254
+ }, [queueLayoutSave, settingsId])
255
+
256
+ const handleReorder = React.useCallback((dragId: string | null, targetId: string) => {
257
+ if (!dragId || dragId === targetId) return
258
+ setLayout((prev) => {
259
+ const items = [...prev]
260
+ const from = items.findIndex((item) => item.id === dragId)
261
+ const to = items.findIndex((item) => item.id === targetId)
262
+ if (from === -1 || to === -1) return prev
263
+ const [moved] = items.splice(from, 1)
264
+ items.splice(to, 0, moved)
265
+ const next = items.map((item, index) => ({
266
+ ...item,
267
+ order: index,
268
+ priority: index,
269
+ }))
270
+ queueLayoutSave(next)
271
+ return next
272
+ })
273
+ }, [queueLayoutSave])
274
+
275
+ const handleSettingsChange = React.useCallback((itemId: string, nextSettings: unknown) => {
276
+ setLayout((prev) => prev.map((item) => (item.id === itemId ? { ...item, settings: nextSettings } : item)))
277
+ void patchWidgetSettings(itemId, nextSettings)
278
+ }, [patchWidgetSettings])
279
+
280
+ const toggleEditing = React.useCallback(() => {
281
+ if (!canConfigure) return
282
+ setEditing((prev) => {
283
+ const next = !prev
284
+ if (!next) setSettingsId(null)
285
+ return next
286
+ })
287
+ }, [canConfigure])
288
+
289
+ const handleRefresh = React.useCallback(() => {
290
+ load()
291
+ }, [load])
292
+
293
+ const injectionContext = React.useMemo(
294
+ () => ({
295
+ layout,
296
+ widgetCatalog,
297
+ allowedWidgetIds,
298
+ canConfigure,
299
+ editing,
300
+ userContext: context,
301
+ }),
302
+ [allowedWidgetIds, canConfigure, context, editing, layout, widgetCatalog],
303
+ )
304
+ const dashboardBeforeSpotId = 'dashboard:before'
305
+ const dashboardAfterSpotId = 'dashboard:after'
306
+
307
+ if (loading) {
308
+ return (
309
+ <div className="flex min-h-[320px] items-center justify-center">
310
+ <Spinner size="lg" />
311
+ </div>
312
+ )
313
+ }
314
+
315
+ if (error && layout.length === 0) {
316
+ return (
317
+ <ErrorNotice
318
+ title={t('dashboard.unavailable')}
319
+ message={error}
320
+ action={<Button variant="outline" onClick={handleRefresh}>{t('dashboard.retry')}</Button>}
321
+ />
322
+ )
323
+ }
324
+
325
+ return (
326
+ <div className="space-y-6">
327
+ <div className="flex flex-wrap items-center justify-between gap-3">
328
+ <div>
329
+ <h1 className="text-2xl font-semibold tracking-tight">{t('dashboard.title')}</h1>
330
+ <p className="text-sm text-muted-foreground">{t('dashboard.subtitle')}</p>
331
+ </div>
332
+ <div className="flex items-center gap-2">
333
+ {saving && (
334
+ <div className="flex items-center gap-1 text-sm text-muted-foreground">
335
+ <Loader2 className="h-4 w-4 animate-spin" />
336
+ <span>{t('dashboard.saving')}</span>
337
+ </div>
338
+ )}
339
+ {canConfigure && (
340
+ <Button variant={editing ? 'secondary' : 'outline'} onClick={toggleEditing}>
341
+ <Settings2 className="h-4 w-4" />
342
+ <span>{editing ? t('dashboard.action.done') : t('dashboard.action.customize')}</span>
343
+ </Button>
344
+ )}
345
+ </div>
346
+ </div>
347
+
348
+ {error && layout.length > 0 && (
349
+ <ErrorNotice
350
+ title={t('dashboard.error.partial')}
351
+ message={error}
352
+ action={<Button variant="ghost" onClick={handleRefresh}>{t('dashboard.error.reload')}</Button>}
353
+ />
354
+ )}
355
+
356
+ <InjectionSpot spotId={dashboardBeforeSpotId} context={injectionContext} />
357
+
358
+ {editing && availableWidgets.length > 0 && (
359
+ <div className="rounded-lg border border-dashed bg-muted/40 p-4">
360
+ <div className="mb-2 text-sm font-medium text-muted-foreground">{t('dashboard.addWidget')}</div>
361
+ <div className="flex flex-wrap gap-2">
362
+ {availableWidgets.map((meta) => (
363
+ <Button
364
+ key={meta.id}
365
+ variant="outline"
366
+ size="sm"
367
+ onClick={() => handleAddWidget(meta.id)}
368
+ >
369
+ <Plus className="h-4 w-4" />
370
+ {resolveWidgetTitle(meta)}
371
+ </Button>
372
+ ))}
373
+ </div>
374
+ </div>
375
+ )}
376
+
377
+ <div className={cn(
378
+ 'grid gap-4',
379
+ 'grid-cols-1',
380
+ 'md:grid-cols-2',
381
+ 'xl:grid-cols-3'
382
+ )}
383
+ onDragOver={(event) => {
384
+ if (!editing || !canConfigure) return
385
+ event.preventDefault()
386
+ event.dataTransfer.dropEffect = 'move'
387
+ }}
388
+ onDrop={(event) => {
389
+ if (!editing || !canConfigure) return
390
+ event.preventDefault()
391
+ const dragId = event.dataTransfer.getData('text/plain') || draggingIdRef.current
392
+ if (!dragId) return
393
+ setLayout((prev) => {
394
+ const items = [...prev]
395
+ const from = items.findIndex((entry) => entry.id === dragId)
396
+ if (from === -1) return prev
397
+ const [moved] = items.splice(from, 1)
398
+ items.push(moved)
399
+ const next = items.map((item, index) => ({
400
+ ...item,
401
+ order: index,
402
+ priority: index,
403
+ }))
404
+ queueLayoutSave(next)
405
+ return next
406
+ })
407
+ draggingIdRef.current = null
408
+ }}>
409
+ {layout.map((item) => {
410
+ const meta = metaById.get(item.widgetId)
411
+ if (!meta) return null
412
+ const title = resolveWidgetTitle(meta)
413
+ const description = resolveWidgetDescription(meta)
414
+ return (
415
+ <DashboardWidgetCard
416
+ key={item.id}
417
+ item={item}
418
+ meta={meta}
419
+ title={title}
420
+ description={description}
421
+ context={context}
422
+ editing={editing && canConfigure}
423
+ activeSettings={settingsId === item.id}
424
+ onToggleSettings={() => setSettingsId((current) => (current === item.id ? null : item.id))}
425
+ onRemove={() => handleRemoveWidget(item.id)}
426
+ onSettingsChange={(settings) => handleSettingsChange(item.id, settings)}
427
+ onDragStart={() => { draggingIdRef.current = item.id }}
428
+ onDragEnd={() => { draggingIdRef.current = null }}
429
+ onDrop={(event) => {
430
+ const dragId = event.dataTransfer.getData('text/plain') || draggingIdRef.current
431
+ handleReorder(dragId, item.id)
432
+ draggingIdRef.current = null
433
+ }}
434
+ onDragEnter={() => {}}
435
+ onDragLeave={() => {}}
436
+ sizeClass={sizeClass(item.size)}
437
+ />
438
+ )
439
+ })}
440
+ </div>
441
+
442
+ {layout.length === 0 && (
443
+ <div className="rounded-lg border border-dashed bg-muted/30 p-10 text-center text-sm text-muted-foreground">
444
+ {canConfigure ? t('dashboard.empty.configurable') : t('dashboard.empty.readonly')}
445
+ </div>
446
+ )}
447
+
448
+ <InjectionSpot spotId={dashboardAfterSpotId} context={injectionContext} />
449
+ </div>
450
+ )
451
+ }
452
+
453
+ type DashboardWidgetCardProps = {
454
+ item: LayoutItem
455
+ meta: WidgetMeta
456
+ title: string
457
+ description: string | null
458
+ context: LayoutContext | null
459
+ editing: boolean
460
+ activeSettings: boolean
461
+ onToggleSettings: () => void
462
+ onRemove: () => void
463
+ onSettingsChange: (next: unknown) => void
464
+ onDragStart: () => void
465
+ onDragEnd: () => void
466
+ onDrop: (event: React.DragEvent<HTMLDivElement>) => void
467
+ onDragEnter: () => void
468
+ onDragLeave: () => void
469
+ sizeClass: string
470
+ }
471
+
472
+ function DashboardWidgetCard({
473
+ item,
474
+ meta,
475
+ title,
476
+ description,
477
+ context,
478
+ editing,
479
+ activeSettings,
480
+ onToggleSettings,
481
+ onRemove,
482
+ onSettingsChange,
483
+ onDragStart,
484
+ onDragEnd,
485
+ onDrop,
486
+ onDragEnter,
487
+ onDragLeave,
488
+ sizeClass,
489
+ }: DashboardWidgetCardProps) {
490
+ const t = useT()
491
+ const [module, setModule] = React.useState<WidgetModule | null>(null)
492
+ const [loading, setLoading] = React.useState(true)
493
+ const [loadError, setLoadError] = React.useState<string | null>(null)
494
+ const [isDragOver, setIsDragOver] = React.useState(false)
495
+ const [refreshToken, setRefreshToken] = React.useState(0)
496
+ const [refreshing, setRefreshing] = React.useState(false)
497
+
498
+ React.useEffect(() => {
499
+ let cancelled = false
500
+ setLoading(true)
501
+ setLoadError(null)
502
+ loadDashboardWidgetModule(meta.loaderKey)
503
+ .then((loaded) => {
504
+ if (cancelled) return
505
+ setModule(loaded)
506
+ setLoading(false)
507
+ })
508
+ .catch((err) => {
509
+ if (cancelled) return
510
+ console.error('Failed to load widget module', err)
511
+ setLoadError(t('dashboard.widget.loadError'))
512
+ setLoading(false)
513
+ })
514
+ return () => { cancelled = true }
515
+ }, [meta.loaderKey, t])
516
+
517
+ React.useEffect(() => {
518
+ if (!meta.supportsRefresh) {
519
+ setRefreshing(false)
520
+ }
521
+ }, [meta.supportsRefresh])
522
+
523
+ React.useEffect(() => {
524
+ if (activeSettings) {
525
+ setRefreshing(false)
526
+ }
527
+ }, [activeSettings])
528
+
529
+ React.useEffect(() => {
530
+ if (loadError) {
531
+ setRefreshing(false)
532
+ }
533
+ }, [loadError])
534
+
535
+ const handleRefreshStateChange = React.useCallback((next: boolean) => {
536
+ setRefreshing(next)
537
+ }, [])
538
+
539
+ const triggerRefresh = React.useCallback(() => {
540
+ if (loading || !!loadError) return
541
+ setRefreshing(true)
542
+ setRefreshToken((current) => current + 1)
543
+ }, [loadError, loading])
544
+
545
+ const hydratedSettings = React.useMemo(() => {
546
+ const raw = item.settings ?? meta.defaultSettings ?? null
547
+ if (module?.hydrateSettings) {
548
+ try {
549
+ return module.hydrateSettings(raw)
550
+ } catch (err) {
551
+ console.warn('Failed to hydrate widget settings', err)
552
+ return raw
553
+ }
554
+ }
555
+ return raw
556
+ }, [item.settings, meta.defaultSettings, module])
557
+
558
+ const handleSettingsChange = React.useCallback((next: unknown) => {
559
+ let raw = next
560
+ if (module?.dehydrateSettings) {
561
+ try {
562
+ raw = module.dehydrateSettings(next as never)
563
+ } catch (err) {
564
+ console.warn('Failed to dehydrate widget settings', err)
565
+ }
566
+ }
567
+ onSettingsChange(raw)
568
+ }, [module, onSettingsChange])
569
+
570
+ const WidgetComponent = module?.Widget
571
+ const mode = activeSettings ? 'settings' : 'view'
572
+
573
+ return (
574
+ <div
575
+ className={cn(
576
+ 'group relative flex h-full flex-col rounded-lg border bg-background shadow-sm transition',
577
+ isDragOver ? 'border-primary ring-2 ring-primary/20' : 'hover:border-primary/40',
578
+ editing ? 'cursor-grab' : 'cursor-default',
579
+ sizeClass
580
+ )}
581
+ draggable={editing}
582
+ onDragStart={(event) => {
583
+ if (!editing) return
584
+ event.dataTransfer.effectAllowed = 'move'
585
+ event.dataTransfer.setData('text/plain', item.id)
586
+ onDragStart()
587
+ }}
588
+ onDragEnd={() => {
589
+ if (!editing) return
590
+ onDragEnd()
591
+ }}
592
+ onDragOver={(event) => {
593
+ if (!editing) return
594
+ event.preventDefault()
595
+ event.stopPropagation()
596
+ event.dataTransfer.dropEffect = 'move'
597
+ if (!isDragOver) {
598
+ setIsDragOver(true)
599
+ onDragEnter()
600
+ }
601
+ }}
602
+ onDrop={(event) => {
603
+ if (!editing) return
604
+ event.preventDefault()
605
+ event.stopPropagation()
606
+ onDrop(event)
607
+ setIsDragOver(false)
608
+ onDragLeave()
609
+ }}
610
+ onDragLeave={(event) => {
611
+ if (!editing) return
612
+ event.stopPropagation()
613
+ if (event.currentTarget.contains(event.relatedTarget as Node)) return
614
+ setIsDragOver(false)
615
+ onDragLeave()
616
+ }}
617
+ >
618
+ <div className="flex items-center justify-between gap-2 border-b px-3 py-2">
619
+ <div className="flex items-center gap-2">
620
+ {editing && <GripVertical className="h-4 w-4 text-muted-foreground" />}
621
+ <div>
622
+ <div className="text-sm font-medium leading-none">{title}</div>
623
+ {description ? <div className="text-xs text-muted-foreground">{description}</div> : null}
624
+ </div>
625
+ </div>
626
+ <div className="flex items-center gap-1">
627
+ {!editing && meta.supportsRefresh && (
628
+ <Button
629
+ variant="ghost"
630
+ size="icon"
631
+ disabled={refreshing || loading || !!loadError}
632
+ onClick={triggerRefresh}
633
+ aria-label={t('dashboard.widget.refresh')}
634
+ >
635
+ {refreshing ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
636
+ <span className="sr-only">{t('dashboard.widget.refresh')}</span>
637
+ </Button>
638
+ )}
639
+ {editing && (
640
+ <>
641
+ <Button
642
+ variant={activeSettings ? 'secondary' : 'ghost'}
643
+ size="icon"
644
+ onClick={onToggleSettings}
645
+ aria-label={activeSettings ? t('dashboard.widget.closeSettings') : t('dashboard.widget.editSettings')}
646
+ >
647
+ {activeSettings ? <X className="h-4 w-4" /> : <Settings2 className="h-4 w-4" />}
648
+ </Button>
649
+ <Button
650
+ variant="ghost"
651
+ size="icon"
652
+ onClick={onRemove}
653
+ aria-label={t('dashboard.widget.remove')}
654
+ >
655
+ <Trash2 className="h-4 w-4" />
656
+ </Button>
657
+ </>
658
+ )}
659
+ </div>
660
+ </div>
661
+ <div className="flex-1 p-4">
662
+ {loading && (
663
+ <div className="flex h-full min-h-[120px] items-center justify-center">
664
+ <Spinner />
665
+ </div>
666
+ )}
667
+ {loadError && !loading && (
668
+ <div className="text-sm text-muted-foreground">{loadError}</div>
669
+ )}
670
+ {!loading && !loadError && WidgetComponent && (
671
+ <WidgetComponent
672
+ mode={mode as 'view' | 'settings'}
673
+ layout={item}
674
+ context={context ?? { userId: '', tenantId: null, organizationId: null, userName: null, userEmail: null, userLabel: null }}
675
+ settings={hydratedSettings}
676
+ onSettingsChange={handleSettingsChange}
677
+ refreshToken={refreshToken}
678
+ onRefreshStateChange={handleRefreshStateChange}
679
+ />
680
+ )}
681
+ </div>
682
+ </div>
683
+ )
684
+ }