@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,337 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import { Button } from '../primitives/button'
4
+ import { Spinner } from '../primitives/spinner'
5
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
6
+ import type {
7
+ PerspectiveDto,
8
+ RolePerspectiveDto,
9
+ } from '@open-mercato/shared/modules/perspectives/types'
10
+
11
+ type ColumnOption = {
12
+ id: string
13
+ label: string
14
+ visible: boolean
15
+ canHide: boolean
16
+ }
17
+
18
+ export type PerspectiveSidebarProps = {
19
+ open: boolean
20
+ onOpenChange: (open: boolean) => void
21
+ loading: boolean
22
+ perspectives: PerspectiveDto[]
23
+ rolePerspectives: RolePerspectiveDto[]
24
+ roles: Array<{ id: string; name: string; hasPerspective: boolean; hasDefault: boolean }>
25
+ activePerspectiveId: string | null
26
+ onActivatePerspective: (perspective: PerspectiveDto | RolePerspectiveDto, source: 'personal' | 'role') => void
27
+ onDeletePerspective: (perspectiveId: string) => Promise<void>
28
+ onClearRole: (roleId: string) => Promise<void>
29
+ onSave: (input: { name: string; isDefault: boolean; applyToRoles: string[]; setRoleDefault: boolean }) => Promise<void>
30
+ canApplyToRoles: boolean
31
+ columnOptions: ColumnOption[]
32
+ onToggleColumn: (id: string, visible: boolean) => void
33
+ onMoveColumn: (id: string, direction: 'up' | 'down') => void
34
+ saving: boolean
35
+ deletingIds: string[]
36
+ roleClearingIds: string[]
37
+ apiWarning?: string | null
38
+ }
39
+
40
+ const emptyArray: any[] = []
41
+
42
+ export function PerspectiveSidebar({
43
+ open,
44
+ onOpenChange,
45
+ loading,
46
+ perspectives,
47
+ rolePerspectives,
48
+ roles,
49
+ activePerspectiveId,
50
+ onActivatePerspective,
51
+ onDeletePerspective,
52
+ onClearRole,
53
+ onSave,
54
+ canApplyToRoles,
55
+ columnOptions,
56
+ onToggleColumn,
57
+ onMoveColumn,
58
+ saving,
59
+ deletingIds,
60
+ roleClearingIds,
61
+ apiWarning,
62
+ }: PerspectiveSidebarProps) {
63
+ const t = useT()
64
+
65
+ function perspectiveLabel(p: PerspectiveDto | RolePerspectiveDto) {
66
+ return p.name.trim().length ? p.name : t('ui.perspectives.untitled', 'Untitled perspective')
67
+ }
68
+ const [name, setName] = React.useState('')
69
+ const [isDefault, setIsDefault] = React.useState(false)
70
+ const [applyToRoles, setApplyToRoles] = React.useState<string[]>([])
71
+ const [setRoleDefault, setSetRoleDefault] = React.useState(false)
72
+ const [error, setError] = React.useState<string | null>(null)
73
+
74
+ React.useEffect(() => {
75
+ if (!open) {
76
+ setError(null)
77
+ }
78
+ }, [open])
79
+
80
+ React.useEffect(() => {
81
+ if (!open) return
82
+ const active = perspectives.find((p) => p.id === activePerspectiveId)
83
+ ?? rolePerspectives.find((p) => p.id === activePerspectiveId)
84
+ if (active) {
85
+ setName(active.name)
86
+ setIsDefault(active.isDefault)
87
+ } else if (!name) {
88
+ setName('')
89
+ setIsDefault(false)
90
+ }
91
+ // eslint-disable-next-line react-hooks/exhaustive-deps
92
+ }, [open, activePerspectiveId])
93
+
94
+ const groupedRolePerspectives = React.useMemo(() => {
95
+ const map = new Map<string, RolePerspectiveDto[]>()
96
+ for (const rp of rolePerspectives) {
97
+ if (!map.has(rp.roleId)) map.set(rp.roleId, [])
98
+ map.get(rp.roleId)!.push(rp)
99
+ }
100
+ return map
101
+ }, [rolePerspectives])
102
+
103
+ const toggleRoleSelection = (roleId: string) => {
104
+ setApplyToRoles((prev) => {
105
+ const next = new Set(prev)
106
+ if (next.has(roleId)) next.delete(roleId)
107
+ else next.add(roleId)
108
+ return Array.from(next)
109
+ })
110
+ }
111
+
112
+ const handleSave = async () => {
113
+ setError(null)
114
+ try {
115
+ await onSave({ name: name.trim(), isDefault, applyToRoles, setRoleDefault })
116
+ if (!isDefault) setIsDefault(false)
117
+ setApplyToRoles([])
118
+ setSetRoleDefault(false)
119
+ } catch (err: any) {
120
+ setError(err?.message ?? 'Failed to save perspective')
121
+ }
122
+ }
123
+
124
+ if (!open) return null
125
+
126
+ return (
127
+ <div className="fixed inset-0 z-50">
128
+ <div className="absolute inset-0 bg-black/30" onClick={() => onOpenChange(false)} />
129
+ <div className="absolute left-0 top-0 h-full w-full sm:w-[420px] bg-background shadow-xl border-r flex flex-col">
130
+ <div className="flex items-center justify-between p-4 border-b">
131
+ <h2 className="text-base font-semibold">{t('ui.perspectives.title', 'Perspectives')}</h2>
132
+ <button className="text-sm text-muted-foreground" onClick={() => onOpenChange(false)}>{t('ui.perspectives.close', 'Close')}</button>
133
+ </div>
134
+ <div className="flex-1 overflow-auto divide-y">
135
+ <section className="p-4 space-y-3">
136
+ <div className="flex items-center justify-between">
137
+ <h3 className="text-sm font-semibold uppercase text-muted-foreground">{t('ui.perspectives.myPerspectives.title', 'My perspectives')}</h3>
138
+ {loading ? <Spinner size="sm" /> : null}
139
+ </div>
140
+ {(perspectives ?? emptyArray).length === 0 ? (
141
+ <p className="text-sm text-muted-foreground">{t('ui.perspectives.myPerspectives.empty', 'No saved perspectives yet. Adjust columns or filters and save your first perspective.')}</p>
142
+ ) : (
143
+ <div className="space-y-2">
144
+ {perspectives.map((p) => {
145
+ const isActive = activePerspectiveId === p.id
146
+ const deleting = deletingIds.includes(p.id)
147
+ return (
148
+ <div key={p.id} className={`rounded border px-3 py-2 flex items-start justify-between gap-3 ${isActive ? 'border-primary/80 bg-primary/5' : 'border-border bg-card'}`}>
149
+ <div className="space-y-1">
150
+ <div className="text-sm font-medium">{perspectiveLabel(p)}</div>
151
+ <div className="text-xs text-muted-foreground flex items-center gap-2">
152
+ {p.isDefault ? <span className="inline-flex items-center gap-1 rounded bg-primary/10 px-2 py-0.5 text-primary text-[11px] uppercase tracking-wide">{t('ui.perspectives.badge.default', 'Default')}</span> : null}
153
+ <span>{t('ui.perspectives.updated', 'Updated {date}', { date: new Date(p.updatedAt ?? p.createdAt).toLocaleString() })}</span>
154
+ </div>
155
+ </div>
156
+ <div className="flex flex-col gap-1">
157
+ <Button
158
+ size="sm"
159
+ variant={isActive ? 'secondary' : 'outline'}
160
+ onClick={() => onActivatePerspective(p, 'personal')}
161
+ disabled={isActive || deleting}
162
+ >
163
+ {isActive ? t('ui.perspectives.actions.active', 'Active') : t('ui.perspectives.actions.use', 'Use')}
164
+ </Button>
165
+ <Button
166
+ size="sm"
167
+ variant="ghost"
168
+ onClick={() => void onDeletePerspective(p.id)}
169
+ disabled={deleting}
170
+ >
171
+ {deleting ? t('ui.perspectives.actions.removing', 'Removing…') : t('common.delete', 'Delete')}
172
+ </Button>
173
+ </div>
174
+ </div>
175
+ )
176
+ })}
177
+ </div>
178
+ )}
179
+ </section>
180
+ <section className="p-4 space-y-3">
181
+ <div className="flex items-center justify-between">
182
+ <h3 className="text-sm font-semibold uppercase text-muted-foreground">{t('ui.perspectives.rolePerspectives.title', 'Role perspectives')}</h3>
183
+ {rolePerspectives.length === 0 ? null : <span className="text-xs text-muted-foreground">{rolePerspectives.length}</span>}
184
+ </div>
185
+ {rolePerspectives.length === 0 ? (
186
+ <p className="text-sm text-muted-foreground">{t('ui.perspectives.rolePerspectives.empty', 'No shared role perspectives available.')}</p>
187
+ ) : (
188
+ <div className="space-y-3">
189
+ {Array.from(groupedRolePerspectives.entries()).map(([roleId, items]) => {
190
+ const role = roles.find((r) => r.id === roleId)
191
+ const clearing = roleClearingIds.includes(roleId)
192
+ return (
193
+ <div key={roleId} className="rounded border px-3 py-2 space-y-2 bg-muted/40">
194
+ <div className="flex items-center justify-between gap-2">
195
+ <div>
196
+ <div className="text-sm font-semibold">{role?.name ?? t('ui.perspectives.role.fallback', 'Role')}</div>
197
+ {role?.hasDefault ? <div className="text-xs text-muted-foreground">{t('ui.perspectives.role.defaultConfigured', 'Default perspective configured')}</div> : null}
198
+ </div>
199
+ <Button
200
+ size="sm"
201
+ variant="ghost"
202
+ onClick={() => void onClearRole(roleId)}
203
+ disabled={clearing}
204
+ >
205
+ {clearing ? t('ui.perspectives.role.clearing', 'Clearing…') : t('ui.perspectives.role.clear', 'Clear role')}
206
+ </Button>
207
+ </div>
208
+ <div className="space-y-2">
209
+ {items.map((item) => {
210
+ const isActive = activePerspectiveId === item.id
211
+ return (
212
+ <div key={item.id} className={`rounded border px-3 py-2 flex items-start justify-between gap-3 ${isActive ? 'border-primary/80 bg-primary/5' : 'border-border bg-background'}`}>
213
+ <div className="space-y-1">
214
+ <div className="text-sm font-medium">{perspectiveLabel(item)}</div>
215
+ <div className="text-xs text-muted-foreground flex items-center gap-2">
216
+ {item.isDefault ? <span className="inline-flex items-center gap-1 rounded bg-primary/10 px-2 py-0.5 text-primary text-[11px] uppercase tracking-wide">{t('ui.perspectives.badge.roleDefault', 'Role default')}</span> : null}
217
+ <span>{t('ui.perspectives.updated', 'Updated {date}', { date: new Date(item.updatedAt ?? item.createdAt).toLocaleString() })}</span>
218
+ </div>
219
+ </div>
220
+ <Button
221
+ size="sm"
222
+ variant={isActive ? 'secondary' : 'outline'}
223
+ onClick={() => onActivatePerspective(item, 'role')}
224
+ disabled={isActive}
225
+ >
226
+ {isActive ? t('ui.perspectives.actions.active', 'Active') : t('ui.perspectives.actions.use', 'Use')}
227
+ </Button>
228
+ </div>
229
+ )
230
+ })}
231
+ </div>
232
+ </div>
233
+ )
234
+ })}
235
+ </div>
236
+ )}
237
+ </section>
238
+ <section className="p-4 space-y-3">
239
+ <h3 className="text-sm font-semibold uppercase text-muted-foreground">{t('ui.perspectives.saveCurrentView.title', 'Save current view')}</h3>
240
+ {apiWarning ? (
241
+ <div className="rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
242
+ {apiWarning}
243
+ </div>
244
+ ) : null}
245
+ <div className="space-y-2">
246
+ <label className="text-xs font-medium text-muted-foreground uppercase">{t('ui.perspectives.form.nameLabel', 'Name')}</label>
247
+ <input
248
+ value={name}
249
+ onChange={(e) => setName(e.target.value)}
250
+ placeholder={t('ui.perspectives.form.namePlaceholder', 'e.g. My condensed view')}
251
+ className="w-full h-9 rounded border px-2 text-sm"
252
+ />
253
+ </div>
254
+ <div className="space-y-2">
255
+ <label className="inline-flex items-center gap-2 text-sm">
256
+ <input
257
+ type="checkbox"
258
+ checked={isDefault}
259
+ onChange={(e) => setIsDefault(e.target.checked)}
260
+ />
261
+ {t('ui.perspectives.form.makeDefault', 'Make this my default perspective')}
262
+ </label>
263
+ </div>
264
+ {canApplyToRoles ? (
265
+ <div className="space-y-2">
266
+ <div className="text-xs font-medium text-muted-foreground uppercase">{t('ui.perspectives.form.shareWithRoles', 'Share with roles')}</div>
267
+ <div className="max-h-32 overflow-auto border rounded p-2 space-y-1">
268
+ {roles.length === 0 ? (
269
+ <div className="text-xs text-muted-foreground">{t('ui.perspectives.form.noRolesAvailable', 'No roles available.')}</div>
270
+ ) : roles.map((role) => (
271
+ <label key={role.id} className="flex items-center gap-2 text-sm">
272
+ <input
273
+ type="checkbox"
274
+ checked={applyToRoles.includes(role.id)}
275
+ onChange={() => toggleRoleSelection(role.id)}
276
+ />
277
+ <span>{role.name}</span>
278
+ </label>
279
+ ))}
280
+ </div>
281
+ <label className="inline-flex items-center gap-2 text-sm">
282
+ <input
283
+ type="checkbox"
284
+ checked={setRoleDefault}
285
+ onChange={(e) => setSetRoleDefault(e.target.checked)}
286
+ disabled={applyToRoles.length === 0}
287
+ />
288
+ {t('ui.perspectives.form.setRoleDefault', 'Set as default for selected roles')}
289
+ </label>
290
+ </div>
291
+ ) : null}
292
+ {error ? <div className="text-sm text-red-600">{error}</div> : null}
293
+ <Button size="sm" onClick={() => void handleSave()} disabled={saving || !name.trim() || Boolean(apiWarning)}>
294
+ {saving ? t('ui.perspectives.form.saving', 'Saving…') : t('ui.perspectives.form.save', 'Save perspective')}
295
+ </Button>
296
+ </section>
297
+ <section className="p-4 space-y-3">
298
+ <h3 className="text-sm font-semibold uppercase text-muted-foreground">{t('ui.perspectives.form.columns', 'Columns')}</h3>
299
+ <div className="space-y-2">
300
+ {columnOptions.map((col, index) => (
301
+ <div key={col.id} className="flex items-center justify-between gap-2 rounded border px-3 py-2 bg-card">
302
+ <label className="flex items-center gap-2 text-sm">
303
+ <input
304
+ type="checkbox"
305
+ checked={col.visible}
306
+ onChange={(e) => onToggleColumn(col.id, e.target.checked)}
307
+ disabled={!col.canHide}
308
+ />
309
+ <span>{col.label}</span>
310
+ </label>
311
+ <div className="flex items-center gap-1">
312
+ <Button
313
+ size="sm"
314
+ variant="ghost"
315
+ onClick={() => onMoveColumn(col.id, 'up')}
316
+ disabled={index === 0}
317
+ >
318
+
319
+ </Button>
320
+ <Button
321
+ size="sm"
322
+ variant="ghost"
323
+ onClick={() => onMoveColumn(col.id, 'down')}
324
+ disabled={index === columnOptions.length - 1}
325
+ >
326
+
327
+ </Button>
328
+ </div>
329
+ </div>
330
+ ))}
331
+ </div>
332
+ </section>
333
+ </div>
334
+ </div>
335
+ </div>
336
+ )
337
+ }
@@ -0,0 +1,151 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import { createPortal } from 'react-dom'
4
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
5
+
6
+ export type RowActionItem = {
7
+ id?: string
8
+ label: string
9
+ onSelect?: () => void
10
+ href?: string
11
+ destructive?: boolean
12
+ }
13
+
14
+ export function RowActions({ items = [] }: { items?: RowActionItem[] }) {
15
+ if (items.length === 0) return null
16
+ const t = useT()
17
+ const [open, setOpen] = React.useState(false)
18
+ const btnRef = React.useRef<HTMLButtonElement>(null)
19
+ const menuRef = React.useRef<HTMLDivElement>(null)
20
+ const hoverTimeoutRef = React.useRef<NodeJS.Timeout | null>(null)
21
+ const [anchorRect, setAnchorRect] = React.useState<DOMRect | null>(null)
22
+ const [direction, setDirection] = React.useState<'down' | 'up'>('down')
23
+
24
+ const updatePosition = React.useCallback(() => {
25
+ if (!btnRef.current) return
26
+ const rect = btnRef.current.getBoundingClientRect()
27
+ setAnchorRect(rect)
28
+ // Decide whether to open up or down based on available viewport space
29
+ const spaceBelow = window.innerHeight - rect.bottom
30
+ const spaceAbove = rect.top
31
+ setDirection(spaceBelow < 180 && spaceAbove > spaceBelow ? 'up' : 'down')
32
+ }, [])
33
+
34
+ React.useEffect(() => {
35
+ if (!open) return
36
+ updatePosition()
37
+ function onDocClick(e: MouseEvent) {
38
+ const t = e.target as Node
39
+ if (menuRef.current && !menuRef.current.contains(t) && btnRef.current && !btnRef.current.contains(t)) {
40
+ setOpen(false)
41
+ }
42
+ }
43
+ function onKey(e: KeyboardEvent) {
44
+ if (e.key === 'Escape') {
45
+ setOpen(false)
46
+ btnRef.current?.focus()
47
+ }
48
+ }
49
+ function onScrollOrResize() {
50
+ updatePosition()
51
+ }
52
+ document.addEventListener('mousedown', onDocClick)
53
+ document.addEventListener('keydown', onKey)
54
+ window.addEventListener('scroll', onScrollOrResize, true)
55
+ window.addEventListener('resize', onScrollOrResize)
56
+ return () => {
57
+ document.removeEventListener('mousedown', onDocClick)
58
+ document.removeEventListener('keydown', onKey)
59
+ window.removeEventListener('scroll', onScrollOrResize, true)
60
+ window.removeEventListener('resize', onScrollOrResize)
61
+ }
62
+ }, [open, updatePosition])
63
+
64
+ // Cleanup timeout on unmount
65
+ React.useEffect(() => {
66
+ return () => {
67
+ if (hoverTimeoutRef.current) {
68
+ clearTimeout(hoverTimeoutRef.current)
69
+ }
70
+ }
71
+ }, [])
72
+
73
+ const handleMouseEnter = () => {
74
+ if (hoverTimeoutRef.current) {
75
+ clearTimeout(hoverTimeoutRef.current)
76
+ }
77
+ setOpen(true)
78
+ }
79
+
80
+ const handleMouseLeave = () => {
81
+ hoverTimeoutRef.current = setTimeout(() => {
82
+ setOpen(false)
83
+ }, 150) // Small delay to prevent flickering when moving to menu
84
+ }
85
+
86
+ return (
87
+ <div
88
+ className="relative inline-block text-left"
89
+ onMouseEnter={handleMouseEnter}
90
+ onMouseLeave={handleMouseLeave}
91
+ >
92
+ <button
93
+ ref={btnRef}
94
+ type="button"
95
+ className="h-8 w-8 inline-flex items-center justify-center rounded hover:bg-accent"
96
+ aria-haspopup="menu"
97
+ aria-expanded={open}
98
+ onClick={() => { setOpen((v) => !v); requestAnimationFrame(updatePosition) }}
99
+ >
100
+ <span aria-hidden="true">⋯</span>
101
+ <span className="sr-only">{t('ui.rowActions.openActions', 'Open actions')}</span>
102
+ </button>
103
+ {open && anchorRect && createPortal(
104
+ <div
105
+ ref={menuRef}
106
+ role="menu"
107
+ className="fixed w-44 rounded-md border bg-background p-1 shadow focus:outline-none z-[1000]"
108
+ style={{
109
+ top: direction === 'down' ? anchorRect.bottom + 8 : anchorRect.top - 8,
110
+ left: anchorRect.right,
111
+ transform: `translate(-100%, ${direction === 'down' ? '0' : '-100%'})`,
112
+ }}
113
+ onMouseEnter={handleMouseEnter}
114
+ onMouseLeave={handleMouseLeave}
115
+ >
116
+ {items.map((it, idx) => (
117
+ it.href ? (
118
+ <a
119
+ key={idx}
120
+ href={it.href}
121
+ className={`block w-full text-left px-2 py-1 text-sm rounded hover:bg-accent ${it.destructive ? 'text-red-600' : ''}`}
122
+ role="menuitem"
123
+ onClick={(event) => {
124
+ event.stopPropagation()
125
+ setOpen(false)
126
+ }}
127
+ >
128
+ {it.label}
129
+ </a>
130
+ ) : (
131
+ <button
132
+ key={idx}
133
+ type="button"
134
+ className={`block w-full text-left px-2 py-1 text-sm rounded hover:bg-accent ${it.destructive ? 'text-red-600' : ''}`}
135
+ role="menuitem"
136
+ onClick={(event) => {
137
+ event.stopPropagation()
138
+ setOpen(false)
139
+ it.onSelect?.()
140
+ }}
141
+ >
142
+ {it.label}
143
+ </button>
144
+ )
145
+ ))}
146
+ </div>,
147
+ document.body
148
+ )}
149
+ </div>
150
+ )
151
+ }
@@ -0,0 +1,133 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { SimpleTooltip } from '../primitives/tooltip'
5
+ import { cn } from '@open-mercato/shared/lib/utils'
6
+
7
+ export type TruncatedCellProps = {
8
+ children: React.ReactNode
9
+ /** Maximum width for the cell content. Can be a Tailwind class (e.g., 'max-w-[200px]') or CSS value */
10
+ maxWidth?: string
11
+ /** Custom class name for the wrapper */
12
+ className?: string
13
+ /** Tooltip content - if not provided, will try to extract text from children */
14
+ tooltipContent?: React.ReactNode
15
+ /** Disable truncation and tooltip */
16
+ disabled?: boolean
17
+ }
18
+
19
+ /**
20
+ * Extracts text content from React nodes for tooltip display
21
+ */
22
+ function extractTextContent(node: React.ReactNode): string {
23
+ if (node == null) return ''
24
+ if (typeof node === 'string') return node
25
+ if (typeof node === 'number') return String(node)
26
+ if (typeof node === 'boolean') return ''
27
+ if (Array.isArray(node)) {
28
+ return node.map(extractTextContent).join('')
29
+ }
30
+ if (React.isValidElement(node)) {
31
+ // Handle React elements - extract text from props.children
32
+ const props = node.props as Record<string, unknown>
33
+ if (props) {
34
+ // First try children
35
+ if (props.children != null) {
36
+ const childText = extractTextContent(props.children as React.ReactNode)
37
+ if (childText) return childText
38
+ }
39
+ // Try common text props
40
+ if (typeof props.value === 'string') return props.value
41
+ if (typeof props.label === 'string') return props.label
42
+ if (typeof props.title === 'string') return props.title
43
+ }
44
+ }
45
+ // Try to convert to string as last resort
46
+ if (node && typeof node === 'object' && 'toString' in node) {
47
+ const str = String(node)
48
+ if (str !== '[object Object]') return str
49
+ }
50
+ return ''
51
+ }
52
+
53
+ /**
54
+ * A cell wrapper that truncates content and shows a tooltip on hover
55
+ * only when the content is wider than the available space.
56
+ *
57
+ * @example
58
+ * <TruncatedCell maxWidth="max-w-[200px]">
59
+ * <span>This is a very long text that will be truncated</span>
60
+ * </TruncatedCell>
61
+ */
62
+ export function TruncatedCell({
63
+ children,
64
+ maxWidth = 'max-w-[150px]',
65
+ className,
66
+ tooltipContent,
67
+ disabled = false,
68
+ }: TruncatedCellProps) {
69
+ const contentRef = React.useRef<HTMLDivElement>(null)
70
+ const [isTruncated, setIsTruncated] = React.useState(false)
71
+
72
+ // Get tooltip content - prefer explicit tooltipContent, fall back to extracting from children
73
+ const resolvedTooltipContent = tooltipContent ?? extractTextContent(children)
74
+
75
+ // Check if content is truncated after render and on resize
76
+ React.useEffect(() => {
77
+ const checkTruncation = () => {
78
+ const el = contentRef.current
79
+ if (el) {
80
+ setIsTruncated(el.scrollWidth > el.clientWidth)
81
+ }
82
+ }
83
+
84
+ // Check on mount
85
+ checkTruncation()
86
+
87
+ // Use ResizeObserver to detect size changes
88
+ const el = contentRef.current
89
+ if (el) {
90
+ const observer = new ResizeObserver(checkTruncation)
91
+ observer.observe(el)
92
+ return () => observer.disconnect()
93
+ }
94
+ }, [children, maxWidth])
95
+
96
+ if (disabled) {
97
+ return <>{children}</>
98
+ }
99
+
100
+ // Determine if maxWidth is a Tailwind class or a CSS value
101
+ const isTailwindClass = maxWidth.startsWith('max-w-')
102
+ const styleMaxWidth = isTailwindClass ? undefined : maxWidth
103
+ const classMaxWidth = isTailwindClass ? maxWidth : ''
104
+
105
+ const content = (
106
+ <div
107
+ ref={contentRef}
108
+ className={cn(
109
+ 'overflow-hidden text-ellipsis whitespace-nowrap',
110
+ classMaxWidth,
111
+ className
112
+ )}
113
+ style={styleMaxWidth ? { maxWidth: styleMaxWidth } : undefined}
114
+ >
115
+ {children}
116
+ </div>
117
+ )
118
+
119
+ // Only show tooltip when content is actually truncated
120
+ if (!resolvedTooltipContent || !isTruncated) {
121
+ return content
122
+ }
123
+
124
+ return (
125
+ <SimpleTooltip
126
+ content={resolvedTooltipContent}
127
+ side="top"
128
+ delayDuration={300}
129
+ >
130
+ {content}
131
+ </SimpleTooltip>
132
+ )
133
+ }