viur-ui 0.0.1

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 (114) hide show
  1. package/README.md +79 -0
  2. package/index.d.ts +351 -0
  3. package/index.js +46 -0
  4. package/package.json +84 -0
  5. package/src/components/Browser/Browser.vue +496 -0
  6. package/src/components/Browser/TreeNav.vue +408 -0
  7. package/src/components/Browser/ViurBrowser.d.vue.ts +64 -0
  8. package/src/components/Browser/ViurBrowser.vue +271 -0
  9. package/src/components/Browser/dragSession.ts +134 -0
  10. package/src/components/Browser/index.ts +14 -0
  11. package/src/components/Browser/types.ts +30 -0
  12. package/src/components/DataTable/ColumnFilterPanel.vue +225 -0
  13. package/src/components/DataTable/DataTable.vue +3155 -0
  14. package/src/components/DataTable/ViurDataTable.d.vue.ts +72 -0
  15. package/src/components/DataTable/ViurDataTable.vue +331 -0
  16. package/src/components/DataTable/columns.ts +400 -0
  17. package/src/components/DataTable/index.ts +60 -0
  18. package/src/components/DataTable/rows.ts +446 -0
  19. package/src/components/DataTable/types.ts +267 -0
  20. package/src/components/DataTable/useColumnLayout.ts +294 -0
  21. package/src/components/DataTable/useGridNavigation.ts +193 -0
  22. package/src/components/DataTable/useVirtualRows.ts +126 -0
  23. package/src/components/DataTable/viurColumns.ts +330 -0
  24. package/src/forms/ViForm.d.vue.ts +106 -0
  25. package/src/forms/ViForm.vue +592 -0
  26. package/src/forms/ViFormCategory.vue +57 -0
  27. package/src/forms/bones/Bone.vue +1023 -0
  28. package/src/forms/bones/BoneActions.vue +127 -0
  29. package/src/forms/bones/BoneLabel.vue +122 -0
  30. package/src/forms/bones/BoneMultipleList.vue +91 -0
  31. package/src/forms/bones/BoneMultipleWrapper.vue +105 -0
  32. package/src/forms/bones/BoneNestedWrapper.vue +155 -0
  33. package/src/forms/bones/BoneSingleEntry.vue +51 -0
  34. package/src/forms/bones/FilterBone.vue +694 -0
  35. package/src/forms/bones/actionbar/defaultBar.vue +98 -0
  36. package/src/forms/bones/actionbar/fileBar.vue +165 -0
  37. package/src/forms/bones/actionbar/relationalBar.vue +125 -0
  38. package/src/forms/bones/actions/ActionDialog.vue +70 -0
  39. package/src/forms/bones/actions/Debug.vue +106 -0
  40. package/src/forms/bones/actions/Delete.vue +13 -0
  41. package/src/forms/bones/actions/DescribeImage.vue +201 -0
  42. package/src/forms/bones/actions/Download.vue +27 -0
  43. package/src/forms/bones/actions/Placeholder.vue +14 -0
  44. package/src/forms/bones/actions/Translation.vue +238 -0
  45. package/src/forms/bones/actions/index.js +280 -0
  46. package/src/forms/bones/bone.d.ts +45 -0
  47. package/src/forms/bones/country_information.json +779 -0
  48. package/src/forms/bones/index.js +225 -0
  49. package/src/forms/bones/uploadAdapter.js +46 -0
  50. package/src/forms/bones/utils.js +284 -0
  51. package/src/forms/bones/view/base_item.vue +35 -0
  52. package/src/forms/bones/view/boneLogic.js +202 -0
  53. package/src/forms/bones/view/index.js +5 -0
  54. package/src/forms/bones/widgets/booleanBone.vue +83 -0
  55. package/src/forms/bones/widgets/booleanBoneChoose.vue +57 -0
  56. package/src/forms/bones/widgets/booleanBoneSelect.vue +58 -0
  57. package/src/forms/bones/widgets/codeBone.vue +214 -0
  58. package/src/forms/bones/widgets/colorBone.vue +75 -0
  59. package/src/forms/bones/widgets/dateBone.vue +114 -0
  60. package/src/forms/bones/widgets/emailBone.vue +100 -0
  61. package/src/forms/bones/widgets/fileBone.vue +273 -0
  62. package/src/forms/bones/widgets/jsonBone.vue +63 -0
  63. package/src/forms/bones/widgets/keyBone.vue +71 -0
  64. package/src/forms/bones/widgets/numericBone.vue +125 -0
  65. package/src/forms/bones/widgets/passwordBone.vue +215 -0
  66. package/src/forms/bones/widgets/phoneBone.vue +173 -0
  67. package/src/forms/bones/widgets/rawBone.vue +94 -0
  68. package/src/forms/bones/widgets/recordBone.vue +64 -0
  69. package/src/forms/bones/widgets/relationalBone.vue +164 -0
  70. package/src/forms/bones/widgets/relationalBoneSelect.vue +120 -0
  71. package/src/forms/bones/widgets/selectBone.vue +122 -0
  72. package/src/forms/bones/widgets/selectBoneChoose.vue +104 -0
  73. package/src/forms/bones/widgets/spatialBone.vue +111 -0
  74. package/src/forms/bones/widgets/stringBone.vue +139 -0
  75. package/src/forms/bones/widgets/textBone.vue +641 -0
  76. package/src/forms/install.d.ts +4 -0
  77. package/src/forms/install.js +43 -0
  78. package/src/forms/layouts/LayloutList2Columns.vue +57 -0
  79. package/src/forms/layouts/LayoutCategory.vue +38 -0
  80. package/src/forms/layouts/LayoutList.vue +31 -0
  81. package/src/forms/utils.js +601 -0
  82. package/src/public/assets/icons/fontawesome-manifest.json +8 -0
  83. package/src/public/assets/icons/regular/file.svg +1 -0
  84. package/src/public/assets/icons/solid/arrows-left-right.svg +1 -0
  85. package/src/public/assets/icons/solid/bug.svg +1 -0
  86. package/src/public/assets/icons/solid/check.svg +1 -0
  87. package/src/public/assets/icons/solid/circle-info.svg +1 -0
  88. package/src/public/assets/icons/solid/compress.svg +1 -0
  89. package/src/public/assets/icons/solid/download.svg +1 -0
  90. package/src/public/assets/icons/solid/expand.svg +1 -0
  91. package/src/public/assets/icons/solid/grip-vertical.svg +1 -0
  92. package/src/public/assets/icons/solid/plus.svg +1 -0
  93. package/src/public/assets/icons/solid/question.svg +1 -0
  94. package/src/public/assets/icons/solid/triangle-exclamation.svg +1 -0
  95. package/src/public/assets/icons/solid/upload.svg +1 -0
  96. package/src/public/assets/icons/solid/xmark.svg +1 -0
  97. package/src/request/cache.js +183 -0
  98. package/src/request/configStore.js +516 -0
  99. package/src/request/envelope.js +211 -0
  100. package/src/request/index.js +35 -0
  101. package/src/request/messageStore.js +201 -0
  102. package/src/request/request.js +752 -0
  103. package/src/request/skel.js +90 -0
  104. package/src/theme/colorStore.js +168 -0
  105. package/src/translations/de.js +178 -0
  106. package/src/translations/en.js +177 -0
  107. package/src/user/LoginScreen.d.vue.ts +54 -0
  108. package/src/user/LoginScreen.vue +627 -0
  109. package/src/user/google.js +75 -0
  110. package/src/user/index.js +3 -0
  111. package/src/user/store.d.ts +59 -0
  112. package/src/user/store.js +183 -0
  113. package/src/webawesomeCompat.css +72 -0
  114. package/src/webawesomeConfig.js +61 -0
@@ -0,0 +1,446 @@
1
+ // Reine Zeilen-Pipeline: Filter → Suche → Sortierung → Baum/Gruppen →
2
+ // Paginierung → flache Anzeigezeilen. Ebenfalls Vue-frei und damit direkt
3
+ // testbar; die Komponente ruft das Ganze nur in einem computed auf.
4
+
5
+ import { aggregateColumn, defaultText, findColumn, rowMatchesFilters, rowMatchesSearch, sortRows } from "./columns"
6
+ import type { ColumnFacets, DisplayRow, FilterEntry, ResolvedColumn, Row, RowKey, RowNode, SortEntry } from "./types"
7
+ import { computeFacets, isEmptyFilter } from "./columns"
8
+
9
+ export type ChildRowsAccessor<R extends Row = Row> = string | ((row: R) => R[] | undefined | null)
10
+
11
+ export interface BuildRowsOptions<R extends Row = Row> {
12
+ data: R[]
13
+ columns: ResolvedColumn<R>[]
14
+ sort?: SortEntry[]
15
+ filters?: FilterEntry[]
16
+ search?: string
17
+ searchFn?: ((value: unknown, term: string, row: R) => boolean) | null
18
+ /** Baumdaten: Feldname oder Funktion, die die Kinder liefert. */
19
+ childRows?: ChildRowsAccessor<R> | null
20
+ /** Elternzeile bleibt sichtbar, wenn ein Nachfahre passt. */
21
+ filterFromLeafRows?: boolean
22
+ /** Gruppierung nach einer oder mehreren Spalten-ids (ohne Wirkung bei Baumdaten). */
23
+ groupBy?: string[]
24
+ /** Feld mit stabiler Zeilen-id. Ohne das werden Indexpfade benutzt ("0", "0.1"). */
25
+ rowKey?: string | null
26
+ }
27
+
28
+ export interface BuiltRows<R extends Row = Row> {
29
+ /** Hierarchie nach Filter/Sortierung/Gruppierung. */
30
+ nodes: RowNode<R>[]
31
+ /** Alle Datenzeilen in Anzeigereihenfolge (auch eingeklappte). */
32
+ dataRows: R[]
33
+ /** Nur oberste Ebene — für Fußzeilen-Funktionen bei Baumdaten. */
34
+ topLevelRows: R[]
35
+ /** Zeilen im Ergebnis (oberste Ebene bzw. Gruppen) über alle Seiten. */
36
+ filteredCount: number
37
+ facets: Record<string, ColumnFacets>
38
+ }
39
+
40
+ function childrenOf<R extends Row>(row: R, accessor: ChildRowsAccessor<R> | null | undefined): R[] {
41
+ if (!accessor) return []
42
+ const value = typeof accessor === "function" ? accessor(row) : (row[accessor] as R[] | undefined)
43
+ return Array.isArray(value) ? value : []
44
+ }
45
+
46
+ function keyOf<R extends Row>(row: R, rowKey: string | null | undefined, path: string): RowKey {
47
+ if (rowKey) {
48
+ const value = row[rowKey]
49
+ if (value !== undefined && value !== null) return value as RowKey
50
+ }
51
+ return path
52
+ }
53
+
54
+ /** Datenzeilen (mit Kindern) in Knoten überführen — Filter und Sortierung inklusive. */
55
+ function buildDataNodes<R extends Row>(
56
+ rows: R[],
57
+ options: BuildRowsOptions<R>,
58
+ depth: number,
59
+ pathPrefix: string
60
+ ): RowNode<R>[] {
61
+ const { columns, filters = [], search = "", searchFn, childRows, filterFromLeafRows } = options
62
+ const sorted = sortRows(rows, options.sort ?? [], columns)
63
+
64
+ const nodes: RowNode<R>[] = []
65
+ sorted.forEach((row, index) => {
66
+ const path = pathPrefix ? `${pathPrefix}.${index}` : String(index)
67
+ const selfMatches = rowMatchesFilters(row, filters, columns) && rowMatchesSearch(row, search, columns, searchFn)
68
+
69
+ const rawChildren = childrenOf(row, childRows)
70
+ // Bei filterFromLeafRows zählt der Treffer eines Nachfahren; sonst nimmt
71
+ // eine nicht passende Zeile ihren ganzen Teilbaum mit.
72
+ const children = rawChildren.length ? buildDataNodes(rawChildren, options, depth + 1, path) : []
73
+
74
+ if (!selfMatches && !(filterFromLeafRows && children.length)) return
75
+
76
+ nodes.push({
77
+ kind: "data",
78
+ key: keyOf(row, options.rowKey, path),
79
+ depth,
80
+ row,
81
+ children,
82
+ })
83
+ })
84
+ return nodes
85
+ }
86
+
87
+ /** Datenknoten nach einer oder mehreren Spalten gruppieren. */
88
+ function groupNodes<R extends Row>(
89
+ nodes: RowNode<R>[],
90
+ groupBy: string[],
91
+ columns: ResolvedColumn<R>[],
92
+ depth: number,
93
+ pathPrefix: string
94
+ ): RowNode<R>[] {
95
+ const [current, ...rest] = groupBy
96
+ const column = findColumn(columns, current)
97
+ if (!column) return nodes
98
+
99
+ const groups = new Map<string, { value: unknown; nodes: RowNode<R>[] }>()
100
+ for (const node of nodes) {
101
+ if (!node.row) continue
102
+ const value = column.get(node.row)
103
+ const key = defaultText(value)
104
+ const bucket = groups.get(key)
105
+ if (bucket) bucket.nodes.push(node)
106
+ else groups.set(key, { value, nodes: [node] })
107
+ }
108
+
109
+ return [...groups.entries()].map(([key, bucket], index) => {
110
+ const path = pathPrefix ? `${pathPrefix}.${key}` : `group:${current}:${key}`
111
+ const children = rest.length
112
+ ? groupNodes(bucket.nodes, rest, columns, depth + 1, path)
113
+ : bucket.nodes.map((node) => ({ ...node, depth: depth + 1 }))
114
+ const groupRows = bucket.nodes.map((node) => node.row!).filter(Boolean)
115
+ const aggregates: Record<string, unknown> = {}
116
+ for (const col of columns) {
117
+ const value = aggregateColumn(col, groupRows)
118
+ if (value !== undefined) aggregates[col.id] = value
119
+ }
120
+ return {
121
+ kind: "group" as const,
122
+ key: `${path}#${index}`,
123
+ depth,
124
+ children,
125
+ groupColumnId: current,
126
+ groupValue: bucket.value,
127
+ groupCount: groupRows.length,
128
+ groupRows,
129
+ aggregates,
130
+ }
131
+ })
132
+ }
133
+
134
+ function collectDataRows<R extends Row>(nodes: RowNode<R>[], into: R[] = []): R[] {
135
+ for (const node of nodes) {
136
+ if (node.row) into.push(node.row)
137
+ if (node.children.length) collectDataRows(node.children, into)
138
+ }
139
+ return into
140
+ }
141
+
142
+ /** Facetten je filterbarer Spalte — jeweils ohne den eigenen Filter. */
143
+ function buildFacets<R extends Row>(options: BuildRowsOptions<R>): Record<string, ColumnFacets> {
144
+ const { columns, filters = [], search = "", searchFn, childRows } = options
145
+ const flat = flattenData(options.data, childRows)
146
+ const facets: Record<string, ColumnFacets> = {}
147
+ for (const column of columns) {
148
+ if (!column.filterable) continue
149
+ const others = filters.filter((filter) => filter.id !== column.id && !isEmptyFilter(filter.value))
150
+ const base = flat.filter(
151
+ (row) => rowMatchesFilters(row, others, columns) && rowMatchesSearch(row, search, columns, searchFn)
152
+ )
153
+ facets[column.id] = computeFacets(base, column)
154
+ }
155
+ return facets
156
+ }
157
+
158
+ function flattenData<R extends Row>(
159
+ rows: R[],
160
+ childRows: ChildRowsAccessor<R> | null | undefined,
161
+ into: R[] = []
162
+ ): R[] {
163
+ for (const row of rows) {
164
+ into.push(row)
165
+ const children = childrenOf(row, childRows)
166
+ if (children.length) flattenData(children, childRows, into)
167
+ }
168
+ return into
169
+ }
170
+
171
+ export function buildRows<R extends Row>(options: BuildRowsOptions<R>): BuiltRows<R> {
172
+ const nodes = buildDataNodes(options.data, options, 0, "")
173
+ const grouped =
174
+ options.groupBy?.length && !options.childRows ? groupNodes(nodes, options.groupBy, options.columns, 0, "") : nodes
175
+
176
+ return {
177
+ nodes: grouped,
178
+ dataRows: collectDataRows(grouped),
179
+ topLevelRows: nodes.map((node) => node.row!).filter(Boolean),
180
+ filteredCount: grouped.length,
181
+ facets: buildFacets(options),
182
+ }
183
+ }
184
+
185
+ /** Eine Seite aus der obersten Ebene schneiden (Teilbäume bleiben bei ihrem Elternteil). */
186
+ export function paginateNodes<R extends Row>(nodes: RowNode<R>[], page: number, pageSize: number): RowNode<R>[] {
187
+ if (pageSize <= 0) return nodes
188
+ const start = page * pageSize
189
+ return nodes.slice(start, start + pageSize)
190
+ }
191
+
192
+ export interface FlattenOptions<R extends Row = Row> {
193
+ isExpanded: (key: RowKey) => boolean
194
+ /** Zeilen bekommen ein Detail-Panel (eigene Zeile unter der Datenzeile). */
195
+ withDetail?: boolean
196
+ /**
197
+ * Für nachgeladene Ebenen: Zeile gilt als aufklappbar, obwohl (noch) keine
198
+ * Kinder in den Daten stehen. Beim Nachladen weiß man vorher nicht, ob es
199
+ * welche gibt — der Pfeil muss trotzdem von Anfang an da sein.
200
+ */
201
+ mayHaveChildren?: (node: RowNode<R>) => boolean
202
+ }
203
+
204
+ /**
205
+ * Hierarchie zu einer flachen Renderliste machen: eingeklappte Teilbäume
206
+ * fallen weg, aufgeklappte Detail-Panels kommen als eigene Zeile dazu.
207
+ */
208
+ export function flattenNodes<R extends Row>(nodes: RowNode<R>[], options: FlattenOptions<R>): DisplayRow<R>[] {
209
+ const out: DisplayRow<R>[] = []
210
+ let dataIndex = 0
211
+
212
+ /**
213
+ * `lines` sind die Durchlauf-Spalten links des eigenen Winkels: je Eintrag
214
+ * eine Spalte, in der eine senkrechte Linie durchläuft, weil der Vorfahre der
215
+ * NÄCHSTEN Ebene noch ein Geschwister hat (siehe `DisplayRow.ancestorLines`).
216
+ * Nach dem letzten Kind bleibt die Spalte leer.
217
+ */
218
+ const walk = (list: RowNode<R>[], lines: boolean[]): void => {
219
+ list.forEach((node, position) => {
220
+ const isLastChild = position === list.length - 1
221
+ const hasChildren = node.children.length > 0
222
+ const expandable =
223
+ node.kind === "group"
224
+ ? hasChildren
225
+ : hasChildren || Boolean(options.withDetail) || Boolean(options.mayHaveChildren?.(node))
226
+ const expanded = expandable && options.isExpanded(node.key)
227
+
228
+ const base = {
229
+ nodeKey: node.key,
230
+ depth: node.depth,
231
+ ancestorLines: lines,
232
+ isLastChild,
233
+ row: node.row,
234
+ node,
235
+ }
236
+
237
+ out.push({
238
+ ...base,
239
+ kind: node.kind,
240
+ key: String(node.key),
241
+ expandable,
242
+ expanded,
243
+ index: node.kind === "data" ? dataIndex++ : -1,
244
+ })
245
+
246
+ if (!expanded) return
247
+ const detailRow = (): DisplayRow<R> => ({
248
+ ...base,
249
+ kind: "detail",
250
+ key: `${String(node.key)}::detail`,
251
+ expandable: false,
252
+ expanded: true,
253
+ index: -1,
254
+ })
255
+
256
+ if (node.kind === "data" && options.withDetail && !hasChildren) out.push(detailRow())
257
+ // Wurzeln erzeugen keine Durchlauf-Spalte (zwischen Wurzeln gibt es keine
258
+ // Linie); ab Ebene 1 kommt je Ebene eine dazu.
259
+ else if (hasChildren) {
260
+ walk(node.children, node.depth === 0 ? [] : [...lines, !isLastChild])
261
+ } else if (options.withDetail) out.push(detailRow())
262
+ })
263
+ }
264
+
265
+ walk(nodes, [])
266
+ return out
267
+ }
268
+
269
+ // ── Auswahl (Baum/Gruppen) ────────────────────────────────────────────
270
+
271
+ /** Alle Datenschlüssel eines Knotens inklusive Nachfahren. */
272
+ export function collectKeys<R extends Row>(node: RowNode<R>, into: RowKey[] = []): RowKey[] {
273
+ if (node.kind === "data") into.push(node.key)
274
+ for (const child of node.children) collectKeys(child, into)
275
+ return into
276
+ }
277
+
278
+ export type SelectionState = "checked" | "indeterminate" | "unchecked"
279
+
280
+ /** Zustand der Checkbox eines Knotens (Elternteil spiegelt seine Kinder). */
281
+ export function selectionStateFor<R extends Row>(node: RowNode<R>, selected: Set<RowKey>): SelectionState {
282
+ const keys = collectKeys(node)
283
+ if (!keys.length) return "unchecked"
284
+ const hits = keys.filter((key) => selected.has(key)).length
285
+ if (hits === 0) return "unchecked"
286
+ return hits === keys.length ? "checked" : "indeterminate"
287
+ }
288
+
289
+ // ── Flache Liste zu Baum ──────────────────────────────────────────────
290
+
291
+ export interface NestedTree<R extends Row = Row> {
292
+ /** Zeilen der obersten Ebene, in Eingabereihenfolge. */
293
+ roots: R[]
294
+ /** Kinder je Zeilenschlüssel. */
295
+ children: Map<RowKey, R[]>
296
+ }
297
+
298
+ /**
299
+ * Aus einer FLACHEN Liste mit Eltern-Verweis (ViUR: `parententry`) die
300
+ * Hierarchie bauen.
301
+ *
302
+ * Grundsatz: es geht nichts verloren. Zeilen ohne Elternwert sind Wurzeln;
303
+ * Zeilen, deren Elternteil nicht in den Daten steckt (Waisen — etwa weil nur
304
+ * eine Seite geladen ist), ebenfalls. Auch Zyklen (A → B → A, oder eine Zeile
305
+ * als ihr eigener Elternteil) landen als Wurzel, statt die Liste zu sprengen
306
+ * oder Zeilen verschwinden zu lassen.
307
+ */
308
+ export function nestByParent<R extends Row>(rows: R[], rowKey: string, parentField: string): NestedTree<R> {
309
+ const byKey = new Map<RowKey, R>()
310
+ for (const row of rows) {
311
+ const key = row[rowKey] as RowKey | undefined
312
+ if (key !== undefined && key !== null) byKey.set(key, row)
313
+ }
314
+
315
+ const children = new Map<RowKey, R[]>()
316
+ const roots: R[] = []
317
+ const attached = new Set<R>()
318
+
319
+ for (const row of rows) {
320
+ const key = row[rowKey] as RowKey | undefined
321
+ const parent = row[parentField] as RowKey | undefined
322
+ const hasParent = parent !== undefined && parent !== null && parent !== "" && parent !== key && byKey.has(parent)
323
+
324
+ if (!hasParent) {
325
+ roots.push(row)
326
+ continue
327
+ }
328
+ const list = children.get(parent!) ?? []
329
+ list.push(row)
330
+ children.set(parent!, list)
331
+ attached.add(row)
332
+ }
333
+
334
+ // Zyklen: alles, was zwar einen Elternteil hat, aber von keiner Wurzel aus
335
+ // erreichbar ist, wird selbst zur Wurzel — sonst wäre es unsichtbar.
336
+ const reachable = new Set<R>()
337
+ const walk = (list: R[]): void => {
338
+ for (const row of list) {
339
+ if (reachable.has(row)) continue
340
+ reachable.add(row)
341
+ const key = row[rowKey] as RowKey | undefined
342
+ if (key !== undefined) walk(children.get(key) ?? [])
343
+ }
344
+ }
345
+ walk(roots)
346
+
347
+ for (const row of rows) {
348
+ if (!attached.has(row) || reachable.has(row)) continue
349
+ // Kante zum (kreisenden) Elternteil kappen und als Wurzel führen.
350
+ const parent = row[parentField] as RowKey
351
+ const siblings = children.get(parent)
352
+ if (siblings) {
353
+ const index = siblings.indexOf(row)
354
+ if (index !== -1) siblings.splice(index, 1)
355
+ }
356
+ roots.push(row)
357
+ walk([row])
358
+ }
359
+
360
+ return { roots, children }
361
+ }
362
+
363
+ // ── Position im Baum ──────────────────────────────────────────────────
364
+
365
+ export interface NodeLocation<R extends Row = Row> {
366
+ node: RowNode<R>
367
+ /** Schlüssel der Elternzeile; null auf der obersten Ebene. */
368
+ parentKey: RowKey | null
369
+ /** Geschwister einschließlich des Knotens selbst, in Anzeigereihenfolge. */
370
+ siblings: RowNode<R>[]
371
+ index: number
372
+ }
373
+
374
+ /** Knoten samt Elternteil und Geschwistern suchen. */
375
+ export function locateNode<R extends Row>(
376
+ nodes: RowNode<R>[],
377
+ key: RowKey,
378
+ parentKey: RowKey | null = null
379
+ ): NodeLocation<R> | null {
380
+ const index = nodes.findIndex((node) => node.key === key)
381
+ if (index !== -1) return { node: nodes[index], parentKey, siblings: nodes, index }
382
+ for (const node of nodes) {
383
+ if (!node.children.length) continue
384
+ const found = locateNode(node.children, key, node.key)
385
+ if (found) return found
386
+ }
387
+ return null
388
+ }
389
+
390
+ /**
391
+ * Liegt `candidateKey` im Teilbaum von `ancestorKey`? Verhindert, dass eine
392
+ * Zeile in ihren eigenen Nachfahren gezogen wird — der Teilbaum hinge sonst
393
+ * an sich selbst und wäre aus der Ansicht verschwunden.
394
+ */
395
+ export function isDescendant<R extends Row>(nodes: RowNode<R>[], ancestorKey: RowKey, candidateKey: RowKey): boolean {
396
+ const ancestor = locateNode(nodes, ancestorKey)
397
+ if (!ancestor) return false
398
+ const walk = (list: RowNode<R>[]): boolean => list.some((node) => node.key === candidateKey || walk(node.children))
399
+ return walk(ancestor.node.children)
400
+ }
401
+
402
+ // ── Feste Reihenfolge (numerische Ordnungsspalte) ─────────────────────
403
+
404
+ export interface OrderValue {
405
+ /** Neuer Wert für die Ordnungsspalte der verschobenen Zeile. */
406
+ order: number
407
+ /**
408
+ * true, wenn die beiden Nachbarn NICHT unterscheidbar sind — praktisch immer
409
+ * dann, wenn mehrere Datensätze denselben Ordnungswert tragen (frisch
410
+ * angelegte Einträge mit Default 0 etwa). Die Mitte wäre dort wieder derselbe
411
+ * Wert, die Zeile würde also nicht sichtbar wandern; stattdessen wird hinter
412
+ * dem Block eingeordnet und die Anwendung kann neu durchnummerieren.
413
+ *
414
+ * Bei verteilten Fließkommawerten passiert das nicht: zwischen zwei Werten
415
+ * liegen rund 50 Halbierungen, bevor die Genauigkeit ausgeht.
416
+ */
417
+ needsReindex: boolean
418
+ }
419
+
420
+ /**
421
+ * Neuen Ordnungswert für eine Einfügeposition bestimmen — dieselbe Rechnung wie
422
+ * im admin4: `(prev + next) / 2`, an den Rändern ±Schritt.
423
+ *
424
+ * `values` sind die Ordnungswerte der Zielliste OHNE die verschobene Zeile,
425
+ * aufsteigend; `index` ist die Einfügeposition (0 = ganz vorn, values.length =
426
+ * ganz hinten). Die Mitte zwischen den Nachbarn heißt: beim Verschieben muss
427
+ * genau EIN Datensatz gespeichert werden, nicht die ganze Liste.
428
+ */
429
+ export function orderValueFor(values: number[], index: number, step = 1): OrderValue {
430
+ const clean = values.filter((value) => Number.isFinite(value))
431
+ const position = Math.max(0, Math.min(clean.length, index))
432
+
433
+ if (!clean.length) return { order: step, needsReindex: false }
434
+ if (position === 0) return { order: clean[0] - step, needsReindex: false }
435
+ if (position === clean.length) {
436
+ return { order: clean[clean.length - 1] + step, needsReindex: false }
437
+ }
438
+
439
+ const previous = clean[position - 1]
440
+ const next = clean[position]
441
+ const middle = previous + (next - previous) / 2
442
+ // Gleiche (oder nicht unterscheidbare) Nachbarn: die Mitte träfe einen von
443
+ // beiden — dann hinter dem Block einordnen, statt still nichts zu tun.
444
+ const needsReindex = middle <= previous || middle >= next
445
+ return { order: needsReindex ? previous + step : middle, needsReindex }
446
+ }