@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,1292 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { Cog, GripVertical, Pencil, Plus, Trash2 } from 'lucide-react'
5
+ import { CUSTOM_FIELD_KINDS } from '@open-mercato/shared/modules/entities/kinds'
6
+ import { FieldRegistry } from '../fields/registry'
7
+ import { slugify } from '@open-mercato/shared/lib/slugify'
8
+ import {
9
+ Dialog,
10
+ DialogContent,
11
+ DialogDescription,
12
+ DialogFooter,
13
+ DialogHeader,
14
+ DialogTitle,
15
+ } from '../../primitives/dialog'
16
+ import {
17
+ normalizeCustomFieldOptions,
18
+ type CustomFieldOptionDto,
19
+ } from '@open-mercato/shared/modules/entities/options'
20
+
21
+ type FieldsetGroup = { code: string; title?: string; hint?: string }
22
+ type FieldsetConfig = { code: string; label: string; icon?: string; description?: string; groups?: FieldsetGroup[] }
23
+
24
+ export type FieldDefinition = {
25
+ key: string
26
+ kind: string
27
+ configJson?: Record<string, unknown>
28
+ isActive?: boolean
29
+ }
30
+
31
+ export type FieldDefinitionError = { key?: string; kind?: string }
32
+
33
+ export type FieldDefinitionsEditorProps = {
34
+ definitions: FieldDefinition[]
35
+ errors?: Record<number, FieldDefinitionError>
36
+ deletedKeys?: string[]
37
+ kindOptions?: Array<{ value: string; label: string }>
38
+ orderNotice?: { dirty: boolean; saving?: boolean; message?: string }
39
+ infoNote?: React.ReactNode
40
+ addButtonLabel?: string
41
+ fieldsets?: FieldsetConfig[]
42
+ activeFieldset?: string | null
43
+ onActiveFieldsetChange?: (code: string | null) => void
44
+ onFieldsetsChange?: (next: FieldsetConfig[]) => void
45
+ onFieldsetCodeChange?: (previousCode: string, nextCode: string) => void
46
+ onFieldsetRemoved?: (code: string) => void
47
+ onAddField: () => void
48
+ onRemoveField: (index: number) => void
49
+ onDefinitionChange: (index: number, next: FieldDefinition) => void
50
+ onRestoreField?: (key: string) => void
51
+ onReorder?: (from: number, to: number) => void
52
+ listRef?: React.Ref<HTMLDivElement>
53
+ listProps?: React.HTMLAttributes<HTMLDivElement>
54
+ singleFieldsetPerRecord?: boolean
55
+ onSingleFieldsetPerRecordChange?: (value: boolean) => void
56
+ translate?: (key: string, fallback: string) => string
57
+ }
58
+
59
+ const DEFAULT_KIND_OPTIONS = CUSTOM_FIELD_KINDS.map((k) => ({
60
+ value: k,
61
+ label: k.charAt(0).toUpperCase() + k.slice(1),
62
+ }))
63
+
64
+ const FIELDSET_ICON_OPTIONS = [
65
+ { value: 'layers', label: 'Layers' },
66
+ { value: 'tag', label: 'Tag' },
67
+ { value: 'sparkles', label: 'Sparkles' },
68
+ { value: 'package', label: 'Package' },
69
+ { value: 'shirt', label: 'Shirt' },
70
+ { value: 'grid', label: 'Grid' },
71
+ { value: 'shoppingBag', label: 'Shopping bag' },
72
+ { value: 'shoppingCart', label: 'Shopping cart' },
73
+ { value: 'store', label: 'Store' },
74
+ { value: 'users', label: 'Users' },
75
+ { value: 'briefcase', label: 'Briefcase' },
76
+ { value: 'building', label: 'Building' },
77
+ { value: 'bookOpen', label: 'Book open' },
78
+ { value: 'bookmark', label: 'Bookmark' },
79
+ { value: 'camera', label: 'Camera' },
80
+ { value: 'car', label: 'Car' },
81
+ { value: 'clock', label: 'Clock' },
82
+ { value: 'cloud', label: 'Cloud' },
83
+ { value: 'compass', label: 'Compass' },
84
+ { value: 'creditCard', label: 'Credit card' },
85
+ { value: 'database', label: 'Database' },
86
+ { value: 'flame', label: 'Flame' },
87
+ { value: 'gift', label: 'Gift' },
88
+ { value: 'globe', label: 'Globe' },
89
+ { value: 'heart', label: 'Heart' },
90
+ { value: 'key', label: 'Key' },
91
+ { value: 'map', label: 'Map' },
92
+ { value: 'palette', label: 'Palette' },
93
+ { value: 'shield', label: 'Shield' },
94
+ { value: 'star', label: 'Star' },
95
+ { value: 'truck', label: 'Truck' },
96
+ { value: 'zap', label: 'Zap' },
97
+ { value: 'coins', label: 'Coins' },
98
+ ]
99
+
100
+ function slugifyFieldsetCode(value: string): string {
101
+ return slugify(value, { replacement: '', allowedChars: '_-' })
102
+ }
103
+
104
+ function ensureUniqueFieldsetCode(base: string, existing: FieldsetConfig[]): string {
105
+ const sanitizedBase = slugifyFieldsetCode(base) || 'fieldset'
106
+ let candidate = sanitizedBase
107
+ let counter = 1
108
+ const existingCodes = new Set(existing.map((fs) => fs.code))
109
+ while (existingCodes.has(candidate)) {
110
+ counter += 1
111
+ candidate = `${sanitizedBase}_${counter}`
112
+ }
113
+ return candidate
114
+ }
115
+
116
+ function normalizeGroupValue(raw: unknown): FieldsetGroup | null {
117
+ if (!raw) return null
118
+ if (typeof raw === 'string') {
119
+ const code = raw.trim()
120
+ return code ? { code } : null
121
+ }
122
+ if (typeof raw !== 'object') return null
123
+ const entry = raw as Record<string, unknown>
124
+ const code = typeof entry.code === 'string' ? entry.code.trim() : ''
125
+ if (!code) return null
126
+ const group: FieldsetGroup = { code }
127
+ if (typeof entry.title === 'string' && entry.title.trim()) group.title = entry.title.trim()
128
+ if (typeof entry.hint === 'string' && entry.hint.trim()) group.hint = entry.hint.trim()
129
+ return group
130
+ }
131
+
132
+ export function FieldDefinitionsEditor({
133
+ definitions,
134
+ errors,
135
+ deletedKeys,
136
+ kindOptions = DEFAULT_KIND_OPTIONS,
137
+ orderNotice,
138
+ infoNote = (
139
+ <div className="text-xs text-muted-foreground mt-2">
140
+ Supported kinds: text, multiline, integer, float, boolean, select (with options/optionsUrl), currency (fixed currencies list), relation (with related entity and options URL).
141
+ </div>
142
+ ),
143
+ addButtonLabel = 'Add Field',
144
+ fieldsets = [],
145
+ activeFieldset = null,
146
+ onActiveFieldsetChange,
147
+ onFieldsetsChange,
148
+ onFieldsetCodeChange,
149
+ onFieldsetRemoved,
150
+ singleFieldsetPerRecord,
151
+ onSingleFieldsetPerRecordChange,
152
+ onAddField,
153
+ onRemoveField,
154
+ onDefinitionChange,
155
+ onRestoreField,
156
+ onReorder,
157
+ listRef,
158
+ listProps,
159
+ translate,
160
+ }: FieldDefinitionsEditorProps) {
161
+ const dragIndex = React.useRef<number | null>(null)
162
+ const hasFieldsets = fieldsets.length > 0
163
+ const t = React.useCallback((key: string, fallback: string) => (translate ? translate(key, fallback) : fallback), [translate])
164
+ const resolvedActiveFieldset = React.useMemo(() => {
165
+ if (!hasFieldsets) return activeFieldset ?? null
166
+ if (activeFieldset === null) return null
167
+ return fieldsets.some((fs) => fs.code === activeFieldset) ? activeFieldset : (fieldsets[0]?.code ?? null)
168
+ }, [activeFieldset, fieldsets, hasFieldsets])
169
+
170
+ const filteredDefinitions = React.useMemo(
171
+ () =>
172
+ definitions
173
+ .map((definition, index) => ({ definition, index }))
174
+ .filter(({ definition }) => {
175
+ if (!hasFieldsets) return true
176
+ const assigned = typeof definition.configJson?.fieldset === 'string' ? definition.configJson.fieldset : undefined
177
+ if (!resolvedActiveFieldset) return !assigned
178
+ return assigned === resolvedActiveFieldset
179
+ }),
180
+ [definitions, hasFieldsets, resolvedActiveFieldset],
181
+ )
182
+
183
+ const activeFieldsetConfig = hasFieldsets && resolvedActiveFieldset
184
+ ? fieldsets.find((fs) => fs.code === resolvedActiveFieldset) ?? null
185
+ : null
186
+
187
+ const handleActiveFieldsetChange = (value: string) => {
188
+ if (!onActiveFieldsetChange) return
189
+ onActiveFieldsetChange(value ? value : null)
190
+ }
191
+
192
+ const handleFieldsetPatch = (code: string, patch: Partial<FieldsetConfig>) => {
193
+ if (!onFieldsetsChange) return
194
+ const next = fieldsets.map((fs) => (fs.code === code ? { ...fs, ...patch } : fs))
195
+ onFieldsetsChange(next)
196
+ }
197
+
198
+ const handleFieldsetCodeInput = (code: string, nextValue: string) => {
199
+ if (!onFieldsetsChange) return
200
+ const target = fieldsets.find((fs) => fs.code === code)
201
+ if (!target) return
202
+ const sanitized = slugifyFieldsetCode(nextValue)
203
+ if (!sanitized) return
204
+ const next = fieldsets.map((fs) => (fs.code === code ? { ...fs, code: sanitized } : fs))
205
+ onFieldsetsChange(next)
206
+ onFieldsetCodeChange?.(code, sanitized)
207
+ onActiveFieldsetChange?.(sanitized)
208
+ }
209
+
210
+ const handleAddFieldset = () => {
211
+ if (!onFieldsetsChange) return
212
+ const code = ensureUniqueFieldsetCode(`fieldset_${fieldsets.length + 1}`, fieldsets)
213
+ const nextFieldsets = [...fieldsets, { code, label: 'New fieldset', icon: 'layers' }]
214
+ onFieldsetsChange(nextFieldsets)
215
+ onActiveFieldsetChange?.(code)
216
+ }
217
+
218
+ const handleRemoveFieldset = () => {
219
+ if (!onFieldsetsChange) return
220
+ if (!resolvedActiveFieldset) return
221
+ if (!window.confirm(`Delete fieldset "${resolvedActiveFieldset}"? This will move its fields to Unassigned.`)) return
222
+ const next = fieldsets.filter((fs) => fs.code !== resolvedActiveFieldset)
223
+ onFieldsetsChange(next)
224
+ onFieldsetRemoved?.(resolvedActiveFieldset)
225
+ const fallback = next[0]?.code ?? null
226
+ onActiveFieldsetChange?.(fallback)
227
+ }
228
+
229
+ const registerGroup = React.useCallback(
230
+ (fieldsetCode: string, group: FieldsetGroup) => {
231
+ if (!onFieldsetsChange || !fieldsetCode) return
232
+ const next = fieldsets.map((fs) => {
233
+ if (fs.code !== fieldsetCode) return fs
234
+ const list = Array.isArray(fs.groups) ? fs.groups : []
235
+ const existingIndex = list.findIndex((entry) => entry.code === group.code)
236
+ if (existingIndex >= 0) {
237
+ const updated = [...list]
238
+ updated[existingIndex] = { ...list[existingIndex], ...group }
239
+ return { ...fs, groups: updated }
240
+ }
241
+ return { ...fs, groups: [...list, group] }
242
+ })
243
+ onFieldsetsChange(next)
244
+ },
245
+ [fieldsets, onFieldsetsChange],
246
+ )
247
+ const removeGroup = React.useCallback(
248
+ (fieldsetCode: string, groupCode: string) => {
249
+ if (!onFieldsetsChange || !fieldsetCode || !groupCode) return
250
+ const next = fieldsets.map((fs) => {
251
+ if (fs.code !== fieldsetCode) return fs
252
+ const list = Array.isArray(fs.groups) ? fs.groups : []
253
+ return { ...fs, groups: list.filter((entry) => entry.code !== groupCode) }
254
+ })
255
+ onFieldsetsChange(next)
256
+ },
257
+ [fieldsets, onFieldsetsChange],
258
+ )
259
+ const availableGroups = activeFieldsetConfig?.groups ?? []
260
+ const canToggleSingleFieldset = hasFieldsets && fieldsets.length > 1
261
+ const singleFieldsetChecked = singleFieldsetPerRecord !== false
262
+
263
+ const handleReorder = React.useCallback(
264
+ (from: number, to: number) => {
265
+ if (from === to) return
266
+ onReorder?.(from, to)
267
+ },
268
+ [onReorder],
269
+ )
270
+
271
+ return (
272
+ <div
273
+ ref={listRef}
274
+ className="space-y-3"
275
+ {...listProps}
276
+ >
277
+ {hasFieldsets ? (
278
+ <div className="rounded border bg-card p-3 space-y-3">
279
+ <div className="flex flex-wrap items-center gap-2">
280
+ <label className="text-xs font-medium text-muted-foreground">Fieldset</label>
281
+ <select
282
+ className="border rounded px-2 py-1 text-sm"
283
+ value={resolvedActiveFieldset ?? ''}
284
+ onChange={(event) => handleActiveFieldsetChange(event.target.value)}
285
+ >
286
+ <option value="">Unassigned fields</option>
287
+ {fieldsets.map((fs) => (
288
+ <option key={fs.code} value={fs.code}>
289
+ {fs.label || fs.code}
290
+ </option>
291
+ ))}
292
+ </select>
293
+ <button
294
+ type="button"
295
+ onClick={handleAddFieldset}
296
+ className="px-2 py-1 border rounded hover:bg-muted inline-flex items-center gap-1 text-xs"
297
+ >
298
+ <Plus className="h-3.5 w-3.5" /> Add
299
+ </button>
300
+ <button
301
+ type="button"
302
+ onClick={handleRemoveFieldset}
303
+ disabled={!resolvedActiveFieldset}
304
+ className="px-2 py-1 border rounded hover:bg-muted inline-flex items-center gap-1 text-xs disabled:opacity-50"
305
+ >
306
+ <Trash2 className="h-3.5 w-3.5" /> Delete
307
+ </button>
308
+ </div>
309
+ {resolvedActiveFieldset && activeFieldsetConfig ? (
310
+ <div className="grid grid-cols-1 md:grid-cols-4 gap-3">
311
+ <div>
312
+ <label className="text-xs">Code</label>
313
+ <input
314
+ className="border rounded w-full px-2 py-1 text-sm font-mono"
315
+ value={activeFieldsetConfig.code}
316
+ onChange={(event) => handleFieldsetCodeInput(activeFieldsetConfig.code, event.target.value)}
317
+ />
318
+ </div>
319
+ <div>
320
+ <label className="text-xs">Label</label>
321
+ <input
322
+ className="border rounded w-full px-2 py-1 text-sm"
323
+ value={activeFieldsetConfig.label}
324
+ onChange={(event) => handleFieldsetPatch(activeFieldsetConfig.code, { label: event.target.value })}
325
+ />
326
+ </div>
327
+ <div>
328
+ <label className="text-xs">Icon</label>
329
+ <select
330
+ className="border rounded w-full px-2 py-1 text-sm"
331
+ value={activeFieldsetConfig.icon ?? ''}
332
+ onChange={(event) =>
333
+ handleFieldsetPatch(activeFieldsetConfig.code, {
334
+ icon: event.target.value || undefined,
335
+ })
336
+ }
337
+ >
338
+ <option value="">Default</option>
339
+ {FIELDSET_ICON_OPTIONS.map((option) => (
340
+ <option key={option.value} value={option.value}>
341
+ {option.label}
342
+ </option>
343
+ ))}
344
+ </select>
345
+ </div>
346
+ <div>
347
+ <label className="text-xs">Description</label>
348
+ <input
349
+ className="border rounded w-full px-2 py-1 text-sm"
350
+ value={activeFieldsetConfig.description ?? ''}
351
+ onChange={(event) => handleFieldsetPatch(activeFieldsetConfig.code, { description: event.target.value })}
352
+ />
353
+ </div>
354
+ </div>
355
+ ) : null}
356
+ <div className="flex items-center gap-2 text-xs">
357
+ <label className="inline-flex items-center gap-2">
358
+ <input
359
+ type="checkbox"
360
+ disabled={!canToggleSingleFieldset}
361
+ checked={singleFieldsetChecked}
362
+ onChange={(event) => onSingleFieldsetPerRecordChange?.(event.target.checked)}
363
+ />
364
+ Single fieldset per entity
365
+ </label>
366
+ {!canToggleSingleFieldset ? (
367
+ <span className="text-muted-foreground">(add at least two fieldsets to toggle)</span>
368
+ ) : null}
369
+ </div>
370
+ </div>
371
+ ) : (
372
+ <div className="rounded border border-dashed bg-muted/30 p-4 text-sm text-muted-foreground flex flex-col gap-3">
373
+ <div>No fieldsets defined yet. Fieldsets let you group custom fields for different variants of the same entity (e.g., Fashion vs. Sport products).</div>
374
+ <div>
375
+ <button
376
+ type="button"
377
+ onClick={handleAddFieldset}
378
+ className="px-3 py-1.5 border rounded bg-card text-sm font-medium inline-flex items-center gap-2"
379
+ >
380
+ <Plus className="h-4 w-4" />
381
+ Add first fieldset
382
+ </button>
383
+ </div>
384
+ </div>
385
+ )}
386
+ {orderNotice?.dirty && (
387
+ <div className="sticky top-0 z-10 -mt-1 -mb-1">
388
+ <div className="inline-flex items-center gap-2 text-xs px-2 py-1 rounded border bg-amber-50 text-amber-800 shadow-sm">
389
+ {orderNotice?.saving ? 'Saving order…' : (orderNotice?.message ?? 'Reordered — saving soon')}
390
+ </div>
391
+ </div>
392
+ )}
393
+ {filteredDefinitions.map(({ definition, index }) => {
394
+ const assignedFieldset = typeof definition.configJson?.fieldset === 'string' ? definition.configJson.fieldset : null
395
+ const groupOptions = assignedFieldset
396
+ ? fieldsets.find((fs) => fs.code === assignedFieldset)?.groups ?? []
397
+ : availableGroups
398
+ return (
399
+ <div
400
+ key={definition.key || `def-${index}`}
401
+ className="group"
402
+ draggable
403
+ onDragStart={() => { dragIndex.current = index }}
404
+ onDragOver={(event) => { event.preventDefault() }}
405
+ onDrop={() => {
406
+ const from = dragIndex.current
407
+ if (from == null) return
408
+ dragIndex.current = null
409
+ handleReorder(from, index)
410
+ }}
411
+ onDragEnd={() => { dragIndex.current = null }}
412
+ tabIndex={0}
413
+ onKeyDown={(event) => {
414
+ if (!event.altKey) return
415
+ if (event.key === 'ArrowUp' || event.key === 'Up') {
416
+ event.preventDefault()
417
+ handleReorder(index, Math.max(0, index - 1))
418
+ }
419
+ if (event.key === 'ArrowDown' || event.key === 'Down') {
420
+ event.preventDefault()
421
+ handleReorder(index, Math.min(definitions.length - 1, index + 1))
422
+ }
423
+ }}
424
+ >
425
+ <FieldDefinitionCard
426
+ definition={definition}
427
+ error={errors?.[index]}
428
+ kindOptions={kindOptions}
429
+ onChange={(next) => onDefinitionChange(index, next)}
430
+ onRemove={() => onRemoveField(index)}
431
+ allowFieldsetSelection={hasFieldsets}
432
+ fieldsets={fieldsets}
433
+ activeFieldset={resolvedActiveFieldset}
434
+ availableGroups={groupOptions}
435
+ onRegisterGroup={registerGroup}
436
+ onRemoveGroup={removeGroup}
437
+ translate={t}
438
+ />
439
+ </div>
440
+ )})}
441
+ <div>
442
+ <button
443
+ type="button"
444
+ onClick={onAddField}
445
+ className="px-3 py-1.5 text-sm border rounded hover:bg-muted inline-flex items-center gap-1"
446
+ >
447
+ <Plus className="h-4 w-4" /> {addButtonLabel}
448
+ </button>
449
+ {infoNote}
450
+ {deletedKeys && deletedKeys.length > 0 && onRestoreField ? (
451
+ <div className="text-xs text-muted-foreground mt-2">
452
+ Restore deleted fields:{' '}
453
+ {deletedKeys.map((key, idx) => (
454
+ <span key={key}>
455
+ <button
456
+ type="button"
457
+ className="underline hover:no-underline text-blue-600 disabled:opacity-50"
458
+ onClick={() => onRestoreField(key)}
459
+ >
460
+ {key}
461
+ </button>
462
+ {idx < deletedKeys.length - 1 ? ', ' : ''}
463
+ </span>
464
+ ))}
465
+ </div>
466
+ ) : null}
467
+ </div>
468
+ </div>
469
+ )
470
+ }
471
+
472
+ type FieldDefinitionCardProps = {
473
+ definition: FieldDefinition
474
+ error?: FieldDefinitionError
475
+ kindOptions: Array<{ value: string; label: string }>
476
+ onChange: (next: FieldDefinition) => void
477
+ onRemove: () => void
478
+ allowFieldsetSelection?: boolean
479
+ fieldsets?: FieldsetConfig[]
480
+ activeFieldset?: string | null
481
+ availableGroups?: FieldsetGroup[]
482
+ onRegisterGroup?: (fieldsetCode: string, group: FieldsetGroup) => void
483
+ onRemoveGroup?: (fieldsetCode: string, groupCode: string) => void
484
+ translate?: (key: string, fallback: string) => string
485
+ }
486
+
487
+ const FieldDefinitionCard = React.memo(function FieldDefinitionCard({
488
+ definition,
489
+ error,
490
+ kindOptions,
491
+ onChange,
492
+ onRemove,
493
+ allowFieldsetSelection = false,
494
+ fieldsets = [],
495
+ activeFieldset,
496
+ availableGroups = [],
497
+ onRegisterGroup,
498
+ onRemoveGroup,
499
+ translate,
500
+ }: FieldDefinitionCardProps) {
501
+ const [local, setLocal] = React.useState<FieldDefinition>(definition)
502
+ const [optionValueDraft, setOptionValueDraft] = React.useState('')
503
+ const [optionLabelDraft, setOptionLabelDraft] = React.useState('')
504
+ const [optionDialogOpen, setOptionDialogOpen] = React.useState(false)
505
+ const [optionFormError, setOptionFormError] = React.useState<string | null>(null)
506
+ const [groupDialogOpen, setGroupDialogOpen] = React.useState(false)
507
+ const [groupDraft, setGroupDraft] = React.useState({ code: '', title: '', hint: '' })
508
+ const [editingGroupCode, setEditingGroupCode] = React.useState<string | null>(null)
509
+ const [groupError, setGroupError] = React.useState<string | null>(null)
510
+ const currentFieldsetValue = React.useMemo(
511
+ () => (typeof local.configJson?.fieldset === 'string' ? local.configJson.fieldset : ''),
512
+ [local.configJson?.fieldset],
513
+ )
514
+ React.useEffect(() => { setLocal(definition) }, [definition.key])
515
+ React.useEffect(() => {
516
+ setOptionValueDraft('')
517
+ setOptionLabelDraft('')
518
+ setGroupDialogOpen(false)
519
+ setGroupDraft({ code: '', title: '', hint: '' })
520
+ setEditingGroupCode(null)
521
+ setGroupError(null)
522
+ }, [definition.key])
523
+ React.useEffect(() => {
524
+ if (!currentFieldsetValue) {
525
+ setGroupDialogOpen(false)
526
+ setGroupDraft({ code: '', title: '', hint: '' })
527
+ setEditingGroupCode(null)
528
+ setGroupError(null)
529
+ }
530
+ }, [currentFieldsetValue])
531
+ const currentGroup = React.useMemo(() => normalizeGroupValue(local.configJson?.group), [local])
532
+ const groupOptions = React.useMemo(() => {
533
+ const list = Array.isArray(availableGroups) ? [...availableGroups] : []
534
+ if (currentGroup && !list.some((entry) => entry.code === currentGroup.code)) {
535
+ list.push(currentGroup)
536
+ }
537
+ return list
538
+ }, [availableGroups, currentGroup])
539
+ const resolvedOptions = React.useMemo<CustomFieldOptionDto[]>(
540
+ () => normalizeCustomFieldOptions(local.configJson?.options),
541
+ [local.configJson?.options],
542
+ )
543
+
544
+ const sanitize = (def: FieldDefinition): FieldDefinition => {
545
+ if (!def.configJson || !Array.isArray(def.configJson.options)) return def
546
+ const normalizedOptions = normalizeCustomFieldOptions(def.configJson.options)
547
+ return {
548
+ ...def,
549
+ configJson: {
550
+ ...def.configJson,
551
+ options: normalizedOptions,
552
+ },
553
+ }
554
+ }
555
+
556
+ const apply = (patch: Partial<FieldDefinition> | ((current: FieldDefinition) => Partial<FieldDefinition>), propagateNow = false) => {
557
+ setLocal((prev) => {
558
+ const resolvedPatch = typeof patch === 'function' ? patch(prev) : patch
559
+ const next = { ...prev, ...resolvedPatch }
560
+ if (!propagateNow) return next
561
+ const sanitized = sanitize(next)
562
+ onChange(sanitized)
563
+ return sanitized
564
+ })
565
+ }
566
+
567
+ const commit = () => {
568
+ setLocal((prev) => {
569
+ const sanitized = sanitize(prev)
570
+ onChange(sanitized)
571
+ return sanitized
572
+ })
573
+ }
574
+
575
+ const handleFieldsetSelect = (value: string) => {
576
+ setLocal((prev) => {
577
+ const nextConfig = { ...(prev.configJson || {}) }
578
+ if (value) nextConfig.fieldset = value
579
+ else delete nextConfig.fieldset
580
+ delete nextConfig.group
581
+ const next = { ...prev, configJson: nextConfig }
582
+ onChange(next)
583
+ return next
584
+ })
585
+ }
586
+
587
+ const handleGroupSelect = (value: string) => {
588
+ if (!currentFieldsetValue) return
589
+ if (!value) {
590
+ const nextConfig = { ...(local.configJson || {}) }
591
+ delete nextConfig.group
592
+ apply({ configJson: nextConfig }, true)
593
+ return
594
+ }
595
+ const match = groupOptions.find((group) => group.code === value)
596
+ const nextGroup = match ?? { code: value }
597
+ const nextConfig = { ...(local.configJson || {}) }
598
+ nextConfig.group = nextGroup
599
+ apply({ configJson: nextConfig }, true)
600
+ onRegisterGroup?.(currentFieldsetValue, nextGroup)
601
+ }
602
+
603
+ const handleOpenGroupDialog = (group?: FieldsetGroup) => {
604
+ if (!currentFieldsetValue) return
605
+ if (group) {
606
+ setGroupDraft({
607
+ code: group.code,
608
+ title: group.title ?? '',
609
+ hint: group.hint ?? '',
610
+ })
611
+ setEditingGroupCode(group.code)
612
+ } else {
613
+ setGroupDraft({ code: '', title: '', hint: '' })
614
+ setEditingGroupCode(null)
615
+ }
616
+ setGroupError(null)
617
+ setGroupDialogOpen(true)
618
+ }
619
+
620
+ const handleGroupDialogSubmit = () => {
621
+ if (!currentFieldsetValue) return
622
+ const code = slugifyFieldsetCode(groupDraft.code || '')
623
+ if (!code) {
624
+ setGroupError('Group code is required.')
625
+ return
626
+ }
627
+ const group: FieldsetGroup = {
628
+ code,
629
+ title: groupDraft.title.trim() || undefined,
630
+ hint: groupDraft.hint.trim() || undefined,
631
+ }
632
+ onRegisterGroup?.(currentFieldsetValue, group)
633
+ const shouldAttachToField = !editingGroupCode || currentGroup?.code === editingGroupCode
634
+ if (shouldAttachToField) {
635
+ const nextConfig = { ...(local.configJson || {}) }
636
+ nextConfig.group = group
637
+ apply({ configJson: nextConfig }, true)
638
+ }
639
+ setGroupDraft({ code: '', title: '', hint: '' })
640
+ setEditingGroupCode(null)
641
+ setGroupDialogOpen(false)
642
+ }
643
+
644
+ const handleRemoveGroupEntry = (code: string) => {
645
+ if (!currentFieldsetValue) return
646
+ onRemoveGroup?.(currentFieldsetValue, code)
647
+ if (currentGroup?.code === code) {
648
+ handleGroupSelect('')
649
+ }
650
+ if (editingGroupCode === code) {
651
+ setGroupDraft({ code: '', title: '', hint: '' })
652
+ setEditingGroupCode(null)
653
+ }
654
+ }
655
+
656
+ const handleEditGroupEntry = (group: FieldsetGroup) => {
657
+ handleOpenGroupDialog(group)
658
+ }
659
+
660
+ const resetOptionDialog = () => {
661
+ setOptionValueDraft('')
662
+ setOptionLabelDraft('')
663
+ setOptionFormError(null)
664
+ }
665
+
666
+ const handleOpenOptionDialog = () => {
667
+ resetOptionDialog()
668
+ setOptionDialogOpen(true)
669
+ }
670
+
671
+ const handleCloseOptionDialog = () => {
672
+ resetOptionDialog()
673
+ setOptionDialogOpen(false)
674
+ }
675
+
676
+ const handleAddOption = () => {
677
+ const value = optionValueDraft.trim()
678
+ const label = optionLabelDraft.trim()
679
+ if (!value) {
680
+ setOptionFormError('Value is required')
681
+ return
682
+ }
683
+ setOptionFormError(null)
684
+ const nextOptions = Array.isArray(local.configJson?.options) ? [...local.configJson!.options] : []
685
+ nextOptions.push({ value, label: label || value })
686
+ apply({ configJson: { ...(local.configJson || {}), options: nextOptions } }, true)
687
+ handleCloseOptionDialog()
688
+ }
689
+
690
+ const handleRemoveOption = (index: number) => {
691
+ const nextOptions = Array.isArray(local.configJson?.options) ? [...local.configJson!.options] : []
692
+ nextOptions.splice(index, 1)
693
+ apply({ configJson: { ...(local.configJson || {}), options: nextOptions } }, true)
694
+ }
695
+
696
+ return (
697
+ <>
698
+ <div className="rounded border p-3 bg-card transition-colors hover:border-muted-foreground/60">
699
+ <div className="flex items-center justify-between">
700
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
701
+ <span className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-muted cursor-grab active:cursor-grabbing">
702
+ <GripVertical className="h-4 w-4 opacity-70" />
703
+ </span>
704
+ Drag to reorder
705
+ </div>
706
+ <div className="flex items-center gap-3">
707
+ <label className="inline-flex items-center gap-2 text-sm">
708
+ <input type="checkbox" checked={local.isActive !== false} onChange={(event) => { apply({ isActive: event.target.checked }, true) }} /> Active
709
+ </label>
710
+ <button type="button" onClick={onRemove} className="px-2 py-1 border rounded hover:bg-muted" aria-label="Remove field">
711
+ <Trash2 className="h-4 w-4" />
712
+ </button>
713
+ </div>
714
+ </div>
715
+
716
+ <div className="mt-3 grid grid-cols-1 md:grid-cols-12 gap-3 items-center">
717
+ <div className="md:col-span-6">
718
+ <label className="text-xs">Key</label>
719
+ <input
720
+ className={`rounded w-full px-2 py-1 text-sm font-mono ${error?.key ? 'border-red-500 border' : 'border'}`}
721
+ placeholder="snake_case"
722
+ value={local.key}
723
+ onChange={(event) => apply({ key: event.target.value })}
724
+ onBlur={commit}
725
+ />
726
+ {error?.key ? <div className="text-xs text-red-600 mt-1">{error.key}</div> : null}
727
+ </div>
728
+ <div className="md:col-span-6">
729
+ <label className="text-xs">Kind</label>
730
+ <select
731
+ className={`rounded w-full px-2 py-1 text-sm ${error?.kind ? 'border-red-500 border' : 'border'}`}
732
+ value={local.kind}
733
+ onChange={(event) => { apply({ kind: event.target.value }, true) }}
734
+ >
735
+ {kindOptions.map((option) => (
736
+ <option key={option.value} value={option.value}>
737
+ {option.label}
738
+ </option>
739
+ ))}
740
+ </select>
741
+ {error?.kind ? <div className="text-xs text-red-600 mt-1">{error.kind}</div> : null}
742
+ </div>
743
+ </div>
744
+
745
+ {allowFieldsetSelection ? (
746
+ <div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-3">
747
+ <div>
748
+ <label className="text-xs">Assign to fieldset</label>
749
+ <select
750
+ className="border rounded w-full px-2 py-1 text-sm"
751
+ value={currentFieldsetValue}
752
+ onChange={(event) => handleFieldsetSelect(event.target.value)}
753
+ >
754
+ <option value="">Unassigned</option>
755
+ {(fieldsets || []).map((fs) => (
756
+ <option key={fs.code} value={fs.code}>
757
+ {fs.label || fs.code}
758
+ </option>
759
+ ))}
760
+ </select>
761
+ </div>
762
+ {currentFieldsetValue ? (
763
+ <div>
764
+ <div className="flex items-center justify-between">
765
+ <label className="text-xs">Group</label>
766
+ </div>
767
+ <div className="flex items-center gap-2 mt-1">
768
+ <select
769
+ className="flex-1 border rounded px-2 py-1 text-sm"
770
+ value={currentGroup?.code ?? ''}
771
+ onChange={(event) => handleGroupSelect(event.target.value)}
772
+ >
773
+ <option value="">No group</option>
774
+ {groupOptions.map((group) => (
775
+ <option key={group.code} value={group.code}>
776
+ {group.title || group.code}
777
+ </option>
778
+ ))}
779
+ </select>
780
+ <button
781
+ type="button"
782
+ className="h-8 w-8 inline-flex items-center justify-center rounded border text-muted-foreground hover:bg-muted/40"
783
+ onClick={() => handleOpenGroupDialog()}
784
+ aria-label="Create group"
785
+ >
786
+ <Plus className="h-4 w-4" />
787
+ </button>
788
+ <button
789
+ type="button"
790
+ className="h-8 w-8 inline-flex items-center justify-center rounded border text-muted-foreground hover:bg-muted/40"
791
+ onClick={() => handleOpenGroupDialog()}
792
+ aria-label="Edit groups"
793
+ >
794
+ <Cog className="h-4 w-4" />
795
+ <span className="sr-only">Edit groups</span>
796
+ </button>
797
+ </div>
798
+ </div>
799
+ ) : null}
800
+ </div>
801
+ ) : null}
802
+
803
+ <div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-3">
804
+ <div>
805
+ <label className="text-xs">Label</label>
806
+ <input
807
+ className="border rounded w-full px-2 py-1 text-sm"
808
+ value={typeof local.configJson?.label === 'string' ? local.configJson.label : ''}
809
+ onChange={(event) => apply({ configJson: { ...(local.configJson || {}), label: event.target.value } })}
810
+ onBlur={commit}
811
+ />
812
+ </div>
813
+ <div>
814
+ <label className="text-xs">Description</label>
815
+ <input
816
+ className="border rounded w-full px-2 py-1 text-sm"
817
+ value={typeof local.configJson?.description === 'string' ? local.configJson.description : ''}
818
+ onChange={(event) => apply({ configJson: { ...(local.configJson || {}), description: event.target.value } })}
819
+ onBlur={commit}
820
+ />
821
+ </div>
822
+
823
+ {(local.kind === 'text' || local.kind === 'multiline') && (
824
+ <>
825
+ <div>
826
+ <label className="text-xs">Editor</label>
827
+ <select
828
+ className="border rounded w-full px-2 py-1 text-sm"
829
+ value={typeof local.configJson?.editor === 'string' ? local.configJson.editor : ''}
830
+ onChange={(event) => { apply({ configJson: { ...(local.configJson || {}), editor: event.target.value || undefined } }, true) }}
831
+ >
832
+ <option value="">Default</option>
833
+ <option value="markdown">Markdown (UIW)</option>
834
+ <option value="simpleMarkdown">Simple Markdown</option>
835
+ <option value="htmlRichText">HTML Rich Text</option>
836
+ </select>
837
+ </div>
838
+ {local.kind === 'text' && (
839
+ <>
840
+ <div className="md:col-span-2">
841
+ <label className="inline-flex items-center gap-2 text-xs">
842
+ <input type="checkbox" checked={!!local.configJson?.multi} onChange={(event) => { apply({ configJson: { ...(local.configJson || {}), multi: event.target.checked } }, true) }} /> Multiple
843
+ </label>
844
+ </div>
845
+ {!!local.configJson?.multi && (
846
+ <div className="md:col-span-2">
847
+ <label className="text-xs">Multi-select input style</label>
848
+ <select
849
+ className="border rounded w-full px-2 py-1 text-sm"
850
+ value={local.configJson?.input === 'listbox' ? 'listbox' : 'default'}
851
+ onChange={(event) => {
852
+ const { value } = event.target
853
+ const nextConfig = { ...(local.configJson || {}) }
854
+ if (value === 'listbox') nextConfig.input = 'listbox'
855
+ else delete nextConfig.input
856
+ apply({ configJson: nextConfig }, true)
857
+ }}
858
+ >
859
+ <option value="default">Default</option>
860
+ <option value="listbox">Listbox (searchable)</option>
861
+ </select>
862
+ </div>
863
+ )}
864
+ </>
865
+ )}
866
+ </>
867
+ )}
868
+
869
+ {local.kind === 'select' && (
870
+ <div className="md:col-span-6 space-y-3">
871
+ <label className="text-xs">Options</label>
872
+ <div className="space-y-2">
873
+ {resolvedOptions.length > 0 ? (
874
+ resolvedOptions.map((option, idx) => (
875
+ <div
876
+ key={`${option.value}-${idx}`}
877
+ className="flex items-center justify-between rounded border px-3 py-2 text-xs bg-muted"
878
+ >
879
+ <div>
880
+ <div className="font-medium text-foreground">{option.label}</div>
881
+ <div className="text-muted-foreground font-mono text-[11px]">{option.value}</div>
882
+ </div>
883
+ <button
884
+ type="button"
885
+ onClick={() => handleRemoveOption(idx)}
886
+ className="text-red-500 hover:text-red-700"
887
+ aria-label="Remove option"
888
+ >
889
+ <Trash2 className="h-3.5 w-3.5" />
890
+ </button>
891
+ </div>
892
+ ))
893
+ ) : (
894
+ <span className="text-xs text-muted-foreground">No options defined.</span>
895
+ )}
896
+ </div>
897
+ <div className="flex justify-end">
898
+ <button
899
+ type="button"
900
+ className="text-xs px-2 py-1 border rounded hover:bg-muted inline-flex items-center gap-1"
901
+ onClick={handleOpenOptionDialog}
902
+ >
903
+ <Plus className="h-3.5 w-3.5" />
904
+ Add option
905
+ </button>
906
+ </div>
907
+ </div>
908
+ )}
909
+
910
+ {(local.kind === 'select' || local.kind === 'relation') && (
911
+ <>
912
+ <div>
913
+ <label className="text-xs">Options URL</label>
914
+ <input
915
+ className="border rounded w-full px-2 py-1 text-sm"
916
+ placeholder="/api/..."
917
+ value={typeof local.configJson?.optionsUrl === 'string' ? local.configJson.optionsUrl : ''}
918
+ onChange={(event) => apply({ configJson: { ...(local.configJson || {}), optionsUrl: event.target.value } })}
919
+ onBlur={commit}
920
+ />
921
+ </div>
922
+ {local.kind === 'relation' && (
923
+ <div>
924
+ <label className="text-xs">Related Entity ID</label>
925
+ <input
926
+ className="border rounded w-full px-2 py-1 text-sm font-mono"
927
+ placeholder="module:entity"
928
+ value={typeof local.configJson?.relatedEntityId === 'string' ? local.configJson.relatedEntityId : ''}
929
+ onChange={(event) => {
930
+ const relatedEntityId = event.target.value
931
+ const defOptionsUrl = relatedEntityId
932
+ ? `/api/entities/relations/options?entityId=${encodeURIComponent(relatedEntityId)}`
933
+ : ''
934
+ apply({
935
+ configJson: {
936
+ ...(local.configJson || {}),
937
+ relatedEntityId,
938
+ optionsUrl: local.configJson?.optionsUrl || defOptionsUrl,
939
+ },
940
+ })
941
+ }}
942
+ onBlur={commit}
943
+ />
944
+ </div>
945
+ )}
946
+ </>
947
+ )}
948
+
949
+ {local.kind === 'currency' && (
950
+ <div className="md:col-span-2">
951
+ <label className="text-xs">Options source</label>
952
+ <div className="rounded border bg-muted px-2 py-1 text-xs text-muted-foreground">
953
+ /api/currencies/options
954
+ </div>
955
+ </div>
956
+ )}
957
+
958
+ {(local.kind === 'integer' || local.kind === 'float') && (
959
+ <div className="md:col-span-2">
960
+ <label className="text-xs">Units (optional)</label>
961
+ <input
962
+ className="border rounded w-full px-2 py-1 text-sm"
963
+ placeholder="kg, cm, etc."
964
+ value={typeof local.configJson?.unit === 'string' ? local.configJson.unit : ''}
965
+ onChange={(event) => apply({ configJson: { ...(local.configJson || {}), unit: event.target.value } })}
966
+ onBlur={commit}
967
+ />
968
+ </div>
969
+ )}
970
+ </div>
971
+
972
+ <div className="mt-3 pt-3 border-t">
973
+ <div className="flex items-center justify-between mb-2">
974
+ <label className="text-sm font-medium">Validation rules</label>
975
+ <button
976
+ type="button"
977
+ className="text-xs px-2 py-1 border rounded hover:bg-muted inline-flex items-center gap-1"
978
+ onClick={() => {
979
+ apply((current) => {
980
+ const list = Array.isArray(current.configJson?.validation) ? [...current.configJson.validation] : []
981
+ list.push({ rule: 'required', message: 'This field is required' } as any)
982
+ return { configJson: { ...(current.configJson || {}), validation: list } }
983
+ }, true)
984
+ }}
985
+ >
986
+ <Plus className="h-3.5 w-3.5" />
987
+ Add rule
988
+ </button>
989
+ </div>
990
+ <div className="space-y-2">
991
+ {(Array.isArray(local.configJson?.validation) ? local.configJson!.validation : []).map((rule: any, index: number) => (
992
+ <div key={index} className="grid grid-cols-1 md:grid-cols-12 gap-2 items-center">
993
+ <div className="md:col-span-3">
994
+ <select
995
+ className="border rounded w-full px-2 py-1 text-sm"
996
+ value={rule?.rule || 'required'}
997
+ onChange={(event) => {
998
+ const nextRule = event.target.value
999
+ apply((current) => {
1000
+ const list = Array.isArray(current.configJson?.validation) ? [...current.configJson.validation] : []
1001
+ const existing = (list[index] as any) || {}
1002
+ list[index] = { ...existing, rule: nextRule, message: existing.message || rule?.message || '' }
1003
+ return { configJson: { ...(current.configJson || {}), validation: list } }
1004
+ }, true)
1005
+ }}
1006
+ >
1007
+ <option value="required">required</option>
1008
+ <option value="date">date</option>
1009
+ <option value="integer">integer</option>
1010
+ <option value="float">float</option>
1011
+ <option value="lt">lt</option>
1012
+ <option value="lte">lte</option>
1013
+ <option value="gt">gt</option>
1014
+ <option value="gte">gte</option>
1015
+ <option value="eq">eq</option>
1016
+ <option value="ne">ne</option>
1017
+ <option value="regex">regex</option>
1018
+ </select>
1019
+ </div>
1020
+ <div className="md:col-span-4">
1021
+ <input
1022
+ className="border rounded w-full px-2 py-1 text-sm"
1023
+ placeholder={rule?.rule === 'regex' ? 'Pattern (e.g. ^[a-z]+$)' : (['lt','lte','gt','gte'].includes(rule?.rule) ? 'Number' : '—')}
1024
+ value={rule?.param ?? ''}
1025
+ onChange={(event) => {
1026
+ const value = ['lt','lte','gt','gte'].includes(rule?.rule) ? Number(event.target.value) : event.target.value
1027
+ apply((current) => {
1028
+ const list = Array.isArray(current.configJson?.validation) ? [...current.configJson.validation] : []
1029
+ const existing = (list[index] as any) || {}
1030
+ list[index] = { ...existing, ...rule, param: value }
1031
+ return { configJson: { ...(current.configJson || {}), validation: list } }
1032
+ })
1033
+ }}
1034
+ onBlur={commit}
1035
+ disabled={rule?.rule === 'required' || rule?.rule === 'date' || rule?.rule === 'integer' || rule?.rule === 'float'}
1036
+ />
1037
+ </div>
1038
+ <div className="md:col-span-4">
1039
+ <input
1040
+ className="border rounded w-full px-2 py-1 text-sm"
1041
+ placeholder="Error message"
1042
+ value={rule?.message || ''}
1043
+ onChange={(event) => {
1044
+ const message = event.target.value
1045
+ apply((current) => {
1046
+ const list = Array.isArray(current.configJson?.validation) ? [...current.configJson.validation] : []
1047
+ const existing = (list[index] as any) || {}
1048
+ list[index] = { ...existing, ...rule, message }
1049
+ return { configJson: { ...(current.configJson || {}), validation: list } }
1050
+ })
1051
+ }}
1052
+ onBlur={commit}
1053
+ />
1054
+ </div>
1055
+ <div className="md:col-span-1 flex justify-end">
1056
+ <button
1057
+ type="button"
1058
+ className="px-2 py-1 border rounded hover:bg-muted"
1059
+ aria-label="Remove rule"
1060
+ onClick={() => {
1061
+ apply((current) => {
1062
+ const list = Array.isArray(current.configJson?.validation) ? [...current.configJson.validation] : []
1063
+ list.splice(index, 1)
1064
+ return { configJson: { ...(current.configJson || {}), validation: list } }
1065
+ }, true)
1066
+ }}
1067
+ >
1068
+ <Trash2 className="h-4 w-4" />
1069
+ </button>
1070
+ </div>
1071
+ </div>
1072
+ ))}
1073
+ {(!Array.isArray(local.configJson?.validation) || local.configJson!.validation.length === 0) && (
1074
+ <div className="text-xs text-muted-foreground">No validation rules defined.</div>
1075
+ )}
1076
+ </div>
1077
+ </div>
1078
+
1079
+ <div className="mt-3">
1080
+ {(() => {
1081
+ const Editor = FieldRegistry.getDefEditor(local.kind)
1082
+ if (!Editor) return null
1083
+ return (
1084
+ <Editor
1085
+ def={{ key: local.key, kind: local.kind, configJson: local.configJson }}
1086
+ onChange={(patch) => apply({ configJson: { ...(local.configJson || {}), ...(patch || {}) } }, true)}
1087
+ />
1088
+ )
1089
+ })()}
1090
+ </div>
1091
+
1092
+ <div className="mt-3 pt-2 border-t flex flex-wrap items-center gap-4">
1093
+ <span className="text-xs text-muted-foreground">Visibility:</span>
1094
+ <label className="inline-flex items-center gap-2 text-xs">
1095
+ <input
1096
+ type="checkbox"
1097
+ checked={local.configJson?.listVisible !== false}
1098
+ onChange={(event) => { apply({ configJson: { ...(local.configJson || {}), listVisible: event.target.checked } }, true) }}
1099
+ />
1100
+ List
1101
+ </label>
1102
+ <label className="inline-flex items-center gap-2 text-xs">
1103
+ <input
1104
+ type="checkbox"
1105
+ checked={!!local.configJson?.filterable}
1106
+ onChange={(event) => { apply({ configJson: { ...(local.configJson || {}), filterable: event.target.checked } }, true) }}
1107
+ />
1108
+ Filter
1109
+ </label>
1110
+ <label className="inline-flex items-center gap-2 text-xs">
1111
+ <input
1112
+ type="checkbox"
1113
+ checked={local.configJson?.formEditable !== false}
1114
+ onChange={(event) => { apply({ configJson: { ...(local.configJson || {}), formEditable: event.target.checked } }, true) }}
1115
+ />
1116
+ Form
1117
+ </label>
1118
+ <label className="inline-flex items-center gap-2 text-xs">
1119
+ <input
1120
+ type="checkbox"
1121
+ checked={!!local.configJson?.encrypted}
1122
+ onChange={(event) => { apply({ configJson: { ...(local.configJson || {}), encrypted: event.target.checked } }, true) }}
1123
+ />
1124
+ {translate?.('entities.customFields.fields.encrypted', 'Encrypted') ?? 'Encrypted'}
1125
+ </label>
1126
+ </div>
1127
+ </div>
1128
+ <Dialog
1129
+ open={optionDialogOpen}
1130
+ onOpenChange={(open) => {
1131
+ setOptionDialogOpen(open)
1132
+ if (!open) resetOptionDialog()
1133
+ }}
1134
+ >
1135
+ <DialogContent className="max-w-sm">
1136
+ <DialogHeader>
1137
+ <DialogTitle>Add option</DialogTitle>
1138
+ <DialogDescription>Provide the stored value and optional label shown to users.</DialogDescription>
1139
+ </DialogHeader>
1140
+ <div className="space-y-4 py-2">
1141
+ <div>
1142
+ <label className="text-xs">Value</label>
1143
+ <input
1144
+ className="mt-1 w-full rounded border px-2 py-1 text-sm font-mono"
1145
+ placeholder="unique_value"
1146
+ value={optionValueDraft}
1147
+ onChange={(event) => {
1148
+ setOptionFormError(null)
1149
+ setOptionValueDraft(event.target.value)
1150
+ }}
1151
+ />
1152
+ {optionFormError ? <p className="mt-1 text-xs text-red-600">{optionFormError}</p> : null}
1153
+ </div>
1154
+ <div>
1155
+ <label className="text-xs">Label</label>
1156
+ <input
1157
+ className="mt-1 w-full rounded border px-2 py-1 text-sm"
1158
+ placeholder="Label shown to users (optional)"
1159
+ value={optionLabelDraft}
1160
+ onChange={(event) => setOptionLabelDraft(event.target.value)}
1161
+ />
1162
+ </div>
1163
+ </div>
1164
+ <DialogFooter>
1165
+ <button
1166
+ type="button"
1167
+ className="h-8 rounded border px-3 text-sm"
1168
+ onClick={handleCloseOptionDialog}
1169
+ >
1170
+ Cancel
1171
+ </button>
1172
+ <button
1173
+ type="button"
1174
+ className="h-8 rounded bg-primary px-3 text-sm text-primary-foreground"
1175
+ onClick={handleAddOption}
1176
+ >
1177
+ Add option
1178
+ </button>
1179
+ </DialogFooter>
1180
+ </DialogContent>
1181
+ </Dialog>
1182
+ <Dialog
1183
+ open={groupDialogOpen}
1184
+ onOpenChange={(open) => {
1185
+ setGroupDialogOpen(open)
1186
+ if (!open) {
1187
+ setGroupError(null)
1188
+ setEditingGroupCode(null)
1189
+ setGroupDraft({ code: '', title: '', hint: '' })
1190
+ }
1191
+ }}
1192
+ >
1193
+ <DialogContent className="max-w-md">
1194
+ <DialogHeader>
1195
+ <DialogTitle>{editingGroupCode ? 'Edit group' : 'New group'}</DialogTitle>
1196
+ <DialogDescription>
1197
+ {editingGroupCode ? 'Update the selected group for this fieldset.' : 'Add a reusable group for this fieldset.'}
1198
+ </DialogDescription>
1199
+ </DialogHeader>
1200
+ <div className="space-y-3">
1201
+ <div>
1202
+ <label className="text-xs font-medium">Group code</label>
1203
+ <input
1204
+ className="mt-1 w-full rounded border px-2 py-1 text-sm font-mono disabled:cursor-not-allowed disabled:bg-muted/40 disabled:text-muted-foreground"
1205
+ value={groupDraft.code}
1206
+ onChange={(event) => {
1207
+ setGroupDraft((prev) => ({ ...prev, code: event.target.value }))
1208
+ if (groupError) setGroupError(null)
1209
+ }}
1210
+ disabled={!!editingGroupCode}
1211
+ placeholder="e.g. buying_committee"
1212
+ />
1213
+ {groupError ? <div className="text-xs text-red-600 mt-1">{groupError}</div> : null}
1214
+ </div>
1215
+ <div>
1216
+ <label className="text-xs font-medium">Label</label>
1217
+ <input
1218
+ className="mt-1 w-full rounded border px-2 py-1 text-sm"
1219
+ value={groupDraft.title}
1220
+ onChange={(event) => setGroupDraft((prev) => ({ ...prev, title: event.target.value }))}
1221
+ placeholder="Buying committee"
1222
+ />
1223
+ </div>
1224
+ <div>
1225
+ <label className="text-xs font-medium">Hint</label>
1226
+ <input
1227
+ className="mt-1 w-full rounded border px-2 py-1 text-sm"
1228
+ value={groupDraft.hint}
1229
+ onChange={(event) => setGroupDraft((prev) => ({ ...prev, hint: event.target.value }))}
1230
+ placeholder="Visible to merchandisers"
1231
+ />
1232
+ </div>
1233
+ {currentFieldsetValue && groupOptions.length > 0 ? (
1234
+ <div>
1235
+ <div className="text-xs font-medium mb-1">Existing groups</div>
1236
+ <div className="space-y-2 max-h-40 overflow-y-auto">
1237
+ {groupOptions.map((group) => (
1238
+ <div key={group.code} className="flex items-center justify-between rounded border px-3 py-2 text-sm">
1239
+ <div>
1240
+ <div className="font-medium">{group.title || group.code}</div>
1241
+ <div className="text-xs text-muted-foreground font-mono">{group.code}</div>
1242
+ {group.hint ? (
1243
+ <div className="text-xs text-muted-foreground">{group.hint}</div>
1244
+ ) : null}
1245
+ </div>
1246
+ <div className="flex items-center gap-2">
1247
+ <button
1248
+ type="button"
1249
+ className="text-muted-foreground hover:text-foreground"
1250
+ onClick={() => handleEditGroupEntry(group)}
1251
+ aria-label={`Edit ${group.code}`}
1252
+ >
1253
+ <Pencil className="h-4 w-4" />
1254
+ </button>
1255
+ <button
1256
+ type="button"
1257
+ className="text-red-500 hover:text-red-600"
1258
+ onClick={() => handleRemoveGroupEntry(group.code)}
1259
+ aria-label={`Delete ${group.code}`}
1260
+ >
1261
+ <Trash2 className="h-4 w-4" />
1262
+ </button>
1263
+ </div>
1264
+ </div>
1265
+ ))}
1266
+ </div>
1267
+ </div>
1268
+ ) : null}
1269
+ </div>
1270
+ <DialogFooter>
1271
+ <button
1272
+ type="button"
1273
+ className="h-8 rounded border px-3 text-sm"
1274
+ onClick={() => setGroupDialogOpen(false)}
1275
+ >
1276
+ Cancel
1277
+ </button>
1278
+ <button
1279
+ type="button"
1280
+ className="h-8 rounded bg-primary px-3 text-sm text-primary-foreground"
1281
+ onClick={handleGroupDialogSubmit}
1282
+ >
1283
+ Save group
1284
+ </button>
1285
+ </DialogFooter>
1286
+ </DialogContent>
1287
+ </Dialog>
1288
+ </>
1289
+ )
1290
+ })
1291
+
1292
+ FieldDefinitionCard.displayName = 'FieldDefinitionCard'