@cosmicdrift/kumiko-renderer 0.328.0 → 0.329.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (259) hide show
  1. package/dist/app/action-form-shim.d.ts +14 -0
  2. package/{src/app/action-form-shim.ts → dist/app/action-form-shim.js} +26 -46
  3. package/dist/app/app-features-context.d.ts +8 -0
  4. package/dist/app/app-features-context.js +16 -0
  5. package/dist/app/column-renderers.d.ts +30 -0
  6. package/dist/app/column-renderers.js +34 -0
  7. package/dist/app/config-edit-shim.d.ts +11 -0
  8. package/{src/app/config-edit-shim.ts → dist/app/config-edit-shim.js} +12 -25
  9. package/dist/app/content-editors.d.ts +38 -0
  10. package/dist/app/content-editors.js +26 -0
  11. package/dist/app/content-preview.d.ts +11 -0
  12. package/dist/app/content-preview.js +30 -0
  13. package/dist/app/custom-screens.d.ts +11 -0
  14. package/dist/app/custom-screens.js +20 -0
  15. package/dist/app/dashboard-body.d.ts +16 -0
  16. package/dist/app/dashboard-body.js +11 -0
  17. package/dist/app/extension-form-submit.d.ts +32 -0
  18. package/dist/app/extension-form-submit.js +73 -0
  19. package/dist/app/extension-sections.d.ts +72 -0
  20. package/dist/app/extension-sections.js +34 -0
  21. package/dist/app/feature-schema.d.ts +4 -0
  22. package/{src/app/feature-schema.ts → dist/app/feature-schema.js} +12 -22
  23. package/dist/app/form-schema.d.ts +4 -0
  24. package/dist/app/form-schema.js +85 -0
  25. package/dist/app/kumiko-screen.d.ts +43 -0
  26. package/dist/app/kumiko-screen.js +2206 -0
  27. package/dist/app/layout-fields.d.ts +3 -0
  28. package/dist/app/layout-fields.js +20 -0
  29. package/dist/app/list-facets.d.ts +48 -0
  30. package/dist/app/list-facets.js +185 -0
  31. package/dist/app/nav.d.ts +61 -0
  32. package/dist/app/nav.js +139 -0
  33. package/dist/app/projection-detail-shim.d.ts +8 -0
  34. package/dist/app/projection-detail-shim.js +71 -0
  35. package/dist/app/projection-list-shim.d.ts +13 -0
  36. package/{src/app/projection-list-shim.ts → dist/app/projection-list-shim.js} +26 -41
  37. package/dist/app/qn.d.ts +7 -0
  38. package/{src/app/qn.ts → dist/app/qn.js} +9 -11
  39. package/dist/app/qualify-screen-id.d.ts +2 -0
  40. package/{src/app/qualify-screen-id.ts → dist/app/qualify-screen-id.js} +5 -6
  41. package/dist/app/reference-facet-bridge.d.ts +10 -0
  42. package/dist/app/reference-facet-bridge.js +22 -0
  43. package/dist/app/return-to.d.ts +31 -0
  44. package/dist/app/return-to.js +188 -0
  45. package/dist/app/row-actions.d.ts +77 -0
  46. package/dist/app/row-actions.js +416 -0
  47. package/dist/app/screen-access.d.ts +8 -0
  48. package/dist/app/screen-access.js +48 -0
  49. package/dist/app/secret-mint-body.d.ts +10 -0
  50. package/dist/app/secret-mint-body.js +140 -0
  51. package/dist/app/secrets-edit-body.d.ts +8 -0
  52. package/dist/app/secrets-edit-body.js +76 -0
  53. package/dist/app/use-embedded-screen.d.ts +6 -0
  54. package/dist/app/use-embedded-screen.js +19 -0
  55. package/dist/app/variable-chips.d.ts +7 -0
  56. package/dist/app/variable-chips.js +16 -0
  57. package/dist/app/write-failed-error.d.ts +6 -0
  58. package/dist/app/write-failed-error.js +21 -0
  59. package/dist/components/embedded-list-field.d.ts +10 -0
  60. package/dist/components/embedded-list-field.js +259 -0
  61. package/dist/components/grid-cell-for-field.d.ts +25 -0
  62. package/dist/components/grid-cell-for-field.js +31 -0
  63. package/dist/components/multi-select-checkboxes.d.ts +14 -0
  64. package/dist/components/multi-select-checkboxes.js +36 -0
  65. package/dist/components/reference-create-dialog.d.ts +13 -0
  66. package/dist/components/reference-create-dialog.js +46 -0
  67. package/dist/components/related-list-section.d.ts +25 -0
  68. package/dist/components/related-list-section.js +273 -0
  69. package/dist/components/render-edit-action-button.d.ts +22 -0
  70. package/dist/components/render-edit-action-button.js +43 -0
  71. package/dist/components/render-edit-logic.d.ts +6 -0
  72. package/dist/components/render-edit-logic.js +63 -0
  73. package/dist/components/render-edit-types.d.ts +209 -0
  74. package/dist/components/render-edit-types.js +1 -0
  75. package/dist/components/render-edit.d.ts +6 -0
  76. package/dist/components/render-edit.js +1169 -0
  77. package/dist/components/render-field.d.ts +38 -0
  78. package/dist/components/render-field.js +544 -0
  79. package/dist/components/render-list.d.ts +117 -0
  80. package/dist/components/render-list.js +232 -0
  81. package/dist/components/write-form-section.d.ts +17 -0
  82. package/dist/components/write-form-section.js +104 -0
  83. package/dist/context/dispatcher-context.d.ts +10 -0
  84. package/{src/context/dispatcher-context.tsx → dist/context/dispatcher-context.js} +16 -29
  85. package/dist/context/draft-storage-context.d.ts +12 -0
  86. package/dist/context/draft-storage-context.js +18 -0
  87. package/dist/context/user-roles-context.d.ts +7 -0
  88. package/{src/context/user-roles-context.tsx → dist/context/user-roles-context.js} +7 -15
  89. package/dist/format-when.d.ts +1 -0
  90. package/{src/format-when.ts → dist/format-when.js} +7 -7
  91. package/dist/hooks/reference-limits.d.ts +3 -0
  92. package/{src/hooks/reference-limits.ts → dist/hooks/reference-limits.js} +0 -1
  93. package/dist/hooks/use-ai-text.d.ts +52 -0
  94. package/dist/hooks/use-ai-text.js +90 -0
  95. package/dist/hooks/use-disclosure.d.ts +7 -0
  96. package/dist/hooks/use-disclosure.js +8 -0
  97. package/dist/hooks/use-form.d.ts +7 -0
  98. package/dist/hooks/use-form.js +36 -0
  99. package/dist/hooks/use-list-url-state.d.ts +43 -0
  100. package/dist/hooks/use-list-url-state.js +131 -0
  101. package/dist/hooks/use-mutation.d.ts +9 -0
  102. package/dist/hooks/use-mutation.js +37 -0
  103. package/dist/hooks/use-query.d.ts +14 -0
  104. package/dist/hooks/use-query.js +118 -0
  105. package/dist/hooks/use-reference-lookup.d.ts +14 -0
  106. package/dist/hooks/use-reference-lookup.js +63 -0
  107. package/dist/hooks/use-store.d.ts +3 -0
  108. package/dist/hooks/use-store.js +38 -0
  109. package/dist/hooks/use-stream-handler.d.ts +14 -0
  110. package/dist/hooks/use-stream-handler.js +93 -0
  111. package/dist/i18n-defaults.d.ts +2 -0
  112. package/dist/i18n-defaults.js +201 -0
  113. package/dist/i18n.d.ts +64 -0
  114. package/dist/i18n.js +166 -0
  115. package/dist/index.d.ts +77 -0
  116. package/dist/index.js +55 -0
  117. package/dist/inside-drawer.d.ts +2 -0
  118. package/{src/inside-drawer.ts → dist/inside-drawer.js} +2 -5
  119. package/dist/page-header-slot.d.ts +2 -0
  120. package/{src/page-header-slot.ts → dist/page-header-slot.js} +2 -5
  121. package/dist/primitives.d.ts +1355 -0
  122. package/dist/primitives.js +73 -0
  123. package/dist/screen-fills-height.d.ts +3 -0
  124. package/dist/screen-fills-height.js +3 -0
  125. package/dist/share-links.d.ts +4 -0
  126. package/dist/share-links.js +11 -0
  127. package/dist/sort-by-accessor.d.ts +5 -0
  128. package/dist/sort-by-accessor.js +16 -0
  129. package/dist/sse/live-events.d.ts +23 -0
  130. package/dist/sse/live-events.js +28 -0
  131. package/dist/tokens.d.ts +83 -0
  132. package/dist/tokens.js +80 -0
  133. package/package.json +16 -9
  134. package/src/__tests__/error-i18n-defaults.test.ts +0 -82
  135. package/src/__tests__/format-when.test.ts +0 -12
  136. package/src/__tests__/i18n.test.tsx +0 -283
  137. package/src/__tests__/merge-search-params-into-initial.test.ts +0 -525
  138. package/src/__tests__/qn.test.ts +0 -83
  139. package/src/__tests__/share-links.test.ts +0 -26
  140. package/src/__tests__/sort-by-accessor.test.ts +0 -48
  141. package/src/__tests__/stringify-nav-params.test.ts +0 -33
  142. package/src/__tests__/use-list-url-state.test.tsx +0 -250
  143. package/src/app/__tests__/action-form-field-labels.test.tsx +0 -92
  144. package/src/app/__tests__/action-form-shim.test.ts +0 -137
  145. package/src/app/__tests__/build-list-query-payload.test.ts +0 -107
  146. package/src/app/__tests__/config-edit-shim.test.ts +0 -57
  147. package/src/app/__tests__/content-editors.test.tsx +0 -128
  148. package/src/app/__tests__/content-preview.test.tsx +0 -184
  149. package/src/app/__tests__/entity-edit-create-search-params.test.tsx +0 -236
  150. package/src/app/__tests__/entity-edit-fill-height.test.tsx +0 -118
  151. package/src/app/__tests__/entity-edit-jsonb-embedded-submit.test.tsx +0 -239
  152. package/src/app/__tests__/entity-list-create-singleton.test.tsx +0 -128
  153. package/src/app/__tests__/entity-list-row-action-entity-target.test.tsx +0 -424
  154. package/src/app/__tests__/entity-list-row-action-icon.test.tsx +0 -248
  155. package/src/app/__tests__/entity-list-row-action-kumiko-actions-view.test.tsx +0 -162
  156. package/src/app/__tests__/entity-list-row-action-refetch.test.tsx +0 -401
  157. package/src/app/__tests__/entity-list-screen-padding.test.tsx +0 -128
  158. package/src/app/__tests__/feature-schema.test.ts +0 -40
  159. package/src/app/__tests__/form-schema.test.ts +0 -247
  160. package/src/app/__tests__/list-daterange-facet.test.ts +0 -165
  161. package/src/app/__tests__/list-filter-facets.test.tsx +0 -521
  162. package/src/app/__tests__/nav.test.ts +0 -198
  163. package/src/app/__tests__/projection-detail-actions.test.tsx +0 -719
  164. package/src/app/__tests__/projection-detail-reference-field.test.tsx +0 -165
  165. package/src/app/__tests__/projection-detail-shim.test.ts +0 -188
  166. package/src/app/__tests__/projection-detail-singleton.test.tsx +0 -191
  167. package/src/app/__tests__/projection-list-bad-shape.test.tsx +0 -152
  168. package/src/app/__tests__/projection-list-reference-column.test.tsx +0 -219
  169. package/src/app/__tests__/projection-list-search-sort.test.tsx +0 -212
  170. package/src/app/__tests__/qualified-names.test.ts +0 -37
  171. package/src/app/__tests__/return-to.test.ts +0 -396
  172. package/src/app/__tests__/row-actions-navigate-tab.test.ts +0 -223
  173. package/src/app/__tests__/row-default-edit-action.test.tsx +0 -324
  174. package/src/app/__tests__/screen-access-allows.test.ts +0 -34
  175. package/src/app/__tests__/secret-mint-body.test.tsx +0 -404
  176. package/src/app/__tests__/variable-chips.test.tsx +0 -70
  177. package/src/app/app-features-context.tsx +0 -25
  178. package/src/app/column-renderers.tsx +0 -68
  179. package/src/app/content-editors.tsx +0 -85
  180. package/src/app/content-preview.tsx +0 -67
  181. package/src/app/custom-screens.tsx +0 -29
  182. package/src/app/dashboard-body.tsx +0 -33
  183. package/src/app/extension-form-submit.tsx +0 -123
  184. package/src/app/extension-sections.tsx +0 -111
  185. package/src/app/form-schema.ts +0 -91
  186. package/src/app/kumiko-screen.tsx +0 -3801
  187. package/src/app/layout-fields.ts +0 -31
  188. package/src/app/list-facets.ts +0 -259
  189. package/src/app/nav.tsx +0 -258
  190. package/src/app/projection-detail-shim.ts +0 -86
  191. package/src/app/reference-facet-bridge.tsx +0 -58
  192. package/src/app/return-to.tsx +0 -234
  193. package/src/app/row-actions.ts +0 -640
  194. package/src/app/screen-access.ts +0 -63
  195. package/src/app/secret-mint-body.tsx +0 -259
  196. package/src/app/secrets-edit-body.tsx +0 -194
  197. package/src/app/use-embedded-screen.ts +0 -27
  198. package/src/app/variable-chips.tsx +0 -39
  199. package/src/app/write-failed-error.ts +0 -27
  200. package/src/components/__tests__/embedded-list-field.test.tsx +0 -531
  201. package/src/components/__tests__/multi-select-checkboxes.test.tsx +0 -162
  202. package/src/components/__tests__/related-list-section-empty-state.test.tsx +0 -143
  203. package/src/components/__tests__/related-list-section.test.tsx +0 -1733
  204. package/src/components/__tests__/render-edit-action-button.test.tsx +0 -205
  205. package/src/components/__tests__/render-edit-hidden-section.test.tsx +0 -128
  206. package/src/components/__tests__/render-edit-logic.test.ts +0 -314
  207. package/src/components/__tests__/render-edit-screen-description.test.tsx +0 -152
  208. package/src/components/__tests__/render-edit-submit-actions.test.tsx +0 -460
  209. package/src/components/__tests__/render-field-app-locale.test.tsx +0 -142
  210. package/src/components/__tests__/render-field-enum-option.test.tsx +0 -99
  211. package/src/components/__tests__/render-field-icon-derivation.test.tsx +0 -119
  212. package/src/components/__tests__/render-field-json-format.test.tsx +0 -164
  213. package/src/components/__tests__/render-field-money-embedded-list-consistency.test.tsx +0 -212
  214. package/src/components/__tests__/render-field-money-roundtrip.test.tsx +0 -217
  215. package/src/components/__tests__/render-field-number-grouping.test.tsx +0 -76
  216. package/src/components/__tests__/render-field-number-unit.test.tsx +0 -227
  217. package/src/components/__tests__/render-field-password-format.test.tsx +0 -101
  218. package/src/components/__tests__/render-field-select-display.test.tsx +0 -134
  219. package/src/components/__tests__/render-field-unit-format.test.tsx +0 -107
  220. package/src/components/__tests__/render-field-unsupported-types.test.tsx +0 -195
  221. package/src/components/__tests__/render-list-toolbar-icon-only.test.tsx +0 -146
  222. package/src/components/__tests__/write-form-section.test.tsx +0 -375
  223. package/src/components/embedded-list-field.tsx +0 -354
  224. package/src/components/grid-cell-for-field.tsx +0 -88
  225. package/src/components/multi-select-checkboxes.tsx +0 -93
  226. package/src/components/reference-create-dialog.tsx +0 -91
  227. package/src/components/related-list-section.tsx +0 -491
  228. package/src/components/render-edit-action-button.tsx +0 -109
  229. package/src/components/render-edit-logic.ts +0 -85
  230. package/src/components/render-edit-types.ts +0 -226
  231. package/src/components/render-edit.tsx +0 -1871
  232. package/src/components/render-field.tsx +0 -951
  233. package/src/components/render-list.tsx +0 -593
  234. package/src/components/write-form-section.tsx +0 -198
  235. package/src/context/draft-storage-context.tsx +0 -39
  236. package/src/hooks/__tests__/use-ai-text.test.tsx +0 -177
  237. package/src/hooks/__tests__/use-disclosure.test.tsx +0 -25
  238. package/src/hooks/__tests__/use-form.test.tsx +0 -65
  239. package/src/hooks/__tests__/use-mutation.test.tsx +0 -77
  240. package/src/hooks/__tests__/use-query.test.tsx +0 -149
  241. package/src/hooks/__tests__/use-stream-handler.test.tsx +0 -107
  242. package/src/hooks/use-ai-text.ts +0 -172
  243. package/src/hooks/use-disclosure.ts +0 -20
  244. package/src/hooks/use-form.ts +0 -78
  245. package/src/hooks/use-list-url-state.ts +0 -207
  246. package/src/hooks/use-mutation.ts +0 -61
  247. package/src/hooks/use-query.ts +0 -166
  248. package/src/hooks/use-reference-lookup.ts +0 -76
  249. package/src/hooks/use-store.ts +0 -48
  250. package/src/hooks/use-stream-handler.ts +0 -134
  251. package/src/i18n-defaults.ts +0 -240
  252. package/src/i18n.tsx +0 -286
  253. package/src/index.ts +0 -279
  254. package/src/primitives.tsx +0 -1567
  255. package/src/screen-fills-height.ts +0 -3
  256. package/src/share-links.ts +0 -12
  257. package/src/sort-by-accessor.ts +0 -20
  258. package/src/sse/live-events.tsx +0 -56
  259. package/src/tokens.tsx +0 -170
@@ -0,0 +1,2206 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { evalFieldCondition, isWriteFormEditSection, metricField, parseRefTarget, } from "@cosmicdrift/kumiko-framework/ui-types";
3
+ import { fieldLabelKey, fieldOptionLabelKey, isSafeHref } from "@cosmicdrift/kumiko-headless";
4
+ import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
5
+ import { TENANT_CURRENCY_CONFIG_KEY } from "@cosmicdrift/kumiko-types/fields";
6
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
7
+ import { extractCreatedId, extractIdField } from "../components/reference-create-dialog.js";
8
+ import { RenderEdit, } from "../components/render-edit.js";
9
+ import { needsActionConfirm, RenderEditActionButton, RenderEditActionConfirmDialog, } from "../components/render-edit-action-button.js";
10
+ import { RenderList } from "../components/render-list.js";
11
+ import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-context.js";
12
+ import { useUserRoles } from "../context/user-roles-context.js";
13
+ import { PAGE_SIZE_OPTIONS, useListUrlState } from "../hooks/use-list-url-state.js";
14
+ import { useQuery } from "../hooks/use-query.js";
15
+ import { useLocale, useOptionalTimeZone, useTranslation } from "../i18n.js";
16
+ import { InsideDrawerProvider, useInsideDrawer } from "../inside-drawer.js";
17
+ import { PageHeaderSlotAvailableProvider, usePageHeaderSlotAvailable, } from "../page-header-slot.js";
18
+ import { statusToneForOptionTone, statusToneForValue, usePrimitives, } from "../primitives.js";
19
+ import { screenFillsHeight } from "../screen-fills-height.js";
20
+ import { synthesizeActionFormEntity, synthesizeActionFormScreen } from "./action-form-shim.js";
21
+ import { useAppFeatures } from "./app-features-context.js";
22
+ import { synthesizeConfigEditEntity, synthesizeConfigEditScreen } from "./config-edit-shim.js";
23
+ import { useCustomScreenComponent } from "./custom-screens.js";
24
+ import { useDashboardBody } from "./dashboard-body.js";
25
+ import { buildFormSchema } from "./form-schema.js";
26
+ import { layoutFieldNames } from "./layout-fields.js";
27
+ import { buildDateRangePayload, buildFilterFacets, buildFilterPayload, clampDateRange, mergeReferenceFacetOptions, readDateRange, resolveDateRangeFacets, resolveProjectionFacetSpecs, } from "./list-facets.js";
28
+ import { useInitialValuesHandoff, useNav } from "./nav.js";
29
+ import { synthesizeProjectionDetailEntity, synthesizeProjectionDetailScreen, } from "./projection-detail-shim.js";
30
+ import { synthesizeProjectionEntity, synthesizeProjectionScreen } from "./projection-list-shim.js";
31
+ import { lastSegment, toKebab } from "./qn.js";
32
+ import { featureNameFromQualifiedScreenId, qualifyScreenId } from "./qualify-screen-id.js";
33
+ import { ReferenceFacetBridges } from "./reference-facet-bridge.js";
34
+ import { navigateToReturn, navigateToReturnOr, navigateWithReturnTo, ReturnHostProvider, useReturnHost, useReturnTarget, } from "./return-to.js";
35
+ import { buildDefaultEditRowAction, buildProjectionRowActions, buildProjectionToolbarActions, buildRecordActions, evalRowExtractor, isWriteHandlerRowAction, navigateActionSearchParams, refetchAfterWrite, runProjectionRowNavigate, } from "./row-actions.js";
36
+ import { findEditScreenFor, navigateTargetAllows, screenAccessAllows } from "./screen-access.js";
37
+ import { SecretMintBody } from "./secret-mint-body.js";
38
+ import { SecretsEditBody } from "./secrets-edit-body.js";
39
+ import { dispatcherErrorText, WriteFailedError } from "./write-failed-error.js";
40
+ export { qualifyScreenId };
41
+ /** Mirrors qualifyScreenId for nav QNs (registry form `<feature>:nav:<short-id>`).
42
+ * Callers building QNs (e.g. the WorkspaceShell resolver) should use this instead of string-concat, so a QN-schema change only touches one place. */
43
+ export function qualifyNavId(featureName, navId) {
44
+ return `${featureName}:nav:${navId}`;
45
+ }
46
+ export { screenAccessAllows };
47
+ export function KumikoScreen({ schema, qn, translate, entityId, onRowClick, onCopyLink, }) {
48
+ const { Banner, Text } = usePrimitives();
49
+ const userRoles = useUserRoles();
50
+ const outerHost = useReturnHost();
51
+ const screen = useMemo(() => schema.screens.find((s) => qualifyScreenId(schema.featureName, s.id) === qn), [schema.featureName, schema.screens, qn]);
52
+ // Computed before the early returns below — hooks must run unconditionally.
53
+ const ownHost = useMemo(() => ({
54
+ screenId: screen !== undefined ? lastSegment(screen.id) : "",
55
+ ...(entityId !== undefined && { entityId }),
56
+ }), [screen, entityId]);
57
+ if (!screen) {
58
+ return (_jsxs(Banner, { padded: true, variant: "error", testId: "kumiko-screen-not-found", children: ["Screen not found: ", _jsx(Text, { variant: "code", children: qn })] }));
59
+ }
60
+ if (!screenAccessAllows(screen.access, userRoles)) {
61
+ return (_jsxs(Banner, { padded: true, variant: "error", testId: "kumiko-screen-access-denied", children: ["Access denied: ", _jsx(Text, { variant: "code", children: qn })] }));
62
+ }
63
+ const body = renderScreenBody({ schema, screen, translate, entityId, onRowClick, onCopyLink });
64
+ // An embedded screen (e.g. a dashboard panel) keeps its parent's host and
65
+ // must not portal its page header into the shell that hosts the outer screen.
66
+ if (outerHost !== undefined) {
67
+ return _jsx(PageHeaderSlotAvailableProvider, { value: false, children: body });
68
+ }
69
+ return _jsx(ReturnHostProvider, { value: ownHost, children: body });
70
+ }
71
+ function renderScreenBody({ schema, screen, translate, entityId, onRowClick, onCopyLink, }) {
72
+ switch (screen.type) {
73
+ case "entityEdit":
74
+ return (_jsx(EntityEditScreen, { schema: schema, screen: screen, translate: translate, ...(entityId !== undefined && { entityId }), ...(onCopyLink !== undefined && { onCopyLink }) }));
75
+ case "entityList":
76
+ return (_jsx(EntityListScreen, { schema: schema, screen: screen, translate: translate, ...(onRowClick !== undefined && { onRowClick }) }));
77
+ case "projectionList":
78
+ return (_jsx(ProjectionListBody, { schema: schema, screen: screen, translate: translate, ...(onRowClick !== undefined && { onRowClick }) }));
79
+ case "projectionDetail":
80
+ return (_jsx(ProjectionDetailBody, { schema: schema, screen: screen, translate: translate, ...(entityId !== undefined && { entityId }) }, entityId ?? "no-entity"));
81
+ case "dashboard":
82
+ return (_jsx(DashboardScreenBody, { featureName: schema.featureName, screen: screen, translate: translate }));
83
+ case "actionForm":
84
+ return _jsx(ActionFormBody, { schema: schema, screen: screen, translate: translate });
85
+ case "secretMint":
86
+ return _jsx(SecretMintBody, { schema: schema, screen: screen, translate: translate });
87
+ case "configEdit":
88
+ return _jsx(ConfigEditBody, { schema: schema, screen: screen, translate: translate });
89
+ case "secretsEdit":
90
+ return _jsx(SecretsEditBody, { screen: screen, translate: translate });
91
+ case "custom":
92
+ return _jsx(CustomScreenBody, { screenId: screen.id, featureName: schema.featureName });
93
+ }
94
+ }
95
+ // Injection-Body für dashboard-Screens: die Panel-Implementierung (StatCard,
96
+ // Charts) ist plattform-spezifisch und kommt aus dem DashboardBody-Context
97
+ // (renderer-web registriert die Web-Variante in createKumikoApp).
98
+ function DashboardScreenBody({ featureName, screen, translate, }) {
99
+ const { Banner, Text } = usePrimitives();
100
+ const Body = useDashboardBody();
101
+ if (Body === undefined) {
102
+ return (_jsxs(Banner, { padded: true, variant: "info", testId: "kumiko-screen-dashboard-placeholder", children: ["Dashboard screen ", _jsx(Text, { variant: "code", children: screen.id }), " \u2014 kein Dashboard-Body registriert (createKumikoApp aus ", _jsx(Text, { variant: "code", children: "kumiko-renderer-web" }), " wired ihn automatisch)."] }));
103
+ }
104
+ return (_jsx(Body, { featureName: featureName, screen: screen, ...(translate !== undefined && { translate }) }));
105
+ }
106
+ // Lookup-Body für custom-screens: schaut die Component aus dem
107
+ // CustomScreens-Context (gefüttert von clientFeatures.components in
108
+ // createKumikoApp). Wenn weder Provider gemounted noch screenId
109
+ // registriert ist, fällt es auf einen error-Banner zurück statt leer zu
110
+ // rendern — der Screen ist per URL erreichbar sobald das Server-Feature
111
+ // gemountet ist, unabhängig davon ob ein Client-Plugin existiert
112
+ // (kumiko-framework#2025).
113
+ function CustomScreenBody({ screenId, featureName, }) {
114
+ const { Banner, Text } = usePrimitives();
115
+ const Component = useCustomScreenComponent(screenId);
116
+ if (Component === undefined) {
117
+ return (_jsxs(Banner, { padded: true, variant: "error", testId: "kumiko-screen-custom-placeholder", children: ["Custom screen ", _jsx(Text, { variant: "code", children: screenId }), " (Feature", " ", _jsx(Text, { variant: "code", children: featureName }), ") hat keine Component in", " ", _jsx(Text, { variant: "code", children: "clientFeatures.components" }), " \u2014 das Web-Client-Plugin des Features (siehe ", _jsxs(Text, { variant: "code", children: [featureName, "/web/client-plugin.tsx"] }), ") fehlt in", " ", _jsx(Text, { variant: "code", children: "clientFeatures" }), "."] }));
118
+ }
119
+ return _jsx(Component, {});
120
+ }
121
+ // ---- entity-edit ----
122
+ // Derives `<feature>:write:<entity>:<verb>` from the screen's entity
123
+ // and the schema's feature name. Matches qualifyEntityName / toKebab in
124
+ // packages/framework/src/engine/qualified-name.ts so camelCase entity
125
+ // ids (e.g. driverModel) resolve to server QNs (driver-model:create).
126
+ function entityWriteCommand(featureName, entity, verb) {
127
+ return `${toKebab(featureName)}:write:${toKebab(entity)}:${verb}`;
128
+ }
129
+ // Default "success → zurück zur Liste"-Navigation. Findet den ersten
130
+ // entityList-Screen für die Entity und navigiert dahin. Wird von
131
+ // Create/Update/Delete genauso verwendet — alle drei haben "fertig
132
+ // editiert, raus hier" als sinnvolles Default-Verhalten.
133
+ function useNavigateToListAfter(schema, entityName) {
134
+ const nav = useNav();
135
+ return useCallback(() => {
136
+ const list = schema.screens.find((s) => s.type === "entityList" && s.entity === entityName);
137
+ if (!list)
138
+ return;
139
+ // schema.screens.id ist QN-form (registry-stamped); nav.navigate
140
+ // erwartet Short-Form. Sonst landet die URL doppelt-qualifiziert.
141
+ nav.navigate({ screenId: lastSegment(list.id) });
142
+ }, [nav, schema.screens, entityName]);
143
+ }
144
+ // Default "+ Neu"-Navigation für die List-Toolbar: findet den ersten
145
+ // entityEdit-Screen ohne entityId-Anhang und navigiert dorthin.
146
+ // Returns undefined wenn kein Edit-Screen registriert ist — RenderList
147
+ // rendert dann keinen + Neu Button.
148
+ //
149
+ // Schema-Screens kommen mit qualifizierten ids ("publicstatus:screen:
150
+ // component-edit") aus der Registry; lastSegment strippt den Prefix
151
+ // für nav.navigate (siehe ./qn.ts für Doku).
152
+ function pageSizeOptionsFor(currentLimit) {
153
+ return PAGE_SIZE_OPTIONS.includes(currentLimit)
154
+ ? PAGE_SIZE_OPTIONS
155
+ : [...PAGE_SIZE_OPTIONS, currentLimit].sort((a, b) => a - b);
156
+ }
157
+ function useNavigateToCreateFor(schema, entityName) {
158
+ const nav = useNav();
159
+ const host = useReturnHost();
160
+ const editScreenId = useMemo(() => {
161
+ // allowCreate:false = update-only Edit-Screen (Create läuft über einen
162
+ // Lifecycle-Write) — der zählt nicht als „+ Neu"-Ziel. singleton:true
163
+ // öffnet ohne entityId den vorhandenen Record (EntityEditSingletonBody)
164
+ // statt eines leeren Create-Forms — zählt aus demselben Grund nicht.
165
+ const edit = schema.screens.find((s) => s.type === "entityEdit" &&
166
+ s.entity === entityName &&
167
+ s.allowCreate !== false &&
168
+ s.singleton !== true);
169
+ return edit !== undefined ? lastSegment(edit.id) : undefined;
170
+ }, [schema.screens, entityName]);
171
+ const navigate = useCallback(() => {
172
+ if (editScreenId !== undefined)
173
+ navigateWithReturnTo(nav, { screenId: editScreenId }, host);
174
+ }, [nav, editScreenId, host]);
175
+ return editScreenId !== undefined ? navigate : undefined;
176
+ }
177
+ // `defaultCurrency` is entityEdit-only — it enables the `{amount, currency}`
178
+ // write shape (fw#1923); config-edit's plain-number contract needs bare `0`.
179
+ // `moneyCurrencyOverrides` (fw#2933) replaces `defaultCurrency` per field name
180
+ // — used for a `money` field that declares `currency: { kind: "tenant" }`, so
181
+ // its empty initial value carries the tenant's own currency instead of the
182
+ // entity-wide default.
183
+ export function buildInitialValues(fields, defaultCurrency, moneyCurrencyOverrides) {
184
+ const out = {};
185
+ for (const [name, def] of Object.entries(fields)) {
186
+ const shape = def;
187
+ if (shape.default !== undefined) {
188
+ out[name] = shape.default;
189
+ continue;
190
+ }
191
+ if (shape.type === "embedded" && shape.multiple === true) {
192
+ out[name] = [];
193
+ continue;
194
+ }
195
+ if (shape.type === "money") {
196
+ const currency = moneyCurrencyOverrides?.[name] ?? defaultCurrency;
197
+ if (currency !== undefined) {
198
+ out[name] = { amount: 0, currency };
199
+ continue;
200
+ }
201
+ }
202
+ out[name] =
203
+ shape.type === "boolean"
204
+ ? false
205
+ : shape.type === "number" || shape.type === "money"
206
+ ? 0
207
+ : shape.type === "multiSelect"
208
+ ? []
209
+ : "";
210
+ }
211
+ return out;
212
+ }
213
+ // A `money` field opts a currently-empty value into the tenant-settings
214
+ // bundle's per-tenant currency (fw#2933) via `currency: { kind: "tenant" }`
215
+ // on its FieldDefinition. Read structurally — renderer only depends on the
216
+ // client-safe FieldDefinition subset, not the concrete MoneyFieldDef/
217
+ // MoneyCurrencySource types, same idiom as the other field-shape narrowings
218
+ // in this file (PrefillFieldShape etc.).
219
+ function tenantCurrencyMoneyFieldNames(fields) {
220
+ const names = [];
221
+ for (const [name, def] of Object.entries(fields)) {
222
+ const shape = def;
223
+ if (shape.type === "money" && shape.currency?.kind === "tenant")
224
+ names.push(name);
225
+ }
226
+ return names;
227
+ }
228
+ // Resolves what a tenant-declared money field's currency should be, from the
229
+ // same `config:query:values` query ConfigEditBody already uses. Never falls
230
+ // back to "EUR" while the query is genuinely still in flight (fw#2933) — a
231
+ // screen with no tenant-declared money field, or a query that errored or
232
+ // returned no value for the key, resolves immediately instead of blocking.
233
+ function resolveTenantCurrency(fieldNames, query, fallback) {
234
+ if (fieldNames.length === 0)
235
+ return { status: "not-needed" };
236
+ if (query.error)
237
+ return { status: "ready", currency: fallback };
238
+ if (query.data === null)
239
+ return { status: "loading" };
240
+ const raw = query.data[TENANT_CURRENCY_CONFIG_KEY]?.value;
241
+ return { status: "ready", currency: typeof raw === "string" && raw !== "" ? raw : fallback };
242
+ }
243
+ // An entity-less form screen has no `entity.defaultCurrency` to fall back on,
244
+ // so a tenant-declared field whose config query errors or returns no value
245
+ // lands here — same last resort as the entityEdit path's `?? "EUR"`.
246
+ const ACTION_FORM_CURRENCY_FALLBACK = "EUR";
247
+ // Shared by the update payload shape and the server-error path prefix so both can't drift.
248
+ const UPDATE_CHANGES_KEY = "changes";
249
+ // A money field naming a fixed ISO code (`currency: { kind: "literal", code }`,
250
+ // fw#2839) resolves with no query at all. Read structurally, same idiom as
251
+ // tenantCurrencyMoneyFieldNames above.
252
+ export function literalCurrencyOverrides(fields) {
253
+ const out = {};
254
+ for (const [name, def] of Object.entries(fields)) {
255
+ const shape = def;
256
+ const code = shape.currency?.code;
257
+ if (shape.type === "money" && shape.currency?.kind === "literal" && code !== undefined) {
258
+ out[name] = code;
259
+ }
260
+ }
261
+ return out;
262
+ }
263
+ // Per-field currency for the money fields that declare their own source: a
264
+ // literal code resolves synchronously (fw#2839), a `{ kind: "tenant" }` field
265
+ // needs the tenant-settings config query and makes the caller hold the form
266
+ // until it lands (fw#2933) — seeding the fallback meanwhile would render, and
267
+ // on a fast click submit, the wrong currency. Callers pass the tenant field
268
+ // names themselves because an edit form only needs the fetch for fields the
269
+ // record has no stored value for.
270
+ function useMoneyCurrencyOverrides(fields, tenantFieldNames, fallback) {
271
+ const query = useQuery("config:query:values", {}, { enabled: tenantFieldNames.length > 0 });
272
+ const resolution = resolveTenantCurrency(tenantFieldNames, query, fallback);
273
+ const resolvedTenantCurrency = resolution.status === "ready" ? resolution.currency : undefined;
274
+ const overrides = useMemo(() => {
275
+ const out = { ...literalCurrencyOverrides(fields) };
276
+ if (resolvedTenantCurrency !== undefined) {
277
+ for (const name of tenantFieldNames)
278
+ out[name] = resolvedTenantCurrency;
279
+ }
280
+ return Object.keys(out).length > 0 ? out : undefined;
281
+ }, [fields, tenantFieldNames, resolvedTenantCurrency]);
282
+ return { overrides, loading: resolution.status === "loading" };
283
+ }
284
+ function multiSelectOptionValues(shape) {
285
+ if (shape.options === undefined || shape.options.length === 0)
286
+ return undefined;
287
+ return new Set(shape.options.map((o) => (typeof o === "string" ? o : o.value)));
288
+ }
289
+ // The currency now comes from a user-controlled URL param and reaches
290
+ // `Intl.NumberFormat`, which throws a RangeError on a malformed code — reject
291
+ // it here instead of crashing the form render.
292
+ const CURRENCY_CODE_PATTERN = /^[A-Za-z]{3}$/;
293
+ function isMoneyValue(value) {
294
+ if (typeof value !== "object" || value === null)
295
+ return false;
296
+ const { amount, currency } = value;
297
+ return (typeof amount === "number" &&
298
+ Number.isFinite(amount) &&
299
+ typeof currency === "string" &&
300
+ CURRENCY_CODE_PATTERN.test(currency));
301
+ }
302
+ const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
303
+ function formatSummaryValue(value, locale) {
304
+ if (isMoneyValue(value)) {
305
+ return new Intl.NumberFormat(locale, { style: "currency", currency: value.currency }).format(value.amount);
306
+ }
307
+ if (typeof value === "number")
308
+ return value.toLocaleString(locale);
309
+ if (typeof value === "string" && ISO_DATE_PATTERN.test(value)) {
310
+ return new Date(`${value}T00:00:00Z`).toLocaleDateString(locale, {
311
+ year: "numeric",
312
+ month: "2-digit",
313
+ day: "2-digit",
314
+ timeZone: "UTC",
315
+ });
316
+ }
317
+ return value === null || value === undefined ? "" : String(value);
318
+ }
319
+ function resolveActionFormSummary(summary, prefill, translate, locale) {
320
+ if (summary === undefined)
321
+ return undefined;
322
+ const params = Object.fromEntries(Object.entries(prefill ?? {}).map(([name, value]) => [name, formatSummaryValue(value, locale)]));
323
+ return {
324
+ title: translate(summary.title, params),
325
+ ...(summary.subtitle !== undefined && { subtitle: translate(summary.subtitle, params) }),
326
+ };
327
+ }
328
+ function parseJsonOrRaw(raw) {
329
+ try {
330
+ return JSON.parse(raw);
331
+ }
332
+ catch {
333
+ return raw;
334
+ }
335
+ }
336
+ function warnPrefillParam(message) {
337
+ // biome-ignore lint/suspicious/noConsole: dev-warning for an authoring error
338
+ console.warn(`[kumiko] ${message}`);
339
+ }
340
+ // An actionForm has no entity and thus no defaultCurrency, so a bare-number
341
+ // prefill would stay a number the handler's zod schema rejects (fw#2763).
342
+ function coerceMoneyValue(raw, fieldName, defaultCurrency) {
343
+ if (raw === null || raw === undefined)
344
+ return undefined;
345
+ const value = typeof raw === "string" ? parseJsonOrRaw(raw) : raw;
346
+ if (value === null)
347
+ return undefined;
348
+ if (isMoneyValue(value))
349
+ return { amount: value.amount, currency: value.currency.toUpperCase() };
350
+ if (typeof value === "object") {
351
+ warnPrefillParam(`money field "${fieldName}" got an object without a usable {amount, currency} shape — the prefill is ignored.`);
352
+ return undefined;
353
+ }
354
+ const amount = Number(typeof value === "number" ? value : raw);
355
+ if (!Number.isFinite(amount))
356
+ return undefined;
357
+ if (defaultCurrency !== undefined)
358
+ return { amount, currency: defaultCurrency };
359
+ warnPrefillParam(`money field "${fieldName}" was prefilled with a bare number and no currency is available — pass {"amount":<number>,"currency":"<ISO code>"} as the param value, otherwise the handler's schema rejects the submit.`);
360
+ return amount;
361
+ }
362
+ const EMBEDDED_LIST_PREFILL_ROW_LIMIT = 500;
363
+ function coerceEmbeddedCell(value, cell) {
364
+ switch (cell.type) {
365
+ case "number":
366
+ case "decimal":
367
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
368
+ case "money":
369
+ // Row cells are signed minor units; the currency lives on the head, so a
370
+ // top-level `{amount, currency}` in major units would land 100x off.
371
+ return typeof value === "number" && Number.isSafeInteger(value) ? value : undefined;
372
+ case "boolean":
373
+ return typeof value === "boolean" ? value : undefined;
374
+ case "select":
375
+ return typeof value === "string" && (cell.options ?? []).includes(value) ? value : undefined;
376
+ default:
377
+ return typeof value === "string" ? value : undefined;
378
+ }
379
+ }
380
+ // A partially coerced list is the hazard the prefill exists to avoid: the
381
+ // handler replaces the list whole, so half a list overwrites the rest.
382
+ function coerceEmbeddedListRows(raw, fieldName, schema, maxItems) {
383
+ const parsed = parseJsonOrRaw(raw);
384
+ if (!Array.isArray(parsed)) {
385
+ warnPrefillParam(`embedded-list field "${fieldName}" was prefilled with something that is not a JSON array — the prefill is ignored.`);
386
+ return undefined;
387
+ }
388
+ if (parsed.length > (maxItems ?? EMBEDDED_LIST_PREFILL_ROW_LIMIT)) {
389
+ warnPrefillParam(`embedded-list field "${fieldName}" was prefilled with ${parsed.length} rows, more than it accepts — the prefill is ignored.`);
390
+ return undefined;
391
+ }
392
+ const rows = [];
393
+ for (const candidate of parsed) {
394
+ if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) {
395
+ warnPrefillParam(`embedded-list field "${fieldName}" was prefilled with a row that is not an object — the prefill is ignored.`);
396
+ return undefined;
397
+ }
398
+ const cellEntries = [];
399
+ for (const [cellName, cell] of Object.entries(schema)) {
400
+ const cellValue = candidate[cellName];
401
+ if (cellValue === undefined || cellValue === null)
402
+ continue;
403
+ const coerced = coerceEmbeddedCell(cellValue, cell);
404
+ if (coerced === undefined) {
405
+ warnPrefillParam(`embedded-list field "${fieldName}" was prefilled with a cell "${cellName}" the sub-schema rejects — the prefill is ignored.`);
406
+ return undefined;
407
+ }
408
+ cellEntries.push([cellName, coerced]);
409
+ }
410
+ rows.push(Object.fromEntries(cellEntries));
411
+ }
412
+ return rows;
413
+ }
414
+ function coercePrefillString(raw, name, shape, fallback, defaultCurrency, moneyCurrencyOverrides) {
415
+ if (shape.type === "number") {
416
+ const parsed = Number(raw);
417
+ return Number.isNaN(parsed) ? fallback : parsed;
418
+ }
419
+ if (shape.type === "money") {
420
+ const coerced = coerceMoneyValue(raw, name, moneyCurrencyOverrides?.[name] ?? defaultCurrency);
421
+ return coerced === undefined ? fallback : coerced;
422
+ }
423
+ if (shape.type === "boolean")
424
+ return raw === "true";
425
+ if (shape.type === "multiSelect") {
426
+ // Row-action navigate stringifies arrays via String(arr) → "a,b" (or
427
+ // JSON when the navigate helper JSON.stringifies). Try JSON first
428
+ // (even without a "[" prefix), then comma-split; filter against
429
+ // field.options when present so unknown values never reach the form.
430
+ const optionValues = multiSelectOptionValues(shape);
431
+ const splitComma = () => raw === ""
432
+ ? []
433
+ : raw
434
+ .split(",")
435
+ .map((s) => s.trim())
436
+ .filter(Boolean);
437
+ let values;
438
+ try {
439
+ const parsed = JSON.parse(raw);
440
+ if (Array.isArray(parsed)) {
441
+ values = parsed.map((v) => String(v));
442
+ }
443
+ else if (typeof parsed === "string") {
444
+ values = [parsed];
445
+ }
446
+ else {
447
+ values = splitComma();
448
+ }
449
+ }
450
+ catch {
451
+ values = splitComma();
452
+ }
453
+ return optionValues !== undefined ? values.filter((v) => optionValues.has(v)) : values;
454
+ }
455
+ if (shape.type === "embedded" && shape.multiple === true) {
456
+ return coerceEmbeddedListRows(raw, name, shape.schema ?? {}, shape.maxItems) ?? fallback;
457
+ }
458
+ return raw;
459
+ }
460
+ export function mergeSearchParamsIntoInitial(fields, sources) {
461
+ const { searchParams, renderableFields, defaultCurrency, moneyCurrencyOverrides, drawerOverrides, handoffValues, } = sources;
462
+ const urlPrefillFields = new Set(sources.urlPrefillFields ?? []);
463
+ const defaults = buildInitialValues(fields, defaultCurrency, moneyCurrencyOverrides);
464
+ const merged = { ...defaults };
465
+ for (const [name, fieldDef] of Object.entries(fields)) {
466
+ if (renderableFields !== undefined && !renderableFields.has(name))
467
+ continue;
468
+ const shape = fieldDef;
469
+ // Neither gate is lifted by an allowlist entry or a handoff.
470
+ if (shape.sensitive === true)
471
+ continue;
472
+ if (shape.format === "password")
473
+ continue;
474
+ if (drawerOverrides !== undefined && name in drawerOverrides) {
475
+ merged[name] = drawerOverrides[name];
476
+ continue;
477
+ }
478
+ const handedOff = handoffValues !== undefined && Object.hasOwn(handoffValues, name)
479
+ ? handoffValues[name]
480
+ : undefined;
481
+ if (handedOff !== undefined) {
482
+ merged[name] =
483
+ typeof handedOff === "string"
484
+ ? coercePrefillString(handedOff, name, shape, defaults[name], defaultCurrency, moneyCurrencyOverrides)
485
+ : handedOff;
486
+ continue;
487
+ }
488
+ if (!urlPrefillFields.has(name))
489
+ continue;
490
+ const raw = searchParams[name];
491
+ if (raw === undefined)
492
+ continue;
493
+ merged[name] = coercePrefillString(raw, name, shape, defaults[name], defaultCurrency, moneyCurrencyOverrides);
494
+ }
495
+ return merged;
496
+ }
497
+ function EntityEditScreen({ schema, screen, translate, entityId, onCopyLink, }) {
498
+ const { Banner, Text } = usePrimitives();
499
+ const entity = schema.entities[screen.entity];
500
+ if (!entity) {
501
+ return (_jsxs(Banner, { padded: true, variant: "error", testId: "kumiko-screen-entity-missing", children: ["Entity ", _jsx(Text, { variant: "code", children: screen.entity }), " referenced by screen", " ", _jsx(Text, { variant: "code", children: screen.id }), " not registered in the schema."] }));
502
+ }
503
+ // Split into create-body / update-body so the update-only hooks
504
+ // (useQuery(detail)) don't fire in create mode, and vice versa.
505
+ // Same shape as EntityListScreen.
506
+ if (entityId !== undefined) {
507
+ return (_jsx(EntityEditUpdateBody, { schema: schema, screen: screen, entity: entity, entityId: entityId, ...(translate !== undefined && { translate }), ...(onCopyLink !== undefined && { onCopyLink }) }));
508
+ }
509
+ if (screen.singleton === true) {
510
+ return (_jsx(EntityEditSingletonBody, { schema: schema, screen: screen, entity: entity, ...(translate !== undefined && { translate }), ...(onCopyLink !== undefined && { onCopyLink }) }));
511
+ }
512
+ return (_jsx(EntityEditCreateOrDisabled, { schema: schema, screen: screen, entity: entity, ...(translate !== undefined && { translate }) }));
513
+ }
514
+ function EntityEditCreateBody({ schema, screen, entity, translate, onSaved, }) {
515
+ const { Banner } = usePrimitives();
516
+ const nav = useNav();
517
+ const handoffValues = useInitialValuesHandoff(screen.id);
518
+ const appFeatures = useAppFeatures();
519
+ const returnTargetParam = useReturnTarget(screen.id);
520
+ // A singleton stays on its own screen after saving (onSaved/refetch) —
521
+ // returnTo would fight that.
522
+ const returnTarget = screen.singleton === true ? undefined : returnTargetParam;
523
+ const entityDefaultCurrency = entity.defaultCurrency ?? "EUR";
524
+ // A money field declaring `currency: { kind: "tenant" }` (fw#2933) resolves
525
+ // its empty-value currency from the tenant-settings config key instead of
526
+ // entityDefaultCurrency — `enabled` keeps the query a no-op for entities
527
+ // without any such field.
528
+ const tenantCurrencyFieldNames = useMemo(() => tenantCurrencyMoneyFieldNames(entity.fields), [entity.fields]);
529
+ const { overrides: moneyCurrencyOverrides, loading: currencyLoading } = useMoneyCurrencyOverrides(entity.fields, tenantCurrencyFieldNames, entityDefaultCurrency);
530
+ const initial = useMemo(() => mergeSearchParamsIntoInitial(entity.fields, {
531
+ searchParams: nav.searchParams,
532
+ urlPrefillFields: screen.urlPrefillFields,
533
+ renderableFields: layoutFieldNames(screen),
534
+ defaultCurrency: entityDefaultCurrency,
535
+ ...(moneyCurrencyOverrides !== undefined && { moneyCurrencyOverrides }),
536
+ ...(handoffValues !== undefined && { handoffValues }),
537
+ }), [
538
+ entity.fields,
539
+ nav.searchParams,
540
+ screen,
541
+ entityDefaultCurrency,
542
+ moneyCurrencyOverrides,
543
+ handoffValues,
544
+ ]);
545
+ const formSchema = useMemo(() => buildFormSchema(entity, screen), [entity, screen]);
546
+ const writeCommand = entityWriteCommand(schema.featureName, screen.entity, "create");
547
+ const navigateToList = useNavigateToListAfter(schema, screen.entity);
548
+ const handleCancel = useCallback(() => navigateToReturnOr(nav, returnTarget, navigateToList), [nav, returnTarget, navigateToList]);
549
+ // Create's write-handler success payload doesn't flatly expose a parent FK
550
+ // (`{ kind, id, data, … }`), so an object-form redirect's `idFrom` falls
551
+ // back to the values just submitted to the handler.
552
+ const controlsRef = useRef(null);
553
+ const handleControlsReady = useCallback((controls) => {
554
+ controlsRef.current = controls;
555
+ }, []);
556
+ const handleSubmitted = useCallback((result) => {
557
+ if (!result.isSuccess)
558
+ return;
559
+ const redirect = screen.redirect;
560
+ if (redirect !== undefined) {
561
+ if (returnToWinsOverRedirect(returnTarget, redirect, schema, appFeatures)) {
562
+ navigateToReturn(nav, returnTarget);
563
+ }
564
+ else if (typeof redirect === "string") {
565
+ // String form unchanged: always carries the newly created
566
+ // record's own id, regardless of the target screen's type. The
567
+ // object form resolves like actionForm's — a child record's own id
568
+ // is useless for a parent-detail redirect.
569
+ const entityId = extractCreatedId(result.data);
570
+ nav.navigate({
571
+ screenId: lastSegment(redirect),
572
+ ...(entityId !== undefined && { entityId }),
573
+ });
574
+ }
575
+ else {
576
+ const { screenId, entityId } = resolveRedirectTarget(redirect, result.data, schema, appFeatures, controlsRef.current?.getValues());
577
+ nav.navigate({ screenId, ...(entityId !== undefined && { entityId }) });
578
+ }
579
+ }
580
+ else {
581
+ navigateToReturnOr(nav, returnTarget, navigateToList);
582
+ onSaved?.();
583
+ }
584
+ }, [nav, screen.redirect, schema, appFeatures, navigateToList, onSaved, returnTarget]);
585
+ // Never seed a tenant-declared money field with entityDefaultCurrency while
586
+ // its real tenant currency is still in flight (fw#2933) — wait instead.
587
+ if (currencyLoading) {
588
+ return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
589
+ }
590
+ // Deliberately no `actions` prop here: `screen.actions` targets an
591
+ // EXISTING record (publish/archive/duplicate and friends), which the
592
+ // create branch has none of yet — see EntityEditUpdateForm for the
593
+ // wired-up counterpart.
594
+ return (_jsx(RenderEdit, { screen: screen, entity: entity, featureName: schema.featureName, initial: initial, schema: formSchema, writeCommand: writeCommand, onSubmit: handleSubmitted, onControlsReady: handleControlsReady, onCancel: handleCancel, ...(screenFillsHeight(screen) && { fillScreenHeight: true }), ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }), ...(translate !== undefined && { translate }) }));
595
+ }
596
+ // Update body: loads the existing record via `<feature>:query:<entity>:detail`,
597
+ // then mounts a form pre-filled with the server values and dispatches
598
+ // `<feature>:write:<entity>:update` on submit. `buildPayload` shapes the
599
+ // snapshot into Kumiko's update-event envelope `{ id, version, changes }`.
600
+ function EntityEditUpdateBody({ schema, screen, entity, entityId, translate, onCopyLink, autoReloadOnSave, onDeleted, }) {
601
+ const { Banner, Text } = usePrimitives();
602
+ const t = useTranslation();
603
+ const effectiveTranslate = translate ?? t;
604
+ const detailQn = `${toKebab(schema.featureName)}:query:${toKebab(screen.entity)}:detail`;
605
+ const detailQuery = useQuery(detailQn, { id: entityId });
606
+ if (detailQuery.loading && detailQuery.data === null) {
607
+ return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
608
+ }
609
+ if (detailQuery.error) {
610
+ return (_jsx(Banner, { padded: true, variant: "error", testId: "kumiko-screen-error", children: dispatcherErrorText(detailQuery.error, effectiveTranslate) }));
611
+ }
612
+ const record = detailQuery.data;
613
+ if (!record) {
614
+ return (_jsxs(Banner, { padded: true, variant: "error", testId: "kumiko-screen-record-missing", children: ["Record ", _jsx(Text, { variant: "code", children: entityId }), " not found."] }));
615
+ }
616
+ // Record-version als React-key: bei "Neu laden" refetched detail,
617
+ // liefert neue version, und das Form remountet komplett. Ohne den
618
+ // Key-Wechsel bliebe der useForm-Controller lifetime-scoped auf
619
+ // der ursprünglichen version sitzen — der buildPayload würde
620
+ // weiter die stale version stampen und der Konflikt-Recovery wäre
621
+ // kaputt.
622
+ const recordKey = record.version ?? 1;
623
+ return (_jsx(EntityEditUpdateForm, { schema: schema, screen: screen, entity: entity, entityId: entityId, record: record, onReload: detailQuery.refetch, ...(translate !== undefined && { translate }), ...(onCopyLink !== undefined && { onCopyLink }), ...(autoReloadOnSave === true && { onSaved: () => void detailQuery.refetch() }), ...(onDeleted !== undefined && { onDeleted }) }, `${entityId}:${recordKey}`));
624
+ }
625
+ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onReload, translate, onCopyLink, onSaved, onDeleted, }) {
626
+ const { Banner } = usePrimitives();
627
+ // Seed the form with the server values for the entity's declared
628
+ // fields; anything else (id, tenant_id, created_at…) stays out of
629
+ // the form and lives in the closure. The record's `version` is
630
+ // captured once and stamped into every update payload — if a
631
+ // concurrent writer bumps it, the server returns a version-conflict
632
+ // error and the user reloads.
633
+ const recordVersion = record.version ?? 1;
634
+ const entityDefaultCurrency = entity.defaultCurrency ?? "EUR";
635
+ // A money field declaring `currency: { kind: "tenant" }` (fw#2933) resolves
636
+ // its empty-value currency from the tenant-settings config key instead of
637
+ // entityDefaultCurrency — `enabled` keeps the query a no-op for entities
638
+ // without any such field. A record's own stored value (below) always wins
639
+ // over this, regardless of the declaration.
640
+ //
641
+ // Only fields the record itself has no value for need the fetch at all —
642
+ // an already-set tenant-declared field keeps its own stored currency and
643
+ // must never block the form on this query.
644
+ const tenantCurrencyFieldNames = useMemo(() => tenantCurrencyMoneyFieldNames(entity.fields).filter((name) => record[name] === null || record[name] === undefined), [entity.fields, record]);
645
+ const { overrides: moneyCurrencyOverrides, loading: currencyLoading } = useMoneyCurrencyOverrides(entity.fields, tenantCurrencyFieldNames, entityDefaultCurrency);
646
+ const initial = useMemo(() => {
647
+ const out = {};
648
+ for (const name of Object.keys(entity.fields)) {
649
+ out[name] =
650
+ record[name] ??
651
+ buildInitialValues({ [name]: entity.fields[name] }, entityDefaultCurrency, moneyCurrencyOverrides)[name];
652
+ }
653
+ return out;
654
+ }, [entity.fields, entityDefaultCurrency, moneyCurrencyOverrides, record]);
655
+ const formSchema = useMemo(() => buildFormSchema(entity, screen), [entity, screen]);
656
+ // Extension-Werte (z.B. customFields-jsonb) an extension-sections geben,
657
+ // damit sie beim Edit den Bestand zeigen statt write-only zu sein.
658
+ const extensionInitialValues = useMemo(() => {
659
+ const cf = record["customFields"];
660
+ return cf !== null && typeof cf === "object" && !Array.isArray(cf)
661
+ ? cf
662
+ : undefined;
663
+ }, [record]);
664
+ const writeCommand = entityWriteCommand(schema.featureName, screen.entity, "update");
665
+ const deleteCommand = entityWriteCommand(schema.featureName, screen.entity, "delete");
666
+ const buildPayload = useMemo(() => (snap) => ({
667
+ id: entityId,
668
+ version: recordVersion,
669
+ [UPDATE_CHANGES_KEY]: snap.changes,
670
+ }), [entityId, recordVersion]);
671
+ const nav = useNav();
672
+ const appFeatures = useAppFeatures();
673
+ const dispatcher = useDispatcher();
674
+ const t = useTranslation();
675
+ const effectiveTranslate = translate ?? t;
676
+ const navigateToList = useNavigateToListAfter(schema, screen.entity);
677
+ const userRoles = useUserRoles();
678
+ const host = useReturnHost();
679
+ const returnTargetParam = useReturnTarget(screen.id);
680
+ // A singleton stays on its own screen after saving (onSaved/refetch) —
681
+ // returnTo would fight that.
682
+ const returnTarget = screen.singleton === true ? undefined : returnTargetParam;
683
+ const { drawerAction, drawerScreen, drawerInitialValues, openDrawer, closeDrawer } = useDrawerAction(schema);
684
+ // Header action buttons (fw entityEdit-actions) — same shape/dispatch
685
+ // pattern as ProjectionDetailBody.headerActions above (the edited record
686
+ // stands in for the "row"), minus the cross-feature defaultEditAction
687
+ // lookup that doesn't apply here (this screen already IS the edit form).
688
+ const buildSectionActions = useCallback((actions) => buildRecordActions({
689
+ actions,
690
+ record,
691
+ translate: effectiveTranslate,
692
+ nav,
693
+ host,
694
+ dispatcher,
695
+ openDrawer,
696
+ onWriteSuccess: onReload,
697
+ defaultScreenTargetEntityId: entityId,
698
+ }), [record, effectiveTranslate, nav, host, dispatcher, openDrawer, onReload, entityId]);
699
+ const headerActions = useMemo(() => buildSectionActions(screen.actions ?? []), [buildSectionActions, screen.actions]);
700
+ const handleSubmitted = useCallback((result) => {
701
+ if (!result.isSuccess)
702
+ return;
703
+ const redirect = screen.redirect;
704
+ if (redirect !== undefined) {
705
+ if (returnToWinsOverRedirect(returnTarget, redirect, schema, appFeatures)) {
706
+ navigateToReturn(nav, returnTarget);
707
+ }
708
+ else if (typeof redirect === "string") {
709
+ // String form unchanged: navigates without an entityId, same as
710
+ // before the object form existed. The object form resolves like
711
+ // actionForm's — the update handler's success payload usually
712
+ // reports only this record's own id (event-store-executor-write.ts),
713
+ // so a parent FK named by `idFrom` falls back to the already-loaded
714
+ // `record`.
715
+ nav.navigate({ screenId: lastSegment(redirect) });
716
+ }
717
+ else {
718
+ const { screenId, entityId: targetEntityId } = resolveRedirectTarget(redirect, result.data, schema, appFeatures, record);
719
+ nav.navigate({
720
+ screenId,
721
+ ...(targetEntityId !== undefined && { entityId: targetEntityId }),
722
+ });
723
+ }
724
+ }
725
+ else {
726
+ navigateToReturnOr(nav, returnTarget, navigateToList);
727
+ onSaved?.();
728
+ }
729
+ }, [nav, screen.redirect, schema, appFeatures, record, navigateToList, onSaved, returnTarget]);
730
+ const handleCancel = useCallback(() => navigateToReturnOr(nav, returnTarget, navigateToList), [nav, returnTarget, navigateToList]);
731
+ const handleDelete = useCallback(async () => {
732
+ const res = await dispatcher.write(deleteCommand, { id: entityId });
733
+ if (res.isSuccess) {
734
+ // Never return onto the just-deleted record.
735
+ const target = returnTarget?.entityId !== entityId ? returnTarget : undefined;
736
+ navigateToReturnOr(nav, target, navigateToList);
737
+ onDeleted?.();
738
+ }
739
+ }, [dispatcher, deleteCommand, entityId, navigateToList, onDeleted, returnTarget, nav]);
740
+ // Never seed a tenant-declared money field with entityDefaultCurrency while
741
+ // its real tenant currency is still in flight (fw#2933) — wait instead. A
742
+ // stored (non-empty) value is unaffected — it never reaches this branch's
743
+ // fallback because `initial` above already prefers `record[name]`.
744
+ if (currencyLoading) {
745
+ return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
746
+ }
747
+ return (_jsxs(_Fragment, { children: [_jsx(RenderEdit, { screen: screen, entity: entity, featureName: schema.featureName, initial: initial,
748
+ // Real route id for the extension section (set-value UI): the update
749
+ // form deliberately keeps `id` out of the form values, so the section
750
+ // needs it explicitly — otherwise it renders in create mode.
751
+ entityId: entityId,
752
+ // Stored customFields for the extension section, so editing shows the
753
+ // persisted values instead of behaving write-only.
754
+ extensionInitialValues: extensionInitialValues, schema: formSchema, writeCommand: writeCommand, payloadMode: "changes", buildPayload: buildPayload, serverFieldPathPrefix: `${UPDATE_CHANGES_KEY}.`, onSubmit: handleSubmitted, ...(screen.allowDelete !== false && { onDelete: handleDelete }), onCancel: handleCancel, onReload: () => void onReload(), ...(screenFillsHeight(screen) && { fillScreenHeight: true }), ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }), ...(translate !== undefined && { translate }), ...(onCopyLink !== undefined && { onCopyLink }), ...(headerActions !== undefined && { actions: headerActions }), buildSectionActions: buildSectionActions }), _jsx(DrawerHost, { schema: schema, drawerAction: drawerAction, drawerScreen: drawerScreen, ...(drawerInitialValues !== undefined && { drawerInitialValues }), userRoles: userRoles, ...(translate !== undefined && { translate }), onClose: closeDrawer, onSuccess: () => {
755
+ closeDrawer();
756
+ void onReload();
757
+ } })] }));
758
+ }
759
+ // Singleton entities (`singleton: true`, exactly one record per tenant):
760
+ // EntityEditScreen reaches here only without an entityId. Resolve the
761
+ // existing record via list(limit:1) before deciding create vs update —
762
+ // otherwise every visit without an id would create another record.
763
+ function EntityEditSingletonBody({ schema, screen, entity, translate, onCopyLink, }) {
764
+ const { Banner } = usePrimitives();
765
+ const t = useTranslation();
766
+ const effectiveTranslate = translate ?? t;
767
+ const listQn = entityQueryCommand(schema.featureName, screen.entity, "list");
768
+ const listQuery = useQuery(listQn, { limit: 1 });
769
+ if (listQuery.loading && listQuery.data === null) {
770
+ return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
771
+ }
772
+ if (listQuery.error) {
773
+ return (_jsx(Banner, { padded: true, variant: "error", testId: "kumiko-screen-error", children: dispatcherErrorText(listQuery.error, effectiveTranslate) }));
774
+ }
775
+ const rawExistingId = listQuery.data?.rows[0]?.["id"];
776
+ const existingId = typeof rawExistingId === "string" ? rawExistingId : undefined;
777
+ if (existingId !== undefined) {
778
+ return (_jsx(EntityEditUpdateBody, { schema: schema, screen: screen, entity: entity, entityId: existingId, ...(translate !== undefined && { translate }), ...(onCopyLink !== undefined && { onCopyLink }), autoReloadOnSave: true, onDeleted: () => void listQuery.refetch() }));
779
+ }
780
+ return (_jsx(EntityEditCreateOrDisabled, { schema: schema, screen: screen, entity: entity, ...(translate !== undefined && { translate }), onSaved: () => void listQuery.refetch() }));
781
+ }
782
+ // Shared no-entityId tail for both the plain create path and the
783
+ // singleton path's empty-table fallback: `allowCreate: false` blocks
784
+ // both the same way, since a create submit there would hit an
785
+ // unregistered `<entity>:create` handler.
786
+ function EntityEditCreateOrDisabled({ schema, screen, entity, translate, onSaved, }) {
787
+ const { Banner, Text } = usePrimitives();
788
+ if (screen.allowCreate === false) {
789
+ return (_jsxs(Banner, { padded: true, variant: "error", testId: "kumiko-screen-create-disabled", children: ["Screen ", _jsx(Text, { variant: "code", children: screen.id }), " is update-only (allowCreate: false) \u2014 open it from a row action with an entity id."] }));
790
+ }
791
+ return (_jsx(EntityEditCreateBody, { schema: schema, screen: screen, entity: entity, ...(translate !== undefined && { translate }), ...(onSaved !== undefined && { onSaved }) }));
792
+ }
793
+ // ---- entity-list ----
794
+ function entityQueryCommand(featureName, entity, verb) {
795
+ return `${toKebab(featureName)}:query:${toKebab(entity)}:${verb}`;
796
+ }
797
+ // Payload for the server-side list query handler (LIST_PAYLOAD_SCHEMA):
798
+ // search/sort/sortDirection/limit + offset/totalCount for pager mode OR
799
+ // cursor for infinite scroll. Shared by EntityListBody and
800
+ // ProjectionListBody so the two branches can't drift on this shape
801
+ // (fw#2165) — entity-only additions (screen.filter, faceted filters) are
802
+ // layered on top by the caller instead of living in here.
803
+ export function buildListQueryPayload(state) {
804
+ const payload = { limit: state.limit };
805
+ if (state.search !== "")
806
+ payload["search"] = state.search;
807
+ if (state.sort !== null) {
808
+ payload["sort"] = state.sort.field;
809
+ payload["sortDirection"] = state.sort.dir;
810
+ }
811
+ if (state.usePager) {
812
+ // page=1 → offset=0, page=2 → offset=limit, etc. Server clamps itself
813
+ // when offset >= total.
814
+ const offset = (state.page - 1) * state.limit;
815
+ if (offset > 0)
816
+ payload["offset"] = offset;
817
+ // totalCount: extra COUNT(*) so the pager can render "Page X of Y".
818
+ payload["totalCount"] = true;
819
+ }
820
+ else if (state.useInfinite && state.cursor !== undefined) {
821
+ payload["cursor"] = state.cursor;
822
+ }
823
+ return payload;
824
+ }
825
+ // entityList adapter — one DataTableFacet per filterable select/boolean
826
+ // entity field, labels via the standard field/option i18n convention.
827
+ function resolveEntityFacetSpecs(fields, featureName, entityName, translate) {
828
+ const out = [];
829
+ for (const [field, rawDef] of Object.entries(fields)) {
830
+ // entity.fields ist am Renderer-Layer schwach getypt (Record<string,
831
+ // unknown>, vom Schema deserialisiert) — Boundary-Cast wie buildInitialValues.
832
+ const def = rawDef;
833
+ if (def.filterable !== true)
834
+ continue;
835
+ const label = translate(fieldLabelKey(featureName, entityName, field));
836
+ if (def.type === "select" && Array.isArray(def.options)) {
837
+ out.push({
838
+ field,
839
+ type: "select",
840
+ label,
841
+ options: def.options.map((value) => ({
842
+ value,
843
+ label: translate(fieldOptionLabelKey(featureName, entityName, field, value)),
844
+ })),
845
+ });
846
+ }
847
+ else if (def.type === "boolean") {
848
+ out.push({
849
+ field,
850
+ type: "boolean",
851
+ label,
852
+ options: [
853
+ {
854
+ value: "true",
855
+ label: translate(fieldOptionLabelKey(featureName, entityName, field, "true")),
856
+ },
857
+ {
858
+ value: "false",
859
+ label: translate(fieldOptionLabelKey(featureName, entityName, field, "false")),
860
+ },
861
+ ],
862
+ });
863
+ }
864
+ else if (def.type === "reference" && def.entity !== undefined) {
865
+ const target = parseRefTarget(def.entity, featureName);
866
+ out.push({
867
+ field,
868
+ type: "reference",
869
+ label,
870
+ options: [],
871
+ reference: {
872
+ refEntity: target.entityName,
873
+ refFeature: target.featureName,
874
+ labelField: def.labelField ?? "id",
875
+ },
876
+ });
877
+ }
878
+ }
879
+ return out;
880
+ }
881
+ function useDrawerAction(schema) {
882
+ const [drawerState, setDrawerState] = useState(null);
883
+ const drawerAction = drawerState?.action ?? null;
884
+ const drawerScreen = useMemo(() => {
885
+ if (drawerAction === null)
886
+ return undefined;
887
+ // Same same-feature, short-id resolution as runNavigate — the drawer's
888
+ // `screen` reference is scoped to schema.screens, not cross-feature.
889
+ return schema.screens.find((s) => s.type === "actionForm" && lastSegment(s.id) === drawerAction.screen);
890
+ }, [drawerAction, schema.screens]);
891
+ const openDrawer = useCallback((action, initialValues) => setDrawerState({ action, initialValues }), []);
892
+ const closeDrawer = useCallback(() => setDrawerState(null), []);
893
+ return {
894
+ drawerAction,
895
+ drawerScreen,
896
+ drawerInitialValues: drawerState?.initialValues,
897
+ openDrawer,
898
+ closeDrawer,
899
+ };
900
+ }
901
+ function DrawerHost({ schema, drawerAction, drawerScreen, drawerInitialValues, userRoles, translate, onClose, onSuccess, }) {
902
+ const { Drawer, Banner, Text, Dialog } = usePrimitives();
903
+ const t = useTranslation();
904
+ const effectiveTranslate = translate ?? t;
905
+ const [hasUnsavedInput, setHasUnsavedInput] = useState(false);
906
+ const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false);
907
+ const closeAndReset = useCallback(() => {
908
+ setHasUnsavedInput(false);
909
+ setConfirmDiscardOpen(false);
910
+ onClose();
911
+ }, [onClose]);
912
+ const requestClose = useCallback(() => {
913
+ if (hasUnsavedInput)
914
+ setConfirmDiscardOpen(true);
915
+ else
916
+ closeAndReset();
917
+ }, [hasUnsavedInput, closeAndReset]);
918
+ // Drawer is an optional Core-Primitive (additive rollout) — same "skip +
919
+ // warn once" precedent as rowActions without a mounted DispatcherProvider
920
+ // above, instead of crashing when a web app hasn't upgraded its
921
+ // createKumikoApp wiring yet.
922
+ useEffect(() => {
923
+ if (drawerAction !== null && Drawer === undefined) {
924
+ // biome-ignore lint/suspicious/noConsole: dev-warning for a setup error
925
+ console.warn(`[kumiko] toolbarAction "${drawerAction.id}" is kind:"drawer", but no <Drawer> primitive is registered — it will not open. createKumikoApp() from kumiko-renderer-web wires it automatically.`);
926
+ }
927
+ }, [drawerAction, Drawer]);
928
+ if (drawerAction === null || Drawer === undefined)
929
+ return null;
930
+ // Access mirrors kind:"navigate" exactly: the toolbar button itself stays
931
+ // visible either way (same as navigate — access is enforced at the
932
+ // target, not by hiding the trigger), but the drawer shows the same
933
+ // "Access denied" state KumikoScreen's top-level gate would show for a
934
+ // direct hit on that screen, never the form.
935
+ const allowed = screenAccessAllows(drawerScreen?.access, userRoles);
936
+ return (_jsxs(_Fragment, { children: [_jsx(Drawer, { open: true, onOpenChange: (open) => {
937
+ if (!open)
938
+ requestClose();
939
+ }, title: effectiveTranslate(drawerAction.label), testId: `toolbar-drawer-${drawerAction.id}`, children: _jsx(PageHeaderSlotAvailableProvider, { value: false, children: _jsx(InsideDrawerProvider, { value: true, children: drawerScreen === undefined ? (_jsxs(Banner, { padded: true, variant: "error", testId: "kumiko-toolbar-drawer-not-found", children: ["Screen not found: ", _jsx(Text, { variant: "code", children: drawerAction.screen })] })) : !allowed ? (_jsxs(Banner, { padded: true, variant: "error", testId: "kumiko-toolbar-drawer-access-denied", children: ["Access denied: ", _jsx(Text, { variant: "code", children: drawerAction.screen })] })) : (_jsx(ActionFormBody, { schema: schema, screen: drawerScreen, ...(translate !== undefined && { translate }), ...(drawerInitialValues !== undefined && {
940
+ initialOverrides: drawerInitialValues,
941
+ }), onSuccess: () => {
942
+ setHasUnsavedInput(false);
943
+ onSuccess();
944
+ }, onCancelOverride: requestClose, onDirtyChange: setHasUnsavedInput, submitLabelFallback: drawerAction.label })) }) }) }), _jsx(Dialog, { open: confirmDiscardOpen, onOpenChange: setConfirmDiscardOpen, title: t("kumiko.drawer.discard.title"), description: t("kumiko.drawer.discard.body"), confirmLabel: t("kumiko.drawer.discard.confirm"), cancelLabel: t("kumiko.drawer.discard.cancel"), variant: "danger", initialFocus: "cancel", onConfirm: closeAndReset, testId: "drawer-discard-dialog" })] }));
945
+ }
946
+ function EntityListScreen({ schema, screen, translate, onRowClick, }) {
947
+ const { Banner, Text } = usePrimitives();
948
+ const entity = schema.entities[screen.entity];
949
+ if (!entity) {
950
+ return (_jsxs(Banner, { padded: true, variant: "error", testId: "kumiko-screen-entity-missing", children: ["Entity ", _jsx(Text, { variant: "code", children: screen.entity }), " referenced by screen", " ", _jsx(Text, { variant: "code", children: screen.id }), " not registered in the schema."] }));
951
+ }
952
+ // Entity resolved — mount the inner component so useQuery only fires
953
+ // when there's actually something to render. A missing entity is a
954
+ // dev-error state; no point hitting the server for it.
955
+ return (_jsx(EntityListBody, { schema: schema, screen: screen, entity: entity, ...(translate !== undefined && { translate }), ...(onRowClick !== undefined && { onRowClick }) }));
956
+ }
957
+ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
958
+ const featureName = schema.featureName;
959
+ const onCreate = useNavigateToCreateFor(schema, screen.entity);
960
+ const { Banner } = usePrimitives();
961
+ const queryType = entityQueryCommand(featureName, screen.entity, "list");
962
+ const nav = useNav();
963
+ const host = useReturnHost();
964
+ const userRoles = useUserRoles();
965
+ const appFeatures = useAppFeatures();
966
+ const { drawerAction, drawerScreen, drawerInitialValues, openDrawer, closeDrawer } = useDrawerAction(schema);
967
+ const defaultEditScreen = useMemo(() => findEditScreenFor(screen.entity, appFeatures, userRoles), [appFeatures, screen.entity, userRoles]);
968
+ // URL-State: sort/dir/q/page leben unter dem screen.id-Namespace
969
+ // (`/orders?orders.sort=createdAt&orders.dir=desc&orders.q=acme`),
970
+ // damit zwei Lists auf derselben Route nicht über dieselben
971
+ // Query-Keys streiten. Default-Sort aus der Screen-Def gewinnt nur
972
+ // wenn URL keinen sort hat — Author-Default vs User-Choice.
973
+ const urlState = useListUrlState(screen.id);
974
+ const effectiveSort = urlState.sort ?? screen.defaultSort ?? null;
975
+ const limit = urlState.pageSize ?? screen.pageSize ?? 50;
976
+ const paginationMode = screen.pagination ?? "pages";
977
+ const usePager = paginationMode === "pages";
978
+ const useInfinite = paginationMode === "infinite";
979
+ // Infinite-Scroll: lokaler State akkumuliert rows über mehrere
980
+ // useQuery-Aufrufe (statt replacen wie bei Pager). cursor wechselt
981
+ // wenn der User den Bottom-Sentinel erreicht; bei sort/q-Change
982
+ // resetten wir alles, da die Insert-Order der akkumulierten Rows
983
+ // sonst inkonsistent ist mit der neuen Sortierung.
984
+ const [accumulated, setAccumulated] = useState([]);
985
+ const [cursor, setCursor] = useState(undefined);
986
+ const [hasMore, setHasMore] = useState(true);
987
+ // Ref damit der useEffect-Cleanup nicht alten state sieht.
988
+ const sortQRef = useRef("");
989
+ const sortQKey = `${urlState.q}|${effectiveSort?.field ?? ""}|${effectiveSort?.dir ?? ""}`;
990
+ // Bei sort/q-Wechsel rows + cursor reseten — vor dem nächsten useQuery
991
+ // damit der Reload mit cursor=undefined startet.
992
+ useEffect(() => {
993
+ if (!useInfinite)
994
+ return;
995
+ if (sortQRef.current === sortQKey)
996
+ return;
997
+ sortQRef.current = sortQKey;
998
+ setAccumulated([]);
999
+ setCursor(undefined);
1000
+ setHasMore(true);
1001
+ }, [useInfinite, sortQKey]);
1002
+ // User-gewählte Faceted-Filter aus dem URL-State → payload.filters.
1003
+ // typeOf liest den Field-Type direkt aus entity.fields (nicht gated auf
1004
+ // filterable — bewusst permissiv, siehe buildFilterPayload-Doc).
1005
+ const filterPayload = useMemo(() => buildFilterPayload(urlState.filters, (field) => {
1006
+ // entity.fields ist am Renderer-Layer schwach getypt (vom Schema
1007
+ // deserialisiert) — Boundary-Cast wie buildInitialValues.
1008
+ const def = entity.fields[field];
1009
+ return def?.type;
1010
+ }), [urlState.filters, entity.fields]);
1011
+ // Entity-only additions (screen.filter, faceted filters) layer on top of
1012
+ // the shared buildListQueryPayload — projectionList has neither.
1013
+ const queryPayload = useMemo(() => {
1014
+ const payload = buildListQueryPayload({
1015
+ limit,
1016
+ search: urlState.q,
1017
+ sort: effectiveSort,
1018
+ usePager,
1019
+ page: urlState.page,
1020
+ useInfinite,
1021
+ cursor,
1022
+ });
1023
+ // Screen-Filter (Tier 2.7c) — vom Author am Schema deklariert,
1024
+ // unabhängig vom User-q-Search. Mehrere Buckets derselben Entity
1025
+ // ("Upcoming" / "Active" / "Past") nutzen unterschiedliche filter
1026
+ // bei gleichem Query-Handler.
1027
+ if (screen.filter !== undefined) {
1028
+ payload["filter"] = screen.filter;
1029
+ }
1030
+ if (filterPayload.length > 0) {
1031
+ payload["filters"] = filterPayload;
1032
+ }
1033
+ return payload;
1034
+ }, [
1035
+ limit,
1036
+ urlState.q,
1037
+ effectiveSort,
1038
+ screen.filter,
1039
+ filterPayload,
1040
+ usePager,
1041
+ urlState.page,
1042
+ useInfinite,
1043
+ cursor,
1044
+ ]);
1045
+ const rowsQuery = useQuery(queryType, queryPayload, { live: true });
1046
+ const refreshRowsAfterWrite = useCallback(async () => {
1047
+ if (useInfinite) {
1048
+ setAccumulated([]);
1049
+ setCursor(undefined);
1050
+ setHasMore(true);
1051
+ return;
1052
+ }
1053
+ await refetchAfterWrite(rowsQuery.refetch);
1054
+ }, [useInfinite, rowsQuery.refetch]);
1055
+ // Infinite-Scroll: bei jedem erfolgreichen Result die rows appenden +
1056
+ // hasMore aus nextCursor ableiten. Live-Updates (postgres NOTIFY) und
1057
+ // initiale Loads laufen beide hier durch — das useEffect-Dep-Array
1058
+ // pinnt die Dedup auf dem letzten verarbeiteten data-Pointer.
1059
+ const lastDataRef = useRef(null);
1060
+ useEffect(() => {
1061
+ if (!useInfinite)
1062
+ return;
1063
+ const data = rowsQuery.data;
1064
+ if (data === null)
1065
+ return;
1066
+ if (data === lastDataRef.current)
1067
+ return;
1068
+ lastDataRef.current = data;
1069
+ setAccumulated((prev) => {
1070
+ // Wenn cursor undefined ist, ist das die erste Page nach
1071
+ // sort/q-Change → komplett ersetzen statt anhängen. Sonst dedupe
1072
+ // auf id falls Live-Updates einen Eintrag in der nächsten Page
1073
+ // bringen die schon angehängt war.
1074
+ if (cursor === undefined)
1075
+ return data.rows;
1076
+ const seen = new Set(prev.map((r) => r["id"]));
1077
+ const fresh = data.rows.filter((r) => !seen.has(r["id"]));
1078
+ return [...prev, ...fresh];
1079
+ });
1080
+ setHasMore(data.nextCursor !== null);
1081
+ }, [rowsQuery.data, useInfinite, cursor]);
1082
+ const loadMore = useCallback(() => {
1083
+ if (!useInfinite)
1084
+ return;
1085
+ if (rowsQuery.loading)
1086
+ return;
1087
+ const data = rowsQuery.data;
1088
+ if (data?.nextCursor === undefined || data.nextCursor === null)
1089
+ return;
1090
+ setCursor(data.nextCursor);
1091
+ }, [useInfinite, rowsQuery.loading, rowsQuery.data]);
1092
+ // RowActions: Schema-Form (handler-QN + i18n-Key) → Resolved-Form
1093
+ // (dispatcher-Call + translated Strings). dispatcher.write kennt den
1094
+ // User intern (JWT-Cookie). Schema kann sowohl raw-Strings als auch
1095
+ // i18n-Keys enthalten — translate() returnt den Key wenn das Bundle
1096
+ // ihn nicht kennt (Convention überall im Renderer).
1097
+ // Hooks-Reihenfolge: ALLE Hooks vor early-return für loading/error,
1098
+ // sonst kollidieren die Hook-Slots zwischen Renders.
1099
+ const t = useTranslation();
1100
+ const effectiveTranslate = translate ?? t;
1101
+ // Faceted-Filter: ein Dropdown pro filterable select/boolean/reference-Feld.
1102
+ // Labels + select-Option-Labels über dieselbe i18n-Konvention wie die
1103
+ // Spalten-Header (fieldLabelKey / :option:<value>); reference-Felder laden
1104
+ // ihre Optionen asynchron über ReferenceFacetBridges.
1105
+ const entityFacetSpecs = useMemo(() => resolveEntityFacetSpecs(entity.fields, featureName, screen.entity, effectiveTranslate), [entity.fields, featureName, screen.entity, effectiveTranslate]);
1106
+ const [entityReferenceFacetOptions, setEntityReferenceFacetOptions] = useState({});
1107
+ const handleEntityFacetOptions = useCallback((field, options) => setEntityReferenceFacetOptions((prev) => prev[field] === options ? prev : { ...prev, [field]: options }), []);
1108
+ const filterFacets = useMemo(() => buildFilterFacets(mergeReferenceFacetOptions(entityFacetSpecs, entityReferenceFacetOptions)), [entityFacetSpecs, entityReferenceFacetOptions]);
1109
+ // Soft-Dispatcher: in Tests die ohne DispatcherProvider mounten,
1110
+ // bleibt rowActions undefined statt zu crashen. Echte Apps haben
1111
+ // den Provider via createKumikoApp — wenn nicht, ist es vermutlich
1112
+ // ein Setup-Fehler, also einmal warnen damit der Author das findet
1113
+ // (sonst rendert die Action-Spalte still nichts und der "warum sind
1114
+ // meine Buttons weg?"-Debug ist teuer).
1115
+ const dispatcher = useOptionalDispatcher();
1116
+ const hasRowActions = screen.rowActions !== undefined && screen.rowActions.length > 0;
1117
+ useEffect(() => {
1118
+ if (hasRowActions && dispatcher === undefined) {
1119
+ // biome-ignore lint/suspicious/noConsole: dev-warning für Setup-Fehler
1120
+ console.warn(`[kumiko] Screen "${screen.id}" deklariert rowActions, aber kein <DispatcherProvider> ist mounted — die Action-Spalte wird nicht gerendert. createKumikoApp() wired den Provider automatisch.`);
1121
+ }
1122
+ }, [hasRowActions, dispatcher, screen.id]);
1123
+ // Discriminated Union: writeHandler (default) dispatched einen Server-
1124
+ // Handler, navigate ruft nav.navigate() ggf. mit URL-Search-Params aus
1125
+ // params(row). nav lebt schon weiter unten in EntityListBody-Scope.
1126
+ // Gemeinsame navigate-Ausführung: sowohl das "…"-Aktionsmenü (rowActions) als
1127
+ // auch der Row-Body-Klick (rowClick) laufen hierdurch — ein Pfad, damit beide
1128
+ // nicht auseinanderdriften.
1129
+ const runNavigate = useCallback((action, row) => {
1130
+ if (action.entity !== undefined) {
1131
+ // Entity-Targets (fw#2228) lösen sich erst in der NavApi-Impl gegen
1132
+ // ALLE Features auf (resolveTarget in renderer-web/nav.tsx) — dieses
1133
+ // Package kennt nur `schema` (das eine aufrufende Feature) und kann
1134
+ // detailFor cross-feature nicht selbst nachschlagen.
1135
+ const explicit = action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : undefined;
1136
+ // Same-entity only — cross-entity targets must declare entityId (boot-validator).
1137
+ const fallback = action.entity === screen.entity ? String(row.values["id"] ?? "") : undefined;
1138
+ const id = explicit ?? fallback ?? "";
1139
+ if (id === "")
1140
+ return;
1141
+ const params = navigateActionSearchParams(action, row.values);
1142
+ navigateWithReturnTo(nav, { entity: action.entity, id }, host, params);
1143
+ }
1144
+ else if (action.screen !== undefined) {
1145
+ // Default entityId für entityEdit-Targets: row["id"] wenn kein expliziter
1146
+ // entityId-Feldname gesetzt ist. Nur für Targets DERSELBEN Entity — sonst
1147
+ // bekäme ein Cross-Entity-Edit-Screen die falsche row.id injiziert.
1148
+ const targetIsEntityEdit = schema.screens.some((s) => s.type === "entityEdit" &&
1149
+ s.entity === screen.entity &&
1150
+ lastSegment(s.id) === action.screen);
1151
+ const explicit = action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : undefined;
1152
+ const fallback = targetIsEntityEdit ? String(row.values["id"] ?? "") : undefined;
1153
+ const entityId = explicit ?? fallback;
1154
+ const target = {
1155
+ screenId: action.screen,
1156
+ ...(entityId !== undefined && entityId !== "" && { entityId }),
1157
+ };
1158
+ // setSearchParams nimmt string|null — komplexe Werte zu String (der Reader
1159
+ // kennt via URL nur Strings). Known-edge: zielt die Action auf den
1160
+ // AKTUELLEN pathname, mergen die Params auf den alten ?-String (für
1161
+ // Row-Actions praktisch nicht erreichbar, Pfad differiert).
1162
+ const params = navigateActionSearchParams(action, row.values);
1163
+ navigateWithReturnTo(nav, target, host, params);
1164
+ }
1165
+ }, [nav, schema.screens, screen.entity, host]);
1166
+ const rowActions = useMemo(() => {
1167
+ const declared = screen.rowActions ?? [];
1168
+ // Prepended unless a declared rowAction already has id "edit" — declared wins.
1169
+ const declaredHasEdit = declared.some((a) => a.id === "edit");
1170
+ const defaultEditRowAction = declaredHasEdit
1171
+ ? undefined
1172
+ : buildDefaultEditRowAction(defaultEditScreen);
1173
+ const effectiveActions = defaultEditRowAction !== undefined ? [defaultEditRowAction, ...declared] : declared;
1174
+ if (effectiveActions.length === 0)
1175
+ return undefined;
1176
+ return effectiveActions
1177
+ .map((action) => {
1178
+ // navigate-Variante braucht keinen Dispatcher; nav ist
1179
+ // immer da (Provider von createKumikoApp).
1180
+ if (action.kind === "navigate") {
1181
+ const navigateAction = action;
1182
+ const actionVisible = action.visible;
1183
+ const actionIcon = resolveActionIcon(action.id, action.icon);
1184
+ return {
1185
+ id: action.id,
1186
+ label: effectiveTranslate(action.label),
1187
+ ...(action.style !== undefined && { style: action.style }),
1188
+ confirmRequired: false,
1189
+ ...(navigateAction.rowClick === true && { rowClick: true }),
1190
+ ...(actionIcon !== undefined && { icon: actionIcon }),
1191
+ onTrigger: (row) => runNavigate(navigateAction, row),
1192
+ ...(actionVisible !== undefined && {
1193
+ isVisible: (row) => evalFieldCondition(actionVisible, row.values),
1194
+ }),
1195
+ };
1196
+ }
1197
+ if (action.kind === "drawer") {
1198
+ const drawerAction = action;
1199
+ const actionVisible = action.visible;
1200
+ const actionIcon = resolveActionIcon(action.id, action.icon);
1201
+ return {
1202
+ id: action.id,
1203
+ label: effectiveTranslate(action.label),
1204
+ ...(action.style !== undefined && { style: action.style }),
1205
+ confirmRequired: false,
1206
+ ...(actionIcon !== undefined && { icon: actionIcon }),
1207
+ onTrigger: (row) => {
1208
+ const initialValues = drawerAction.params !== undefined
1209
+ ? evalRowExtractor(drawerAction.params, row.values)
1210
+ : undefined;
1211
+ openDrawer(drawerAction, initialValues);
1212
+ },
1213
+ ...(actionVisible !== undefined && {
1214
+ isVisible: (row) => evalFieldCondition(actionVisible, row.values),
1215
+ }),
1216
+ };
1217
+ }
1218
+ if (dispatcher === undefined)
1219
+ return null;
1220
+ if (!isWriteHandlerRowAction(action))
1221
+ return null;
1222
+ const writeAction = action;
1223
+ const writeActionVisible = writeAction.visible;
1224
+ return {
1225
+ id: writeAction.id,
1226
+ label: effectiveTranslate(writeAction.label),
1227
+ style: writeAction.style,
1228
+ icon: resolveActionIcon(writeAction.id, writeAction.icon),
1229
+ confirm: writeAction.confirm !== undefined ? effectiveTranslate(writeAction.confirm) : undefined,
1230
+ confirmLabel: writeAction.confirmLabel !== undefined
1231
+ ? effectiveTranslate(writeAction.confirmLabel)
1232
+ : undefined,
1233
+ onTrigger: async (row) => {
1234
+ const buildPayload = writeAction.payload;
1235
+ const payload = buildPayload !== undefined
1236
+ ? evalRowExtractor(buildPayload, row.values)
1237
+ : { id: row.values["id"] };
1238
+ const result = await dispatcher.write(writeAction.handler, payload);
1239
+ // write() wirft nicht — Failure-Result MUSS hier zum Error
1240
+ // werden, sonst schließt der Confirm-Dialog kommentarlos und
1241
+ // der User sieht "nichts passiert" (Prod-Bug 2026-06-07).
1242
+ if (!result.isSuccess) {
1243
+ throw new WriteFailedError(result.error, dispatcherErrorText(result.error, effectiveTranslate));
1244
+ }
1245
+ await refreshRowsAfterWrite();
1246
+ },
1247
+ isVisible: writeActionVisible !== undefined
1248
+ ? (row) => evalFieldCondition(writeActionVisible, row.values)
1249
+ : undefined,
1250
+ };
1251
+ })
1252
+ .filter((a) => a !== null);
1253
+ }, [
1254
+ screen.rowActions,
1255
+ defaultEditScreen,
1256
+ effectiveTranslate,
1257
+ dispatcher,
1258
+ runNavigate,
1259
+ refreshRowsAfterWrite,
1260
+ openDrawer,
1261
+ ]);
1262
+ // ToolbarActions: Schema → Resolved-Form (analog rowActions), shared with
1263
+ // projectionList and relatedList sections via buildProjectionToolbarActions.
1264
+ const toolbarActions = useMemo(() => buildProjectionToolbarActions({
1265
+ toolbarActions: screen.toolbarActions,
1266
+ translate: effectiveTranslate,
1267
+ dispatcher,
1268
+ nav,
1269
+ refetch: refreshRowsAfterWrite,
1270
+ openDrawer,
1271
+ host,
1272
+ }), [
1273
+ screen.toolbarActions,
1274
+ effectiveTranslate,
1275
+ nav,
1276
+ host,
1277
+ dispatcher,
1278
+ refreshRowsAfterWrite,
1279
+ openDrawer,
1280
+ ]);
1281
+ if (rowsQuery.loading && rowsQuery.data === null) {
1282
+ return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
1283
+ }
1284
+ if (rowsQuery.error) {
1285
+ return (_jsx(Banner, { padded: true, variant: "error", testId: "kumiko-screen-error", children: dispatcherErrorText(rowsQuery.error, effectiveTranslate) }));
1286
+ }
1287
+ // RenderList's onRowClick is a 1-arg callback; KumikoScreen's
1288
+ // 2-arg shape (row, entityName) is the public surface — thread the
1289
+ // screen's entity through here so callers don't have to re-derive
1290
+ // it.
1291
+ // Row-Body-Klick: eine explizit als rowClick markierte navigate-rowAction
1292
+ // gewinnt (deklarierte Author-Absicht "Klick auf die Zeile tut X"). Ohne Flag
1293
+ // bleibt der Pfad unverändert — create-app's entityEdit-Default bzw. ein vom
1294
+ // App-Author gesetztes onRowClick.
1295
+ const rowClickAction = screen.rowActions?.find((a) => a.kind === "navigate" && a.rowClick === true);
1296
+ const wrappedOnRowClick = rowClickAction
1297
+ ? (row) => runNavigate(rowClickAction, row)
1298
+ : onRowClick !== undefined
1299
+ ? (row) => onRowClick(row, screen.entity)
1300
+ : undefined;
1301
+ // Searchable default: explicit author choice wins, otherwise auto-on when
1302
+ // the entity has searchable fields (dead toolbar slot otherwise — the
1303
+ // server search index has nothing to filter). Gated on
1304
+ // schema.searchAdapterMissing !== true regardless of source (explicit or
1305
+ // auto) — a search bar the server can't serve would 422 at request-time
1306
+ // (#2032); matches the boot-time check in api/server.ts (#2051).
1307
+ const searchable = schema.searchAdapterMissing !== true &&
1308
+ (screen.searchable ??
1309
+ Object.values(entity.fields).some((f) => "searchable" in f && f.searchable === true));
1310
+ // Pager-Props nur bei pagination="pages" zusammenstellen. Server-
1311
+ // total kommt async — bis es da ist, rendert RenderList die Tabelle
1312
+ // ohne Pager (Pager hat eine eigene total>0-Guard).
1313
+ const total = rowsQuery.data?.total;
1314
+ const pager = usePager && total !== undefined
1315
+ ? {
1316
+ page: urlState.page,
1317
+ limit,
1318
+ total,
1319
+ onPageChange: urlState.setPage,
1320
+ pageSizeOptions: pageSizeOptionsFor(limit),
1321
+ onPageSizeChange: urlState.setPageSize,
1322
+ }
1323
+ : undefined;
1324
+ // Bei Infinite-Mode: rows kommen aus accumulated (über mehrere
1325
+ // useQuery-Calls gesammelt), bei pages/false aus dem aktuellen Result.
1326
+ const renderRows = useInfinite ? accumulated : (rowsQuery.data?.rows ?? []);
1327
+ return (_jsxs(_Fragment, { children: [_jsx(ReferenceFacetBridges, { specs: entityFacetSpecs, onOptions: handleEntityFacetOptions }), _jsx(RenderList, { screen: screen, entity: entity, rows: renderRows, featureName: featureName, searchable: searchable, searchValue: urlState.q, onSearchChange: urlState.setQ, sort: effectiveSort, onSortChange: urlState.setSort, screenPadding: true, ...(screenFillsHeight(screen) && { scrollBody: true }), ...(pager !== undefined && { pager }), ...(rowActions !== undefined && { rowActions }), ...(toolbarActions !== undefined && toolbarActions.length > 0 && { toolbarActions }), ...(useInfinite && {
1328
+ onReachEnd: loadMore,
1329
+ loadingMore: rowsQuery.loading,
1330
+ hasMore,
1331
+ }), ...(onCreate !== undefined && { onCreate }), ...(translate !== undefined && { translate }), ...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick }), ...(filterFacets.length > 0 && {
1332
+ filterFacets,
1333
+ filterValues: urlState.filters,
1334
+ onFilterChange: urlState.setFilter,
1335
+ onFilterReset: urlState.clearFilters,
1336
+ }) }), _jsx(DrawerHost, { schema: schema, drawerAction: drawerAction, drawerScreen: drawerScreen, ...(drawerInitialValues !== undefined && { drawerInitialValues }), userRoles: userRoles, ...(translate !== undefined && { translate }), onClose: closeDrawer, onSuccess: () => {
1337
+ closeDrawer();
1338
+ void rowsQuery.refetch();
1339
+ } })] }));
1340
+ }
1341
+ // ---- projection-list ----
1342
+ // Like entityList, but the list query comes DIRECTLY from `screen.query`
1343
+ // (instead of being derived from an entity) — cross-feature capable.
1344
+ // search/sort/pagination reuse the same URL-state + buildListQueryPayload
1345
+ // wiring as EntityListBody; which of them are actually offered is derived
1346
+ // from the query handler's Zod schema at buildAppSchema time (fw#2165), not
1347
+ // declared here. Pages-mode pagination only — infinite-scroll accumulation
1348
+ // isn't wired for projectionList.
1349
+ function ProjectionListBody({ schema, screen, translate, onRowClick, }) {
1350
+ const { Banner, Text } = usePrimitives();
1351
+ const t = useTranslation();
1352
+ const nav = useNav();
1353
+ const host = useReturnHost();
1354
+ const dispatcher = useOptionalDispatcher();
1355
+ const effectiveTranslate = translate ?? t;
1356
+ const userRoles = useUserRoles();
1357
+ const appFeatures = useAppFeatures();
1358
+ const { drawerAction, drawerScreen, drawerInitialValues, openDrawer, closeDrawer } = useDrawerAction(schema);
1359
+ const defaultEditScreen = useMemo(() => {
1360
+ const detailFor = screen.detailFor;
1361
+ if (detailFor === undefined)
1362
+ return undefined;
1363
+ return findEditScreenFor(detailFor, appFeatures, userRoles);
1364
+ }, [appFeatures, screen.detailFor, userRoles]);
1365
+ // searchable/sortable/paginated are derived at buildAppSchema time from the
1366
+ // query handler's Zod schema (fw#2165) — not authored on the screen.
1367
+ const searchable = screen.searchable ?? false;
1368
+ const sortable = screen.sortable ?? false;
1369
+ const paginated = screen.paginated ?? false;
1370
+ const entity = useMemo(() => synthesizeProjectionEntity(screen.columns, sortable), [screen.columns, sortable]);
1371
+ const listScreen = useMemo(() => synthesizeProjectionScreen(screen), [screen]);
1372
+ const urlState = useListUrlState(screen.id);
1373
+ // Gated on the derived capability, not just on whether URL-state happens to
1374
+ // carry a value — a stale/hand-crafted URL (?…q=x on a non-searchable
1375
+ // screen) must not smuggle a param the query's Zod schema doesn't accept.
1376
+ const activeSearch = searchable ? urlState.q : "";
1377
+ const activeSort = sortable ? (urlState.sort ?? screen.defaultSort ?? null) : null;
1378
+ const limit = urlState.pageSize ?? screen.pageSize ?? 50;
1379
+ // Pages-mode only (see header comment) — an author-set pagination:
1380
+ // "infinite" on a paginated projectionList is silently a no-op today.
1381
+ const usePager = paginated && (screen.pagination ?? "pages") === "pages";
1382
+ // Facets (fw#2224) — a projectionList has no entity, so screen.facets is
1383
+ // the only field inventory; resolveProjectionFacetSpecs reshapes +
1384
+ // translates its explicit labels into the same ResolvedFacetSpec the
1385
+ // entityList adapter produces, so both feed the same
1386
+ // buildFilterFacets/buildFilterPayload.
1387
+ const facetSpecs = useMemo(() => resolveProjectionFacetSpecs(screen.facets, effectiveTranslate, schema.featureName), [screen.facets, effectiveTranslate, schema.featureName]);
1388
+ const [projectionReferenceFacetOptions, setProjectionReferenceFacetOptions] = useState({});
1389
+ const handleProjectionFacetOptions = useCallback((field, options) => setProjectionReferenceFacetOptions((prev) => prev[field] === options ? prev : { ...prev, [field]: options }), []);
1390
+ const resolvedFacetSpecs = useMemo(() => mergeReferenceFacetOptions(facetSpecs, projectionReferenceFacetOptions), [facetSpecs, projectionReferenceFacetOptions]);
1391
+ const filterPayload = useMemo(() => buildFilterPayload(urlState.filters, (field) => facetSpecs.find((spec) => spec.field === field)?.type), [urlState.filters, facetSpecs]);
1392
+ // dateRange facets (fw#3104) bypass `filters` entirely — their two bounds
1393
+ // become the top-level query params the facet declares, resolved from a
1394
+ // calendar date to an instant in the viewer's zone.
1395
+ const timeZone = useOptionalTimeZone();
1396
+ const dateRangeSpecs = useMemo(() => resolveDateRangeFacets(screen.facets, effectiveTranslate), [screen.facets, effectiveTranslate]);
1397
+ const dateRangePayload = useMemo(() => buildDateRangePayload(dateRangeSpecs, urlState.filters, timeZone), [dateRangeSpecs, urlState.filters, timeZone]);
1398
+ const queryPayload = useMemo(() => {
1399
+ const payload = buildListQueryPayload({
1400
+ limit,
1401
+ search: activeSearch,
1402
+ sort: activeSort,
1403
+ usePager,
1404
+ page: urlState.page,
1405
+ useInfinite: false,
1406
+ cursor: undefined,
1407
+ });
1408
+ // Gated on screen.filter/screen.facets being declared (not just on
1409
+ // urlState/filterPayload happening to carry a value) — same rule as
1410
+ // activeSearch/activeSort above: a stale URL must not smuggle a param
1411
+ // the bound query's Zod schema doesn't accept (fw#2165 pattern).
1412
+ if (screen.filter !== undefined) {
1413
+ payload["filter"] = screen.filter;
1414
+ }
1415
+ if (screen.facets !== undefined && filterPayload.length > 0) {
1416
+ payload["filters"] = filterPayload;
1417
+ }
1418
+ Object.assign(payload, dateRangePayload);
1419
+ return payload;
1420
+ }, [
1421
+ limit,
1422
+ activeSearch,
1423
+ activeSort,
1424
+ usePager,
1425
+ urlState.page,
1426
+ screen.filter,
1427
+ screen.facets,
1428
+ filterPayload,
1429
+ dateRangePayload,
1430
+ ]);
1431
+ const rowsQuery = useQuery(screen.query, queryPayload, { live: true });
1432
+ const filterFacets = useMemo(() => buildFilterFacets(resolvedFacetSpecs), [resolvedFacetSpecs]);
1433
+ const dateRangeFacets = useMemo(() => dateRangeSpecs.map((spec) => ({
1434
+ field: spec.field,
1435
+ label: spec.label,
1436
+ ...readDateRange(urlState.filters, spec.field),
1437
+ })), [dateRangeSpecs, urlState.filters]);
1438
+ const handleDateRangeChange = useCallback((field, bound, value) => {
1439
+ urlState.setDateRange(field, clampDateRange(readDateRange(urlState.filters, field), bound, value));
1440
+ }, [urlState.setDateRange, urlState.filters]);
1441
+ // Entity-Targets (fw#2228) — see EntityListBody.runNavigate for why the
1442
+ // resolution happens in the NavApi impl. Unlike there: NO row["id"]
1443
+ // fallback — projectionList rows come from an arbitrary query projection
1444
+ // with no guaranteed "id" field. The boot validator therefore enforces an
1445
+ // explicit entityId for projectionList entity targets. Same helper
1446
+ // relatedList's rowActions reuse (related-list-section.tsx) — a
1447
+ // projectionDetail relatedList row has the identical "no guaranteed id"
1448
+ // shape.
1449
+ const runNavigate = useCallback((action, row) => runProjectionRowNavigate(nav, action, row, host), [nav, host]);
1450
+ const defaultEditRowAction = useMemo(() => buildDefaultEditRowAction(defaultEditScreen), [defaultEditScreen]);
1451
+ const rowActions = useMemo(() => buildProjectionRowActions({
1452
+ rowActions: screen.rowActions,
1453
+ translate: effectiveTranslate,
1454
+ dispatcher,
1455
+ nav,
1456
+ refetch: rowsQuery.refetch,
1457
+ openDrawer,
1458
+ defaultEditRowAction,
1459
+ host,
1460
+ }), [
1461
+ screen.rowActions,
1462
+ effectiveTranslate,
1463
+ dispatcher,
1464
+ nav,
1465
+ rowsQuery.refetch,
1466
+ openDrawer,
1467
+ defaultEditRowAction,
1468
+ host,
1469
+ ]);
1470
+ const toolbarActions = useMemo(() => buildProjectionToolbarActions({
1471
+ toolbarActions: screen.toolbarActions,
1472
+ translate: effectiveTranslate,
1473
+ dispatcher,
1474
+ nav,
1475
+ refetch: rowsQuery.refetch,
1476
+ openDrawer,
1477
+ host,
1478
+ }), [
1479
+ screen.toolbarActions,
1480
+ effectiveTranslate,
1481
+ nav,
1482
+ host,
1483
+ dispatcher,
1484
+ rowsQuery.refetch,
1485
+ openDrawer,
1486
+ ]);
1487
+ if (rowsQuery.loading && rowsQuery.data === null) {
1488
+ return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
1489
+ }
1490
+ if (rowsQuery.error) {
1491
+ return (_jsx(Banner, { padded: true, variant: "error", testId: "kumiko-screen-error", children: dispatcherErrorText(rowsQuery.error, effectiveTranslate) }));
1492
+ }
1493
+ // rowsQuery.data is typed as PagedRows but the query handler is free to
1494
+ // return anything at runtime (fw#2216: a bare array silently rendered an
1495
+ // empty table instead of surfacing the mismatch); a system-boundary cast
1496
+ // is needed to inspect the actual wire shape before trusting it.
1497
+ const rawRowsData = rowsQuery.data;
1498
+ if (rawRowsData !== null && !Array.isArray(rawRowsData.rows)) {
1499
+ return (_jsxs(Banner, { padded: true, variant: "error", testId: "kumiko-screen-projection-list-bad-shape", children: ["Screen ", _jsx(Text, { variant: "code", children: screen.id }), " query", " ", _jsx(Text, { variant: "code", children: screen.query }), " did not return a PagedRows envelope; a projectionList query must return ", _jsx(Text, { variant: "code", children: "{ rows, nextCursor }" }), "."] }));
1500
+ }
1501
+ const rowClickAction = screen.rowActions?.find((a) => a.kind === "navigate" && a.rowClick === true);
1502
+ const wrappedOnRowClick = rowClickAction
1503
+ ? (row) => runNavigate(rowClickAction, row)
1504
+ : onRowClick !== undefined
1505
+ ? (row) => onRowClick(row, listScreen.entity)
1506
+ : undefined;
1507
+ // Same pager-construction as EntityListBody: no Pager UI until the
1508
+ // server-provided `total` arrives (guards pagination="pages" without
1509
+ // totalCount support, see fw#2165 report on solon's leaseOverviewHandler).
1510
+ const total = rowsQuery.data?.total;
1511
+ const pager = usePager && total !== undefined
1512
+ ? {
1513
+ page: urlState.page,
1514
+ limit,
1515
+ total,
1516
+ onPageChange: urlState.setPage,
1517
+ pageSizeOptions: pageSizeOptionsFor(limit),
1518
+ onPageSizeChange: urlState.setPageSize,
1519
+ }
1520
+ : undefined;
1521
+ return (_jsxs(_Fragment, { children: [_jsx(ReferenceFacetBridges, { specs: facetSpecs, onOptions: handleProjectionFacetOptions }), _jsx(RenderList, { screen: listScreen, entity: entity, rows: rowsQuery.data?.rows ?? [], featureName: schema.featureName, searchable: searchable, searchValue: urlState.q, onSearchChange: urlState.setQ, sort: activeSort, onSortChange: urlState.setSort, screenPadding: true, ...(screenFillsHeight(listScreen) && { scrollBody: true }), ...(pager !== undefined && { pager }), ...(rowActions !== undefined && { rowActions }), ...(toolbarActions !== undefined && { toolbarActions }), ...(translate !== undefined && { translate }), ...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick }), ...(filterFacets.length > 0 && {
1522
+ filterFacets,
1523
+ onFilterChange: urlState.setFilter,
1524
+ }), ...(dateRangeFacets.length > 0 && {
1525
+ dateRangeFacets,
1526
+ onDateRangeChange: handleDateRangeChange,
1527
+ }), ...((filterFacets.length > 0 || dateRangeFacets.length > 0) && {
1528
+ filterValues: urlState.filters,
1529
+ onFilterReset: urlState.clearFilters,
1530
+ }) }), _jsx(DrawerHost, { schema: schema, drawerAction: drawerAction, drawerScreen: drawerScreen, ...(drawerInitialValues !== undefined && { drawerInitialValues }), userRoles: userRoles, ...(translate !== undefined && { translate }), onClose: closeDrawer, onSuccess: () => {
1531
+ closeDrawer();
1532
+ void rowsQuery.refetch();
1533
+ } })] }));
1534
+ }
1535
+ function metricLabelKey(metric, fieldLabels) {
1536
+ if (typeof metric !== "string" && metric.label !== undefined)
1537
+ return metric.label;
1538
+ return fieldLabels?.[metricField(metric)];
1539
+ }
1540
+ function metricNavigateSpec(metric) {
1541
+ return typeof metric === "string" ? undefined : metric.navigate;
1542
+ }
1543
+ // Reuses runProjectionRowNavigate (row-actions.ts) instead of re-deriving
1544
+ // navigate execution — a metric click has the same entity/screen/params
1545
+ // shape as a RowActionNavigate, minus the fields (id, label, style) that
1546
+ // function never reads.
1547
+ function runMetricNavigate(nav, navigate, record, host) {
1548
+ // tab alone (no screen/entity) stays on the current record and just
1549
+ // activates that tab — no route change, so runProjectionRowNavigate
1550
+ // (which always navigates) doesn't apply here.
1551
+ if (navigate.screen === undefined && navigate.entity === undefined) {
1552
+ if (navigate.tab !== undefined)
1553
+ nav.setSearchParams({ tab: navigate.tab });
1554
+ return;
1555
+ }
1556
+ const base = { kind: "navigate", id: "metric-navigate", label: "" };
1557
+ const action = navigate.entity !== undefined
1558
+ ? {
1559
+ ...base,
1560
+ entity: navigate.entity,
1561
+ ...(navigate.entityId !== undefined && { entityId: navigate.entityId }),
1562
+ ...(navigate.params !== undefined && { params: navigate.params }),
1563
+ ...(navigate.tab !== undefined && { tab: navigate.tab }),
1564
+ }
1565
+ : navigate.screen !== undefined
1566
+ ? {
1567
+ ...base,
1568
+ screen: navigate.screen,
1569
+ ...(navigate.entityId !== undefined && { entityId: navigate.entityId }),
1570
+ ...(navigate.params !== undefined && { params: navigate.params }),
1571
+ ...(navigate.tab !== undefined && { tab: navigate.tab }),
1572
+ }
1573
+ : undefined;
1574
+ if (action === undefined)
1575
+ return;
1576
+ runProjectionRowNavigate(nav, action, { id: "", values: record }, host);
1577
+ }
1578
+ // Absolute http(s) check for RecordHeaderSpec.subtitleHref — deliberately
1579
+ // stricter than isSafeHref (which also allows relative paths and mailto:),
1580
+ // since a relative/mailto value here should render as plain text, not a
1581
+ // "_blank" external link.
1582
+ function isAbsoluteHttpUrl(value) {
1583
+ return /^https?:\/\//i.test(value) && isSafeHref(value);
1584
+ }
1585
+ function headerStatusTone(header, record) {
1586
+ if (header.status === undefined)
1587
+ return undefined;
1588
+ const value = String(record[header.status] ?? "");
1589
+ const declared = header.statusTones !== undefined && Object.hasOwn(header.statusTones, value)
1590
+ ? header.statusTones[value]
1591
+ : undefined;
1592
+ return declared !== undefined ? statusToneForOptionTone(declared) : statusToneForValue(value);
1593
+ }
1594
+ function resolveSubtitleHref(header, record) {
1595
+ if (header?.subtitleHref === undefined)
1596
+ return undefined;
1597
+ const value = record[header.subtitleHref];
1598
+ return typeof value === "string" && isAbsoluteHttpUrl(value) ? value : undefined;
1599
+ }
1600
+ // Projection-Detail-Body — read-only single-row inspector über eine explizite
1601
+ // Query statt einer Entity (siehe projection-detail-shim.ts für die Schulden-
1602
+ // Doku). Fetcht selbst über `screen.query` + `idParam` (analog zu
1603
+ // EntityEditUpdateBody, das die Query-QN aus der Entity-Convention ableitet —
1604
+ // hier ist die QN Author-explizit, wie bei ProjectionListBody). Rendert über
1605
+ // RenderEdit MIT customSubmit-No-Op statt writeCommand: hasEditableSection()
1606
+ // blendet den Save-Button aus (jedes Feld ist readOnly), und selbst ein
1607
+ // natives Form-Submit (Enter-Keypress) würde ohne customSubmit gegen
1608
+ // controller.submit() ohne submit-config throwen — der No-Op macht diesen
1609
+ // Pfad harmlos statt ihn dem Zufall zu überlassen.
1610
+ // The primary header action always stays a visible text button — `edit` if
1611
+ // declared, else the first `style: "primary"` action, else the first action
1612
+ // at all. One step longer than row actions' `primaryRowAction` (renderer-web
1613
+ // primitives/index.tsx): rows don't declare `style: "primary"`.
1614
+ function primaryHeaderAction(actions) {
1615
+ return (actions.find((a) => a.id === "edit") ?? actions.find((a) => a.style === "primary") ?? actions[0]);
1616
+ }
1617
+ // Header actions bar (A7): <=2 actions render as plain buttons; >2 with an
1618
+ // `ActionOverflowMenu` primitive collapse to the primary button plus a menu
1619
+ // for the rest — same rule as RowActionsCell for table rows. Owns confirm
1620
+ // state for menu items: RenderEditActionButton already owns a per-button
1621
+ // confirm dialog for the always-visible primary action, but a menu item has
1622
+ // no button of its own to carry one.
1623
+ function HeaderActionsBar({ actions, Button, Dialog, ActionOverflowMenu, onError, collapseAfterPrimary = false, }) {
1624
+ const t = useTranslation();
1625
+ const [pendingAction, setPendingAction] = useState(null);
1626
+ const trigger = async (action) => {
1627
+ onError(null);
1628
+ try {
1629
+ await action.onPress();
1630
+ }
1631
+ catch (e) {
1632
+ onError(e instanceof Error ? e.message : String(e));
1633
+ }
1634
+ };
1635
+ const maxPlainButtons = collapseAfterPrimary ? 1 : 2;
1636
+ if (actions.length <= maxPlainButtons || ActionOverflowMenu === undefined) {
1637
+ return (_jsx(_Fragment, { children: actions.map((action) => (_jsx(RenderEditActionButton, { action: action, Button: Button, Dialog: Dialog, hideIcon: true, onError: onError }, action.id))) }));
1638
+ }
1639
+ const primary = primaryHeaderAction(actions);
1640
+ const rest = actions.filter((a) => a.id !== primary?.id);
1641
+ return (_jsxs(_Fragment, { children: [primary !== undefined && (_jsx(RenderEditActionButton, { action: primary, Button: Button, Dialog: Dialog, hideIcon: true, onError: onError }, primary.id)), _jsx(ActionOverflowMenu, { label: t("kumiko.list.row-actions.more"), testId: "kumiko-screen-projection-detail-actions-overflow", items: rest.map((action) => ({
1642
+ id: action.id,
1643
+ label: action.label,
1644
+ ...(action.icon !== undefined && { icon: action.icon }),
1645
+ variant: action.style === "danger" ? "danger" : "default",
1646
+ onSelect: () => {
1647
+ if (needsActionConfirm(action)) {
1648
+ setPendingAction(action);
1649
+ }
1650
+ else {
1651
+ void trigger(action);
1652
+ }
1653
+ },
1654
+ })) }), pendingAction !== null && (_jsx(RenderEditActionConfirmDialog, { action: pendingAction, open: true, onOpenChange: (open) => {
1655
+ if (!open)
1656
+ setPendingAction(null);
1657
+ }, onConfirm: async () => {
1658
+ const action = pendingAction;
1659
+ setPendingAction(null);
1660
+ await trigger(action);
1661
+ }, Dialog: Dialog }))] }));
1662
+ }
1663
+ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
1664
+ const { Banner, Button, Dialog, Text, Grid, GridCell, Card, Tabs, StatusBadge, Metric, MetricBand, PageHeader, Link, ActionOverflowMenu, } = usePrimitives();
1665
+ const t = useTranslation();
1666
+ const pageHeaderSlotAvailable = usePageHeaderSlotAvailable();
1667
+ const effectiveTranslate = translate ?? t;
1668
+ const nav = useNav();
1669
+ const idParam = screen.idParam ?? "id";
1670
+ // Singleton screens ignore whatever id the path happens to carry — the
1671
+ // server picks the row from session/context, so forwarding a path id
1672
+ // (even a stray/spoofed one) into the query or into extension sections'
1673
+ // entityId fallback (resolveExtensionEntityId in render-edit.tsx) would
1674
+ // let a client-controlled value leak where none is meant to reach.
1675
+ const effectiveEntityId = screen.singleton === true ? undefined : entityId;
1676
+ const isTabsMode = screen.layout.mode === "tabs";
1677
+ const activeSection = useMemo(() => {
1678
+ if (!isTabsMode || Tabs === undefined)
1679
+ return undefined;
1680
+ const tabParam = nav.searchParams["tab"];
1681
+ return (screen.layout.sections.find((section) => section.id === tabParam) ?? screen.layout.sections[0]);
1682
+ }, [isTabsMode, Tabs, screen.layout.sections, nav.searchParams]);
1683
+ const hasTabs = isTabsMode && Tabs !== undefined && activeSection !== undefined;
1684
+ // Tabs is an optional Core-Primitive: without it the screen falls back to
1685
+ // the stacked all-sections layout instead of silently truncating to section 1.
1686
+ useEffect(() => {
1687
+ if (isTabsMode && Tabs === undefined) {
1688
+ // biome-ignore lint/suspicious/noConsole: dev-warning for a setup error
1689
+ console.warn(`[kumiko] screen "${screen.id}" uses layout.mode: "tabs", but no <Tabs> primitive is registered — the tab strip will not render. createKumikoApp() from kumiko-renderer-web wires it automatically.`);
1690
+ }
1691
+ }, [isTabsMode, Tabs, screen.id]);
1692
+ const entity = useMemo(() => synthesizeProjectionDetailEntity(screen.layout), [screen.layout]);
1693
+ const detailScreen = useMemo(() => {
1694
+ const source = activeSection !== undefined
1695
+ ? { ...screen, layout: { ...screen.layout, sections: [activeSection] } }
1696
+ : screen;
1697
+ return synthesizeProjectionDetailScreen(source);
1698
+ }, [screen, activeSection]);
1699
+ const detailQuery = useQuery(screen.query, effectiveEntityId !== undefined ? { [idParam]: effectiveEntityId } : {});
1700
+ // A writeForm section's handler creates a new record (see EditWriteFormSection's
1701
+ // doc) — both the outer record (header/metrics/other sections) and a sibling
1702
+ // relatedList section's own independently-fetched rows must reflect it. A
1703
+ // query refetch alone only refreshes `record` below; bumping this into
1704
+ // <RenderEdit>'s `key` forces a full remount so RelatedListSection's own
1705
+ // useQuery call re-runs too (it has no `live` subscription of its own).
1706
+ const [reloadNonce, setReloadNonce] = useState(0);
1707
+ const reloadDetail = useCallback(async () => {
1708
+ await detailQuery.refetch();
1709
+ setReloadNonce((n) => n + 1);
1710
+ }, [detailQuery.refetch]);
1711
+ // Header actions render directly via RenderEditActionButton (fw#2713,
1712
+ // headerActions moved out of RenderEdit's footer) — this component owns
1713
+ // the failure-surfacing state RenderEdit used to own for them.
1714
+ const [actionError, setActionError] = useState(null);
1715
+ // Default edit action (fw#2166): resolved cross-feature over ALL mounted
1716
+ // features, not just this feature's own schema — detailFor itself is
1717
+ // resolved cross-feature by the boot-validator (detail-screens.ts), and
1718
+ // the motivating case is a projectionDetail whose query belongs to one
1719
+ // feature while the entity's entityEdit screen lives in another. Unlike
1720
+ // useNavigateToCreateFor this must NOT filter on allowCreate/singleton —
1721
+ // those gate create-targets, we're resolving an update-target by a
1722
+ // known id.
1723
+ const appFeatures = useAppFeatures();
1724
+ const userRoles = useUserRoles();
1725
+ const dispatcher = useOptionalDispatcher();
1726
+ const host = useReturnHost();
1727
+ const { drawerAction, drawerScreen, drawerInitialValues, openDrawer, closeDrawer } = useDrawerAction(schema);
1728
+ const editScreen = useMemo(() => {
1729
+ const detailFor = screen.detailFor;
1730
+ if (detailFor === undefined)
1731
+ return undefined;
1732
+ return findEditScreenFor(detailFor, appFeatures, userRoles);
1733
+ }, [appFeatures, screen.detailFor, userRoles]);
1734
+ const defaultEditAction = useMemo(() => {
1735
+ if (editScreen === undefined)
1736
+ return undefined;
1737
+ // editScreen.id is registry-qualified ("feature:screen:contact-edit");
1738
+ // nav.navigate expects the short form (see useNavigateToCreateFor above).
1739
+ const targetScreenId = lastSegment(editScreen.id);
1740
+ return {
1741
+ id: "edit",
1742
+ label: effectiveTranslate("kumiko.actions.edit"),
1743
+ icon: resolveActionIcon("edit"),
1744
+ onPress: () => {
1745
+ const target = {
1746
+ screenId: targetScreenId,
1747
+ ...(effectiveEntityId !== undefined && { entityId: effectiveEntityId }),
1748
+ };
1749
+ navigateWithReturnTo(nav, target, host);
1750
+ },
1751
+ };
1752
+ }, [editScreen, effectiveTranslate, nav, effectiveEntityId, host]);
1753
+ // a navigate-screen action targeting an entityEdit screen for the
1754
+ // SAME entity this projectionDetail shows (screen.detailFor) defaults its
1755
+ // entityId to the shown record's own id — without this fallback the target
1756
+ // opens an empty create-form instead of the shown record, silently.
1757
+ // Searched cross-feature, consistent with editScreen above.
1758
+ const sameEntityScreenId = useCallback((targetScreen) => {
1759
+ const record = detailQuery.data ?? {};
1760
+ const targetIsEntityEditSameEntity = screen.detailFor !== undefined &&
1761
+ appFeatures.some((feature) => feature.screens.some((s) => s.type === "entityEdit" &&
1762
+ s.entity === screen.detailFor &&
1763
+ lastSegment(s.id) === targetScreen));
1764
+ return targetIsEntityEditSameEntity ? String(record["id"] ?? "") : undefined;
1765
+ }, [detailQuery.data, screen.detailFor, appFeatures]);
1766
+ const buildSectionActions = useCallback((actions) => buildRecordActions({
1767
+ actions,
1768
+ record: detailQuery.data ?? {},
1769
+ translate: effectiveTranslate,
1770
+ nav,
1771
+ host,
1772
+ dispatcher,
1773
+ openDrawer,
1774
+ onWriteSuccess: detailQuery.refetch,
1775
+ sameEntityScreenId,
1776
+ }), [
1777
+ detailQuery.data,
1778
+ detailQuery.refetch,
1779
+ effectiveTranslate,
1780
+ nav,
1781
+ host,
1782
+ dispatcher,
1783
+ openDrawer,
1784
+ sameEntityScreenId,
1785
+ ]);
1786
+ const headerActions = useMemo(() => {
1787
+ const declaredHasEdit = screen.actions?.some((a) => a.id === "edit") === true;
1788
+ const resolved = buildRecordActions({
1789
+ actions: screen.actions ?? [],
1790
+ record: detailQuery.data ?? {},
1791
+ translate: effectiveTranslate,
1792
+ nav,
1793
+ host,
1794
+ dispatcher,
1795
+ openDrawer,
1796
+ onWriteSuccess: detailQuery.refetch,
1797
+ sameEntityScreenId,
1798
+ });
1799
+ if (defaultEditAction === undefined || declaredHasEdit)
1800
+ return resolved;
1801
+ return [defaultEditAction, ...(resolved ?? [])];
1802
+ }, [
1803
+ screen.actions,
1804
+ defaultEditAction,
1805
+ effectiveTranslate,
1806
+ nav,
1807
+ host,
1808
+ dispatcher,
1809
+ detailQuery.data,
1810
+ detailQuery.refetch,
1811
+ openDrawer,
1812
+ sameEntityScreenId,
1813
+ ]);
1814
+ if (effectiveEntityId === undefined && screen.singleton !== true) {
1815
+ return (_jsxs(Banner, { padded: true, variant: "error", testId: "kumiko-screen-projection-detail-missing-id", children: ["Screen ", _jsx(Text, { variant: "code", children: screen.id }), " (projectionDetail) needs a row id in the path \u2014 open it from a row action."] }));
1816
+ }
1817
+ if (detailQuery.loading && detailQuery.data === null) {
1818
+ return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
1819
+ }
1820
+ if (detailQuery.error) {
1821
+ return (_jsx(Banner, { padded: true, variant: "error", testId: "kumiko-screen-error", children: dispatcherErrorText(detailQuery.error, effectiveTranslate) }));
1822
+ }
1823
+ const record = detailQuery.data;
1824
+ if (!record) {
1825
+ return (_jsx(Banner, { padded: true, variant: "error", testId: "kumiko-screen-record-missing", children: screen.singleton === true ? ("Record not found.") : (_jsxs(_Fragment, { children: ["Record ", _jsx(Text, { variant: "code", children: entityId }), " not found."] })) }));
1826
+ }
1827
+ const hasHeader = screen.header !== undefined;
1828
+ const hasMetrics = screen.metrics !== undefined && screen.metrics.length > 0;
1829
+ // ?? [] rather than threading `headerActions !== undefined` through every
1830
+ // use below — an empty array is a safe no-op for .map/.length.
1831
+ const headerActionsList = headerActions ?? [];
1832
+ const hasHeaderActions = headerActionsList.length > 0;
1833
+ // Grouped into the head Card alongside title/status/metrics (fw#2713):
1834
+ // these are actions on the record the head shows, not on whichever tab is
1835
+ // open, so they must stay in place across tab switches instead of
1836
+ // trailing the active tab's content in the card footer.
1837
+ const headerActionsContent = hasHeaderActions && (_jsx(Grid, { columns: "auto", testId: "kumiko-screen-projection-detail-actions", children: _jsx(HeaderActionsBar, { actions: headerActionsList, Button: Button, Dialog: Dialog, ActionOverflowMenu: ActionOverflowMenu, onError: setActionError }) }));
1838
+ const hasHeaderCard = hasHeader || hasMetrics || hasHeaderActions;
1839
+ // Rendered as RenderEdit's headerRegion (not as JSX siblings before it) so
1840
+ // this shares the same page padding/width as the record's edit card below
1841
+ // it, instead of sitting flush against the screen edge (fw record-screen
1842
+ // header polish).
1843
+ const header = screen.header;
1844
+ const subtitleHref = resolveSubtitleHref(header, record);
1845
+ // Plain Text, not Heading — the Card title slot already renders as its own
1846
+ // heading element (h3); nesting another heading (h1) inside it is invalid HTML.
1847
+ const headerTitleSlot = header !== undefined ? (header.status !== undefined ? (_jsxs(Grid, { columns: "auto", children: [_jsx(Text, { testId: "kumiko-screen-projection-detail-title", children: String(record[header.title] ?? "") }), StatusBadge !== undefined ? (_jsx(StatusBadge, { value: String(record[header.status] ?? ""), tone: headerStatusTone(header, record), testId: "kumiko-screen-projection-detail-status" })) : (_jsx(Text, { testId: "kumiko-screen-projection-detail-status", children: String(record[header.status] ?? "") }))] })) : (_jsx(Text, { testId: "kumiko-screen-projection-detail-title", children: String(record[header.title] ?? "") }))) : undefined;
1848
+ const headerSubtitleSlot = header?.subtitle !== undefined ? (subtitleHref !== undefined ? (_jsx(Link, { href: subtitleHref, target: "_blank", testId: "kumiko-screen-projection-detail-subtitle", children: String(record[header.subtitle] ?? "") })) : (_jsx(Text, { variant: "muted", testId: "kumiko-screen-projection-detail-subtitle", children: String(record[header.subtitle] ?? "") }))) : undefined;
1849
+ // slots.header shares the header card with the actions (one card, actions
1850
+ // top right) instead of rendering as an unframed block above it.
1851
+ const metricItems = screen.metrics?.map((metric) => {
1852
+ const field = metricField(metric);
1853
+ const labelKey = metricLabelKey(metric, screen.fieldLabels);
1854
+ const label = labelKey !== undefined ? effectiveTranslate(labelKey) : field;
1855
+ const value = String(record[field] ?? "");
1856
+ const testId = `kumiko-screen-projection-detail-metric-${field}`;
1857
+ const navigate = metricNavigateSpec(metric);
1858
+ const onPress = navigate !== undefined && navigateTargetAllows(navigate, appFeatures, userRoles)
1859
+ ? () => runMetricNavigate(nav, navigate, record, host)
1860
+ : undefined;
1861
+ return Metric !== undefined ? (_jsx(Metric, { label: label, value: value, testId: testId, ...(onPress !== undefined && { onPress }) }, field)) : (_jsxs(GridCell, { children: [_jsx(Text, { variant: "small", testId: `${testId}-label`, children: label }), _jsx(Text, { testId: `${testId}-value`, children: value })] }, field));
1862
+ });
1863
+ // Metrics are a dl band when the primitive exists; the old Grid stays as the fallback.
1864
+ const metricsBlock = (subtitle) => MetricBand !== undefined ? (_jsx(MetricBand, { testId: "kumiko-screen-projection-detail-metrics", subtitle: subtitle, children: metricItems })) : (_jsx(Grid, { columns: screen.metrics?.length ?? 1, testId: "kumiko-screen-projection-detail-metrics", children: metricItems }));
1865
+ const usesPageHeaderSlot = pageHeaderSlotAvailable && PageHeader !== undefined;
1866
+ const actionErrorBanner = actionError !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-action-error", children: actionError }));
1867
+ const renderHeaderContent = (headerSlot) => (_jsxs(_Fragment, { children: [usesPageHeaderSlot && headerSlot === undefined ? (_jsxs(_Fragment, { children: [_jsx(PageHeader, { ...(header !== undefined && { title: String(record[header.title] ?? "") }), ...(header?.status !== undefined &&
1868
+ StatusBadge !== undefined && {
1869
+ status: (_jsx(StatusBadge, { value: String(record[header.status] ?? ""), tone: headerStatusTone(header, record), testId: "kumiko-screen-projection-detail-status" })),
1870
+ }), ...(hasHeaderActions && {
1871
+ actions: (_jsx(Grid, { columns: "auto", testId: "kumiko-screen-projection-detail-actions", children: _jsx(HeaderActionsBar, { actions: headerActionsList, Button: Button, Dialog: Dialog, ActionOverflowMenu: ActionOverflowMenu, onError: setActionError, collapseAfterPrimary: true }) })),
1872
+ }) }), (hasMetrics || headerSubtitleSlot !== undefined) &&
1873
+ (MetricBand !== undefined || hasMetrics) &&
1874
+ metricsBlock(headerSubtitleSlot), actionErrorBanner] })) : ((hasHeaderCard || headerSlot !== undefined) && (_jsxs(Card, { slots: {
1875
+ ...(headerTitleSlot !== undefined && { title: headerTitleSlot }),
1876
+ ...(headerSubtitleSlot !== undefined && { subtitle: headerSubtitleSlot }),
1877
+ ...(headerSlot !== undefined && { headerContent: headerSlot }),
1878
+ ...(hasHeaderActions && { headerActions: headerActionsContent }),
1879
+ }, children: [hasMetrics && metricsBlock(), actionErrorBanner] }))), hasTabs && activeSection !== undefined && (_jsx(Tabs, { testId: "kumiko-screen-projection-detail-tabs", items: screen.layout.sections.map((section) => {
1880
+ const countField = isWriteFormEditSection(section) ? undefined : section.countField;
1881
+ const countValue = countField !== undefined ? record[countField] : undefined;
1882
+ const count = typeof countValue === "number" && Number.isFinite(countValue)
1883
+ ? countValue
1884
+ : undefined;
1885
+ return {
1886
+ id: section.id ?? "",
1887
+ label: effectiveTranslate(section.title ?? section.id ?? ""),
1888
+ ...(count !== undefined && { count }),
1889
+ };
1890
+ }), activeId: activeSection.id ?? "", onSelect: (id) => nav.setSearchParams({ tab: id }) }))] }));
1891
+ return (_jsxs(_Fragment, { children: [_jsx(RenderEdit, { screen: detailScreen, entity: entity, featureName: schema.featureName, initial: record, entityId: effectiveEntityId, customSubmit: async () => ({ isSuccess: true, validationBlocked: false, data: undefined }), onReload: reloadDetail, onRelatedListDrawerAction: openDrawer, ...(translate !== undefined && { translate }), ...(hasTabs && { hideSectionTitles: true }), ...(screenFillsHeight(screen) && { fillScreenHeight: true }), ...((hasHeaderCard || hasTabs || screen.slots?.header !== undefined) && {
1892
+ headerRegion: renderHeaderContent,
1893
+ }), buildSectionActions: buildSectionActions, valueDisplay: screen.valueDisplay ?? "text" }, `${entityId}:${reloadNonce}`), _jsx(DrawerHost, { schema: schema, drawerAction: drawerAction, drawerScreen: drawerScreen, ...(drawerInitialValues !== undefined && { drawerInitialValues }), userRoles: userRoles, ...(translate !== undefined && { translate }), onClose: closeDrawer, onSuccess: () => {
1894
+ closeDrawer();
1895
+ void reloadDetail();
1896
+ } })] }));
1897
+ }
1898
+ // ---- actionForm (Tier 2.7d) ----
1899
+ function redirectScreenTarget(redirect) {
1900
+ return typeof redirect === "string" ? redirect : redirect.screen;
1901
+ }
1902
+ // Checks this schema first, then every mounted feature — the target may live
1903
+ // in another (cross-feature) feature than the redirect's own screen.
1904
+ function findRedirectTargetScreen(redirect, schema, appFeatures) {
1905
+ const redirectScreen = redirectScreenTarget(redirect);
1906
+ const targetId = lastSegment(redirectScreen);
1907
+ const targetFeatureName = featureNameFromQualifiedScreenId(redirectScreen);
1908
+ return (schema.screens.find((s) => lastSegment(s.id) === targetId) ??
1909
+ (targetFeatureName !== undefined
1910
+ ? appFeatures
1911
+ .find((f) => f.featureName === targetFeatureName)
1912
+ ?.screens.find((s) => lastSegment(s.id) === targetId)
1913
+ : appFeatures.flatMap((f) => f.screens).find((s) => lastSegment(s.id) === targetId)));
1914
+ }
1915
+ // A redirect to a record screen ("show the result") is a deliberate forward
1916
+ // navigation and keeps precedence over returnTo.
1917
+ function redirectTargetsRecord(redirect, schema, appFeatures) {
1918
+ const target = findRedirectTargetScreen(redirect, schema, appFeatures);
1919
+ return (target !== undefined && (target.type === "entityEdit" || target.type === "projectionDetail"));
1920
+ }
1921
+ // Type-guard form narrows `returnTarget` for the caller's subsequent
1922
+ // navigateToReturn(nav, returnTarget) call.
1923
+ function returnToWinsOverRedirect(returnTarget, redirect, schema, appFeatures) {
1924
+ return returnTarget !== undefined && !redirectTargetsRecord(redirect, schema, appFeatures);
1925
+ }
1926
+ // Resolves an object-form redirect to a nav target — shared by
1927
+ // actionForm and entityEdit (create + update) so the id
1928
+ // carries over identically regardless of which screen type triggered it.
1929
+ // `carriesId` gates entityId on the TARGET screen type: a redirect to a
1930
+ // list screen never gets an id attached, matching actionForm's original
1931
+ // behavior. The id itself prefers the write-handler's success payload
1932
+ // (`submittedData`) and falls back to `fallbackRecord` — the entityEdit
1933
+ // update path's already-loaded record, or the create path's just-submitted
1934
+ // form values — both of which carry fields (e.g. a parent FK) the CRUD
1935
+ // write executor's success payload doesn't flatly expose.
1936
+ function resolveRedirectTarget(redirect, submittedData, schema, appFeatures, fallbackRecord) {
1937
+ const redirectScreen = redirectScreenTarget(redirect);
1938
+ const idField = typeof redirect === "string" ? "id" : redirect.idFrom;
1939
+ const targetId = lastSegment(redirectScreen);
1940
+ const target = findRedirectTargetScreen(redirect, schema, appFeatures);
1941
+ const carriesId = target !== undefined && (target.type === "entityEdit" || target.type === "projectionDetail");
1942
+ if (!carriesId)
1943
+ return { screenId: targetId, entityId: undefined };
1944
+ const entityId = extractIdField(submittedData, idField) ??
1945
+ (fallbackRecord !== undefined ? extractIdField(fallbackRecord, idField) : undefined);
1946
+ return { screenId: targetId, entityId };
1947
+ }
1948
+ // Action-Form-Body — non-CRUD Write-Handler-driven Form. Re-uses
1949
+ // RenderEdit über synthetisierte EntityDefinition + EntityEditScreen-
1950
+ // Definition (siehe action-form-shim.ts für die Schulden-Doku). Die
1951
+ // Form-Mechanik (useForm, RenderEdit, DefaultInput, Banner, Submit-
1952
+ // Button) ist identisch zu entityEdit, nur der Submit-Pfad wechselt
1953
+ // vom CRUD-verb auf den Author-deklarierten handler-QN +
1954
+ // payloadMode="values" (alle Form-Werte schicken statt nur Changes).
1955
+ function ActionFormBody({ schema, screen, translate, initialOverrides, onSuccess, onCancelOverride, onDirtyChange, submitLabelFallback, }) {
1956
+ const nav = useNav();
1957
+ const appFeatures = useAppFeatures();
1958
+ const insideDrawer = useInsideDrawer();
1959
+ const locale = useLocale().locale();
1960
+ const { Banner } = usePrimitives();
1961
+ // Unused when drawer-hosted — onSuccess/onCancelOverride win below first.
1962
+ const returnTarget = useReturnTarget(screen.id);
1963
+ const synthEntity = useMemo(() => synthesizeActionFormEntity(screen.fields), [screen.fields]);
1964
+ const synthScreen = useMemo(() => synthesizeActionFormScreen(screen), [screen]);
1965
+ const pendingHandoff = useInitialValuesHandoff(screen.id);
1966
+ // A drawer-hosted form is not a navigation target, so it never takes a handoff.
1967
+ const handoffValues = onSuccess === undefined ? pendingHandoff : undefined;
1968
+ // This screen has no entity, so a money field here declares its own currency
1969
+ // source (fw#2839, boot-enforced) — without one the form would seed a bare
1970
+ // `0` the handler's schema rejects. ACTION_FORM_CURRENCY_FALLBACK only
1971
+ // applies when a tenant-declared field's config query errors or holds no
1972
+ // value, matching the entityEdit path's behaviour (fw#2937).
1973
+ const tenantCurrencyFieldNames = useMemo(() => tenantCurrencyMoneyFieldNames(screen.fields), [screen.fields]);
1974
+ const { overrides: moneyCurrencyOverrides, loading: currencyLoading } = useMoneyCurrencyOverrides(screen.fields, tenantCurrencyFieldNames, ACTION_FORM_CURRENCY_FALLBACK);
1975
+ const initial = useMemo(() => mergeSearchParamsIntoInitial(screen.fields, {
1976
+ searchParams: nav.searchParams,
1977
+ urlPrefillFields: screen.urlPrefillFields,
1978
+ renderableFields: layoutFieldNames(synthScreen),
1979
+ ...(moneyCurrencyOverrides !== undefined && { moneyCurrencyOverrides }),
1980
+ ...(initialOverrides !== undefined && { drawerOverrides: initialOverrides }),
1981
+ ...(handoffValues !== undefined && { handoffValues }),
1982
+ }), [
1983
+ screen.fields,
1984
+ screen.urlPrefillFields,
1985
+ nav.searchParams,
1986
+ synthScreen,
1987
+ moneyCurrencyOverrides,
1988
+ initialOverrides,
1989
+ handoffValues,
1990
+ ]);
1991
+ const t = useTranslation();
1992
+ const effectiveTranslate = translate ?? t;
1993
+ const summary = useMemo(() => resolveActionFormSummary(screen.summary, initialOverrides, effectiveTranslate, locale), [screen.summary, initialOverrides, effectiveTranslate, locale]);
1994
+ const handleSubmitted = useCallback((result) => {
1995
+ if (!result.isSuccess)
1996
+ return;
1997
+ if (onSuccess !== undefined) {
1998
+ onSuccess();
1999
+ return;
2000
+ }
2001
+ // Without a redirect the form stays put; returnTo only replaces an existing
2002
+ // navigation, and never one to a record screen (see redirectTargetsRecord).
2003
+ if (screen.redirect !== undefined) {
2004
+ if (returnToWinsOverRedirect(returnTarget, screen.redirect, schema, appFeatures)) {
2005
+ navigateToReturn(nav, returnTarget);
2006
+ }
2007
+ else {
2008
+ const { screenId, entityId } = resolveRedirectTarget(screen.redirect, result.data, schema, appFeatures);
2009
+ nav.navigate({ screenId, ...(entityId !== undefined && { entityId }) });
2010
+ }
2011
+ }
2012
+ }, [nav, screen.redirect, onSuccess, schema, appFeatures, returnTarget]);
2013
+ // Cancel ist nur sinnvoll wenn ein Navigations-Ziel existiert —
2014
+ // sonst hätte der Button nirgendwo hin zu navigieren. cancelTarget
2015
+ // gewinnt über redirect; `false` schaltet den Button explizit ab
2016
+ // (Single-Action-Screens, wo Cancel nur Submit-ohne-Senden wäre).
2017
+ // returnTo wins over the declared target when both are present.
2018
+ const handleCancel = useMemo(() => {
2019
+ if (onCancelOverride !== undefined)
2020
+ return onCancelOverride;
2021
+ const target = screen.cancelTarget ??
2022
+ (screen.redirect !== undefined ? redirectScreenTarget(screen.redirect) : undefined);
2023
+ if (target === undefined || target === false)
2024
+ return undefined;
2025
+ return () => navigateToReturnOr(nav, returnTarget, () => nav.navigate({ screenId: lastSegment(target) }));
2026
+ }, [nav, screen.redirect, screen.cancelTarget, onCancelOverride, returnTarget]);
2027
+ // Rendering the form before a tenant-declared currency lands would show the
2028
+ // fallback and, on a fast click, submit it (fw#2933) — hold instead.
2029
+ if (currencyLoading) {
2030
+ return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
2031
+ }
2032
+ return (_jsx(RenderEdit, { screen: synthScreen, entity: synthEntity, featureName: schema.featureName, initial: initial,
2033
+ // Extension-Sections im actionForm sehen die initialen Form-Values
2034
+ // (inkl. searchParams-Prefill, z.B. ?incidentId=…) als initialValues
2035
+ // — anders als im entityEdit gibt es hier keinen record, aus dem
2036
+ // sie Kontext ziehen könnten.
2037
+ extensionInitialValues: initial, writeCommand: screen.handler, payloadMode: "values", onSubmit: handleSubmitted, ...(handleCancel !== undefined && { onCancel: handleCancel }), ...(onDirtyChange !== undefined && { onDirtyChange }), ...((insideDrawer || screenFillsHeight(screen)) && { fillScreenHeight: true }), ...((screen.submitLabel ?? submitLabelFallback) !== undefined && {
2038
+ submitLabel: screen.submitLabel ?? submitLabelFallback,
2039
+ }), ...(screen.submitStyle !== undefined && { submitVariant: screen.submitStyle }), ...(summary !== undefined && { summary }), ...(translate !== undefined && { translate }) }));
2040
+ }
2041
+ // A money-typed config-edit field renders through RenderField's entityEdit
2042
+ // `{amount, currency}` payload shape (render-field.tsx, #1923), but
2043
+ // `ConfigKeyType` (write-helpers.ts validateType) only ever knows
2044
+ // number/boolean/text/select — a config value is always a bare scalar.
2045
+ // Unwrap back to the amount before it hits config:write:set.
2046
+ function unwrapMoneyValue(value) {
2047
+ if (typeof value === "object" && value !== null && "amount" in value) {
2048
+ const amount = value.amount;
2049
+ if (typeof amount === "number")
2050
+ return amount;
2051
+ }
2052
+ return value;
2053
+ }
2054
+ function ConfigEditBody({ schema, screen, translate, }) {
2055
+ const { Banner, ConfigCascadeView } = usePrimitives();
2056
+ const t = useTranslation();
2057
+ const effectiveTranslate = translate ?? t;
2058
+ const dispatcher = useDispatcher();
2059
+ // Detail-Load: config:query:values returnt ALLE Keys des Tenants.
2060
+ // Wir mappen via screen.configKeys von short → qualified-name auf
2061
+ // unsere Form-Field-Werte.
2062
+ const valuesQuery = useQuery("config:query:values", {});
2063
+ const cascadeQuery = useQuery("config:query:cascade", {});
2064
+ const synthEntity = useMemo(() => synthesizeConfigEditEntity(screen.fields), [screen.fields]);
2065
+ const synthScreen = useMemo(() => synthesizeConfigEditScreen(screen), [screen]);
2066
+ // Initial-Values: pro Field-Name den Wert aus `values[qualifiedKey]`
2067
+ // abholen. Fehlt der Key auf dem Server (= noch nie gesetzt), nutzen
2068
+ // wir den Field-Default (createTextField/createNumberField/...).
2069
+ // String-coerce nur für Text-Fields; andere Types sollten in der
2070
+ // Response bereits Native-Type sein.
2071
+ const initial = useMemo(() => {
2072
+ if (valuesQuery.data === null)
2073
+ return null;
2074
+ const out = {};
2075
+ const defaults = buildInitialValues(screen.fields); // @cast-boundary render-helper
2076
+ for (const [shortName, fieldDef] of Object.entries(screen.fields)) {
2077
+ const qualified = screen.configKeys[shortName];
2078
+ if (qualified === undefined) {
2079
+ // Author hat ein Field deklariert ohne Mapping — nimm Default.
2080
+ // Boot-Validator pinnt das, sollte nie zur Runtime greifen.
2081
+ out[shortName] = defaults[shortName];
2082
+ continue;
2083
+ }
2084
+ const stored = valuesQuery.data[qualified]?.value;
2085
+ if (stored === undefined) {
2086
+ out[shortName] = defaults[shortName];
2087
+ continue;
2088
+ }
2089
+ const ftype = fieldDef.type;
2090
+ // Field-Type-Coercion: config-Werte sind string|number|boolean,
2091
+ // aber der Form-State erwartet das passende Field-Native-Type.
2092
+ if (ftype === "number" || ftype === "money") {
2093
+ out[shortName] = typeof stored === "number" ? stored : Number(stored);
2094
+ }
2095
+ else if (ftype === "boolean") {
2096
+ out[shortName] = typeof stored === "boolean" ? stored : stored === "true";
2097
+ }
2098
+ else {
2099
+ out[shortName] = typeof stored === "string" ? stored : String(stored);
2100
+ }
2101
+ }
2102
+ return out;
2103
+ }, [valuesQuery.data, screen.fields, screen.configKeys]);
2104
+ // Cascade-Lookup: qualifiedKey → ConfigCascade für die
2105
+ // Cascade-Anzeige unter jedem Feld. Defensive `levels`-Shape-Check
2106
+ // damit fremde Query-Mocks (z.B. configEdit-Unit-Tests die nur
2107
+ // `config:query:values` mocken) nicht durch das Cascade-Rendering
2108
+ // crashen.
2109
+ const cascades = useMemo(() => {
2110
+ if (!cascadeQuery.data)
2111
+ return {};
2112
+ const out = {};
2113
+ for (const [shortName, qualified] of Object.entries(screen.configKeys)) {
2114
+ const cascade = cascadeQuery.data[qualified];
2115
+ if (cascade && Array.isArray(cascade.levels))
2116
+ out[shortName] = cascade;
2117
+ }
2118
+ return out;
2119
+ }, [cascadeQuery.data, screen.configKeys]);
2120
+ // Multi-Write Submit: ein einzelner /api/batch Call mit N
2121
+ // config:write:set Commands. Server-side ist batch atomic
2122
+ // (transaktional: alle Writes in einer DB-TX, all-or-nothing) und
2123
+ // browser-side ist es genau eine HTTP-Roundtrip — kein Race zwischen
2124
+ // mehreren in-flight fetches die der Browser bei page.reload mid-
2125
+ // submit aborten könnte. Promise.all von N separaten dispatcher.write-
2126
+ // Calls war fragil: server bekommt + commited alle N, aber das
2127
+ // Browser-Connection-Pool gibt sporadisch "Failed to fetch" für
2128
+ // einzelne Responses zurück, customSubmit returnt failure obwohl der
2129
+ // Write durch ist, das Form bleibt dirty.
2130
+ const customSubmit = useCallback(async (snapshot) => {
2131
+ const commands = [];
2132
+ for (const [shortName, value] of Object.entries(snapshot.changes)) {
2133
+ const qualified = screen.configKeys[shortName];
2134
+ if (qualified === undefined)
2135
+ continue;
2136
+ const ftype = screen.fields[shortName]?.type;
2137
+ commands.push({
2138
+ type: "config:write:set",
2139
+ payload: {
2140
+ key: qualified,
2141
+ value: ftype === "money" ? unwrapMoneyValue(value) : value,
2142
+ scope: screen.scope,
2143
+ },
2144
+ });
2145
+ }
2146
+ if (commands.length === 0) {
2147
+ return { validationBlocked: false, isSuccess: true, data: undefined };
2148
+ }
2149
+ const result = await dispatcher.batch(commands);
2150
+ if (!result.isSuccess) {
2151
+ return { validationBlocked: false, isSuccess: false, error: result.error };
2152
+ }
2153
+ // Cascade-Disclosure + Maskenwerte nach dem Write nachziehen, sonst
2154
+ // bleibt die Anzeige stale bis Reload — gleiche refetch-Calls wie onReset.
2155
+ // allSettled: der Write ist schon committed, ein gescheitertes Refetch
2156
+ // (stale Anzeige) darf das Erfolgsergebnis nicht in einen Fehler kippen.
2157
+ await Promise.allSettled([valuesQuery.refetch?.(), cascadeQuery.refetch?.()]);
2158
+ return { validationBlocked: false, isSuccess: true, data: undefined };
2159
+ }, [
2160
+ dispatcher,
2161
+ screen.configKeys,
2162
+ screen.fields,
2163
+ screen.scope,
2164
+ valuesQuery.refetch,
2165
+ cascadeQuery.refetch,
2166
+ ]);
2167
+ // Cascade-Disclosure (#429): Trigger sitzt in der Label-Row, das Panel
2168
+ // unter dem Input — zwei getrennte Render-Slots teilen sich den
2169
+ // expanded-State, der daher hier (pro Feld) statt in der Komponente lebt.
2170
+ const [expandedFields, setExpandedFields] = useState(new Set());
2171
+ const toggleExpanded = useCallback((fieldName) => {
2172
+ setExpandedFields((prev) => {
2173
+ const next = new Set(prev);
2174
+ if (next.has(fieldName))
2175
+ next.delete(fieldName);
2176
+ else
2177
+ next.add(fieldName);
2178
+ return next;
2179
+ });
2180
+ }, []);
2181
+ if (valuesQuery.loading && valuesQuery.data === null) {
2182
+ return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
2183
+ }
2184
+ if (valuesQuery.error) {
2185
+ return (_jsx(Banner, { padded: true, variant: "error", testId: "kumiko-screen-error", children: dispatcherErrorText(valuesQuery.error, effectiveTranslate) }));
2186
+ }
2187
+ if (initial === null) {
2188
+ return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
2189
+ }
2190
+ return (_jsx(RenderEdit, { screen: synthScreen, entity: synthEntity, featureName: schema.featureName, initial: initial, customSubmit: customSubmit, ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }), ...(translate !== undefined && { translate }), labelAppendix: (fieldName) => {
2191
+ const cascade = cascades[fieldName];
2192
+ if (cascade === undefined)
2193
+ return undefined;
2194
+ return (_jsx(ConfigCascadeView, { slot: "trigger", cascade: cascade, screenScope: screen.scope, expanded: expandedFields.has(fieldName), onToggle: () => toggleExpanded(fieldName) }));
2195
+ }, fieldAppendix: (fieldName) => {
2196
+ const cascade = cascades[fieldName];
2197
+ // Panel nur rendern wenn aufgeklappt — sonst kein leerer Abstand
2198
+ // unter dem Input.
2199
+ if (cascade === undefined || !expandedFields.has(fieldName))
2200
+ return undefined;
2201
+ return (_jsx(ConfigCascadeView, { slot: "panel", cascade: cascade, screenScope: screen.scope, expanded: true, qualifiedKey: screen.configKeys[fieldName], onReset: async (key, scope) => {
2202
+ await dispatcher.write("config:write:reset", { key, scope });
2203
+ await Promise.allSettled([valuesQuery.refetch?.(), cascadeQuery.refetch?.()]);
2204
+ } }));
2205
+ } }));
2206
+ }