@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,35 @@
1
+ import { collectCustomFieldValues } from '../customFieldValues'
2
+
3
+ describe('collectCustomFieldValues', () => {
4
+ it('strips cf_ prefix by default', () => {
5
+ const input = { cf_name: 'Alice', cf_age: 30, email: 'alice@example.com' }
6
+ expect(collectCustomFieldValues(input)).toEqual({ name: 'Alice', age: 30 })
7
+ })
8
+
9
+ it('handles cf: prefix and keeps both by default', () => {
10
+ const input = { 'cf:city': 'Berlin', cf_country: 'DE' }
11
+ expect(collectCustomFieldValues(input)).toEqual({ city: 'Berlin', country: 'DE' })
12
+ })
13
+
14
+ it('applies transform and accept hooks', () => {
15
+ const input = { cf_name: 'Alice', cf_notes: '', cf_skip: 'value' }
16
+ const result = collectCustomFieldValues(input, {
17
+ transform: (value) => (typeof value === 'string' ? value.trim() : value),
18
+ accept: (fieldId) => fieldId !== 'skip',
19
+ })
20
+ expect(result).toEqual({ name: 'Alice', notes: '' })
21
+ })
22
+
23
+ it('retains prefix when stripPrefix is false', () => {
24
+ const input = { cf_name: 'Alice' }
25
+ expect(collectCustomFieldValues(input, { stripPrefix: false })).toEqual({ cf_name: 'Alice' })
26
+ })
27
+
28
+ it('omits undefined results when requested', () => {
29
+ const input = { cf_name: 'Alice', cf_age: 30, cf_extra: null }
30
+ const result = collectCustomFieldValues(input, {
31
+ transform: (value, key) => (key === 'extra' ? undefined : value),
32
+ })
33
+ expect(result).toEqual({ name: 'Alice', age: 30 })
34
+ })
35
+ })
@@ -0,0 +1,149 @@
1
+ "use client"
2
+ // Simple fetch wrapper that redirects to session refresh on 401 (Unauthorized)
3
+ // Used across UI data utilities to avoid duplication.
4
+ import { flash } from '../FlashMessages'
5
+ import { deserializeOperationMetadata } from '@open-mercato/shared/lib/commands/operationMetadata'
6
+ import { pushOperation } from '../operations/store'
7
+ import { pushPartialIndexWarning } from '../indexes/store'
8
+ export class UnauthorizedError extends Error {
9
+ readonly status = 401
10
+ constructor(message = 'Unauthorized') {
11
+ super(message)
12
+ this.name = 'UnauthorizedError'
13
+ }
14
+ }
15
+
16
+ export function redirectToSessionRefresh() {
17
+ if (typeof window === 'undefined') return
18
+ const current = window.location.pathname + window.location.search
19
+ // Avoid redirect loops if already on an auth/session route
20
+ if (window.location.pathname.startsWith('/api/auth')) return
21
+ try {
22
+ flash('Session expired. Redirecting to sign in…', 'warning')
23
+ setTimeout(() => {
24
+ window.location.href = `/api/auth/session/refresh?redirect=${encodeURIComponent(current)}`
25
+ }, 20)
26
+ } catch {
27
+ // no-op
28
+ }
29
+ }
30
+
31
+ export class ForbiddenError extends Error {
32
+ readonly status = 403
33
+ constructor(message = 'Forbidden') {
34
+ super(message)
35
+ this.name = 'ForbiddenError'
36
+ }
37
+ }
38
+
39
+ let DEFAULT_FORBIDDEN_ROLES: string[] = ['admin']
40
+
41
+ export function setAuthRedirectConfig(cfg: { defaultForbiddenRoles?: readonly string[] }) {
42
+ if (cfg?.defaultForbiddenRoles && cfg.defaultForbiddenRoles.length) {
43
+ DEFAULT_FORBIDDEN_ROLES = [...cfg.defaultForbiddenRoles].map(String)
44
+ }
45
+ }
46
+
47
+ export function redirectToForbiddenLogin(options?: { requiredRoles?: string[] | null; requiredFeatures?: string[] | null }) {
48
+ if (typeof window === 'undefined') return
49
+ // We don't know required roles from the API response; use a generic hint.
50
+ if (window.location.pathname.startsWith('/login')) return
51
+ try {
52
+ const current = window.location.pathname + window.location.search
53
+ const features = options?.requiredFeatures?.filter(Boolean) ?? []
54
+ const roles = options?.requiredRoles?.filter(Boolean) ?? []
55
+ const query = features.length
56
+ ? `requireFeature=${encodeURIComponent(features.join(','))}`
57
+ : `requireRole=${encodeURIComponent((roles.length ? roles : DEFAULT_FORBIDDEN_ROLES).map(String).join(','))}`
58
+ const url = `/login?${query}&redirect=${encodeURIComponent(current)}`
59
+ flash('Insufficient permissions. Redirecting to login…', 'warning')
60
+ setTimeout(() => { window.location.href = url }, 60)
61
+ } catch {
62
+ // no-op
63
+ }
64
+ }
65
+
66
+ export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
67
+ type FetchType = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
68
+ const baseFetch: FetchType = (typeof window !== 'undefined' && (window as any).__omOriginalFetch)
69
+ ? ((window as any).__omOriginalFetch as FetchType)
70
+ : fetch;
71
+ const res = await baseFetch(input, init);
72
+ const onLoginPage = typeof window !== 'undefined' && window.location.pathname.startsWith('/login')
73
+ if (res.status === 401) {
74
+ // Trigger same redirect flow as protected pages
75
+ if (!onLoginPage) {
76
+ redirectToSessionRefresh()
77
+ // Throw a typed error for callers that might still handle it
78
+ throw new UnauthorizedError(await res.text().catch(() => 'Unauthorized'))
79
+ }
80
+ return res
81
+ }
82
+ if (res.status === 403) {
83
+ // Try to read requiredRoles from JSON body; ignore if not JSON
84
+ let roles: string[] | null = null
85
+ let features: string[] | null = null
86
+ let payload: unknown = null
87
+ try {
88
+ const clone = res.clone()
89
+ const data = await clone.json()
90
+ if (Array.isArray(data?.requiredRoles)) roles = data.requiredRoles.map((r: any) => String(r))
91
+ if (Array.isArray(data?.requiredFeatures)) features = data.requiredFeatures.map((f: any) => String(f))
92
+ if (data && typeof data === 'object') payload = data
93
+ } catch {}
94
+ // Only redirect if not already on login page
95
+ if (!onLoginPage) {
96
+ const target =
97
+ typeof input === 'string'
98
+ ? input
99
+ : input instanceof URL
100
+ ? input.toString()
101
+ : (typeof Request !== 'undefined' && input instanceof Request)
102
+ ? input.url
103
+ : 'unknown'
104
+ try {
105
+ // eslint-disable-next-line no-console
106
+ console.warn('[apiFetch] Forbidden response', {
107
+ url: target,
108
+ status: res.status,
109
+ requiredRoles: roles,
110
+ requiredFeatures: features,
111
+ details: payload,
112
+ })
113
+ } catch {}
114
+ redirectToForbiddenLogin({ requiredRoles: roles, requiredFeatures: features })
115
+ const msg = await res.text().catch(() => 'Forbidden')
116
+ throw new ForbiddenError(msg)
117
+ }
118
+ // If already on login, just return the response for the caller to handle
119
+ }
120
+ try {
121
+ const header = res.headers.get('x-om-operation')
122
+ const metadata = deserializeOperationMetadata(header)
123
+ if (metadata) pushOperation(metadata)
124
+ } catch {
125
+ // ignore malformed headers
126
+ }
127
+ try {
128
+ const warningRaw = res.headers.get('x-om-partial-index')
129
+ if (warningRaw) {
130
+ const parsed = JSON.parse(warningRaw) as Record<string, unknown>
131
+ if (parsed && typeof parsed === 'object' && parsed.type === 'partial_index') {
132
+ const entity = typeof parsed.entity === 'string' ? parsed.entity : String(parsed.entity ?? '')
133
+ if (entity) {
134
+ const baseCount = typeof parsed.baseCount === 'number' ? parsed.baseCount : null
135
+ const indexedCount = typeof parsed.indexedCount === 'number' ? parsed.indexedCount : null
136
+ const scope = parsed.scope === 'global' ? 'global' : 'scoped'
137
+ const entityLabel =
138
+ typeof parsed.entityLabel === 'string' && parsed.entityLabel.trim()
139
+ ? parsed.entityLabel.trim()
140
+ : entity
141
+ pushPartialIndexWarning({ entity, entityLabel, baseCount, indexedCount, scope })
142
+ }
143
+ }
144
+ }
145
+ } catch {
146
+ // ignore malformed headers
147
+ }
148
+ return res
149
+ }
@@ -0,0 +1,96 @@
1
+ "use client"
2
+
3
+ import { apiFetch } from './api'
4
+ import { raiseCrudError, readJsonSafe } from './serverErrors'
5
+
6
+ export type ApiCallOptions<TReturn> = {
7
+ parse?: (res: Response) => Promise<TReturn | null>
8
+ fallback?: TReturn | null
9
+ }
10
+
11
+ export type ApiCallResult<TReturn> = {
12
+ ok: boolean
13
+ status: number
14
+ result: TReturn | null
15
+ response: Response
16
+ cacheStatus: 'hit' | 'miss' | null
17
+ }
18
+
19
+ export async function apiCall<TReturn = Record<string, unknown>>(
20
+ input: RequestInfo | URL,
21
+ init?: RequestInit,
22
+ options?: ApiCallOptions<TReturn>,
23
+ ): Promise<ApiCallResult<TReturn>> {
24
+ const response = await apiFetch(input, init)
25
+ const parser = options?.parse
26
+ const fallback = options?.fallback ?? null
27
+ let result: TReturn | null = null
28
+ const rawCacheStatus =
29
+ response.headers?.get?.('x-om-cache') ??
30
+ response.headers?.get?.('x-cache-status') ??
31
+ null
32
+ const cacheStatus = rawCacheStatus === 'hit' || rawCacheStatus === 'miss' ? rawCacheStatus : null
33
+ try {
34
+ const source = typeof (response as Response & { clone?: () => Response }).clone === 'function'
35
+ ? response.clone()
36
+ : response
37
+ if (parser) result = await parser(source)
38
+ else result = await readJsonSafe<TReturn>(source, fallback)
39
+ } catch {
40
+ result = fallback
41
+ }
42
+ return {
43
+ ok: response.ok,
44
+ status: response.status,
45
+ result,
46
+ response,
47
+ cacheStatus,
48
+ }
49
+ }
50
+
51
+ export type ApiCallOrThrowOptions<TReturn> = ApiCallOptions<TReturn> & {
52
+ errorMessage?: string
53
+ }
54
+
55
+ export async function apiCallOrThrow<TReturn = Record<string, unknown>>(
56
+ input: RequestInfo | URL,
57
+ init?: RequestInit,
58
+ options?: ApiCallOrThrowOptions<TReturn>,
59
+ ): Promise<ApiCallResult<TReturn>> {
60
+ const { errorMessage, ...callOptions } = options ?? {}
61
+ const call = await apiCall<TReturn>(input, init, callOptions)
62
+ if (!call.ok) {
63
+ await raiseCrudError(call.response, errorMessage)
64
+ }
65
+ return call
66
+ }
67
+
68
+ export type ReadApiResultOrThrowOptions<TReturn> = ApiCallOrThrowOptions<TReturn> & {
69
+ allowNullResult?: boolean
70
+ emptyResultMessage?: string
71
+ }
72
+
73
+ export async function readApiResultOrThrow<TReturn = Record<string, unknown>>(
74
+ input: RequestInfo | URL,
75
+ init?: RequestInit,
76
+ options?: ReadApiResultOrThrowOptions<TReturn> & { allowNullResult?: false },
77
+ ): Promise<TReturn>
78
+ export async function readApiResultOrThrow<TReturn = Record<string, unknown>>(
79
+ input: RequestInfo | URL,
80
+ init: RequestInit | undefined,
81
+ options: ReadApiResultOrThrowOptions<TReturn> & { allowNullResult: true },
82
+ ): Promise<TReturn | null>
83
+ export async function readApiResultOrThrow<TReturn = Record<string, unknown>>(
84
+ input: RequestInfo | URL,
85
+ init?: RequestInit,
86
+ options?: ReadApiResultOrThrowOptions<TReturn>,
87
+ ): Promise<TReturn | null> {
88
+ const { allowNullResult = false, emptyResultMessage, ...callOptions } = options ?? {}
89
+ const call = await apiCallOrThrow<TReturn>(input, init, callOptions)
90
+ if (call.result == null && !allowNullResult) {
91
+ const fallback =
92
+ emptyResultMessage ?? callOptions.errorMessage ?? `Missing response payload (${call.status})`
93
+ throw new Error(fallback)
94
+ }
95
+ return call.result
96
+ }
@@ -0,0 +1,174 @@
1
+ export type SortDir = 'asc' | 'desc'
2
+
3
+ export type ListResponse<T> = {
4
+ items: T[]
5
+ total: number
6
+ page: number
7
+ pageSize: number
8
+ totalPages: number
9
+ }
10
+
11
+ export type CrudExportFormat = 'csv' | 'json' | 'xml' | 'markdown'
12
+
13
+ function toQuery(params: Record<string, any>) {
14
+ const sp = new URLSearchParams()
15
+ for (const [k, v] of Object.entries(params)) {
16
+ if (v === undefined || v === null) continue
17
+ if (Array.isArray(v)) {
18
+ if (v.length === 0) continue
19
+ sp.set(k, v.join(','))
20
+ } else {
21
+ sp.set(k, String(v))
22
+ }
23
+ }
24
+ return sp.toString()
25
+ }
26
+
27
+ export function buildCrudQuery(params: Record<string, any>): string {
28
+ return toQuery(params)
29
+ }
30
+
31
+ import { apiCall, readApiResultOrThrow, type ApiCallResult } from './apiCall'
32
+ import { raiseCrudError } from './serverErrors'
33
+
34
+ function mergeHeaders(base: HeadersInit | undefined, extra: Record<string, string>): HeadersInit {
35
+ if (!base) return extra
36
+ const hasHeadersCtor = typeof Headers !== 'undefined'
37
+ if (hasHeadersCtor && base instanceof Headers) {
38
+ const merged = new Headers(base)
39
+ Object.entries(extra).forEach(([key, value]) => merged.set(key, value))
40
+ return merged
41
+ }
42
+ if (Array.isArray(base)) {
43
+ return [...base, ...Object.entries(extra)]
44
+ }
45
+ return { ...(base as Record<string, string>), ...extra }
46
+ }
47
+
48
+ type CrudRequestExtras<TReturn> = {
49
+ parseResult?: (res: Response) => Promise<TReturn | null>
50
+ fallbackResult?: TReturn | null
51
+ errorMessage?: string
52
+ }
53
+
54
+ export type CrudRequestInit<TReturn> = Omit<RequestInit, 'body' | 'method'> & CrudRequestExtras<TReturn>
55
+ type CrudDeleteOptions<TReturn> = Omit<RequestInit, 'method' | 'body'> &
56
+ CrudRequestExtras<TReturn> & {
57
+ body?: unknown
58
+ id?: string
59
+ }
60
+
61
+ export type CrudResponse<TReturn> = ApiCallResult<TReturn>
62
+
63
+ export async function fetchCrudList<T>(apiPath: string, params: Record<string, any>, init?: RequestInit): Promise<ListResponse<T>> {
64
+ const qs = buildCrudQuery(params)
65
+ return readApiResultOrThrow<ListResponse<T>>(`/api/${apiPath}?${qs}`, init, {
66
+ errorMessage: 'Failed to fetch list',
67
+ })
68
+ }
69
+
70
+ export function buildCrudExportUrl(apiPath: string, params: Record<string, any>, format: CrudExportFormat): string {
71
+ const qs = buildCrudQuery({ ...params, format })
72
+ return `/api/${apiPath}?${qs}`
73
+ }
74
+
75
+ export function buildCrudCsvUrl(apiPath: string, params: Record<string, any>): string {
76
+ return buildCrudExportUrl(apiPath, params, 'csv')
77
+ }
78
+
79
+ export async function createCrud<TReturn = Record<string, unknown>>(
80
+ apiPath: string,
81
+ body: any,
82
+ init?: CrudRequestInit<TReturn>,
83
+ ): Promise<CrudResponse<TReturn>> {
84
+ const { parseResult, fallbackResult, errorMessage, headers, ...rest } = init ?? {}
85
+ const call = await apiCall<TReturn>(
86
+ `/api/${apiPath}`,
87
+ {
88
+ ...rest,
89
+ method: 'POST',
90
+ headers: mergeHeaders(headers, { 'content-type': 'application/json' }),
91
+ body: JSON.stringify(body),
92
+ },
93
+ {
94
+ parse: parseResult,
95
+ fallback: fallbackResult ?? null,
96
+ },
97
+ )
98
+ if (!call.ok) await raiseCrudError(call.response, errorMessage ?? 'Failed to create')
99
+ return call
100
+ }
101
+
102
+ export async function updateCrud<TReturn = Record<string, unknown>>(
103
+ apiPath: string,
104
+ body: any,
105
+ init?: CrudRequestInit<TReturn>,
106
+ ): Promise<CrudResponse<TReturn>> {
107
+ const { parseResult, fallbackResult, errorMessage, headers, ...rest } = init ?? {}
108
+ const call = await apiCall<TReturn>(
109
+ `/api/${apiPath}`,
110
+ {
111
+ ...rest,
112
+ method: 'PUT',
113
+ headers: mergeHeaders(headers, { 'content-type': 'application/json' }),
114
+ body: JSON.stringify(body),
115
+ },
116
+ {
117
+ parse: parseResult,
118
+ fallback: fallbackResult ?? null,
119
+ },
120
+ )
121
+ if (!call.ok) await raiseCrudError(call.response, errorMessage ?? 'Failed to update')
122
+ return call
123
+ }
124
+
125
+ export async function deleteCrud<TReturn = Record<string, unknown>>(
126
+ apiPath: string,
127
+ id: string,
128
+ init?: CrudRequestInit<TReturn>,
129
+ ): Promise<CrudResponse<TReturn>>
130
+ export async function deleteCrud<TReturn = Record<string, unknown>>(
131
+ apiPath: string,
132
+ options: CrudDeleteOptions<TReturn>,
133
+ ): Promise<CrudResponse<TReturn>>
134
+ export async function deleteCrud<TReturn = Record<string, unknown>>(
135
+ apiPath: string,
136
+ idOrOptions: string | CrudDeleteOptions<TReturn>,
137
+ maybeInit?: CrudRequestInit<TReturn>,
138
+ ): Promise<CrudResponse<TReturn>> {
139
+ if (typeof idOrOptions === 'string') {
140
+ const { parseResult, fallbackResult, errorMessage, ...rest } = maybeInit ?? {}
141
+ const call = await apiCall<TReturn>(
142
+ `/api/${apiPath}?id=${encodeURIComponent(idOrOptions)}`,
143
+ {
144
+ ...rest,
145
+ method: 'DELETE',
146
+ },
147
+ {
148
+ parse: parseResult,
149
+ fallback: fallbackResult ?? null,
150
+ },
151
+ )
152
+ if (!call.ok) await raiseCrudError(call.response, errorMessage ?? 'Failed to delete')
153
+ return call
154
+ }
155
+ const { parseResult, fallbackResult, errorMessage, headers, body, id, ...rest } = idOrOptions
156
+ const payload = body ?? (id ? { id } : undefined)
157
+ const requestHeaders =
158
+ payload !== undefined ? mergeHeaders(headers, { 'content-type': 'application/json' }) : headers
159
+ const call = await apiCall<TReturn>(
160
+ `/api/${apiPath}`,
161
+ {
162
+ ...rest,
163
+ method: 'DELETE',
164
+ headers: requestHeaders,
165
+ body: payload !== undefined ? JSON.stringify(payload) : undefined,
166
+ },
167
+ {
168
+ parse: parseResult,
169
+ fallback: fallbackResult ?? null,
170
+ },
171
+ )
172
+ if (!call.ok) await raiseCrudError(call.response, errorMessage ?? 'Failed to delete')
173
+ return call
174
+ }
@@ -0,0 +1,71 @@
1
+ import type { ColumnDef } from '@tanstack/react-table'
2
+ import type { CustomFieldDefDto, CustomFieldVisibility } from './customFieldDefs'
3
+ import { isDefVisible } from './customFieldDefs'
4
+
5
+ // Filters and annotates columns with custom-field definitions:
6
+ // - Drops cf_* columns when no definition exists or listVisible === false
7
+ // - Uses definition label as header when header is missing
8
+ export function applyCustomFieldVisibility<T>(columns: ColumnDef<T, any>[], defs: CustomFieldDefDto[], mode: CustomFieldVisibility = 'list'): ColumnDef<T, any>[] {
9
+ const byKey = new Map(defs.map((d) => [d.key, d]))
10
+ // First, filter and annotate headers
11
+ const filtered = columns.filter((c) => {
12
+ const key = String((c as any).accessorKey || '')
13
+ if (!key.startsWith('cf_')) return true
14
+ const cfKey = key.slice(3)
15
+ const def = byKey.get(cfKey)
16
+ if (!def) return false
17
+ if (!isDefVisible(def, mode)) return false
18
+ const currentHeader = (c as any).header
19
+ const fallbackHeader = typeof currentHeader === 'string' && currentHeader.trim().length ? currentHeader : key
20
+ const label = def.label && def.label.trim().length ? def.label : fallbackHeader
21
+ if (currentHeader == null || typeof currentHeader === 'string') {
22
+ (c as any).header = label
23
+ }
24
+ const existingMeta = ((c as any).meta || {}) as Record<string, unknown>
25
+ const nextMeta = Object.assign({}, existingMeta, { label })
26
+ ;(c as any).meta = nextMeta
27
+ return true
28
+ })
29
+
30
+ // Then, reorder only the cf_* columns by definition priority while preserving
31
+ // the positions of non-cf columns and the count of cf slots.
32
+ const cfEntries: Array<{ col: ColumnDef<T, any>; key: string; prio: number }> = []
33
+ filtered.forEach((c) => {
34
+ const key = String((c as any).accessorKey || '')
35
+ if (key.startsWith('cf_')) {
36
+ const cfKey = key.slice(3)
37
+ const def = byKey.get(cfKey)
38
+ cfEntries.push({ col: c, key: cfKey, prio: def?.priority ?? 0 })
39
+ }
40
+ })
41
+ cfEntries.sort((a, b) => a.prio - b.prio)
42
+ let cfIdx = 0
43
+ const result = filtered.map((c) => {
44
+ const key = String((c as any).accessorKey || '')
45
+ if (!key.startsWith('cf_')) return c
46
+ const next = cfEntries[cfIdx++]?.col ?? c
47
+ return next
48
+ })
49
+
50
+ // Append any missing cf columns (defs visible but not present in incoming columns)
51
+ const existingCfKeys = new Set<string>(result
52
+ .map((c) => String((c as any).accessorKey || ''))
53
+ .filter((k) => k.startsWith('cf_'))
54
+ .map((k) => k.slice(3)))
55
+
56
+ const visibleSorted = defs
57
+ .filter((d) => isDefVisible(d, mode))
58
+ .sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0))
59
+
60
+ const missing = visibleSorted.filter((d) => !existingCfKeys.has(d.key))
61
+ for (const d of missing) {
62
+ const col: ColumnDef<T, any> = {
63
+ accessorKey: `cf_${d.key}` as any,
64
+ header: d.label || `cf_${d.key}`,
65
+ // Respect responsive priority when provided; default leaves it visible
66
+ meta: { priority: (d as any).priority, label: d.label || `cf_${d.key}` } as any,
67
+ }
68
+ result.push(col)
69
+ }
70
+ return result
71
+ }